From 80c511eedd939b4d4206d6cfc808f52f2ff1faf8 Mon Sep 17 00:00:00 2001 From: Nikita Korolev Date: Thu, 16 Apr 2026 18:22:49 +0300 Subject: [PATCH 01/22] refactor report to js Signed-off-by: Nikita Korolev move scripts to folder Signed-off-by: Nikita Korolev use treds for send messages Signed-off-by: Nikita Korolev fix Signed-off-by: Nikita Korolev refactor js scripts report Signed-off-by: Nikita Korolev upd scripts Signed-off-by: Nikita Korolev refactor gen report Signed-off-by: Nikita Korolev refactor flow and js report Signed-off-by: Nikita Korolev fix report, add skipped to report Signed-off-by: Nikita Korolev refactor and fix ci Signed-off-by: Nikita Korolev change pickLatestMatchingFile to findSingleMatchingFile Signed-off-by: Nikita Korolev add jsdoc Signed-off-by: Nikita Korolev use json report instead of junit xml Signed-off-by: Nikita Korolev --- .../scripts/js/e2e/report/cluster-report.js | 347 ++++++++ .../js/e2e/report/cluster-report.test.js | 586 +++++++++++++ .github/scripts/js/e2e/report/fs-utils.js | 70 ++ .../scripts/js/e2e/report/fs-utils.test.js | 52 ++ .../js/e2e/report/ginkgo-report-utils.js | 158 ++++ .../scripts/js/e2e/report/messenger-report.js | 809 ++++++++++++++++++ .../js/e2e/report/messenger-report.test.js | 602 +++++++++++++ .github/scripts/js/eslint.config.cjs | 34 + .github/scripts/js/package.json | 4 +- .github/workflows/e2e-matrix.yml | 284 +----- .github/workflows/e2e-reusable-pipeline.yml | 326 ++++--- 11 files changed, 2920 insertions(+), 352 deletions(-) create mode 100644 .github/scripts/js/e2e/report/cluster-report.js create mode 100644 .github/scripts/js/e2e/report/cluster-report.test.js create mode 100644 .github/scripts/js/e2e/report/fs-utils.js create mode 100644 .github/scripts/js/e2e/report/fs-utils.test.js create mode 100644 .github/scripts/js/e2e/report/ginkgo-report-utils.js create mode 100644 .github/scripts/js/e2e/report/messenger-report.js create mode 100644 .github/scripts/js/e2e/report/messenger-report.test.js create mode 100644 .github/scripts/js/eslint.config.cjs diff --git a/.github/scripts/js/e2e/report/cluster-report.js b/.github/scripts/js/e2e/report/cluster-report.js new file mode 100644 index 0000000000..016f8fc124 --- /dev/null +++ b/.github/scripts/js/e2e/report/cluster-report.js @@ -0,0 +1,347 @@ +// Copyright 2026 Flant JSC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// http://www.apache.org/licenses/LICENSE-2.0 +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +const fs = require("fs"); + +const { findSingleMatchingFile } = require("./fs-utils"); +const { parseGinkgoReport } = require("./ginkgo-report-utils"); + +const stageLabels = { + bootstrap: "BOOTSTRAP CLUSTER", + "configure-sdn": "CONFIGURE SDN", + "storage-setup": "STORAGE SETUP", + "virtualization-setup": "VIRTUALIZATION SETUP", + "e2e-test": "E2E TEST", + success: "SUCCESS", + "artifact-missing": "TEST REPORTS NOT FOUND", +}; + +const preE2EStages = new Set([ + "bootstrap", + "configure-sdn", + "storage-setup", + "virtualization-setup", +]); + +/** + * Escapes special characters in a string for safe use inside a RegExp source. + * + * @param {string} value Raw string value. + * @returns {string} Escaped RegExp fragment. + */ +function escapeRegExp(value) { + return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); +} + +/** + * Reads cluster report configuration from environment variables injected by the + * reusable workflow or the local helper script. + * + * @param {NodeJS.ProcessEnv} [env=process.env] Environment variables source. + * @returns {{ + * storageType: string, + * reportsDir: string, + * reportFile: string, + * workflowRunUrlOverride: string, + * branchNameOverride: string, + * stageResults: { + * bootstrap: string|undefined, + * "configure-sdn": string|undefined, + * "storage-setup": string|undefined, + * "virtualization-setup": string|undefined, + * "e2e-test": string|undefined + * } + * }} Normalized cluster report configuration. + */ +function readClusterConfigFromEnv(env = process.env) { + const storageType = env.STORAGE_TYPE; + + return { + storageType, + reportsDir: env.E2E_REPORT_DIR || "test/e2e", + reportFile: env.REPORT_FILE || `e2e_report_${storageType}.json`, + workflowRunUrlOverride: env.WORKFLOW_RUN_URL || "", + branchNameOverride: env.BRANCH_NAME || "", + stageResults: { + bootstrap: env.BOOTSTRAP_RESULT, + "configure-sdn": env.CONFIGURE_SDN_RESULT, + "storage-setup": env.CONFIGURE_STORAGE_RESULT, + "virtualization-setup": env.CONFIGURE_VIRTUALIZATION_RESULT, + "e2e-test": env.E2E_TEST_RESULT, + }, + }; +} + +/** + * Creates a zero-filled metrics object for cluster report defaults. + * + * @returns {{ + * passed: number, + * failed: number, + * errors: number, + * skipped: number, + * total: number, + * successRate: number + * }} Zeroed metrics payload. + */ +function zeroMetrics() { + return { + passed: 0, + failed: 0, + errors: 0, + skipped: 0, + total: 0, + successRate: 0, + }; +} + +/** + * Builds a descriptor for a non-success stage result. + * + * @param {string} storageType Storage backend name. + * @param {string} stageName Failed or cancelled stage name. + * @param {string} resultValue Raw GitHub Actions result value. + * @returns {{ + * failedStage: string, + * failedStageLabel: string, + * failedJobName: string, + * reportKind: string, + * status: string, + * statusMessage: string + * }} Descriptor used by the final cluster report. + */ +function getStageDescriptor(storageType, stageName, resultValue) { + const result = (resultValue || "").trim(); + const stageLabel = stageLabels[stageName] || stageName; + const reportKind = preE2EStages.has(stageName) ? "stage-failure" : "tests"; + + if (result === "cancelled") { + return { + failedStage: stageName, + failedStageLabel: stageLabel, + failedJobName: `${stageLabel} (${storageType})`, + reportKind, + status: "cancelled", + statusMessage: `⚠️ ${stageLabel} CANCELLED`, + }; + } + + return { + failedStage: stageName, + failedStageLabel: stageLabel, + failedJobName: `${stageLabel} (${storageType})`, + reportKind, + status: "failure", + statusMessage: `❌ ${stageLabel} FAILED`, + }; +} + +/** + * Determines which workflow stage should be represented in the cluster report. + * + * The first non-success stage wins. If every stage succeeded, the cluster is + * treated as test-capable and the Ginkgo JSON report is expected to describe + * results. + * + * @param {string} storageType Storage backend name. + * @param {{ + * bootstrap: string|undefined, + * "configure-sdn": string|undefined, + * "storage-setup": string|undefined, + * "virtualization-setup": string|undefined, + * "e2e-test": string|undefined + * }} stageResults Per-stage GitHub Actions results. + * @returns {{ + * failedStage: string, + * failedStageLabel: string, + * failedJobName: string, + * reportKind: string, + * status: string, + * statusMessage: string + * }} Normalized stage descriptor. + */ +function determineStage(storageType, stageResults) { + const orderedStages = [ + ["bootstrap", stageResults.bootstrap], + ["configure-sdn", stageResults["configure-sdn"]], + ["storage-setup", stageResults["storage-setup"]], + ["virtualization-setup", stageResults["virtualization-setup"]], + ["e2e-test", stageResults["e2e-test"]], + ]; + + for (const [stageName, resultValue] of orderedStages) { + if ((resultValue || "success") !== "success") { + return getStageDescriptor(storageType, stageName, resultValue); + } + } + + return { + failedStage: "success", + failedStageLabel: stageLabels.success, + failedJobName: `E2E test (${storageType})`, + reportKind: "tests", + status: "success", + statusMessage: "✅ SUCCESS", + }; +} + +/** + * Builds a synthetic report descriptor for a successful test stage that did + * not produce any raw E2E artifact. + * + * @param {string} storageType Storage backend name. + * @returns {{ + * failedStage: string, + * failedStageLabel: string, + * failedJobName: string, + * reportKind: string, + * status: string, + * statusMessage: string + * }} Artifact-missing descriptor. + */ +function buildArtifactMissingDescriptor(storageType) { + const stageLabel = stageLabels["artifact-missing"]; + return { + failedStage: "artifact-missing", + failedStageLabel: stageLabel, + failedJobName: `E2E test (${storageType})`, + reportKind: "artifact-missing", + status: "missing", + statusMessage: `⚠️ ${stageLabel}`, + }; +} + +/** + * Exposes the generated report fields as GitHub Actions step outputs. + * + * @param {Record} report Final cluster report payload. + * @param {string} reportFile Path to the written JSON report file. + * @param {{ setOutput(name: string, value: string): void }} core GitHub core API. + */ +function setReportOutputs(report, reportFile, core) { + core.setOutput("report_file", reportFile); + core.setOutput("report_kind", report.reportKind || ""); + core.setOutput("status", report.status || ""); + core.setOutput("failed_stage", report.failedStage || ""); + core.setOutput("failed_stage_label", report.failedStageLabel || ""); + core.setOutput("workflow_run_url", report.workflowRunUrl || ""); + core.setOutput("branch", report.branch || ""); +} + +/** + * Builds a per-cluster JSON report from workflow stage results and an optional + * raw Ginkgo JSON report, writes it to disk, and publishes step outputs. + * + * @param {{ + * core: { + * info(message: string): void, + * warning(message: string): void, + * setOutput(name: string, value: string): void + * }, + * context: { + * serverUrl: string, + * repo: { owner: string, repo: string }, + * runId: string|number, + * ref?: string + * } + * }} params GitHub script dependencies. + * @returns {Promise>} Generated cluster report. + */ +async function buildClusterReport({ core, context }) { + const config = readClusterConfigFromEnv(); + const workflowRunUrl = + config.workflowRunUrlOverride || + `${context.serverUrl}/${context.repo.owner}/${context.repo.repo}/actions/runs/${context.runId}`; + const branchName = + config.branchNameOverride || + String(context.ref || "").replace(/^refs\/heads\//, ""); + const rawReportPattern = new RegExp( + `^e2e_report_${escapeRegExp(config.storageType)}_.*\\.json$` + ); + const rawReportPath = findSingleMatchingFile( + config.reportsDir, + rawReportPattern, + "Ginkgo JSON report" + ); + const stageInfo = determineStage(config.storageType, config.stageResults); + + let parsedReport = { + metrics: zeroMetrics(), + failedTests: [], + startedAt: null, + source: "empty", + }; + + if (rawReportPath) { + core.info(`Found Ginkgo JSON report: ${rawReportPath}`); + try { + parsedReport = { + ...parseGinkgoReport(fs.readFileSync(rawReportPath, "utf8"), zeroMetrics), + source: "ginkgo-json", + }; + } catch (error) { + core.warning( + `Unable to parse Ginkgo JSON report ${rawReportPath}: ${error.message}` + ); + } + } else { + core.warning( + `Ginkgo JSON report was not found for ${config.storageType} under ${config.reportsDir}` + ); + } + + const effectiveStageInfo = + stageInfo.status === "success" && + stageInfo.reportKind === "tests" && + parsedReport.source === "empty" + ? buildArtifactMissingDescriptor(config.storageType) + : stageInfo; + + const report = { + cluster: config.storageType, + storageType: config.storageType, + reportKind: effectiveStageInfo.reportKind, + status: effectiveStageInfo.status, + statusMessage: effectiveStageInfo.statusMessage, + failedStage: effectiveStageInfo.failedStage, + failedStageLabel: effectiveStageInfo.failedStageLabel, + failedJobName: effectiveStageInfo.failedJobName, + workflowRunId: String(context.runId), + workflowRunUrl, + branch: branchName, + startedAt: parsedReport.startedAt, + metrics: parsedReport.metrics, + failedTests: parsedReport.failedTests, + sourceReport: rawReportPath, + reportSource: parsedReport.source, + }; + + try { + fs.writeFileSync(config.reportFile, `${JSON.stringify(report, null, 2)}\n`); + } catch (error) { + throw new Error( + `Unable to write cluster report file ${config.reportFile}: ${error.message}` + ); + } + + setReportOutputs(report, config.reportFile, core); + core.info(`Created report file: ${config.reportFile}`); + core.info(JSON.stringify(report, null, 2)); + + return report; +} + +module.exports = buildClusterReport; +module.exports.determineStage = determineStage; +module.exports.parseGinkgoReport = parseGinkgoReport; +module.exports.buildArtifactMissingDescriptor = buildArtifactMissingDescriptor; +module.exports.readClusterConfigFromEnv = readClusterConfigFromEnv; diff --git a/.github/scripts/js/e2e/report/cluster-report.test.js b/.github/scripts/js/e2e/report/cluster-report.test.js new file mode 100644 index 0000000000..8d6d7ea1bb --- /dev/null +++ b/.github/scripts/js/e2e/report/cluster-report.test.js @@ -0,0 +1,586 @@ +const fs = require("fs"); +const os = require("os"); +const path = require("path"); + +const buildClusterReport = require("./cluster-report"); +const { determineStage } = require("./cluster-report"); +const { parseGinkgoReport } = require("./ginkgo-report-utils"); +const { readClusterConfigFromEnv } = require("./cluster-report"); + +/** + * Creates a mocked GitHub Actions core object for unit tests. + * + * @returns {{ + * info: jest.Mock, + * warning: jest.Mock, + * debug: jest.Mock, + * setOutput: jest.Mock + * }} Mocked core object. + */ +function createCore() { + return { + info: jest.fn(), + warning: jest.fn(), + debug: jest.fn(), + setOutput: jest.fn(), + }; +} + +/** + * Creates a minimal GitHub Actions context object for unit tests. + * + * @returns {{ + * serverUrl: string, + * repo: { owner: string, repo: string }, + * runId: string, + * ref: string + * }} Mocked context object. + */ +function createContext() { + return { + serverUrl: "https://github.com", + repo: { owner: "test", repo: "repo" }, + runId: "12345", + ref: "refs/heads/main", + }; +} + +/** + * Runs a test body inside a temporary directory and removes it afterwards. + * + * @template T + * @param {(tempDir: string) => Promise|T} testFn Test body. + * @returns {Promise} Test result. + */ +async function withTempDir(testFn) { + const tempDir = fs.mkdtempSync( + path.join(os.tmpdir(), "cluster-report-test-") + ); + try { + return await testFn(tempDir); + } finally { + fs.rmSync(tempDir, { recursive: true, force: true }); + } +} + +/** + * Seeds environment variables representing workflow stage results. + * + * @param {Record} [overrides={}] Environment overrides. + */ +function setStageEnv(overrides = {}) { + process.env.STORAGE_TYPE = "replicated"; + process.env.BOOTSTRAP_RESULT = "success"; + process.env.CONFIGURE_SDN_RESULT = "success"; + process.env.CONFIGURE_STORAGE_RESULT = "success"; + process.env.CONFIGURE_VIRTUALIZATION_RESULT = "success"; + process.env.E2E_TEST_RESULT = "success"; + Object.assign(process.env, overrides); +} + +/** + * Creates a synthetic Ginkgo spec report for parser tests. + * + * @param {{ + * containerHierarchyTexts?: string[], + * containerHierarchyLabels?: Array, + * leafNodeText?: string, + * leafNodeType?: string, + * leafNodeLabels?: string[], + * state?: string, + * startTime?: string, + * endTime?: string, + * failure?: Record|undefined + * }} [options={}] Spec overrides. + * @returns {Record} Synthetic spec report. + */ +function createSpecReport({ + containerHierarchyTexts = [], + containerHierarchyLabels = [], + leafNodeText = "", + leafNodeType = "It", + leafNodeLabels = [], + state = "passed", + startTime = "2026-04-15T09:30:44Z", + endTime = "2026-04-15T09:31:44Z", + failure = undefined, +} = {}) { + return { + ContainerHierarchyTexts: containerHierarchyTexts, + ContainerHierarchyLocations: [], + ContainerHierarchyLabels: containerHierarchyLabels, + LeafNodeType: leafNodeType, + LeafNodeLocation: {}, + LeafNodeLabels: leafNodeLabels, + LeafNodeText: leafNodeText, + State: state, + StartTime: startTime, + EndTime: endTime, + RunTime: 60000000000, + ParallelProcess: 1, + ...(failure ? { Failure: failure } : {}), + }; +} + +/** + * Creates a serialized single-suite Ginkgo report for unit tests. + * + * @param {{ startedAt: string, specs: Record[] }} params Report contents. + * @returns {string} JSON-serialized report. + */ +function createGinkgoReport({ startedAt, specs }) { + return JSON.stringify( + [ + { + SuitePath: "/tmp/test/e2e", + SuiteDescription: "Tests", + SuiteSucceeded: false, + StartTime: startedAt, + EndTime: "2026-04-15T10:00:00Z", + RunTime: 1800000000000, + SpecReports: specs, + }, + ], + null, + 2 + ); +} + +/** + * Creates a zero-filled metrics object for parser tests. + * + * @returns {{ + * passed: number, + * failed: number, + * errors: number, + * skipped: number, + * total: number, + * successRate: number + * }} Zeroed metrics payload. + */ +function createZeroMetrics() { + return { + passed: 0, + failed: 0, + errors: 0, + skipped: 0, + total: 0, + successRate: 0, + }; +} + +describe("cluster-report", () => { + afterEach(() => { + delete process.env.STORAGE_TYPE; + delete process.env.E2E_REPORT_DIR; + delete process.env.REPORT_FILE; + delete process.env.BRANCH_NAME; + delete process.env.WORKFLOW_RUN_URL; + delete process.env.BOOTSTRAP_RESULT; + delete process.env.CONFIGURE_SDN_RESULT; + delete process.env.CONFIGURE_STORAGE_RESULT; + delete process.env.CONFIGURE_VIRTUALIZATION_RESULT; + delete process.env.E2E_TEST_RESULT; + }); + + test("reads cluster config from env", () => { + const config = readClusterConfigFromEnv({ + STORAGE_TYPE: "replicated", + E2E_REPORT_DIR: "custom-reports", + REPORT_FILE: "custom.json", + WORKFLOW_RUN_URL: "https://example.invalid/run/1", + BRANCH_NAME: "release", + BOOTSTRAP_RESULT: "success", + CONFIGURE_SDN_RESULT: "failure", + CONFIGURE_STORAGE_RESULT: "skipped", + CONFIGURE_VIRTUALIZATION_RESULT: "skipped", + E2E_TEST_RESULT: "skipped", + }); + + expect(config).toEqual({ + storageType: "replicated", + reportsDir: "custom-reports", + reportFile: "custom.json", + workflowRunUrlOverride: "https://example.invalid/run/1", + branchNameOverride: "release", + stageResults: { + bootstrap: "success", + "configure-sdn": "failure", + "storage-setup": "skipped", + "virtualization-setup": "skipped", + "e2e-test": "skipped", + }, + }); + }); + + test("determines stage from explicit stage results", () => { + expect( + determineStage("replicated", { + bootstrap: "success", + "configure-sdn": "failure", + "storage-setup": "skipped", + "virtualization-setup": "skipped", + "e2e-test": "skipped", + }) + ).toMatchObject({ + failedStage: "configure-sdn", + failedStageLabel: "CONFIGURE SDN", + reportKind: "stage-failure", + status: "failure", + }); + }); + + test("renders test report from Ginkgo JSON when E2E succeeded", async () => + withTempDir(async (tempDir) => { + const rawReportPath = path.join( + tempDir, + "e2e_report_replicated_2026-04-15.json" + ); + fs.writeFileSync( + rawReportPath, + createGinkgoReport({ + startedAt: "2026-04-15T09:30:44Z", + specs: [ + createSpecReport({ + leafNodeType: "SynchronizedBeforeSuite", + state: "passed", + }), + createSpecReport({ + containerHierarchyTexts: ["Suite"], + leafNodeText: "passes", + state: "passed", + }), + createSpecReport({ + containerHierarchyTexts: ["Suite"], + leafNodeText: "fails & burns", + state: "failed", + leafNodeLabels: ["Slow"], + }), + createSpecReport({ + containerHierarchyTexts: ["Other"], + leafNodeText: "errors ", + state: "timedout", + }), + createSpecReport({ + leafNodeText: "skipped", + state: "skipped", + }), + ], + }) + ); + + const reportFile = path.join(tempDir, "report.json"); + setStageEnv({ + E2E_REPORT_DIR: tempDir, + REPORT_FILE: reportFile, + }); + + const core = createCore(); + const report = await buildClusterReport({ + core, + context: createContext(), + }); + + expect(report.reportKind).toBe("tests"); + expect(report.failedStage).toBe("success"); + expect(report.metrics).toEqual({ + passed: 1, + failed: 1, + errors: 1, + skipped: 1, + total: 4, + successRate: 25, + }); + expect(report.failedTests).toEqual([ + "[It] Suite fails & burns [Slow]", + "[It] Other errors ", + ]); + expect(report.reportSource).toBe("ginkgo-json"); + expect(report.sourceReport).toBe(rawReportPath); + expect(JSON.parse(fs.readFileSync(reportFile, "utf8")).reportKind).toBe( + "tests" + ); + expect(core.setOutput).toHaveBeenCalledWith("report_file", reportFile); + expect(core.setOutput).toHaveBeenCalledWith("report_kind", "tests"); + expect(core.setOutput).toHaveBeenCalledWith("status", "success"); + expect(core.setOutput).toHaveBeenCalledWith("failed_stage", "success"); + expect(core.setOutput).toHaveBeenCalledWith( + "failed_stage_label", + "SUCCESS" + ); + expect(core.setOutput).toHaveBeenCalledWith( + "workflow_run_url", + "https://github.com/test/repo/actions/runs/12345" + ); + expect(core.setOutput).toHaveBeenCalledWith("branch", "main"); + })); + + test("fails when multiple matching Ginkgo JSON reports exist", async () => + withTempDir(async (tempDir) => { + const firstReportPath = path.join( + tempDir, + "nested", + "e2e_report_replicated_2026-04-15.json" + ); + const secondReportPath = path.join( + tempDir, + "e2e_report_replicated_2026-04-16.json" + ); + fs.mkdirSync(path.dirname(firstReportPath), { recursive: true }); + + fs.writeFileSync( + firstReportPath, + createGinkgoReport({ + startedAt: "2026-04-15T09:30:44Z", + specs: [createSpecReport({ leafNodeText: "old pass", state: "passed" })], + }) + ); + fs.writeFileSync( + secondReportPath, + createGinkgoReport({ + startedAt: "2026-04-16T09:30:44Z", + specs: [createSpecReport({ leafNodeText: "latest pass", state: "passed" })], + }) + ); + + const reportFile = path.join(tempDir, "report.json"); + setStageEnv({ + E2E_REPORT_DIR: tempDir, + REPORT_FILE: reportFile, + }); + + await expect( + buildClusterReport({ + core: createCore(), + context: createContext(), + }) + ).rejects.toThrow( + "Expected a single Ginkgo JSON report, but found 2" + ); + expect(fs.existsSync(reportFile)).toBe(false); + })); + + test("falls back to missing-report status when raw Ginkgo JSON is invalid", async () => + withTempDir(async (tempDir) => { + const rawReportPath = path.join( + tempDir, + "e2e_report_replicated_2026-04-15.json" + ); + fs.writeFileSync(rawReportPath, "{not-valid-json"); + + const reportFile = path.join(tempDir, "report.json"); + setStageEnv({ + E2E_REPORT_DIR: tempDir, + REPORT_FILE: reportFile, + }); + + const core = createCore(); + const report = await buildClusterReport({ + core, + context: createContext(), + }); + + expect(report.reportKind).toBe("artifact-missing"); + expect(report.failedStage).toBe("artifact-missing"); + expect(report.status).toBe("missing"); + expect(report.reportSource).toBe("empty"); + expect(core.warning).toHaveBeenCalledWith( + expect.stringContaining("Unable to parse Ginkgo JSON report") + ); + })); + + test("throws a descriptive error when writing the cluster report fails", async () => + withTempDir(async (tempDir) => { + const reportFile = path.join(tempDir, "report.json"); + setStageEnv({ + E2E_REPORT_DIR: tempDir, + REPORT_FILE: reportFile, + }); + + const writeSpy = jest + .spyOn(fs, "writeFileSync") + .mockImplementation(() => { + throw new Error("disk full"); + }); + + try { + await expect( + buildClusterReport({ + core: createCore(), + context: createContext(), + }) + ).rejects.toThrow( + `Unable to write cluster report file ${reportFile}: disk full` + ); + } finally { + writeSpy.mockRestore(); + } + })); + + test("parses CI-like nfs counts from Ginkgo JSON and ignores non-It specs", () => { + const specs = [ + createSpecReport({ + leafNodeType: "SynchronizedBeforeSuite", + state: "passed", + }), + ]; + + for (let index = 1; index <= 90; index += 1) { + specs.push( + createSpecReport({ + containerHierarchyTexts: ["PassingSuite"], + leafNodeText: `passed ${index}`, + state: "passed", + }) + ); + } + + specs.push( + createSpecReport({ + containerHierarchyTexts: [ + "VirtualMachineOperationRestore", + "restores a virtual machine from a snapshot", + ], + containerHierarchyLabels: [["Slow"], []], + leafNodeText: + "BestEffort restore mode; automatic restart approval mode; manual run policy", + state: "failed", + }) + ); + + for (let index = 2; index <= 7; index += 1) { + specs.push( + createSpecReport({ + containerHierarchyTexts: ["FailingSuite"], + leafNodeText: `failed ${index}`, + state: "failed", + }) + ); + } + + specs.push( + createSpecReport({ + containerHierarchyTexts: ["SkippedSuite"], + leafNodeText: "skipped with reason", + state: "skipped", + failure: { + Message: "skip reason must not turn into a failure metric", + }, + }) + ); + + for (let index = 2; index <= 34; index += 1) { + specs.push( + createSpecReport({ + containerHierarchyTexts: ["SkippedSuite"], + leafNodeText: `skipped ${index}`, + state: "skipped", + }) + ); + } + + const parsed = parseGinkgoReport( + createGinkgoReport({ + startedAt: "2026-04-28T03:11:27.708387575Z", + specs, + }), + createZeroMetrics + ); + + expect(parsed.metrics).toEqual({ + passed: 90, + failed: 7, + errors: 0, + skipped: 34, + total: 131, + successRate: 68.7, + }); + expect(parsed.startedAt).toBe("2026-04-28T03:11:27.708387575Z"); + expect(parsed.failedTests).toHaveLength(7); + expect(parsed.failedTests).toContain( + "[It] VirtualMachineOperationRestore restores a virtual machine from a snapshot BestEffort restore mode; automatic restart approval mode; manual run policy [Slow]" + ); + }); + + test("reports configure-sdn as the failed pre-E2E phase", async () => + withTempDir(async (tempDir) => { + const reportFile = path.join(tempDir, "report.json"); + setStageEnv({ + E2E_REPORT_DIR: tempDir, + REPORT_FILE: reportFile, + CONFIGURE_SDN_RESULT: "failure", + CONFIGURE_STORAGE_RESULT: "skipped", + CONFIGURE_VIRTUALIZATION_RESULT: "skipped", + E2E_TEST_RESULT: "skipped", + }); + + const report = await buildClusterReport({ + core: createCore(), + context: createContext(), + }); + + expect(report.reportKind).toBe("stage-failure"); + expect(report.failedStage).toBe("configure-sdn"); + expect(report.failedStageLabel).toBe("CONFIGURE SDN"); + expect(report.status).toBe("failure"); + })); + + test("marks missing artifacts when test stage is successful but no reports were found", async () => + withTempDir(async (tempDir) => { + const reportFile = path.join(tempDir, "report.json"); + setStageEnv({ + E2E_REPORT_DIR: tempDir, + REPORT_FILE: reportFile, + }); + + const report = await buildClusterReport({ + core: createCore(), + context: createContext(), + }); + + expect(report.reportKind).toBe("artifact-missing"); + expect(report.failedStage).toBe("artifact-missing"); + expect(report.failedStageLabel).toBe("TEST REPORTS NOT FOUND"); + expect(report.status).toBe("missing"); + })); + + test("keeps cancelled test stage when no reports were found", async () => + withTempDir(async (tempDir) => { + const reportFile = path.join(tempDir, "report.json"); + setStageEnv({ + E2E_REPORT_DIR: tempDir, + REPORT_FILE: reportFile, + E2E_TEST_RESULT: "cancelled", + }); + + const report = await buildClusterReport({ + core: createCore(), + context: createContext(), + }); + + expect(report.reportKind).toBe("tests"); + expect(report.failedStage).toBe("e2e-test"); + expect(report.failedStageLabel).toBe("E2E TEST"); + expect(report.status).toBe("cancelled"); + })); + + test("keeps failed test stage when no reports were found", async () => + withTempDir(async (tempDir) => { + const reportFile = path.join(tempDir, "report.json"); + setStageEnv({ + E2E_REPORT_DIR: tempDir, + REPORT_FILE: reportFile, + E2E_TEST_RESULT: "failure", + }); + + const report = await buildClusterReport({ + core: createCore(), + context: createContext(), + }); + + expect(report.reportKind).toBe("tests"); + expect(report.failedStage).toBe("e2e-test"); + expect(report.failedStageLabel).toBe("E2E TEST"); + expect(report.status).toBe("failure"); + })); +}); diff --git a/.github/scripts/js/e2e/report/fs-utils.js b/.github/scripts/js/e2e/report/fs-utils.js new file mode 100644 index 0000000000..104c4b67c8 --- /dev/null +++ b/.github/scripts/js/e2e/report/fs-utils.js @@ -0,0 +1,70 @@ +const fs = require("fs"); +const path = require("path"); + +/** + * Recursively collects files whose base name matches the provided pattern. + * + * @param {string} dirPath Directory to scan. + * @param {RegExp} filePattern Regular expression applied to file names. + * @param {string[]} [files=[]] Accumulator used during recursion. + * @returns {string[]} Matching file paths. + */ +function listMatchingFiles(dirPath, filePattern, files = []) { + if (!fs.existsSync(dirPath)) { + return files; + } + + let entries; + try { + entries = fs + .readdirSync(dirPath, { withFileTypes: true }) + .sort((left, right) => left.name.localeCompare(right.name)); + } catch (error) { + throw new Error(`Unable to scan directory ${dirPath}: ${error.message}`); + } + + for (const entry of entries) { + const fullPath = path.join(dirPath, entry.name); + if (entry.isDirectory()) { + listMatchingFiles(fullPath, filePattern, files); + continue; + } + + if (filePattern.test(entry.name)) { + files.push(fullPath); + } + } + + return files; +} + +/** + * Resolves a single file matching the provided pattern. + * + * @param {string} dirPath Directory containing candidate files. + * @param {RegExp} filePattern Pattern matching the expected file name. + * @param {string} [description="file"] Human-readable file kind for errors. + * @returns {string|null} Matching file path or null when no match exists. + * @throws {Error} When more than one matching file is found. + */ +function findSingleMatchingFile(dirPath, filePattern, description = "file") { + const matchingFiles = listMatchingFiles(dirPath, filePattern); + if (matchingFiles.length === 0) { + return null; + } + + if (matchingFiles.length > 1) { + throw new Error( + `Expected a single ${description}, but found ${matchingFiles.length}: ${matchingFiles.join( + ", " + )}` + ); + } + + return matchingFiles[0]; +} + +module.exports = { + findSingleMatchingFile, + listMatchingFiles, +}; diff --git a/.github/scripts/js/e2e/report/fs-utils.test.js b/.github/scripts/js/e2e/report/fs-utils.test.js new file mode 100644 index 0000000000..7b72021a38 --- /dev/null +++ b/.github/scripts/js/e2e/report/fs-utils.test.js @@ -0,0 +1,52 @@ +const fs = require("fs"); +const os = require("os"); +const path = require("path"); + +const { listMatchingFiles } = require("./fs-utils"); + +/** + * Runs a test body inside a temporary directory and removes it afterwards. + * + * @template T + * @param {(tempDir: string) => Promise|T} testFn Test body. + * @returns {Promise} Test result. + */ +async function withTempDir(testFn) { + const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "fs-utils-test-")); + try { + return await testFn(tempDir); + } finally { + fs.rmSync(tempDir, { recursive: true, force: true }); + } +} + +describe("fs-utils", () => { + test("returns sorted matching files recursively", async () => + withTempDir((tempDir) => { + const nestedDir = path.join(tempDir, "nested"); + fs.mkdirSync(nestedDir, { recursive: true }); + fs.writeFileSync(path.join(tempDir, "b.json"), "{}\n"); + fs.writeFileSync(path.join(tempDir, "a.txt"), "nope\n"); + fs.writeFileSync(path.join(nestedDir, "a.json"), "{}\n"); + + expect(listMatchingFiles(tempDir, /\.json$/)).toEqual([ + path.join(tempDir, "b.json"), + path.join(nestedDir, "a.json"), + ]); + })); + + test("throws a descriptive error when a directory cannot be scanned", async () => + withTempDir((tempDir) => { + const readdirSpy = jest.spyOn(fs, "readdirSync").mockImplementation(() => { + throw new Error("permission denied"); + }); + + try { + expect(() => listMatchingFiles(tempDir, /\.json$/)).toThrow( + `Unable to scan directory ${tempDir}: permission denied` + ); + } finally { + readdirSpy.mockRestore(); + } + })); +}); diff --git a/.github/scripts/js/e2e/report/ginkgo-report-utils.js b/.github/scripts/js/e2e/report/ginkgo-report-utils.js new file mode 100644 index 0000000000..1242f68e2f --- /dev/null +++ b/.github/scripts/js/e2e/report/ginkgo-report-utils.js @@ -0,0 +1,158 @@ +/** + * Normalizes a value into an array. + * + * @param {any} value Input value. + * @returns {any[]} Array view of the input. + */ +function toArray(value) { + if (!value) { + return []; + } + + return Array.isArray(value) ? value : [value]; +} + +/** + * Flattens nested Ginkgo label arrays into a stable, unique list. + * + * @param {Array|string[]|null} labelGroups Raw label data. + * @returns {string[]} Flattened unique labels. + */ +function flattenLabels(labelGroups) { + const labels = []; + + for (const group of toArray(labelGroups)) { + for (const label of toArray(group)) { + const normalizedLabel = String(label || "").trim(); + if (normalizedLabel && !labels.includes(normalizedLabel)) { + labels.push(normalizedLabel); + } + } + } + + return labels; +} + +/** + * Builds a human-readable test name close to the JUnit testcase naming that + * existing reports already expose to messenger output. + * + * @param {Record} specReport Raw Ginkgo spec report entry. + * @returns {string} Formatted test name. + */ +function formatSpecName(specReport) { + const nodeType = String(specReport.LeafNodeType || "Spec").trim(); + const hierarchyParts = toArray(specReport.ContainerHierarchyTexts) + .map((part) => String(part || "").trim()) + .filter(Boolean); + const leafText = String(specReport.LeafNodeText || "").trim(); + const labels = [ + ...flattenLabels(specReport.ContainerHierarchyLabels), + ...flattenLabels(specReport.LeafNodeLabels), + ].filter((label, index, array) => array.indexOf(label) === index); + const labelSuffix = labels.map((label) => `[${label}]`).join(" "); + const body = [...hierarchyParts, leafText].filter(Boolean).join(" "); + + return [`[${nodeType}]`, body, labelSuffix] + .filter(Boolean) + .join(" ") + .replace(/\s+/g, " ") + .trim(); +} + +/** + * Maps a raw Ginkgo spec state into the metrics bucket used by the final + * messenger report. + * + * @param {string} state Raw `SpecReport.State` value. + * @returns {"passed"|"failed"|"errors"|"skipped"} Metrics key. + */ +function metricKeyForState(state) { + const normalizedState = String(state || "").trim().toLowerCase(); + + if (normalizedState === "passed") { + return "passed"; + } + + if (normalizedState === "failed") { + return "failed"; + } + + if (normalizedState === "skipped" || normalizedState === "pending") { + return "skipped"; + } + + return "errors"; +} + +/** + * Parses a Ginkgo JSON report into metrics and failed test names used by the + * markdown report. + * + * @param {string} jsonContent Raw JSON content. + * @param {() => { + * passed: number, + * failed: number, + * errors: number, + * skipped: number, + * total: number, + * successRate: number + * }} createZeroMetrics Factory creating a zeroed metrics object. + * @returns {{ + * metrics: { + * passed: number, + * failed: number, + * errors: number, + * skipped: number, + * total: number, + * successRate: number + * }, + * failedTests: string[], + * startedAt: string|null + * }} Parsed report payload. + */ +function parseGinkgoReport(jsonContent, createZeroMetrics) { + const suites = toArray(JSON.parse(jsonContent)); + const metrics = createZeroMetrics(); + const failedTests = []; + const startedAt = + suites.find((suite) => suite && suite.StartTime)?.StartTime || null; + + for (const suite of suites) { + for (const specReport of toArray(suite && suite.SpecReports)) { + if (String(specReport && specReport.LeafNodeType) !== "It") { + continue; + } + + metrics.total += 1; + const metricKey = metricKeyForState(specReport.State); + metrics[metricKey] += 1; + + if (metricKey === "failed" || metricKey === "errors") { + const specName = formatSpecName(specReport); + if (specName) { + failedTests.push(specName); + } + } + } + } + + metrics.successRate = + metrics.total > 0 + ? Number(((metrics.passed / metrics.total) * 100).toFixed(2)) + : 0; + + return { + metrics, + failedTests: Array.from(new Set(failedTests)), + startedAt, + }; +} + +module.exports = { + flattenLabels, + formatSpecName, + metricKeyForState, + parseGinkgoReport, + toArray, +}; diff --git a/.github/scripts/js/e2e/report/messenger-report.js b/.github/scripts/js/e2e/report/messenger-report.js new file mode 100644 index 0000000000..f4dd6c2816 --- /dev/null +++ b/.github/scripts/js/e2e/report/messenger-report.js @@ -0,0 +1,809 @@ +// Copyright 2026 Flant JSC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// http://www.apache.org/licenses/LICENSE-2.0 +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +const fs = require("fs"); + +const { listMatchingFiles } = require("./fs-utils"); + +const genericArtifactMissingLabel = "E2E REPORT ARTIFACT NOT FOUND"; +const testReportsMissingLabel = "TEST REPORTS NOT FOUND"; + +/** + * Builds a user-facing status line for a cluster row or fallback report. + * + * @param {string} status Normalized cluster status. + * @param {string} stageLabel Human-readable stage label. + * @returns {string} Rendered status message. + */ +function buildStatusMessage(status, stageLabel) { + if (status === "cancelled") { + return `⚠️ ${stageLabel} CANCELLED`; + } + + if (status === "failure") { + return `❌ ${stageLabel} FAILED`; + } + + if (status === "missing") { + return `⚠️ ${stageLabel}`; + } + + if (status === "success") { + return "✅ SUCCESS"; + } + + return stageLabel; +} + +/** + * Creates a synthetic cluster report when the expected JSON artifact is absent. + * + * This allows the final messenger message to stay informative even when the + * report-preparation step failed or never produced an artifact. + * + * @param {string} clusterName Cluster or storage name. + * @param {{ + * reportKind?: string, + * failedStage?: string, + * failedStageLabel?: string, + * status?: string, + * branch?: string, + * workflowRunUrl?: string + * }} [fallback={}] Optional fallback data propagated from workflow outputs. + * @returns {Record} Synthetic report payload. + */ +function createMissingReport(clusterName, fallback = {}) { + const reportKind = + fallback.reportKind && fallback.reportKind !== "tests" + ? fallback.reportKind + : "artifact-missing"; + const failedStage = + fallback.failedStage && fallback.failedStage !== "success" + ? fallback.failedStage + : "artifact-missing"; + const failedStageLabel = + fallback.failedStageLabel || + (fallback.reportKind === "artifact-missing" + ? testReportsMissingLabel + : genericArtifactMissingLabel); + const status = fallback.status || "missing"; + + return { + cluster: clusterName, + storageType: clusterName, + reportKind, + status, + statusMessage: buildStatusMessage(status, failedStageLabel), + failedStage, + failedStageLabel, + branch: fallback.branch || "", + workflowRunUrl: fallback.workflowRunUrl || "", + metrics: { + passed: 0, + failed: 0, + errors: 0, + skipped: 0, + total: 0, + successRate: 0, + }, + failedTests: [], + }; +} + +/** + * Escapes markdown table cell content and normalizes whitespace. + * + * @param {any} value Raw cell value. + * @returns {string} Sanitized table cell string. + */ +function sanitizeCell(value) { + return String(value || "—") + .replace(/\|/g, "\\|") + .replace(/\r?\n/g, " ") + .trim(); +} + +/** + * Normalizes markdown list item content to a single trimmed line. + * + * @param {any} value Raw list item value. + * @returns {string} Sanitized list item string. + */ +function sanitizeListItem(value) { + return String(value || "") + .replace(/\r?\n/g, " ") + .trim(); +} + +/** + * Formats a numeric success rate as a percentage string. + * + * @param {number|string} value Raw rate value. + * @returns {string} Formatted percentage. + */ +function formatRate(value) { + const rate = Number(value || 0); + return `${Number.isFinite(rate) ? rate.toFixed(2) : "0.00"}%`; +} + +/** + * Picks a report date from the first report that exposes `startedAt`. + * + * @param {Record[]} reports Available cluster reports. + * @returns {string} ISO date string (`YYYY-MM-DD`). + */ +function getReportDate(reports) { + const datedReport = reports.find((report) => report.startedAt); + if (!datedReport) { + return new Date().toISOString().slice(0, 10); + } + + return String(datedReport.startedAt).slice(0, 10); +} + +/** + * Orders reports by the configured cluster order and then by cluster name. + * + * @param {Record[]} reports Reports to sort. + * @param {string[]} preferredOrder Configured cluster order. + * @returns {Record[]} Sorted reports copy. + */ +function sortReports(reports, preferredOrder) { + const orderMap = new Map(preferredOrder.map((name, index) => [name, index])); + + return [...reports].sort((left, right) => { + const leftKey = left.storageType || left.cluster; + const rightKey = right.storageType || right.cluster; + const leftOrder = orderMap.has(leftKey) + ? orderMap.get(leftKey) + : Number.MAX_SAFE_INTEGER; + const rightOrder = orderMap.has(rightKey) + ? orderMap.get(rightKey) + : Number.MAX_SAFE_INTEGER; + + if (leftOrder !== rightOrder) { + return leftOrder - rightOrder; + } + + return String(left.cluster || left.storageType).localeCompare( + String(right.cluster || right.storageType) + ); + }); +} + +/** + * Renders a cluster name as a markdown link when a workflow URL is available. + * + * @param {Record} report Cluster report payload. + * @returns {string} Markdown link or plain sanitized cluster name. + */ +function formatClusterLink(report) { + const clusterName = sanitizeCell(report.cluster || report.storageType); + return report.workflowRunUrl + ? `[${clusterName}](${report.workflowRunUrl})` + : clusterName; +} + +/** + * Extracts the normalized cluster key from a report payload. + * + * @param {Record} report Cluster report payload. + * @returns {string} Cluster key or an empty string when it is missing. + */ +function getReportClusterKey(report) { + return String(report.storageType || report.cluster || "").trim(); +} + +/** + * Tells whether the report represents a missing artifact rather than a real + * cluster-stage failure. + * + * @param {Record} report Cluster report payload. + * @returns {boolean} True when the report describes a missing artifact. + */ +function isMissingReport(report) { + return ( + report.reportKind === "artifact-missing" || + report.failedStage === "artifact-missing" || + report.status === "missing" + ); +} + +/** + * Normalizes the configured Loop API base URL to the `/api/v4/posts` endpoint. + * + * @param {string} value Raw Loop API base URL. + * @returns {string} Normalized posts endpoint URL or an empty string. + */ +function normalizeLoopApiBaseUrl(value) { + const trimmedValue = String(value || "") + .trim() + .replace(/\/+$/, ""); + + if (!trimmedValue) { + return ""; + } + + if (trimmedValue.endsWith("/api/v4/posts")) { + return trimmedValue; + } + + if (trimmedValue.endsWith("/api/v4")) { + return `${trimmedValue}/posts`; + } + + return `${trimmedValue}/api/v4/posts`; +} + +/** + * Reads and normalizes the Loop posts API URL from environment variables. + * + * @param {NodeJS.ProcessEnv} [env=process.env] Environment variables source. + * @returns {string} Normalized posts endpoint URL or an empty string. + */ +function getLoopPostsApiUrl(env = process.env) { + return normalizeLoopApiBaseUrl(env.LOOP_API_BASE_URL); +} + +/** + * Parses the configured cluster list passed via workflow environment variables. + * + * @param {string} value JSON-encoded cluster list. + * @returns {string[]} Ordered cluster names. + */ +function parseConfiguredClusters(value) { + const parsedValue = JSON.parse(value || "[]"); + return Array.isArray(parsedValue) ? parsedValue : []; +} + +/** + * Converts a cluster name into a safe environment-variable suffix. + * + * @param {string} clusterName Raw cluster name. + * @returns {string} Uppercased normalized environment key fragment. + */ +function normalizeClusterEnvKey(clusterName) { + return String(clusterName || "") + .trim() + .replace(/[^a-zA-Z0-9]+/g, "_") + .replace(/^_+|_+$/g, "") + .toUpperCase(); +} + +/** + * Reads per-cluster fallback values exported by reusable workflow jobs. + * + * @param {string[]} configuredClusters Clusters that should appear in the message. + * @param {NodeJS.ProcessEnv} [env=process.env] Environment variables source. + * @returns {Record} Fallbacks indexed by cluster name. + */ +function readReportFallbacksFromEnv(configuredClusters, env = process.env) { + const fallbackByCluster = {}; + + for (const clusterName of configuredClusters) { + const clusterKey = normalizeClusterEnvKey(clusterName); + const reportKind = env[`REPORT_FALLBACK_${clusterKey}_REPORT_KIND`] || ""; + const status = env[`REPORT_FALLBACK_${clusterKey}_STATUS`] || ""; + const failedStage = env[`REPORT_FALLBACK_${clusterKey}_FAILED_STAGE`] || ""; + const failedStageLabel = + env[`REPORT_FALLBACK_${clusterKey}_FAILED_STAGE_LABEL`] || ""; + const workflowRunUrl = + env[`REPORT_FALLBACK_${clusterKey}_WORKFLOW_RUN_URL`] || ""; + const branch = env[`REPORT_FALLBACK_${clusterKey}_BRANCH`] || ""; + + if ( + reportKind || + status || + failedStage || + failedStageLabel || + workflowRunUrl || + branch + ) { + fallbackByCluster[clusterName] = { + reportKind, + status, + failedStage, + failedStageLabel, + workflowRunUrl, + branch, + }; + } + } + + return fallbackByCluster; +} + +/** + * Reads messenger configuration from the environment prepared by the workflow. + * + * @param {NodeJS.ProcessEnv} [env=process.env] Environment variables source. + * @returns {{ + * reportsDir: string, + * configuredClusters: string[], + * reportFallbacks: Record, + * loop: { + * apiUrl: string, + * channelId: string, + * token: string + * } + * }} Normalized messenger configuration. + */ +function readMessengerConfigFromEnv(env = process.env) { + const configuredClusters = parseConfiguredClusters(env.STORAGE_TYPES); + + return { + reportsDir: env.REPORTS_DIR || "downloaded-artifacts", + configuredClusters, + reportFallbacks: readReportFallbacksFromEnv(configuredClusters, env), + loop: { + apiUrl: getLoopPostsApiUrl(env), + channelId: String(env.LOOP_CHANNEL_ID || "").trim(), + token: String(env.LOOP_TOKEN || "").trim(), + }, + }; +} + +/** + * Parses a Loop API response body if it is JSON, otherwise returns an empty + * object and emits a warning for diagnostics. + * + * @param {string} responseText Raw response body. + * @param {{ warning(message: string): void }} core GitHub core API. + * @returns {Record} Parsed response payload or an empty object. + */ +function parseLoopApiPayload(responseText, core) { + if (!responseText) { + return {}; + } + + try { + return JSON.parse(responseText); + } catch (error) { + core.warning( + `Loop API returned a non-JSON response body: ${error.message}` + ); + return {}; + } +} + +/** + * Sends a single post to Loop and returns the parsed API payload. + * + * @param {{ + * apiUrl: string, + * channelId: string, + * token: string, + * message: string, + * rootId?: string + * }} request Loop API request payload. + * @param {{ + * info(message: string): void, + * warning(message: string): void + * }} core GitHub core API. + * @returns {Promise>} Parsed Loop API response. + */ +async function postToLoopApi( + { apiUrl, channelId, token, message, rootId }, + core +) { + const response = await fetch(apiUrl, { + method: "POST", + headers: { + Authorization: `Bearer ${token}`, + "Content-Type": "application/json", + }, + body: JSON.stringify({ + channel_id: channelId, + message, + ...(rootId ? { root_id: rootId } : {}), + }), + }); + const responseText = await response.text(); + + if (!response.ok) { + throw new Error( + `Loop API request failed with status ${response.status}: ${responseText}` + ); + } + + const payload = parseLoopApiPayload(responseText, core); + core.info(`Loop API accepted report with status ${response.status}`); + return payload; +} + +/** + * Loads report JSON files from disk and injects synthetic reports for clusters + * whose artifacts are missing. + * + * @param {string} reportsDir Directory containing `e2e_report_*.json`. + * @param {string[]} configuredClusters Clusters expected in the final report. + * @param {Record} reportFallbacks Fallback data by cluster. + * @param {{ warning(message: string): void }} core GitHub core API. + * @returns {Record[]} Ordered cluster reports. + */ +function readReports(reportsDir, configuredClusters, reportFallbacks, core) { + const reportFiles = listMatchingFiles(reportsDir, /^e2e_report_.*\.json$/); + const reports = []; + + for (const reportFile of reportFiles) { + try { + reports.push(JSON.parse(fs.readFileSync(reportFile, "utf8"))); + } catch (error) { + core.warning(`Unable to parse ${reportFile}: ${error.message}`); + } + } + + const reportsByCluster = new Map(); + for (const report of reports) { + const clusterName = getReportClusterKey(report); + if (!clusterName) { + core.warning( + `Skipping report without cluster name from ${report.sourceReport || "parsed JSON payload"}` + ); + continue; + } + + reportsByCluster.set(clusterName, report); + } + + for (const clusterName of configuredClusters) { + if (!reportsByCluster.has(clusterName)) { + reportsByCluster.set( + clusterName, + createMissingReport(clusterName, reportFallbacks[clusterName]) + ); + } + } + + const orderedReports = sortReports( + Array.from(reportsByCluster.values()), + configuredClusters + ); + return orderedReports; +} + +/** + * Renders the top-level messenger markdown message. + * + * @param {Record[]} orderedReports Reports ordered for display. + * @returns {string} Main markdown message. + */ +function buildMainMessage(orderedReports) { + const reportDate = getReportDate(orderedReports); + const branches = Array.from( + new Set(orderedReports.map((report) => report.branch).filter(Boolean)) + ); + const lines = [`## DVP | E2E on nested clusters | ${reportDate}`, ""]; + + if (branches.length === 1 && branches[0] !== "main") { + lines.push(`Branch: \`${branches[0]}\``); + lines.push(""); + } + + const testsReports = orderedReports.filter( + (report) => report.reportKind === "tests" && getReportClusterKey(report) + ); + const nonTestReports = orderedReports.filter( + (report) => report.reportKind !== "tests" && getReportClusterKey(report) + ); + const stageFailureReports = nonTestReports.filter( + (report) => !isMissingReport(report) + ); + const missingReports = nonTestReports.filter((report) => isMissingReport(report)); + + if (testsReports.length > 0) { + lines.push("### Test results"); + lines.push(""); + lines.push( + "| Cluster | ✅ Passed | ⏭️ Skipped | ❌ Failed | ⚠️ Errors | Total | Success Rate |" + ); + lines.push("|---|---:|---:|---:|---:|---:|---:|"); + + for (const report of testsReports) { + const metrics = report.metrics || {}; + lines.push( + `| ${formatClusterLink(report)} | ${metrics.passed || 0} | ${ + metrics.skipped || 0 + } | ${metrics.failed || 0} | ${metrics.errors || 0} | ${ + metrics.total || 0 + } | ${formatRate(metrics.successRate)} |` + ); + } + + lines.push(""); + } + + if (stageFailureReports.length > 0) { + lines.push("### Cluster failures"); + lines.push(""); + + for (const report of stageFailureReports) { + lines.push( + `- ${formatClusterLink(report)}: ${sanitizeListItem( + report.failedStageLabel || report.statusMessage || report.failedStage + )}` + ); + } + + lines.push(""); + } + + if (missingReports.length > 0) { + lines.push("### Missing reports"); + lines.push(""); + + for (const report of missingReports) { + lines.push( + `- ${formatClusterLink(report)}: ${sanitizeListItem( + report.failedStageLabel || report.statusMessage || report.failedStage + )}` + ); + } + + lines.push(""); + } + + return lines.join("\n").trim(); +} + +/** + * Tells whether the report should contribute failed-test details to the thread. + * + * @param {Record} report Cluster report payload. + * @returns {boolean} True when failed-test details should be rendered. + */ +function hasFailedTests(report) { + if (Array.isArray(report.failedTests) && report.failedTests.length > 0) { + return true; + } + + return Boolean( + (report.metrics && report.metrics.failed) || + (report.metrics && report.metrics.errors) + ); +} + +/** + * Extracts the top-level test group name from a failed test title. + * + * For Ginkgo titles like `[It] VirtualMachineOperationRestore restores ...`, + * this returns `VirtualMachineOperationRestore`. + * + * @param {string} testName Full failed test name. + * @returns {string} Top-level test group label. + */ +function getFailedTestGroupName(testName) { + const normalizedName = sanitizeListItem(testName).replace(/^\[[^\]]+\]\s*/, ""); + const [groupName] = normalizedName.split(/\s+/, 1); + return groupName || "Unknown"; +} + +/** + * Aggregates failed test names into an ordered unique group list. + * + * @param {string[]} failedTests Failed testcase names. + * @returns {string[]} Ordered unique group names. + */ +function summarizeFailedTestGroups(failedTests) { + const groupNames = []; + + for (const testName of failedTests) { + const groupName = getFailedTestGroupName(testName); + if (!groupNames.includes(groupName)) { + groupNames.push(groupName); + } + } + + return groupNames; +} + +/** + * Builds the thread reply body for a single cluster with failed tests. + * + * @param {Record} report Cluster report payload. + * @returns {string} Cluster-specific failed tests markdown. + */ +function buildFailedTestsClusterMessage(report) { + const clusterName = sanitizeListItem(report.cluster || report.storageType); + const lines = [`**${clusterName}**`]; + + if (Array.isArray(report.failedTests) && report.failedTests.length > 0) { + const failedGroups = summarizeFailedTestGroups(report.failedTests); + lines.push(""); + lines.push("| Test group |"); + lines.push("|---|"); + for (const groupName of failedGroups) { + lines.push(`| ${sanitizeCell(groupName)} |`); + } + } else { + lines.push( + "- No testcase-level failures were collected, but the E2E stage reported failures." + ); + } + + return lines.join("\n"); +} + +/** + * Renders thread markdown messages containing failed test names, if any. + * + * @param {Record[]} orderedReports Reports ordered for display. + * @returns {string[]} Thread markdown messages in publish order. + */ +function buildThreadMessages(orderedReports) { + const testsReports = orderedReports.filter( + (report) => report.reportKind === "tests" + ); + const failedTestReports = testsReports.filter(hasFailedTests); + + if (failedTestReports.length === 0) { + return []; + } + + return [ + "### Failed tests", + ...failedTestReports.map(buildFailedTestsClusterMessage), + ]; +} + +/** + * Reads cluster reports from disk and builds both messenger message bodies. + * + * @param {{ + * reportsDir: string, + * configuredClusters: string[], + * reportFallbacks: Record, + * core: { warning(message: string): void } + * }} params Message rendering inputs. + * @returns {{ + * message: string, + * threadMessage: string, + * threadMessages: string[] + * }} Rendered markdown payloads. + */ +function buildMessengerMessages({ + reportsDir, + configuredClusters, + reportFallbacks, + core, +}) { + const orderedReports = readReports( + reportsDir, + configuredClusters, + reportFallbacks, + core + ); + const threadMessages = buildThreadMessages(orderedReports); + return { + message: buildMainMessage(orderedReports), + threadMessage: threadMessages.join("\n\n"), + threadMessages, + }; +} + +/** + * Publishes the main report and optional failed-tests thread to Loop. + * + * @param {{ + * message: string, + * threadMessages: string[], + * loop: { + * apiUrl: string, + * channelId: string, + * token: string + * } + * }} params Message payload and Loop credentials. + * @param {{ + * setOutput(name: string, value: string): void, + * info(message: string): void, + * warning(message: string): void + * }} core GitHub core API. + * @returns {Promise} + */ +async function publishToLoop({ message, threadMessages, loop }, core) { + if (!loop.apiUrl && !loop.channelId && !loop.token) { + return; + } + + if (!loop.apiUrl || !loop.channelId || !loop.token) { + throw new Error( + "LOOP_CHANNEL_ID, LOOP_TOKEN, and LOOP_API_BASE_URL are required" + ); + } + + const rootPost = await postToLoopApi( + { + apiUrl: loop.apiUrl, + channelId: loop.channelId, + token: loop.token, + message, + }, + core + ); + + let lastReplyPost = null; + for (const replyMessage of threadMessages) { + lastReplyPost = await postToLoopApi( + { + apiUrl: loop.apiUrl, + channelId: loop.channelId, + token: loop.token, + message: replyMessage, + rootId: rootPost.id, + }, + core + ); + } + + core.setOutput("root_post_id", rootPost.id || ""); + core.setOutput( + "thread_post_id", + lastReplyPost && lastReplyPost.id ? lastReplyPost.id : "" + ); +} + +/** + * Entry point used by `actions/github-script` to render and optionally publish + * the aggregated E2E messenger report. + * + * @param {{ + * core: { + * info(message: string): void, + * warning(message: string): void, + * setOutput(name: string, value: string): void + * } + * }} params GitHub script dependencies. + * @returns {Promise<{ + * message: string, + * threadMessage: string, + * threadMessages: string[] + * }>} Rendered messages. + */ +async function renderMessengerReport({ core }) { + const config = readMessengerConfigFromEnv(); + const { message, threadMessage, threadMessages } = buildMessengerMessages({ + reportsDir: config.reportsDir, + configuredClusters: config.configuredClusters, + reportFallbacks: config.reportFallbacks, + core, + }); + + core.info(message); + core.setOutput("message", message); + core.setOutput("thread_message", threadMessage); + core.setOutput("thread_messages", JSON.stringify(threadMessages)); + + try { + await publishToLoop( + { message, threadMessages, loop: config.loop }, + core + ); + } catch (error) { + core.warning(`Unable to deliver report to Loop API: ${error.message}`); + } + + return { message, threadMessage, threadMessages }; +} + +module.exports = renderMessengerReport; +module.exports.createMissingReport = createMissingReport; +module.exports.buildMessengerMessages = buildMessengerMessages; +module.exports.getLoopPostsApiUrl = getLoopPostsApiUrl; +module.exports.readReportFallbacksFromEnv = readReportFallbacksFromEnv; +module.exports.readMessengerConfigFromEnv = readMessengerConfigFromEnv; diff --git a/.github/scripts/js/e2e/report/messenger-report.test.js b/.github/scripts/js/e2e/report/messenger-report.test.js new file mode 100644 index 0000000000..7a76568aba --- /dev/null +++ b/.github/scripts/js/e2e/report/messenger-report.test.js @@ -0,0 +1,602 @@ +const fs = require("fs"); +const os = require("os"); +const path = require("path"); + +const renderMessengerReport = require("./messenger-report"); +const { readMessengerConfigFromEnv } = require("./messenger-report"); + +/** + * Creates a mocked GitHub Actions core object for unit tests. + * + * @returns {{ + * info: jest.Mock, + * warning: jest.Mock, + * debug: jest.Mock, + * setOutput: jest.Mock + * }} Mocked core object. + */ +function createCore() { + return { + info: jest.fn(), + warning: jest.fn(), + debug: jest.fn(), + setOutput: jest.fn(), + }; +} + +/** + * Runs a test body inside a temporary directory and removes it afterwards. + * + * @template T + * @param {(tempDir: string) => Promise|T} testFn Test body. + * @returns {Promise} Test result. + */ +async function withTempDir(testFn) { + const tempDir = fs.mkdtempSync( + path.join(os.tmpdir(), "messenger-report-test-") + ); + try { + return await testFn(tempDir); + } finally { + fs.rmSync(tempDir, { recursive: true, force: true }); + } +} + +describe("messenger-report", () => { + afterEach(() => { + delete process.env.REPORTS_DIR; + delete process.env.STORAGE_TYPES; + delete process.env.REPORT_FALLBACK_REPLICATED_REPORT_KIND; + delete process.env.REPORT_FALLBACK_REPLICATED_STATUS; + delete process.env.REPORT_FALLBACK_REPLICATED_FAILED_STAGE; + delete process.env.REPORT_FALLBACK_REPLICATED_FAILED_STAGE_LABEL; + delete process.env.REPORT_FALLBACK_REPLICATED_WORKFLOW_RUN_URL; + delete process.env.REPORT_FALLBACK_REPLICATED_BRANCH; + delete process.env.REPORT_FALLBACK_NFS_REPORT_KIND; + delete process.env.REPORT_FALLBACK_NFS_STATUS; + delete process.env.REPORT_FALLBACK_NFS_FAILED_STAGE; + delete process.env.REPORT_FALLBACK_NFS_FAILED_STAGE_LABEL; + delete process.env.REPORT_FALLBACK_NFS_WORKFLOW_RUN_URL; + delete process.env.REPORT_FALLBACK_NFS_BRANCH; + delete process.env.LOOP_API_BASE_URL; + delete process.env.LOOP_CHANNEL_ID; + delete process.env.LOOP_TOKEN; + delete global.fetch; + }); + + test("reads normalized messenger config from env", () => { + const config = readMessengerConfigFromEnv({ + REPORTS_DIR: "custom-reports", + STORAGE_TYPES: '["replicated","nfs"]', + LOOP_API_BASE_URL: "https://loop.example.invalid/api/v4/", + LOOP_CHANNEL_ID: " channel-id ", + LOOP_TOKEN: " token ", + }); + + expect(config).toEqual({ + reportsDir: "custom-reports", + configuredClusters: ["replicated", "nfs"], + reportFallbacks: {}, + loop: { + apiUrl: "https://loop.example.invalid/api/v4/posts", + channelId: "channel-id", + token: "token", + }, + }); + }); + + test("renders test results, stage failures, and per-cluster thread replies", async () => + withTempDir(async (tempDir) => { + fs.writeFileSync( + path.join(tempDir, "e2e_report_replicated.json"), + JSON.stringify({ + cluster: "replicated", + storageType: "replicated", + reportKind: "tests", + branch: "main", + workflowRunUrl: "https://example.invalid/replicated", + startedAt: "2026-04-15T09:30:44", + metrics: { + passed: 12, + skipped: 2, + failed: 1, + errors: 0, + total: 15, + successRate: 80, + }, + failedTests: ["[It] fails"], + }) + ); + + fs.writeFileSync( + path.join(tempDir, "e2e_report_nfs.json"), + JSON.stringify({ + cluster: "nfs", + storageType: "nfs", + reportKind: "stage-failure", + branch: "main", + workflowRunUrl: "https://example.invalid/nfs", + failedStage: "configure-sdn", + failedStageLabel: "CONFIGURE SDN", + metrics: { + passed: 0, + failed: 0, + errors: 0, + total: 0, + successRate: 0, + }, + failedTests: [], + }) + ); + + process.env.REPORTS_DIR = tempDir; + process.env.STORAGE_TYPES = '["replicated","nfs"]'; + + const result = await renderMessengerReport({ core: createCore() }); + + expect(result.message).toContain("### Test results"); + expect(result.message).toContain( + "| [replicated](https://example.invalid/replicated) | 12 | 2 | 1 | 0 | 15 | 80.00% |" + ); + expect(result.message).toContain("### Cluster failures"); + expect(result.message).toContain( + "- [nfs](https://example.invalid/nfs): CONFIGURE SDN" + ); + expect(result.message).not.toContain("### Failed tests"); + expect(result.threadMessages).toEqual([ + "### Failed tests", + "**replicated**\n\n| Test group |\n|---|\n| fails |", + ]); + expect(result.threadMessage).toContain("### Failed tests"); + expect(result.threadMessage).toContain("**replicated**"); + expect(result.threadMessage).toContain("| Test group |"); + expect(result.threadMessage).toContain("| fails |"); + expect(result.threadMessage).not.toContain("**nfs**\n|"); + })); + + test("creates artifact-missing entry for absent cluster report", async () => + withTempDir(async (tempDir) => { + process.env.REPORTS_DIR = tempDir; + process.env.STORAGE_TYPES = '["replicated"]'; + + const result = await renderMessengerReport({ core: createCore() }); + + expect(result.message).toContain("### Missing reports"); + expect(result.message).toContain( + "- replicated: E2E REPORT ARTIFACT NOT FOUND" + ); + expect(result.threadMessage).toBe(""); + expect(result.threadMessages).toEqual([]); + })); + + test("skips invalid reports without cluster identity", async () => + withTempDir(async (tempDir) => { + fs.writeFileSync( + path.join(tempDir, "e2e_report_invalid.json"), + JSON.stringify({ + reportKind: "stage-failure", + failedStage: "configure-sdn", + failedStageLabel: "CONFIGURE SDN", + status: "failure", + }) + ); + + fs.writeFileSync( + path.join(tempDir, "e2e_report_nfs.json"), + JSON.stringify({ + cluster: "nfs", + storageType: "nfs", + reportKind: "tests", + branch: "main", + workflowRunUrl: "https://example.invalid/nfs", + startedAt: "2026-04-15T09:30:44", + metrics: { + passed: 8, + skipped: 1, + failed: 1, + errors: 0, + total: 10, + successRate: 80, + }, + failedTests: ["[It] nfs fails"], + }) + ); + + process.env.REPORTS_DIR = tempDir; + process.env.STORAGE_TYPES = '["nfs"]'; + + const core = createCore(); + const result = await renderMessengerReport({ core }); + + expect(result.message).toContain("### Test results"); + expect(result.message).not.toContain("### Cluster failures"); + expect(result.message).not.toContain("- —:"); + expect(core.warning).toHaveBeenCalledWith( + "Skipping report without cluster name from parsed JSON payload" + ); + })); + + test("splits failed tests into separate thread messages per cluster", async () => + withTempDir(async (tempDir) => { + fs.writeFileSync( + path.join(tempDir, "e2e_report_replicated.json"), + JSON.stringify({ + cluster: "replicated", + storageType: "replicated", + reportKind: "tests", + branch: "main", + workflowRunUrl: "https://example.invalid/replicated", + startedAt: "2026-04-15T09:30:44", + metrics: { + passed: 12, + skipped: 0, + failed: 1, + errors: 0, + total: 13, + successRate: 92.31, + }, + failedTests: ["[It] replicated fails"], + }) + ); + + fs.writeFileSync( + path.join(tempDir, "e2e_report_nfs.json"), + JSON.stringify({ + cluster: "nfs", + storageType: "nfs", + reportKind: "tests", + branch: "main", + workflowRunUrl: "https://example.invalid/nfs", + startedAt: "2026-04-15T09:30:44", + metrics: { + passed: 8, + skipped: 1, + failed: 1, + errors: 0, + total: 10, + successRate: 80, + }, + failedTests: ["[It] nfs fails"], + }) + ); + + process.env.REPORTS_DIR = tempDir; + process.env.STORAGE_TYPES = '["replicated","nfs"]'; + + const result = await renderMessengerReport({ core: createCore() }); + + expect(result.threadMessages).toEqual([ + "### Failed tests", + "**replicated**\n\n| Test group |\n|---|\n| replicated |", + "**nfs**\n\n| Test group |\n|---|\n| nfs |", + ]); + })); + + test("groups failed tests by top-level describe name", async () => + withTempDir(async (tempDir) => { + fs.writeFileSync( + path.join(tempDir, "e2e_report_nfs.json"), + JSON.stringify({ + cluster: "nfs", + storageType: "nfs", + reportKind: "tests", + branch: "main", + workflowRunUrl: "https://example.invalid/nfs", + startedAt: "2026-04-15T09:30:44", + metrics: { + passed: 90, + skipped: 34, + failed: 7, + errors: 0, + total: 131, + successRate: 68.7, + }, + failedTests: [ + "[It] VirtualMachineOperationRestore restores a virtual machine from a snapshot BestEffort restore mode; manual restart approval mode; always on unless stopped manually run policy [Slow]", + "[It] VirtualMachineOperationRestore restores a virtual machine from a snapshot Strict restore mode; manual restart approval mode; always on unless stopped manually run policy [Slow]", + "[It] VirtualMachineOperationRestore restores a virtual machine from a snapshot BestEffort restore mode; manual restart approval mode; always on unless stopped manually run policy; with resource deletion [Slow]", + "[It] VirtualMachineOperationRestore restores a virtual machine from a snapshot Strict restore mode; manual restart approval mode; always on unless stopped manually run policy; with resource deletion [Slow]", + "[It] VirtualMachineOperationRestore restores a virtual machine from a snapshot BestEffort restore mode; automatic restart approval mode; always on unless stopped manually run policy [Slow]", + "[It] VirtualMachineOperationRestore restores a virtual machine from a snapshot BestEffort restore mode; automatic restart approval mode; manual run policy [Slow]", + "[It] VirtualMachineAdditionalNetworkInterfaces verifies interface name persistence after removing middle ClusterNetwork should preserve interface name after removing middle ClusterNetwork and rebooting", + ], + }) + ); + + process.env.REPORTS_DIR = tempDir; + process.env.STORAGE_TYPES = '["nfs"]'; + + const result = await renderMessengerReport({ core: createCore() }); + + expect(result.threadMessages).toEqual([ + "### Failed tests", + [ + "**nfs**", + "", + "| Test group |", + "|---|", + "| VirtualMachineOperationRestore |", + "| VirtualMachineAdditionalNetworkInterfaces |", + ].join("\n"), + ]); + })); + + test("uses workflow fallback metadata for missing cluster report", async () => + withTempDir(async (tempDir) => { + process.env.REPORTS_DIR = tempDir; + process.env.STORAGE_TYPES = '["replicated"]'; + process.env.REPORT_FALLBACK_REPLICATED_REPORT_KIND = "stage-failure"; + process.env.REPORT_FALLBACK_REPLICATED_STATUS = "failure"; + process.env.REPORT_FALLBACK_REPLICATED_FAILED_STAGE = "configure-sdn"; + process.env.REPORT_FALLBACK_REPLICATED_FAILED_STAGE_LABEL = + "CONFIGURE SDN"; + process.env.REPORT_FALLBACK_REPLICATED_WORKFLOW_RUN_URL = + "https://example.invalid/replicated"; + process.env.REPORT_FALLBACK_REPLICATED_BRANCH = "main"; + + const result = await renderMessengerReport({ core: createCore() }); + + expect(result.message).not.toContain("Branch: `main`"); + expect(result.message).toContain("### Cluster failures"); + expect(result.message).toContain( + "- [replicated](https://example.invalid/replicated): CONFIGURE SDN" + ); + expect(result.threadMessage).toBe(""); + expect(result.threadMessages).toEqual([]); + })); + + test("shows branch line for non-main branches", async () => + withTempDir(async (tempDir) => { + process.env.REPORTS_DIR = tempDir; + process.env.STORAGE_TYPES = '["replicated"]'; + process.env.REPORT_FALLBACK_REPLICATED_REPORT_KIND = "stage-failure"; + process.env.REPORT_FALLBACK_REPLICATED_STATUS = "failure"; + process.env.REPORT_FALLBACK_REPLICATED_FAILED_STAGE = "configure-sdn"; + process.env.REPORT_FALLBACK_REPLICATED_FAILED_STAGE_LABEL = + "CONFIGURE SDN"; + process.env.REPORT_FALLBACK_REPLICATED_WORKFLOW_RUN_URL = + "https://example.invalid/replicated"; + process.env.REPORT_FALLBACK_REPLICATED_BRANCH = "release-1.2"; + + const result = await renderMessengerReport({ core: createCore() }); + + expect(result.message).toContain("Branch: `release-1.2`"); + })); + + test("preserves test-reports-missing fallback from workflow metadata", async () => + withTempDir(async (tempDir) => { + process.env.REPORTS_DIR = tempDir; + process.env.STORAGE_TYPES = '["replicated"]'; + process.env.REPORT_FALLBACK_REPLICATED_REPORT_KIND = "artifact-missing"; + process.env.REPORT_FALLBACK_REPLICATED_STATUS = "missing"; + process.env.REPORT_FALLBACK_REPLICATED_FAILED_STAGE = "artifact-missing"; + process.env.REPORT_FALLBACK_REPLICATED_FAILED_STAGE_LABEL = + "TEST REPORTS NOT FOUND"; + process.env.REPORT_FALLBACK_REPLICATED_WORKFLOW_RUN_URL = + "https://example.invalid/replicated"; + + const result = await renderMessengerReport({ core: createCore() }); + + expect(result.message).toContain("### Missing reports"); + expect(result.message).toContain( + "- [replicated](https://example.invalid/replicated): TEST REPORTS NOT FOUND" + ); + expect(result.threadMessage).toBe(""); + expect(result.threadMessages).toEqual([]); + })); + + test("posts main report and per-cluster failed tests thread via Loop API", async () => + withTempDir(async (tempDir) => { + fs.writeFileSync( + path.join(tempDir, "e2e_report_replicated.json"), + JSON.stringify({ + cluster: "replicated", + storageType: "replicated", + reportKind: "tests", + branch: "main", + workflowRunUrl: "https://example.invalid/replicated", + startedAt: "2026-04-15T09:30:44", + metrics: { + passed: 10, + skipped: 1, + failed: 1, + errors: 0, + total: 12, + successRate: 83.33, + }, + failedTests: ["[It] fails"], + }) + ); + + process.env.REPORTS_DIR = tempDir; + process.env.STORAGE_TYPES = '["replicated"]'; + process.env.LOOP_API_BASE_URL = "https://loop.example.invalid"; + process.env.LOOP_CHANNEL_ID = "channel-id"; + process.env.LOOP_TOKEN = "loop-token"; + + global.fetch = jest + .fn() + .mockResolvedValueOnce({ + ok: true, + status: 201, + text: async () => JSON.stringify({ id: "root-post-id" }), + }) + .mockResolvedValueOnce({ + ok: true, + status: 201, + text: async () => JSON.stringify({ id: "thread-header-post-id" }), + }) + .mockResolvedValueOnce({ + ok: true, + status: 201, + text: async () => JSON.stringify({ id: "thread-cluster-post-id" }), + }); + + const result = await renderMessengerReport({ core: createCore() }); + + expect(global.fetch).toHaveBeenCalledTimes(3); + expect(global.fetch).toHaveBeenNthCalledWith( + 1, + "https://loop.example.invalid/api/v4/posts", + expect.objectContaining({ + method: "POST", + headers: expect.objectContaining({ + Authorization: "Bearer loop-token", + "Content-Type": "application/json", + }), + }) + ); + expect(JSON.parse(global.fetch.mock.calls[0][1].body)).toEqual({ + channel_id: "channel-id", + message: result.message, + }); + expect(JSON.parse(global.fetch.mock.calls[1][1].body)).toEqual({ + channel_id: "channel-id", + message: "### Failed tests", + root_id: "root-post-id", + }); + expect(JSON.parse(global.fetch.mock.calls[2][1].body)).toEqual({ + channel_id: "channel-id", + message: "**replicated**\n\n| Test group |\n|---|\n| fails |", + root_id: "root-post-id", + }); + })); + + test("tolerates an empty Loop API response body", async () => + withTempDir(async (tempDir) => { + fs.writeFileSync( + path.join(tempDir, "e2e_report_replicated.json"), + JSON.stringify({ + cluster: "replicated", + storageType: "replicated", + reportKind: "tests", + branch: "main", + workflowRunUrl: "https://example.invalid/replicated", + startedAt: "2026-04-15T09:30:44", + metrics: { + passed: 11, + skipped: 0, + failed: 0, + errors: 0, + total: 11, + successRate: 100, + }, + failedTests: [], + }) + ); + + process.env.REPORTS_DIR = tempDir; + process.env.STORAGE_TYPES = '["replicated"]'; + process.env.LOOP_API_BASE_URL = "https://loop.example.invalid"; + process.env.LOOP_CHANNEL_ID = "channel-id"; + process.env.LOOP_TOKEN = "loop-token"; + + const core = createCore(); + global.fetch = jest.fn().mockResolvedValue({ + ok: true, + status: 201, + text: async () => "", + }); + + await renderMessengerReport({ core }); + + expect(global.fetch).toHaveBeenCalledTimes(1); + expect(core.warning).not.toHaveBeenCalledWith( + expect.stringContaining("Unable to deliver report to Loop API") + ); + expect(core.setOutput).toHaveBeenCalledWith("thread_messages", "[]"); + expect(core.setOutput).toHaveBeenCalledWith("root_post_id", ""); + expect(core.setOutput).toHaveBeenCalledWith("thread_post_id", ""); + })); + + test("tolerates an invalid JSON Loop API response body", async () => + withTempDir(async (tempDir) => { + fs.writeFileSync( + path.join(tempDir, "e2e_report_replicated.json"), + JSON.stringify({ + cluster: "replicated", + storageType: "replicated", + reportKind: "tests", + branch: "main", + workflowRunUrl: "https://example.invalid/replicated", + startedAt: "2026-04-15T09:30:44", + metrics: { + passed: 11, + skipped: 0, + failed: 0, + errors: 0, + total: 11, + successRate: 100, + }, + failedTests: [], + }) + ); + + process.env.REPORTS_DIR = tempDir; + process.env.STORAGE_TYPES = '["replicated"]'; + process.env.LOOP_API_BASE_URL = "https://loop.example.invalid"; + process.env.LOOP_CHANNEL_ID = "channel-id"; + process.env.LOOP_TOKEN = "loop-token"; + + const core = createCore(); + global.fetch = jest.fn().mockResolvedValue({ + ok: true, + status: 201, + text: async () => "not-json", + }); + + await renderMessengerReport({ core }); + + expect(global.fetch).toHaveBeenCalledTimes(1); + expect(core.warning).toHaveBeenCalledWith( + expect.stringContaining("Loop API returned a non-JSON response body") + ); + expect(core.setOutput).toHaveBeenCalledWith("thread_messages", "[]"); + expect(core.setOutput).toHaveBeenCalledWith("root_post_id", ""); + expect(core.setOutput).toHaveBeenCalledWith("thread_post_id", ""); + })); + + test("logs readable Loop API errors for failed responses", async () => + withTempDir(async (tempDir) => { + fs.writeFileSync( + path.join(tempDir, "e2e_report_replicated.json"), + JSON.stringify({ + cluster: "replicated", + storageType: "replicated", + reportKind: "tests", + branch: "main", + workflowRunUrl: "https://example.invalid/replicated", + startedAt: "2026-04-15T09:30:44", + metrics: { + passed: 11, + skipped: 0, + failed: 0, + errors: 0, + total: 11, + successRate: 100, + }, + failedTests: [], + }) + ); + + process.env.REPORTS_DIR = tempDir; + process.env.STORAGE_TYPES = '["replicated"]'; + process.env.LOOP_API_BASE_URL = "https://loop.example.invalid"; + process.env.LOOP_CHANNEL_ID = "channel-id"; + process.env.LOOP_TOKEN = "loop-token"; + + const core = createCore(); + global.fetch = jest.fn().mockResolvedValue({ + ok: false, + status: 500, + text: async () => "server exploded", + }); + + await renderMessengerReport({ core }); + + expect(global.fetch).toHaveBeenCalledTimes(1); + expect(core.warning).toHaveBeenCalledWith( + "Unable to deliver report to Loop API: Loop API request failed with status 500: server exploded" + ); + })); +}); diff --git a/.github/scripts/js/eslint.config.cjs b/.github/scripts/js/eslint.config.cjs new file mode 100644 index 0000000000..4b2e3fb424 --- /dev/null +++ b/.github/scripts/js/eslint.config.cjs @@ -0,0 +1,34 @@ +module.exports = [ + { + ignores: ['node_modules/**'], + }, + { + files: ['**/*.js'], + languageOptions: { + ecmaVersion: 2022, + sourceType: 'commonjs', + globals: { + __dirname: 'readonly', + afterEach: 'readonly', + beforeEach: 'readonly', + Buffer: 'readonly', + console: 'readonly', + describe: 'readonly', + expect: 'readonly', + fetch: 'readonly', + global: 'readonly', + jest: 'readonly', + module: 'readonly', + process: 'readonly', + require: 'readonly', + setTimeout: 'readonly', + test: 'readonly', + }, + }, + rules: { + 'consistent-return': 'error', + 'no-shadow': 'error', + 'no-unused-vars': ['error', {argsIgnorePattern: '^_'}], + }, + }, +]; diff --git a/.github/scripts/js/package.json b/.github/scripts/js/package.json index 6a8471c4e1..7d57cc285c 100644 --- a/.github/scripts/js/package.json +++ b/.github/scripts/js/package.json @@ -4,7 +4,8 @@ "description": "", "main": "index.js", "scripts": { - "fmt": "prettier --write ./*.js", + "fmt": "prettier --write \"e2e/report/**/*.js\"", + "lint": "eslint \"e2e/report/**/*.js\"", "test": "jest" }, "keywords": [], @@ -15,6 +16,7 @@ "@actions/github": "^5.1.1", "@octokit/graphql": "^4.8.0", "@types/node": "^16.11.11", + "eslint": "^10.2.1", "jest": "28.1.2", "prettier": "^2.5.0" } diff --git a/.github/workflows/e2e-matrix.yml b/.github/workflows/e2e-matrix.yml index 0bf8ab0e0f..07bd6e2ad7 100644 --- a/.github/workflows/e2e-matrix.yml +++ b/.github/workflows/e2e-matrix.yml @@ -477,6 +477,17 @@ jobs: steps: - uses: actions/checkout@v4 + - name: Setup Node.js for report scripts + uses: actions/setup-node@v6 + with: + node-version: "20" + cache: npm + cache-dependency-path: .github/scripts/js/package-lock.json + + - name: Install report script dependencies + working-directory: .github/scripts/js + run: npm ci + - name: Download E2E report artifacts uses: actions/download-artifact@v5 continue-on-error: true @@ -487,254 +498,27 @@ jobs: merge-multiple: false - name: Send results to channel - run: | - # Map storage types to CSI names - get_csi_name() { - local storage_type=$1 - case "$storage_type" in - "replicated") - echo "replicated.csi.storage.deckhouse.io" - ;; - "nfs") - echo "nfs.csi.storage.deckhouse.io" - ;; - *) - echo "$storage_type" - ;; - esac - } - - # Function to load and parse report from artifact - # Outputs: file content to stdout, debug messages to stderr - # Works with pattern-based artifact download (e2e-report-*) - # Artifacts are organized as: downloaded-artifacts/e2e-report--/e2e_report_.json - load_report_from_artifact() { - local storage_type=$1 - local base_path="downloaded-artifacts/" - - echo "[INFO] Searching for report for storage type: $storage_type" >&2 - echo "[DEBUG] Base path: $base_path" >&2 - - if [ ! -d "$base_path" ]; then - echo "[WARN] Base path does not exist: $base_path" >&2 - return 1 - fi - - local report_file="" - - # First, search in artifact directories matching pattern: e2e-report--* - # Pattern downloads create subdirectories named after the artifact - # e.g., downloaded-artifacts/e2e-report-replicated-/e2e_report_replicated.json - echo "[DEBUG] Searching in artifact directories matching pattern: e2e-report-${storage_type}-*" >&2 - local artifact_dir=$(find "$base_path" -type d -name "e2e-report-${storage_type}-*" 2>/dev/null | head -1) - if [ -n "$artifact_dir" ]; then - echo "[DEBUG] Found artifact dir: $artifact_dir" >&2 - report_file=$(find "$artifact_dir" -name "e2e_report_*.json" -type f 2>/dev/null | head -1) - if [ -n "$report_file" ] && [ -f "$report_file" ]; then - echo "[INFO] Found report file in artifact dir: $report_file" >&2 - cat "$report_file" - return 0 - fi - fi - - # Fallback: search for file by name pattern anywhere in base_path - echo "[DEBUG] Searching for file: e2e_report_${storage_type}.json" >&2 - report_file=$(find "$base_path" -type f -name "e2e_report_${storage_type}.json" 2>/dev/null | head -1) - if [ -n "$report_file" ] && [ -f "$report_file" ]; then - echo "[INFO] Found report file by name: $report_file" >&2 - cat "$report_file" - return 0 - fi - - echo "[WARN] Could not load report artifact for $storage_type" >&2 - return 1 - } - - # Function to create failure summary JSON (fallback) - create_failure_summary() { - local storage_type=$1 - local stage=$2 - local run_id=$3 - local csi=$(get_csi_name "$storage_type") - local date=$(date +"%Y-%m-%d") - local time=$(date +"%H:%M:%S") - local branch="${GITHUB_HEAD_REF:-${GITHUB_REF#refs/heads/}}" - local link="${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}/actions/runs/${run_id:-${GITHUB_RUN_ID}}" - - # Map stage to status message - local status_msg - case "$stage" in - "bootstrap") - status_msg=":x: BOOTSTRAP CLUSTER FAILED" - ;; - "storage-setup") - status_msg=":x: STORAGE SETUP FAILED" - ;; - "virtualization-setup") - status_msg=":x: VIRTUALIZATION SETUP FAILED" - ;; - "e2e-test") - status_msg=":x: E2E TEST FAILED" - ;; - *) - status_msg=":question: UNKNOWN" - ;; - esac - - jq -n \ - --arg csi "$csi" \ - --arg date "$date" \ - --arg time "$time" \ - --arg branch "$branch" \ - --arg status "$status_msg" \ - --arg link "$link" \ - '{CSI: $csi, Date: $date, StartTime: $time, Branch: $branch, Status: $status, Passed: 0, Failed: 0, Pending: 0, Skipped: 0, Link: $link}' - } - - - # Parse summary JSON and add to table - parse_summary() { - local summary_json=$1 - local storage_type=$2 - - if [ -z "$summary_json" ] || [ "$summary_json" == "null" ] || [ "$summary_json" == "" ]; then - echo "Warning: Empty summary for $storage_type" - return - fi - - # Try to parse as JSON (handle both JSON string and already parsed JSON) - if ! echo "$summary_json" | jq empty 2>/dev/null; then - echo "Warning: Invalid JSON for $storage_type: $summary_json" - echo "[DEBUG] json: $summary_json" - return - fi - - # Parse JSON fields - csi_raw=$(echo "$summary_json" | jq -r '.CSI // empty' 2>/dev/null) - if [ -z "$csi_raw" ] || [ "$csi_raw" == "null" ] || [ "$csi_raw" == "" ]; then - csi=$(get_csi_name "$storage_type") - else - csi="$csi_raw" - fi - - date=$(echo "$summary_json" | jq -r '.Date // ""' 2>/dev/null) - time=$(echo "$summary_json" | jq -r '.StartTime // ""' 2>/dev/null) - branch=$(echo "$summary_json" | jq -r '.Branch // ""' 2>/dev/null) - status=$(echo "$summary_json" | jq -r '.Status // ":question: UNKNOWN"' 2>/dev/null) - passed=$(echo "$summary_json" | jq -r '.Passed // 0' 2>/dev/null) - failed=$(echo "$summary_json" | jq -r '.Failed // 0' 2>/dev/null) - pending=$(echo "$summary_json" | jq -r '.Pending // 0' 2>/dev/null) - skipped=$(echo "$summary_json" | jq -r '.Skipped // 0' 2>/dev/null) - link=$(echo "$summary_json" | jq -r '.Link // ""' 2>/dev/null) - - # Set defaults if empty - [ -z "$passed" ] && passed=0 - [ -z "$failed" ] && failed=0 - [ -z "$pending" ] && pending=0 - [ -z "$skipped" ] && skipped=0 - [ -z "$status" ] && status=":question: UNKNOWN" - - # Format link - use CSI name as fallback if link is empty - if [ -z "$link" ] || [ "$link" == "" ]; then - link_text="$csi" - else - link_text="[:link: $csi]($link)" - fi - - # Add row to table - markdown_table+="| $link_text | $status | $passed | $failed | $pending | $skipped | $date | $time | $branch |\n" - } - - # Initialize markdown table - echo "[INFO] Generate markdown table" - markdown_table="" - header="| CSI | Status | Passed | Failed | Pending | Skipped | Date | Time | Branch|\n" - separator="|---|---|---|---|---|---|---|---|---|\n" - markdown_table+="$header" - markdown_table+="$separator" - - # Get current date for header - DATE=$(date +"%Y-%m-%d") - COMBINED_SUMMARY="## :dvp: **DVP | E2E on a nested cluster | $DATE**\n\n" - - echo "[INFO] Get storage types" - readarray -t storage_types < <(echo "$STORAGE_TYPES" | jq -r '.[]') - echo "[INFO] Storage types: " "${storage_types[@]}" - - echo "[INFO] Generate summary for each storage type" - for storage in "${storage_types[@]}"; do - echo "[INFO] Processing $storage" - - # Try to load report from artifact - # Debug messages go to stderr (visible in logs), JSON content goes to stdout - echo "[INFO] Attempting to load report for $storage" - structured_report=$(load_report_from_artifact "$storage" || true) - - if [ -n "$structured_report" ]; then - # Check if it's valid JSON - if echo "$structured_report" | jq empty 2>/dev/null; then - echo "[INFO] Report is valid JSON for $storage" - else - echo "[WARN] Report is not valid JSON for $storage" - echo "[DEBUG] Raw report content (first 200 chars):" - echo "$structured_report" | head -c 200 - echo "" - structured_report="" - fi - fi - - if [ -n "$structured_report" ] && echo "$structured_report" | jq empty 2>/dev/null; then - # Extract report data from structured file - report_json=$(echo "$structured_report" | jq -c '.report // empty') - failed_stage=$(echo "$structured_report" | jq -r '.failed_stage // empty') - workflow_run_id=$(echo "$structured_report" | jq -r '.workflow_run_id // empty') - - echo "[INFO] Loaded report for $storage (failed_stage: ${failed_stage}, run_id: ${workflow_run_id})" - - # Validate and parse report - if [ -n "$report_json" ] && [ "$report_json" != "" ] && [ "$report_json" != "null" ]; then - if echo "$report_json" | jq empty 2>/dev/null; then - echo "[INFO] Found valid report for $storage" - parse_summary "$report_json" "$storage" - else - echo "[WARN] Invalid report JSON for $storage, using failed stage info" - # Fallback to failed stage - if [ -n "$failed_stage" ] && [ "$failed_stage" != "" ] && [ "$failed_stage" != "success" ]; then - failed_summary=$(create_failure_summary "$storage" "$failed_stage" "$workflow_run_id") - parse_summary "$failed_summary" "$storage" - else - csi=$(get_csi_name "$storage") - markdown_table+="| $csi | :warning: INVALID REPORT | 0 | 0 | 0 | 0 | — | — | — |\n" - fi - fi - else - # No report in structured file, use failed stage - if [ -n "$failed_stage" ] && [ "$failed_stage" != "" ] && [ "$failed_stage" != "success" ]; then - echo "[INFO] Stage '$failed_stage' failed for $storage" - failed_summary=$(create_failure_summary "$storage" "$failed_stage" "$workflow_run_id") - parse_summary "$failed_summary" "$storage" - else - csi=$(get_csi_name "$storage") - markdown_table+="| $csi | :warning: NO REPORT | 0 | 0 | 0 | 0 | — | — | — |\n" - fi - fi - else - # Artifact not found or invalid, show warning - echo "[WARN] Could not load report artifact for $storage" - csi=$(get_csi_name "$storage") - markdown_table+="| $csi | :warning: ARTIFACT NOT FOUND | 0 | 0 | 0 | 0 | — | — | — |\n" - fi - done - - echo "[INFO] Combined summary" - COMBINED_SUMMARY+="${markdown_table}\n" - - echo -e "$COMBINED_SUMMARY" - - # Send to channel if webhook is configured - echo "[INFO] Send to webhook" - if [ -n "$LOOP_WEBHOOK_URL" ]; then - curl --request POST --header 'Content-Type: application/json' --data "{\"text\": \"${COMBINED_SUMMARY}\"}" "$LOOP_WEBHOOK_URL" - fi + id: render-report + uses: actions/github-script@v7 env: - LOOP_WEBHOOK_URL: ${{ secrets.LOOP_WEBHOOK_URL }} + REPORTS_DIR: downloaded-artifacts/ + STORAGE_TYPES: ${{ env.STORAGE_TYPES }} + REPORT_FALLBACK_REPLICATED_REPORT_KIND: ${{ needs.e2e-replicated.outputs.report_kind }} + REPORT_FALLBACK_REPLICATED_STATUS: ${{ needs.e2e-replicated.outputs.status }} + REPORT_FALLBACK_REPLICATED_FAILED_STAGE: ${{ needs.e2e-replicated.outputs.failed_stage }} + REPORT_FALLBACK_REPLICATED_FAILED_STAGE_LABEL: ${{ needs.e2e-replicated.outputs.failed_stage_label }} + REPORT_FALLBACK_REPLICATED_WORKFLOW_RUN_URL: ${{ needs.e2e-replicated.outputs.workflow_run_url }} + REPORT_FALLBACK_REPLICATED_BRANCH: ${{ needs.e2e-replicated.outputs.branch || github.ref_name }} + REPORT_FALLBACK_NFS_REPORT_KIND: ${{ needs.e2e-nfs.outputs.report_kind }} + REPORT_FALLBACK_NFS_STATUS: ${{ needs.e2e-nfs.outputs.status }} + REPORT_FALLBACK_NFS_FAILED_STAGE: ${{ needs.e2e-nfs.outputs.failed_stage }} + REPORT_FALLBACK_NFS_FAILED_STAGE_LABEL: ${{ needs.e2e-nfs.outputs.failed_stage_label }} + REPORT_FALLBACK_NFS_WORKFLOW_RUN_URL: ${{ needs.e2e-nfs.outputs.workflow_run_url }} + REPORT_FALLBACK_NFS_BRANCH: ${{ needs.e2e-nfs.outputs.branch || github.ref_name }} + LOOP_API_BASE_URL: ${{ secrets.LOOP_API_BASE_URL }} + LOOP_CHANNEL_ID: ${{ secrets.LOOP_CHANNEL_ID }} + LOOP_TOKEN: ${{ secrets.LOOP_TOKEN }} + with: + script: | + const renderMessengerReport = require('./.github/scripts/js/e2e/report/messenger-report'); + await renderMessengerReport({core}); diff --git a/.github/workflows/e2e-reusable-pipeline.yml b/.github/workflows/e2e-reusable-pipeline.yml index d332a54246..8cb18a5bc5 100644 --- a/.github/workflows/e2e-reusable-pipeline.yml +++ b/.github/workflows/e2e-reusable-pipeline.yml @@ -126,6 +126,24 @@ on: artifact-name: description: "Name of the uploaded artifact with E2E report" value: ${{ jobs.prepare-report.outputs.artifact-name }} + report_kind: + description: "E2E report kind for the cluster" + value: ${{ jobs.prepare-report.outputs.report_kind }} + status: + description: "E2E report status for the cluster" + value: ${{ jobs.prepare-report.outputs.status }} + failed_stage: + description: "Failed or final stage name for the cluster" + value: ${{ jobs.prepare-report.outputs.failed_stage }} + failed_stage_label: + description: "Human-readable failed or final stage label for the cluster" + value: ${{ jobs.prepare-report.outputs.failed_stage_label }} + workflow_run_url: + description: "Workflow run URL for the cluster pipeline" + value: ${{ jobs.prepare-report.outputs.workflow_run_url }} + branch: + description: "Branch used for the cluster pipeline" + value: ${{ jobs.prepare-report.outputs.branch }} env: BRANCH: ${{ inputs.branch }} @@ -1306,13 +1324,20 @@ jobs: USB_SUPPORTED: ${{ steps.detect-k8s-version.outputs.usb-supported }} working-directory: ./test/e2e/ run: | - GINKGO_RESULT=$(mktemp -p $RUNNER_TEMP) DATE=$(date +"%Y-%m-%d") - START_TIME=$(date +"%H:%M:%S") +<<<<<<< HEAD summary_file_name_junit="e2e_summary_${CSI}_${DATE}.xml" - ginkgo_json_report="ginkgo_report_${CSI}_${DATE}.json" + ginkgo_json_report="e2e_summary_${CSI}_${DATE}-ginkgo-report.json" +<<<<<<< HEAD summary_file_name_json="e2e_summary_${CSI}_${DATE}.json" + summary_file_name_log="e2e_summary_${CSI}_${DATE}.log" + START_TIME=$(date +"%H:%M:%S") FOCUS="${{ inputs.e2e_focus_tests }}" +======= +>>>>>>> f070cac56 (fix) +======= + e2e_report_file="e2e_report_${CSI}_${DATE}.json" +>>>>>>> 35d806024 (use json report instead of junit xml) cp -a legacy/testdata /tmp/testdata @@ -1325,6 +1350,8 @@ jobs: ./scripts/precheck-prepare_ci.sh set +e +<<<<<<< HEAD + set -o pipefail GINKGO_ARGS=( -v --race @@ -1341,11 +1368,26 @@ jobs: GINKGO_ARGS+=(--focus="$FOCUS") fi - go tool ginkgo "${GINKGO_ARGS[@]}" | tee $GINKGO_RESULT + go tool ginkgo "${GINKGO_ARGS[@]}" 2>&1 | tee "$summary_file_name_log" + GINKGO_EXIT_CODE=${PIPESTATUS[0]} + set +o pipefail +======= + FOCUS="${{ inputs.e2e_focus_tests }}" + if [ -n "$FOCUS" ]; then + go tool ginkgo \ + --focus="$FOCUS" \ + -v --race --timeout=$TIMEOUT \ + --json-report=$e2e_report_file + else + go tool ginkgo \ + -v --race --timeout=$TIMEOUT \ + --json-report=$e2e_report_file + fi GINKGO_EXIT_CODE=$? +>>>>>>> f070cac56 (fix) set -e - RESULT=$(sed -e "s/\x1b\[[0-9;]*m//g" $GINKGO_RESULT | grep --color=never -E "FAIL!|SUCCESS!") + RESULT=$(sed -e "s/\x1b\[[0-9;]*m//g" "$summary_file_name_log" | grep -E "FAIL!|SUCCESS!" | tail -1) if [[ $RESULT == FAIL!* ]]; then RESULT_STATUS=":x: FAIL!" elif [[ $RESULT == SUCCESS!* ]]; then @@ -1354,10 +1396,15 @@ jobs: RESULT_STATUS=":question: UNKNOWN" fi - PASSED=$(echo "$RESULT" | grep -oP "\d+(?= Passed)") - FAILED=$(echo "$RESULT" | grep -oP "\d+(?= Failed)") - PENDING=$(echo "$RESULT" | grep -oP "\d+(?= Pending)") - SKIPPED=$(echo "$RESULT" | grep -oP "\d+(?= Skipped)") + PASSED=$(echo "$RESULT" | grep -oP '\d+(?= Passed)' || true) + FAILED=$(echo "$RESULT" | grep -oP '\d+(?= Failed)' || true) + PENDING=$(echo "$RESULT" | grep -oP '\d+(?= Pending)' || true) + SKIPPED=$(echo "$RESULT" | grep -oP '\d+(?= Skipped)' || true) + + PASSED=${PASSED:-0} + FAILED=${FAILED:-0} + PENDING=${PENDING:-0} + SKIPPED=${SKIPPED:-0} SUMMARY=$(jq -n \ --arg csi "$CSI" \ @@ -1386,21 +1433,29 @@ jobs: echo "$SUMMARY" echo "summary=$(echo "$SUMMARY" | jq -c .)" >> $GITHUB_OUTPUT - echo $SUMMARY > "${summary_file_name_json}" + echo "$SUMMARY" > "${summary_file_name_json}" echo "[INFO] Exit code: $GINKGO_EXIT_CODE" exit $GINKGO_EXIT_CODE - - name: Upload summary test results (junit/xml) + - name: Upload summary test results (json) uses: actions/upload-artifact@v4 id: e2e-report-artifact if: always() && steps.e2e-report.outcome != 'skipped' with: - name: e2e-test-results-${{ inputs.storage_type }}-${{ github.run_id }}-${{ steps.vars.outputs.e2e-start-time }} + name: e2e-test-results-${{ inputs.storage_type }}-${{ github.run_id }}-${{ inputs.date_start }} path: | +<<<<<<< HEAD +<<<<<<< HEAD test/e2e/e2e_summary_*.json - test/e2e/ginkgo_report_*.json + test/e2e/e2e_summary_*-ginkgo-report.json + test/e2e/e2e_summary_*.log +======= +>>>>>>> f070cac56 (fix) test/e2e/e2e_summary_*.xml test/e2e/*junit*.xml +======= + test/e2e/e2e_report_*.json +>>>>>>> 35d806024 (use json report instead of junit xml) if-no-files-found: ignore retention-days: 3 @@ -1418,15 +1473,33 @@ jobs: runs-on: ubuntu-latest needs: - bootstrap + - configure-sdn - configure-storage - configure-virtualization - e2e-test if: always() outputs: artifact-name: ${{ steps.set-artifact-name.outputs.artifact-name }} + report_kind: ${{ steps.determine-stage.outputs.report_kind }} + status: ${{ steps.determine-stage.outputs.status }} + failed_stage: ${{ steps.determine-stage.outputs.failed_stage }} + failed_stage_label: ${{ steps.determine-stage.outputs.failed_stage_label }} + workflow_run_url: ${{ steps.determine-stage.outputs.workflow_run_url }} + branch: ${{ steps.determine-stage.outputs.branch }} steps: - uses: actions/checkout@v4 + - name: Setup Node.js for report scripts + uses: actions/setup-node@v6 + with: + node-version: "20" + cache: npm + cache-dependency-path: .github/scripts/js/package-lock.json + + - name: Install report script dependencies + working-directory: .github/scripts/js + run: npm ci + - name: Download E2E test results if available uses: actions/download-artifact@v5 continue-on-error: true @@ -1436,49 +1509,33 @@ jobs: - name: Determine failed stage and prepare report id: determine-stage +<<<<<<< HEAD run: | - # Get branch name BRANCH_NAME="${{ github.head_ref || github.ref_name }}" if [ -z "$BRANCH_NAME" ] || [ "$BRANCH_NAME" == "refs/heads/" ]; then BRANCH_NAME="${{ github.ref_name }}" fi - # Function to create failure summary JSON with proper job URL - create_failure_summary() { - local stage=$1 - local status_msg=$2 - local job_name=$3 - local csi="${{ inputs.storage_type }}" - local date=$(date +"%Y-%m-%d") - local start_time=$(date +"%H:%M:%S") - local branch="$BRANCH_NAME" - # Create URL pointing to the failed job in the workflow run - # Format: https://github.com/{owner}/{repo}/actions/runs/{run_id} - # The job name will be visible in the workflow run view - local link="${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}" - - jq -n \ - --arg csi "$csi" \ - --arg date "$date" \ - --arg startTime "$start_time" \ - --arg branch "$branch" \ - --arg status "$status_msg" \ - --arg link "$link" \ - '{ - CSI: $csi, - Date: $date, - StartTime: $startTime, - Branch: $branch, - Status: $status, - Passed: 0, - Failed: 0, - Pending: 0, - Skipped: 0, - Link: $link - }' + get_stage_label() { + case "$1" in + "bootstrap") + echo "BOOTSTRAP CLUSTER" + ;; + "storage-setup") + echo "STORAGE SETUP" + ;; + "virtualization-setup") + echo "VIRTUALIZATION SETUP" + ;; + "e2e-test") + echo "E2E TEST" + ;; + *) + echo "UNKNOWN" + ;; + esac } - # Summary report is a flat JSON object; Ginkgo JSON report is an array. is_summary_report() { jq -e ' type == "object" and @@ -1490,11 +1547,10 @@ jobs: ' >/dev/null 2>&1 } - # Try to find and load E2E test report E2E_REPORT_FILE="" REPORT_JSON="" + REPORT_SOURCE="empty" - # Search for the generated summary file and ignore the raw Ginkgo JSON report. E2E_REPORT_FILE=$(find test/e2e -type f \ -name "e2e_summary_${{ inputs.storage_type }}_*.json" \ ! -name "*-ginkgo-report.json" \ @@ -1505,6 +1561,7 @@ jobs: REPORT_JSON=$(jq -c . "$E2E_REPORT_FILE") if echo "$REPORT_JSON" | is_summary_report; then echo "[INFO] Loaded summary report from file" + REPORT_SOURCE="summary-json" echo "$REPORT_JSON" | jq . else echo "[WARN] Ignoring non-summary E2E report file: $E2E_REPORT_FILE" @@ -1512,71 +1569,120 @@ jobs: fi fi - # Function to process a stage - process_stage() { - local result_value="$1" - local stage_name="$2" - local status_msg="$3" - local job_name="$4" - local is_e2e_test="${5:-false}" - - if [ "$result_value" != "success" ]; then - FAILED_STAGE="$stage_name" - FAILED_JOB_NAME="$job_name (${{ inputs.storage_type }})" - - if [ -z "$REPORT_JSON" ] || [ "$REPORT_JSON" == "" ]; then - REPORT_JSON=$(create_failure_summary "$stage_name" "$status_msg" "$FAILED_JOB_NAME") - elif [ "$is_e2e_test" == "true" ]; then - # Special handling for e2e-test: update status if needed - CURRENT_STATUS=$(echo "$REPORT_JSON" | jq -r '.Status // ""') - if [[ "$CURRENT_STATUS" != *"FAIL"* ]] && [[ "$CURRENT_STATUS" != *"SUCCESS"* ]]; then - REPORT_JSON=$(echo "$REPORT_JSON" | jq -c '.Status = ":x: E2E TEST FAILED"') - fi - fi - return 0 # Stage failed + determine_status() { + local stage_name="$1" + local result_value="$2" + local stage_label + + stage_label=$(get_stage_label "$stage_name") + FAILED_STAGE="$stage_name" + FAILED_JOB_NAME="${stage_label} (${{ inputs.storage_type }})" + + if [ "$result_value" = "cancelled" ]; then + REPORT_STATUS="cancelled" + REPORT_STATUS_MESSAGE="⚠️ ${stage_label} CANCELLED" + else + REPORT_STATUS="failure" + REPORT_STATUS_MESSAGE="❌ ${stage_label} FAILED" fi - return 1 # Stage succeeded } - # Determine which stage failed and prepare report - FAILED_STAGE="" - FAILED_JOB_NAME="" - - if process_stage "${{ needs.bootstrap.result }}" "bootstrap" ":x: BOOTSTRAP CLUSTER FAILED" "Bootstrap cluster"; then - : # Stage failed, handled in function - elif process_stage "${{ needs.configure-storage.result }}" "storage-setup" ":x: STORAGE SETUP FAILED" "Configure storage"; then - : # Stage failed, handled in function - elif process_stage "${{ needs.configure-virtualization.result }}" "virtualization-setup" ":x: VIRTUALIZATION SETUP FAILED" "Configure Virtualization"; then - : # Stage failed, handled in function - elif process_stage "${{ needs.e2e-test.result }}" "e2e-test" ":x: E2E TEST FAILED" "E2E test" "true"; then - : # Stage failed, handled in function + if [ "${{ needs.bootstrap.result }}" != "success" ]; then + determine_status "bootstrap" "${{ needs.bootstrap.result }}" + elif [ "${{ needs.configure-storage.result }}" != "success" ]; then + determine_status "storage-setup" "${{ needs.configure-storage.result }}" + elif [ "${{ needs.configure-virtualization.result }}" != "success" ]; then + determine_status "virtualization-setup" "${{ needs.configure-virtualization.result }}" + elif [ "${{ needs.e2e-test.result }}" != "success" ]; then + determine_status "e2e-test" "${{ needs.e2e-test.result }}" else - # All stages succeeded FAILED_STAGE="success" FAILED_JOB_NAME="E2E test (${{ inputs.storage_type }})" - if [ -z "$REPORT_JSON" ] || [ "$REPORT_JSON" == "" ]; then - REPORT_JSON=$(create_failure_summary "success" ":white_check_mark: SUCCESS!" "$FAILED_JOB_NAME") - fi + REPORT_STATUS="success" + REPORT_STATUS_MESSAGE="✅ SUCCESS" fi - # Create structured report file with metadata REPORT_FILE="e2e_report_${{ inputs.storage_type }}.json" - # Parse REPORT_JSON to ensure it's valid JSON before using it - REPORT_JSON_PARSED=$(echo "$REPORT_JSON" | jq -c .) + + PASSED=0 + FAILED=0 + PENDING=0 + SKIPPED=0 + ERRORS=0 + TOTAL=0 + SUCCESS_RATE=0 + STARTED_AT="null" + REPORT_BRANCH="$BRANCH_NAME" + REPORT_WORKFLOW_URL="${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}" + + if [ -n "$REPORT_JSON" ] && [ "$REPORT_JSON" != "" ]; then + PASSED=$(echo "$REPORT_JSON" | jq -r '.Passed // 0') + FAILED=$(echo "$REPORT_JSON" | jq -r '.Failed // 0') + PENDING=$(echo "$REPORT_JSON" | jq -r '.Pending // 0') + SKIPPED=$(echo "$REPORT_JSON" | jq -r '.Skipped // 0') + REPORT_BRANCH=$(echo "$REPORT_JSON" | jq -r --arg branch "$BRANCH_NAME" '.Branch // $branch') + REPORT_WORKFLOW_URL=$(echo "$REPORT_JSON" | jq -r --arg link "$REPORT_WORKFLOW_URL" '.Link // $link') + + TOTAL=$((PASSED + FAILED + PENDING + SKIPPED)) + SKIPPED=$((SKIPPED + PENDING)) + if [ "$TOTAL" -gt 0 ]; then + SUCCESS_RATE=$(awk "BEGIN { printf \"%.2f\", ($PASSED / $TOTAL) * 100 }") + fi + + REPORT_DATE=$(echo "$REPORT_JSON" | jq -r '.Date // empty') + REPORT_TIME=$(echo "$REPORT_JSON" | jq -r '.StartTime // empty') + if [ -n "$REPORT_DATE" ] && [ -n "$REPORT_TIME" ]; then + STARTED_AT=$(jq -Rn --arg started_at "${REPORT_DATE}T${REPORT_TIME}" '$started_at') + elif [ -n "$REPORT_DATE" ]; then + STARTED_AT=$(jq -Rn --arg started_at "$REPORT_DATE" '$started_at') + fi + fi + jq -n \ - --argjson report "$REPORT_JSON_PARSED" \ + --arg cluster "${{ inputs.storage_type }}" \ --arg storage_type "${{ inputs.storage_type }}" \ + --arg status "$REPORT_STATUS" \ + --arg status_message "$REPORT_STATUS_MESSAGE" \ --arg failed_stage "$FAILED_STAGE" \ --arg failed_job_name "$FAILED_JOB_NAME" \ --arg workflow_run_id "${{ github.run_id }}" \ - --arg workflow_run_url "${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}" \ + --arg workflow_run_url "$REPORT_WORKFLOW_URL" \ + --arg branch "$REPORT_BRANCH" \ + --argjson started_at "$STARTED_AT" \ + --argjson passed "$PASSED" \ + --argjson failed "$FAILED" \ + --argjson errors "$ERRORS" \ + --argjson skipped "$SKIPPED" \ + --argjson total "$TOTAL" \ + --argjson success_rate "$SUCCESS_RATE" \ + --arg source_summary_report "${E2E_REPORT_FILE:-}" \ + --arg source_ginkgo_report "$(find test/e2e -type f -name "e2e_summary_${{ inputs.storage_type }}_*-ginkgo-report.json" 2>/dev/null | sort | head -1)" \ + --arg source_ginkgo_log "$(find test/e2e -type f -name "e2e_summary_${{ inputs.storage_type }}_*.log" 2>/dev/null | sort | head -1)" \ + --arg report_source "$REPORT_SOURCE" \ '{ - storage_type: $storage_type, - failed_stage: $failed_stage, - failed_job_name: $failed_job_name, - workflow_run_id: $workflow_run_id, - workflow_run_url: $workflow_run_url, - report: $report + cluster: $cluster, + storageType: $storage_type, + status: $status, + statusMessage: $status_message, + failedStage: $failed_stage, + failedJobName: $failed_job_name, + workflowRunId: $workflow_run_id, + workflowRunUrl: $workflow_run_url, + branch: $branch, + startedAt: $started_at, + metrics: { + passed: $passed, + failed: $failed, + errors: $errors, + skipped: $skipped, + total: $total, + successRate: $success_rate + }, + failedTests: [], + sourceSummaryReport: $source_summary_report, + sourceGinkgoReport: $source_ginkgo_report, + sourceGinkgoLog: $source_ginkgo_log, + reportSource: $report_source }' > "$REPORT_FILE" echo "report_file=$REPORT_FILE" >> $GITHUB_OUTPUT @@ -1584,6 +1690,24 @@ jobs: echo "[INFO] Failed stage: $FAILED_STAGE" echo "[INFO] Failed job: $FAILED_JOB_NAME" cat "$REPORT_FILE" | jq . +======= + uses: actions/github-script@v7 + env: + STORAGE_TYPE: ${{ inputs.storage_type }} + E2E_REPORT_DIR: test/e2e + REPORT_FILE: e2e_report_${{ inputs.storage_type }}.json + BRANCH_NAME: ${{ github.head_ref || github.ref_name }} + BOOTSTRAP_RESULT: ${{ needs.bootstrap.result }} + CONFIGURE_SDN_RESULT: ${{ needs.configure-sdn.result }} + CONFIGURE_STORAGE_RESULT: ${{ needs.configure-storage.result }} + CONFIGURE_VIRTUALIZATION_RESULT: ${{ needs.configure-virtualization.result }} + E2E_TEST_RESULT: ${{ needs.e2e-test.result }} + WORKFLOW_RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }} + with: + script: | + const buildClusterReport = require('./.github/scripts/js/e2e/report/cluster-report'); + await buildClusterReport({core, context}); +>>>>>>> a62f59d67 (use treds for send messages) - name: Upload E2E report artifact id: upload-artifact @@ -1609,7 +1733,7 @@ jobs: - configure-storage - configure-virtualization - e2e-test - if: (cancelled() || success()) && (needs.configure-sdn.result == 'success') + if: cancelled() || success() steps: - uses: actions/checkout@v4 From 43a729e3968895563375d2f733c78130bd8f6b7f Mon Sep 17 00:00:00 2001 From: Nikita Korolev Date: Mon, 4 May 2026 10:06:59 +0300 Subject: [PATCH 02/22] fix after rebase Signed-off-by: Nikita Korolev --- .github/workflows/e2e-reusable-pipeline.yml | 277 +------------------- 1 file changed, 3 insertions(+), 274 deletions(-) diff --git a/.github/workflows/e2e-reusable-pipeline.yml b/.github/workflows/e2e-reusable-pipeline.yml index 8cb18a5bc5..bbe1a51233 100644 --- a/.github/workflows/e2e-reusable-pipeline.yml +++ b/.github/workflows/e2e-reusable-pipeline.yml @@ -1325,19 +1325,7 @@ jobs: working-directory: ./test/e2e/ run: | DATE=$(date +"%Y-%m-%d") -<<<<<<< HEAD - summary_file_name_junit="e2e_summary_${CSI}_${DATE}.xml" - ginkgo_json_report="e2e_summary_${CSI}_${DATE}-ginkgo-report.json" -<<<<<<< HEAD - summary_file_name_json="e2e_summary_${CSI}_${DATE}.json" - summary_file_name_log="e2e_summary_${CSI}_${DATE}.log" - START_TIME=$(date +"%H:%M:%S") - FOCUS="${{ inputs.e2e_focus_tests }}" -======= ->>>>>>> f070cac56 (fix) -======= e2e_report_file="e2e_report_${CSI}_${DATE}.json" ->>>>>>> 35d806024 (use json report instead of junit xml) cp -a legacy/testdata /tmp/testdata @@ -1350,14 +1338,12 @@ jobs: ./scripts/precheck-prepare_ci.sh set +e -<<<<<<< HEAD - set -o pipefail + FOCUS="${{ inputs.e2e_focus_tests }}" GINKGO_ARGS=( -v --race --timeout="$TIMEOUT" - --json-report="$ginkgo_json_report" - --junit-report="$summary_file_name_junit" + --json-report="$e2e_report_file" ) if [ -n "${LABELS:-}" ]; then @@ -1368,73 +1354,10 @@ jobs: GINKGO_ARGS+=(--focus="$FOCUS") fi - go tool ginkgo "${GINKGO_ARGS[@]}" 2>&1 | tee "$summary_file_name_log" - GINKGO_EXIT_CODE=${PIPESTATUS[0]} - set +o pipefail -======= - FOCUS="${{ inputs.e2e_focus_tests }}" - if [ -n "$FOCUS" ]; then - go tool ginkgo \ - --focus="$FOCUS" \ - -v --race --timeout=$TIMEOUT \ - --json-report=$e2e_report_file - else - go tool ginkgo \ - -v --race --timeout=$TIMEOUT \ - --json-report=$e2e_report_file - fi + go tool ginkgo "${GINKGO_ARGS[@]}" GINKGO_EXIT_CODE=$? ->>>>>>> f070cac56 (fix) set -e - RESULT=$(sed -e "s/\x1b\[[0-9;]*m//g" "$summary_file_name_log" | grep -E "FAIL!|SUCCESS!" | tail -1) - if [[ $RESULT == FAIL!* ]]; then - RESULT_STATUS=":x: FAIL!" - elif [[ $RESULT == SUCCESS!* ]]; then - RESULT_STATUS=":white_check_mark: SUCCESS!" - else - RESULT_STATUS=":question: UNKNOWN" - fi - - PASSED=$(echo "$RESULT" | grep -oP '\d+(?= Passed)' || true) - FAILED=$(echo "$RESULT" | grep -oP '\d+(?= Failed)' || true) - PENDING=$(echo "$RESULT" | grep -oP '\d+(?= Pending)' || true) - SKIPPED=$(echo "$RESULT" | grep -oP '\d+(?= Skipped)' || true) - - PASSED=${PASSED:-0} - FAILED=${FAILED:-0} - PENDING=${PENDING:-0} - SKIPPED=${SKIPPED:-0} - - SUMMARY=$(jq -n \ - --arg csi "$CSI" \ - --arg date "$DATE" \ - --arg startTime "$START_TIME" \ - --arg branch "${GITHUB_HEAD_REF:-${GITHUB_REF#refs/heads/}}" \ - --arg status "$RESULT_STATUS" \ - --argjson passed "$PASSED" \ - --argjson failed "$FAILED" \ - --argjson pending "$PENDING" \ - --argjson skipped "$SKIPPED" \ - --arg link "$GITHUB_SERVER_URL/$GITHUB_REPOSITORY/actions/runs/$GITHUB_RUN_ID" \ - '{ - CSI: $csi, - Date: $date, - StartTime: $startTime, - Branch: $branch, - Status: $status, - Passed: $passed, - Failed: $failed, - Pending: $pending, - Skipped: $skipped, - Link: $link - }' - ) - - echo "$SUMMARY" - echo "summary=$(echo "$SUMMARY" | jq -c .)" >> $GITHUB_OUTPUT - echo "$SUMMARY" > "${summary_file_name_json}" - echo "[INFO] Exit code: $GINKGO_EXIT_CODE" exit $GINKGO_EXIT_CODE - name: Upload summary test results (json) @@ -1444,18 +1367,7 @@ jobs: with: name: e2e-test-results-${{ inputs.storage_type }}-${{ github.run_id }}-${{ inputs.date_start }} path: | -<<<<<<< HEAD -<<<<<<< HEAD - test/e2e/e2e_summary_*.json - test/e2e/e2e_summary_*-ginkgo-report.json - test/e2e/e2e_summary_*.log -======= ->>>>>>> f070cac56 (fix) - test/e2e/e2e_summary_*.xml - test/e2e/*junit*.xml -======= test/e2e/e2e_report_*.json ->>>>>>> 35d806024 (use json report instead of junit xml) if-no-files-found: ignore retention-days: 3 @@ -1509,188 +1421,6 @@ jobs: - name: Determine failed stage and prepare report id: determine-stage -<<<<<<< HEAD - run: | - BRANCH_NAME="${{ github.head_ref || github.ref_name }}" - if [ -z "$BRANCH_NAME" ] || [ "$BRANCH_NAME" == "refs/heads/" ]; then - BRANCH_NAME="${{ github.ref_name }}" - fi - - get_stage_label() { - case "$1" in - "bootstrap") - echo "BOOTSTRAP CLUSTER" - ;; - "storage-setup") - echo "STORAGE SETUP" - ;; - "virtualization-setup") - echo "VIRTUALIZATION SETUP" - ;; - "e2e-test") - echo "E2E TEST" - ;; - *) - echo "UNKNOWN" - ;; - esac - } - - is_summary_report() { - jq -e ' - type == "object" and - has("Status") and - has("Passed") and - has("Failed") and - has("Pending") and - has("Skipped") - ' >/dev/null 2>&1 - } - - E2E_REPORT_FILE="" - REPORT_JSON="" - REPORT_SOURCE="empty" - - E2E_REPORT_FILE=$(find test/e2e -type f \ - -name "e2e_summary_${{ inputs.storage_type }}_*.json" \ - ! -name "*-ginkgo-report.json" \ - 2>/dev/null | sort | head -1) - - if [ -n "$E2E_REPORT_FILE" ] && [ -f "$E2E_REPORT_FILE" ]; then - echo "[INFO] Found E2E report file: $E2E_REPORT_FILE" - REPORT_JSON=$(jq -c . "$E2E_REPORT_FILE") - if echo "$REPORT_JSON" | is_summary_report; then - echo "[INFO] Loaded summary report from file" - REPORT_SOURCE="summary-json" - echo "$REPORT_JSON" | jq . - else - echo "[WARN] Ignoring non-summary E2E report file: $E2E_REPORT_FILE" - REPORT_JSON="" - fi - fi - - determine_status() { - local stage_name="$1" - local result_value="$2" - local stage_label - - stage_label=$(get_stage_label "$stage_name") - FAILED_STAGE="$stage_name" - FAILED_JOB_NAME="${stage_label} (${{ inputs.storage_type }})" - - if [ "$result_value" = "cancelled" ]; then - REPORT_STATUS="cancelled" - REPORT_STATUS_MESSAGE="⚠️ ${stage_label} CANCELLED" - else - REPORT_STATUS="failure" - REPORT_STATUS_MESSAGE="❌ ${stage_label} FAILED" - fi - } - - if [ "${{ needs.bootstrap.result }}" != "success" ]; then - determine_status "bootstrap" "${{ needs.bootstrap.result }}" - elif [ "${{ needs.configure-storage.result }}" != "success" ]; then - determine_status "storage-setup" "${{ needs.configure-storage.result }}" - elif [ "${{ needs.configure-virtualization.result }}" != "success" ]; then - determine_status "virtualization-setup" "${{ needs.configure-virtualization.result }}" - elif [ "${{ needs.e2e-test.result }}" != "success" ]; then - determine_status "e2e-test" "${{ needs.e2e-test.result }}" - else - FAILED_STAGE="success" - FAILED_JOB_NAME="E2E test (${{ inputs.storage_type }})" - REPORT_STATUS="success" - REPORT_STATUS_MESSAGE="✅ SUCCESS" - fi - - REPORT_FILE="e2e_report_${{ inputs.storage_type }}.json" - - PASSED=0 - FAILED=0 - PENDING=0 - SKIPPED=0 - ERRORS=0 - TOTAL=0 - SUCCESS_RATE=0 - STARTED_AT="null" - REPORT_BRANCH="$BRANCH_NAME" - REPORT_WORKFLOW_URL="${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}" - - if [ -n "$REPORT_JSON" ] && [ "$REPORT_JSON" != "" ]; then - PASSED=$(echo "$REPORT_JSON" | jq -r '.Passed // 0') - FAILED=$(echo "$REPORT_JSON" | jq -r '.Failed // 0') - PENDING=$(echo "$REPORT_JSON" | jq -r '.Pending // 0') - SKIPPED=$(echo "$REPORT_JSON" | jq -r '.Skipped // 0') - REPORT_BRANCH=$(echo "$REPORT_JSON" | jq -r --arg branch "$BRANCH_NAME" '.Branch // $branch') - REPORT_WORKFLOW_URL=$(echo "$REPORT_JSON" | jq -r --arg link "$REPORT_WORKFLOW_URL" '.Link // $link') - - TOTAL=$((PASSED + FAILED + PENDING + SKIPPED)) - SKIPPED=$((SKIPPED + PENDING)) - if [ "$TOTAL" -gt 0 ]; then - SUCCESS_RATE=$(awk "BEGIN { printf \"%.2f\", ($PASSED / $TOTAL) * 100 }") - fi - - REPORT_DATE=$(echo "$REPORT_JSON" | jq -r '.Date // empty') - REPORT_TIME=$(echo "$REPORT_JSON" | jq -r '.StartTime // empty') - if [ -n "$REPORT_DATE" ] && [ -n "$REPORT_TIME" ]; then - STARTED_AT=$(jq -Rn --arg started_at "${REPORT_DATE}T${REPORT_TIME}" '$started_at') - elif [ -n "$REPORT_DATE" ]; then - STARTED_AT=$(jq -Rn --arg started_at "$REPORT_DATE" '$started_at') - fi - fi - - jq -n \ - --arg cluster "${{ inputs.storage_type }}" \ - --arg storage_type "${{ inputs.storage_type }}" \ - --arg status "$REPORT_STATUS" \ - --arg status_message "$REPORT_STATUS_MESSAGE" \ - --arg failed_stage "$FAILED_STAGE" \ - --arg failed_job_name "$FAILED_JOB_NAME" \ - --arg workflow_run_id "${{ github.run_id }}" \ - --arg workflow_run_url "$REPORT_WORKFLOW_URL" \ - --arg branch "$REPORT_BRANCH" \ - --argjson started_at "$STARTED_AT" \ - --argjson passed "$PASSED" \ - --argjson failed "$FAILED" \ - --argjson errors "$ERRORS" \ - --argjson skipped "$SKIPPED" \ - --argjson total "$TOTAL" \ - --argjson success_rate "$SUCCESS_RATE" \ - --arg source_summary_report "${E2E_REPORT_FILE:-}" \ - --arg source_ginkgo_report "$(find test/e2e -type f -name "e2e_summary_${{ inputs.storage_type }}_*-ginkgo-report.json" 2>/dev/null | sort | head -1)" \ - --arg source_ginkgo_log "$(find test/e2e -type f -name "e2e_summary_${{ inputs.storage_type }}_*.log" 2>/dev/null | sort | head -1)" \ - --arg report_source "$REPORT_SOURCE" \ - '{ - cluster: $cluster, - storageType: $storage_type, - status: $status, - statusMessage: $status_message, - failedStage: $failed_stage, - failedJobName: $failed_job_name, - workflowRunId: $workflow_run_id, - workflowRunUrl: $workflow_run_url, - branch: $branch, - startedAt: $started_at, - metrics: { - passed: $passed, - failed: $failed, - errors: $errors, - skipped: $skipped, - total: $total, - successRate: $success_rate - }, - failedTests: [], - sourceSummaryReport: $source_summary_report, - sourceGinkgoReport: $source_ginkgo_report, - sourceGinkgoLog: $source_ginkgo_log, - reportSource: $report_source - }' > "$REPORT_FILE" - - echo "report_file=$REPORT_FILE" >> $GITHUB_OUTPUT - echo "[INFO] Created report file: $REPORT_FILE" - echo "[INFO] Failed stage: $FAILED_STAGE" - echo "[INFO] Failed job: $FAILED_JOB_NAME" - cat "$REPORT_FILE" | jq . -======= uses: actions/github-script@v7 env: STORAGE_TYPE: ${{ inputs.storage_type }} @@ -1707,7 +1437,6 @@ jobs: script: | const buildClusterReport = require('./.github/scripts/js/e2e/report/cluster-report'); await buildClusterReport({core, context}); ->>>>>>> a62f59d67 (use treds for send messages) - name: Upload E2E report artifact id: upload-artifact From 5abc8fe22719812d3f5777d87aef41f537c1a42d Mon Sep 17 00:00:00 2001 From: Nikita Korolev Date: Mon, 4 May 2026 12:37:01 +0300 Subject: [PATCH 03/22] rafctor json report, cluster stage, send message Signed-off-by: Nikita Korolev fix report when rerun test job, rm magic envs Signed-off-by: Nikita Korolev --- .../scripts/js/e2e/report/cluster-report.js | 318 +++++++++++++----- .../js/e2e/report/cluster-report.test.js | 95 +++++- .../scripts/js/e2e/report/messenger-report.js | 229 ++++++------- .../js/e2e/report/messenger-report.test.js | 163 +++++---- .github/workflows/e2e-matrix.yml | 22 +- .github/workflows/e2e-reusable-pipeline.yml | 36 +- 6 files changed, 534 insertions(+), 329 deletions(-) diff --git a/.github/scripts/js/e2e/report/cluster-report.js b/.github/scripts/js/e2e/report/cluster-report.js index 016f8fc124..af9ba61f53 100644 --- a/.github/scripts/js/e2e/report/cluster-report.js +++ b/.github/scripts/js/e2e/report/cluster-report.js @@ -21,16 +21,16 @@ const stageLabels = { "storage-setup": "STORAGE SETUP", "virtualization-setup": "VIRTUALIZATION SETUP", "e2e-test": "E2E TEST", - success: "SUCCESS", + ready: "CLUSTER READY", "artifact-missing": "TEST REPORTS NOT FOUND", }; -const preE2EStages = new Set([ +const clusterSetupStages = [ "bootstrap", "configure-sdn", "storage-setup", "virtualization-setup", -]); +]; /** * Escapes special characters in a string for safe use inside a RegExp source. @@ -105,100 +105,183 @@ function zeroMetrics() { } /** - * Builds a descriptor for a non-success stage result. + * Builds a user-facing status line for a workflow stage. * - * @param {string} storageType Storage backend name. - * @param {string} stageName Failed or cancelled stage name. - * @param {string} resultValue Raw GitHub Actions result value. - * @returns {{ - * failedStage: string, - * failedStageLabel: string, - * failedJobName: string, - * reportKind: string, - * status: string, - * statusMessage: string - * }} Descriptor used by the final cluster report. + * @param {string} status Normalized stage status. + * @param {string} stageLabel Human-readable stage label. + * @returns {string} Rendered status message. */ -function getStageDescriptor(storageType, stageName, resultValue) { - const result = (resultValue || "").trim(); - const stageLabel = stageLabels[stageName] || stageName; - const reportKind = preE2EStages.has(stageName) ? "stage-failure" : "tests"; +function buildStatusMessage(status, stageLabel) { + if (status === "success") { + return `✅ ${stageLabel}`; + } - if (result === "cancelled") { - return { - failedStage: stageName, - failedStageLabel: stageLabel, - failedJobName: `${stageLabel} (${storageType})`, - reportKind, - status: "cancelled", - statusMessage: `⚠️ ${stageLabel} CANCELLED`, - }; + if (status === "cancelled") { + return `⚠️ ${stageLabel} CANCELLED`; } - return { - failedStage: stageName, - failedStageLabel: stageLabel, - failedJobName: `${stageLabel} (${storageType})`, - reportKind, - status: "failure", - statusMessage: `❌ ${stageLabel} FAILED`, - }; + if (status === "missing") { + return `⚠️ ${stageLabel}`; + } + + if (status === "not-run") { + return `⚠️ ${stageLabel} NOT RUN`; + } + + return `❌ ${stageLabel} FAILED`; } /** - * Determines which workflow stage should be represented in the cluster report. + * Normalizes a GitHub Actions job result into the report status vocabulary. * - * The first non-success stage wins. If every stage succeeded, the cluster is - * treated as test-capable and the Ginkgo JSON report is expected to describe - * results. + * @param {string|undefined} resultValue Raw GitHub Actions result value. + * @returns {"success"|"failure"|"cancelled"|"skipped"} Normalized result. + */ +function normalizeJobResult(resultValue) { + const result = String(resultValue || "success").trim(); + if (result === "cancelled" || result === "skipped" || result === "success") { + return result; + } + + return "failure"; +} + +/** + * Builds the cluster setup status from pre-E2E workflow stages. * - * @param {string} storageType Storage backend name. * @param {{ * bootstrap: string|undefined, * "configure-sdn": string|undefined, * "storage-setup": string|undefined, - * "virtualization-setup": string|undefined, - * "e2e-test": string|undefined + * "virtualization-setup": string|undefined * }} stageResults Per-stage GitHub Actions results. * @returns {{ - * failedStage: string, - * failedStageLabel: string, - * failedJobName: string, - * reportKind: string, * status: string, - * statusMessage: string - * }} Normalized stage descriptor. + * stage: string, + * stageLabel: string, + * message: string, + * reason: string + * }} Normalized cluster setup status. */ -function determineStage(storageType, stageResults) { - const orderedStages = [ - ["bootstrap", stageResults.bootstrap], - ["configure-sdn", stageResults["configure-sdn"]], - ["storage-setup", stageResults["storage-setup"]], - ["virtualization-setup", stageResults["virtualization-setup"]], - ["e2e-test", stageResults["e2e-test"]], - ]; - - for (const [stageName, resultValue] of orderedStages) { - if ((resultValue || "success") !== "success") { - return getStageDescriptor(storageType, stageName, resultValue); +function buildClusterStatus(stageResults) { + for (const stageName of clusterSetupStages) { + const stageResult = normalizeJobResult(stageResults[stageName]); + if (stageResult !== "success") { + const stageLabel = stageLabels[stageName] || stageName; + return { + status: stageResult === "cancelled" ? "cancelled" : "failure", + stage: stageName, + stageLabel, + message: buildStatusMessage(stageResult, stageLabel), + reason: + stageResult === "cancelled" + ? "cluster-stage-cancelled" + : "cluster-stage-failed", + }; } } return { - failedStage: "success", - failedStageLabel: stageLabels.success, - failedJobName: `E2E test (${storageType})`, - reportKind: "tests", status: "success", - statusMessage: "✅ SUCCESS", + stage: "ready", + stageLabel: stageLabels.ready, + message: buildStatusMessage("success", stageLabels.ready), + reason: "", }; } /** - * Builds a synthetic report descriptor for a successful test stage that did - * not produce any raw E2E artifact. + * Builds E2E test status from test job result and Ginkgo report availability. + * + * @param {string|undefined} testResult Raw E2E job result. + * @param {string} reportSource Parsed report source. + * @param {{ status: string }} clusterStatus Cluster setup status. + * @param {{ + * failed?: number, + * errors?: number + * }} [metrics={}] Parsed Ginkgo metrics. + * @returns {{ + * status: string, + * reason: string, + * message: string + * }} Normalized test status. + */ +function buildTestStatus(testResult, reportSource, clusterStatus, metrics = {}) { + const stageLabel = stageLabels["e2e-test"]; + + if (clusterStatus.status !== "success") { + return { + status: "not-run", + reason: "cluster-stage-failed", + message: "E2E tests were not run because cluster setup did not finish", + }; + } + + const normalizedResult = normalizeJobResult(testResult); + + if (reportSource === "ginkgo-json") { + const hasReportedFailures = + Number(metrics.failed || 0) > 0 || Number(metrics.errors || 0) > 0; + const status = + normalizedResult === "success" && hasReportedFailures + ? "failure" + : normalizedResult; + + return { + status, + reason: status === "success" ? "" : "ginkgo-failed", + message: + status === "success" + ? "✅ E2E TESTS PASSED" + : buildStatusMessage(status, stageLabel), + }; + } + + if (reportSource === "ginkgo-json-invalid") { + return { + status: "missing", + reason: "ginkgo-report-invalid", + message: "⚠️ E2E TEST REPORT IS INVALID", + }; + } + + if (normalizedResult === "success") { + return { + status: "missing", + reason: "ginkgo-report-missing", + message: "⚠️ E2E TEST REPORT NOT FOUND", + }; + } + + if (normalizedResult === "cancelled") { + return { + status: "cancelled", + reason: "e2e-cancelled", + message: buildStatusMessage("cancelled", stageLabel), + }; + } + + if (normalizedResult === "skipped") { + return { + status: "not-run", + reason: "e2e-skipped", + message: buildStatusMessage("not-run", stageLabel), + }; + } + + return { + status: "failure", + reason: "ginkgo-report-missing", + message: "❌ E2E TESTS FAILED, GINKGO REPORT NOT FOUND", + }; +} + +/** + * Determines which legacy status fields should be exposed as step outputs. * * @param {string} storageType Storage backend name. + * @param {{ status: string, stage: string, stageLabel: string, message: string }} clusterStatus Cluster setup status. + * @param {{ status: string, message: string }} testStatus Test status. * @returns {{ * failedStage: string, * failedStageLabel: string, @@ -206,17 +289,40 @@ function determineStage(storageType, stageResults) { * reportKind: string, * status: string, * statusMessage: string - * }} Artifact-missing descriptor. + * }} Legacy descriptor. */ -function buildArtifactMissingDescriptor(storageType) { - const stageLabel = stageLabels["artifact-missing"]; +function buildLegacyDescriptor(storageType, clusterStatus, testStatus) { + if (clusterStatus.status !== "success") { + return { + failedStage: clusterStatus.stage, + failedStageLabel: clusterStatus.stageLabel, + failedJobName: `${clusterStatus.stageLabel} (${storageType})`, + reportKind: "stage-failure", + status: clusterStatus.status, + statusMessage: clusterStatus.message, + }; + } + + if (testStatus.status === "missing") { + const stageLabel = stageLabels["artifact-missing"]; + return { + failedStage: "artifact-missing", + failedStageLabel: stageLabel, + failedJobName: `E2E test (${storageType})`, + reportKind: "artifact-missing", + status: "missing", + statusMessage: testStatus.message, + }; + } + return { - failedStage: "artifact-missing", - failedStageLabel: stageLabel, + failedStage: testStatus.status === "success" ? "success" : "e2e-test", + failedStageLabel: + testStatus.status === "success" ? "SUCCESS" : stageLabels["e2e-test"], failedJobName: `E2E test (${storageType})`, - reportKind: "artifact-missing", - status: "missing", - statusMessage: `⚠️ ${stageLabel}`, + reportKind: "tests", + status: testStatus.status, + statusMessage: testStatus.message, }; } @@ -252,16 +358,32 @@ function setReportOutputs(report, reportFile, core) { * repo: { owner: string, repo: string }, * runId: string|number, * ref?: string + * }, + * config?: { + * storageType: string, + * reportsDir: string, + * reportFile: string, + * workflowRunUrl?: string, + * branchName?: string, + * stageResults: { + * bootstrap: string|undefined, + * "configure-sdn": string|undefined, + * "storage-setup": string|undefined, + * "virtualization-setup": string|undefined, + * "e2e-test": string|undefined + * } * } * }} params GitHub script dependencies. * @returns {Promise>} Generated cluster report. */ -async function buildClusterReport({ core, context }) { - const config = readClusterConfigFromEnv(); +async function buildClusterReport({ core, context, config: explicitConfig }) { + const config = explicitConfig || readClusterConfigFromEnv(); const workflowRunUrl = + config.workflowRunUrl || config.workflowRunUrlOverride || `${context.serverUrl}/${context.repo.owner}/${context.repo.repo}/actions/runs/${context.runId}`; const branchName = + config.branchName || config.branchNameOverride || String(context.ref || "").replace(/^refs\/heads\//, ""); const rawReportPattern = new RegExp( @@ -272,7 +394,7 @@ async function buildClusterReport({ core, context }) { rawReportPattern, "Ginkgo JSON report" ); - const stageInfo = determineStage(config.storageType, config.stageResults); + const clusterStatus = buildClusterStatus(config.stageResults); let parsedReport = { metrics: zeroMetrics(), @@ -289,6 +411,7 @@ async function buildClusterReport({ core, context }) { source: "ginkgo-json", }; } catch (error) { + parsedReport.source = "ginkgo-json-invalid"; core.warning( `Unable to parse Ginkgo JSON report ${rawReportPath}: ${error.message}` ); @@ -299,25 +422,33 @@ async function buildClusterReport({ core, context }) { ); } - const effectiveStageInfo = - stageInfo.status === "success" && - stageInfo.reportKind === "tests" && - parsedReport.source === "empty" - ? buildArtifactMissingDescriptor(config.storageType) - : stageInfo; + const testStatus = buildTestStatus( + config.stageResults["e2e-test"], + parsedReport.source, + clusterStatus, + parsedReport.metrics + ); + const legacyDescriptor = buildLegacyDescriptor( + config.storageType, + clusterStatus, + testStatus + ); const report = { + schemaVersion: 1, cluster: config.storageType, storageType: config.storageType, - reportKind: effectiveStageInfo.reportKind, - status: effectiveStageInfo.status, - statusMessage: effectiveStageInfo.statusMessage, - failedStage: effectiveStageInfo.failedStage, - failedStageLabel: effectiveStageInfo.failedStageLabel, - failedJobName: effectiveStageInfo.failedJobName, + reportKind: legacyDescriptor.reportKind, + status: legacyDescriptor.status, + statusMessage: legacyDescriptor.statusMessage, + failedStage: legacyDescriptor.failedStage, + failedStageLabel: legacyDescriptor.failedStageLabel, + failedJobName: legacyDescriptor.failedJobName, workflowRunId: String(context.runId), workflowRunUrl, branch: branchName, + clusterStatus, + testStatus, startedAt: parsedReport.startedAt, metrics: parsedReport.metrics, failedTests: parsedReport.failedTests, @@ -341,7 +472,8 @@ async function buildClusterReport({ core, context }) { } module.exports = buildClusterReport; -module.exports.determineStage = determineStage; +module.exports.buildClusterStatus = buildClusterStatus; +module.exports.buildTestStatus = buildTestStatus; module.exports.parseGinkgoReport = parseGinkgoReport; -module.exports.buildArtifactMissingDescriptor = buildArtifactMissingDescriptor; +module.exports.buildLegacyDescriptor = buildLegacyDescriptor; module.exports.readClusterConfigFromEnv = readClusterConfigFromEnv; diff --git a/.github/scripts/js/e2e/report/cluster-report.test.js b/.github/scripts/js/e2e/report/cluster-report.test.js index 8d6d7ea1bb..80f2c7ec9c 100644 --- a/.github/scripts/js/e2e/report/cluster-report.test.js +++ b/.github/scripts/js/e2e/report/cluster-report.test.js @@ -3,7 +3,7 @@ const os = require("os"); const path = require("path"); const buildClusterReport = require("./cluster-report"); -const { determineStage } = require("./cluster-report"); +const { buildClusterStatus } = require("./cluster-report"); const { parseGinkgoReport } = require("./ginkgo-report-utils"); const { readClusterConfigFromEnv } = require("./cluster-report"); @@ -213,24 +213,56 @@ describe("cluster-report", () => { }); }); - test("determines stage from explicit stage results", () => { + test("determines cluster setup status from explicit stage results", () => { expect( - determineStage("replicated", { + buildClusterStatus({ bootstrap: "success", "configure-sdn": "failure", "storage-setup": "skipped", "virtualization-setup": "skipped", - "e2e-test": "skipped", }) ).toMatchObject({ - failedStage: "configure-sdn", - failedStageLabel: "CONFIGURE SDN", - reportKind: "stage-failure", status: "failure", + stage: "configure-sdn", + stageLabel: "CONFIGURE SDN", + reason: "cluster-stage-failed", }); }); - test("renders test report from Ginkgo JSON when E2E succeeded", async () => + test("builds report from explicit config without reading env", async () => + withTempDir(async (tempDir) => { + const reportFile = path.join(tempDir, "explicit-report.json"); + + const report = await buildClusterReport({ + core: createCore(), + context: createContext(), + config: { + storageType: "nfs", + reportsDir: tempDir, + reportFile, + workflowRunUrl: "https://example.invalid/run/explicit", + branchName: "feature/report", + stageResults: { + bootstrap: "success", + "configure-sdn": "failure", + "storage-setup": "skipped", + "virtualization-setup": "skipped", + "e2e-test": "skipped", + }, + }, + }); + + expect(report.cluster).toBe("nfs"); + expect(report.workflowRunUrl).toBe("https://example.invalid/run/explicit"); + expect(report.branch).toBe("feature/report"); + expect(report.clusterStatus).toMatchObject({ + status: "failure", + stage: "configure-sdn", + }); + expect(JSON.parse(fs.readFileSync(reportFile, "utf8")).cluster).toBe("nfs"); + })); + + test("marks Ginkgo JSON with failed specs as failed", async () => withTempDir(async (tempDir) => { const rawReportPath = path.join( tempDir, @@ -282,7 +314,16 @@ describe("cluster-report", () => { }); expect(report.reportKind).toBe("tests"); - expect(report.failedStage).toBe("success"); + expect(report.failedStage).toBe("e2e-test"); + expect(report.clusterStatus).toMatchObject({ + status: "success", + stage: "ready", + stageLabel: "CLUSTER READY", + }); + expect(report.testStatus).toMatchObject({ + status: "failure", + reason: "ginkgo-failed", + }); expect(report.metrics).toEqual({ passed: 1, failed: 1, @@ -302,11 +343,11 @@ describe("cluster-report", () => { ); expect(core.setOutput).toHaveBeenCalledWith("report_file", reportFile); expect(core.setOutput).toHaveBeenCalledWith("report_kind", "tests"); - expect(core.setOutput).toHaveBeenCalledWith("status", "success"); - expect(core.setOutput).toHaveBeenCalledWith("failed_stage", "success"); + expect(core.setOutput).toHaveBeenCalledWith("status", "failure"); + expect(core.setOutput).toHaveBeenCalledWith("failed_stage", "e2e-test"); expect(core.setOutput).toHaveBeenCalledWith( "failed_stage_label", - "SUCCESS" + "E2E TEST" ); expect(core.setOutput).toHaveBeenCalledWith( "workflow_run_url", @@ -383,7 +424,11 @@ describe("cluster-report", () => { expect(report.reportKind).toBe("artifact-missing"); expect(report.failedStage).toBe("artifact-missing"); expect(report.status).toBe("missing"); - expect(report.reportSource).toBe("empty"); + expect(report.reportSource).toBe("ginkgo-json-invalid"); + expect(report.testStatus).toMatchObject({ + status: "missing", + reason: "ginkgo-report-invalid", + }); expect(core.warning).toHaveBeenCalledWith( expect.stringContaining("Unable to parse Ginkgo JSON report") ); @@ -523,6 +568,15 @@ describe("cluster-report", () => { expect(report.failedStage).toBe("configure-sdn"); expect(report.failedStageLabel).toBe("CONFIGURE SDN"); expect(report.status).toBe("failure"); + expect(report.clusterStatus).toMatchObject({ + status: "failure", + stage: "configure-sdn", + reason: "cluster-stage-failed", + }); + expect(report.testStatus).toMatchObject({ + status: "not-run", + reason: "cluster-stage-failed", + }); })); test("marks missing artifacts when test stage is successful but no reports were found", async () => @@ -542,6 +596,11 @@ describe("cluster-report", () => { expect(report.failedStage).toBe("artifact-missing"); expect(report.failedStageLabel).toBe("TEST REPORTS NOT FOUND"); expect(report.status).toBe("missing"); + expect(report.clusterStatus.status).toBe("success"); + expect(report.testStatus).toMatchObject({ + status: "missing", + reason: "ginkgo-report-missing", + }); })); test("keeps cancelled test stage when no reports were found", async () => @@ -562,6 +621,11 @@ describe("cluster-report", () => { expect(report.failedStage).toBe("e2e-test"); expect(report.failedStageLabel).toBe("E2E TEST"); expect(report.status).toBe("cancelled"); + expect(report.clusterStatus.status).toBe("success"); + expect(report.testStatus).toMatchObject({ + status: "cancelled", + reason: "e2e-cancelled", + }); })); test("keeps failed test stage when no reports were found", async () => @@ -582,5 +646,10 @@ describe("cluster-report", () => { expect(report.failedStage).toBe("e2e-test"); expect(report.failedStageLabel).toBe("E2E TEST"); expect(report.status).toBe("failure"); + expect(report.clusterStatus.status).toBe("success"); + expect(report.testStatus).toMatchObject({ + status: "failure", + reason: "ginkgo-report-missing", + }); })); }); diff --git a/.github/scripts/js/e2e/report/messenger-report.js b/.github/scripts/js/e2e/report/messenger-report.js index f4dd6c2816..cdeb57d96e 100644 --- a/.github/scripts/js/e2e/report/messenger-report.js +++ b/.github/scripts/js/e2e/report/messenger-report.js @@ -15,7 +15,6 @@ const fs = require("fs"); const { listMatchingFiles } = require("./fs-utils"); const genericArtifactMissingLabel = "E2E REPORT ARTIFACT NOT FOUND"; -const testReportsMissingLabel = "TEST REPORTS NOT FOUND"; /** * Builds a user-facing status line for a cluster row or fallback report. @@ -51,42 +50,32 @@ function buildStatusMessage(status, stageLabel) { * report-preparation step failed or never produced an artifact. * * @param {string} clusterName Cluster or storage name. - * @param {{ - * reportKind?: string, - * failedStage?: string, - * failedStageLabel?: string, - * status?: string, - * branch?: string, - * workflowRunUrl?: string - * }} [fallback={}] Optional fallback data propagated from workflow outputs. * @returns {Record} Synthetic report payload. */ -function createMissingReport(clusterName, fallback = {}) { - const reportKind = - fallback.reportKind && fallback.reportKind !== "tests" - ? fallback.reportKind - : "artifact-missing"; - const failedStage = - fallback.failedStage && fallback.failedStage !== "success" - ? fallback.failedStage - : "artifact-missing"; - const failedStageLabel = - fallback.failedStageLabel || - (fallback.reportKind === "artifact-missing" - ? testReportsMissingLabel - : genericArtifactMissingLabel); - const status = fallback.status || "missing"; - +function createMissingReport(clusterName) { return { + schemaVersion: 1, cluster: clusterName, storageType: clusterName, - reportKind, - status, - statusMessage: buildStatusMessage(status, failedStageLabel), - failedStage, - failedStageLabel, - branch: fallback.branch || "", - workflowRunUrl: fallback.workflowRunUrl || "", + reportKind: "artifact-missing", + status: "missing", + statusMessage: buildStatusMessage("missing", genericArtifactMissingLabel), + failedStage: "artifact-missing", + failedStageLabel: genericArtifactMissingLabel, + branch: "", + workflowRunUrl: "", + clusterStatus: { + status: "missing", + stage: "artifact-missing", + stageLabel: genericArtifactMissingLabel, + message: buildStatusMessage("missing", genericArtifactMissingLabel), + reason: "cluster-report-artifact-missing", + }, + testStatus: { + status: "not-run", + reason: "cluster-report-artifact-missing", + message: "E2E status is unavailable because cluster report artifact was not found", + }, metrics: { passed: 0, failed: 0, @@ -96,6 +85,7 @@ function createMissingReport(clusterName, fallback = {}) { successRate: 0, }, failedTests: [], + reportSource: "missing-artifact", }; } @@ -212,12 +202,52 @@ function getReportClusterKey(report) { */ function isMissingReport(report) { return ( + (report.testStatus && report.testStatus.status === "missing") || + (report.clusterStatus && report.clusterStatus.status === "missing") || report.reportKind === "artifact-missing" || report.failedStage === "artifact-missing" || report.status === "missing" ); } +/** + * Tells whether the report describes a failed cluster setup stage. + * + * @param {Record} report Cluster report payload. + * @returns {boolean} True for cluster-stage failures. + */ +function isClusterFailureReport(report) { + if (report.clusterStatus) { + return ( + report.clusterStatus.status !== "success" && + report.clusterStatus.status !== "missing" + ); + } + + return report.reportKind !== "tests" && !isMissingReport(report); +} + +/** + * Tells whether the report should be rendered in the E2E test results table. + * + * @param {Record} report Cluster report payload. + * @returns {boolean} True for reports with test status data. + */ +function isTestResultReport(report) { + if (report.clusterStatus && report.clusterStatus.status !== "success") { + return false; + } + + if (report.testStatus) { + return ( + report.testStatus.status !== "not-run" && + report.testStatus.status !== "missing" + ); + } + + return report.reportKind === "tests"; +} + /** * Normalizes the configured Loop API base URL to the `/api/v4/posts` endpoint. * @@ -265,70 +295,6 @@ function parseConfiguredClusters(value) { return Array.isArray(parsedValue) ? parsedValue : []; } -/** - * Converts a cluster name into a safe environment-variable suffix. - * - * @param {string} clusterName Raw cluster name. - * @returns {string} Uppercased normalized environment key fragment. - */ -function normalizeClusterEnvKey(clusterName) { - return String(clusterName || "") - .trim() - .replace(/[^a-zA-Z0-9]+/g, "_") - .replace(/^_+|_+$/g, "") - .toUpperCase(); -} - -/** - * Reads per-cluster fallback values exported by reusable workflow jobs. - * - * @param {string[]} configuredClusters Clusters that should appear in the message. - * @param {NodeJS.ProcessEnv} [env=process.env] Environment variables source. - * @returns {Record} Fallbacks indexed by cluster name. - */ -function readReportFallbacksFromEnv(configuredClusters, env = process.env) { - const fallbackByCluster = {}; - - for (const clusterName of configuredClusters) { - const clusterKey = normalizeClusterEnvKey(clusterName); - const reportKind = env[`REPORT_FALLBACK_${clusterKey}_REPORT_KIND`] || ""; - const status = env[`REPORT_FALLBACK_${clusterKey}_STATUS`] || ""; - const failedStage = env[`REPORT_FALLBACK_${clusterKey}_FAILED_STAGE`] || ""; - const failedStageLabel = - env[`REPORT_FALLBACK_${clusterKey}_FAILED_STAGE_LABEL`] || ""; - const workflowRunUrl = - env[`REPORT_FALLBACK_${clusterKey}_WORKFLOW_RUN_URL`] || ""; - const branch = env[`REPORT_FALLBACK_${clusterKey}_BRANCH`] || ""; - - if ( - reportKind || - status || - failedStage || - failedStageLabel || - workflowRunUrl || - branch - ) { - fallbackByCluster[clusterName] = { - reportKind, - status, - failedStage, - failedStageLabel, - workflowRunUrl, - branch, - }; - } - } - - return fallbackByCluster; -} - /** * Reads messenger configuration from the environment prepared by the workflow. * @@ -336,7 +302,6 @@ function readReportFallbacksFromEnv(configuredClusters, env = process.env) { * @returns {{ * reportsDir: string, * configuredClusters: string[], - * reportFallbacks: Record, * loop: { * apiUrl: string, * channelId: string, @@ -350,7 +315,6 @@ function readMessengerConfigFromEnv(env = process.env) { return { reportsDir: env.REPORTS_DIR || "downloaded-artifacts", configuredClusters, - reportFallbacks: readReportFallbacksFromEnv(configuredClusters, env), loop: { apiUrl: getLoopPostsApiUrl(env), channelId: String(env.LOOP_CHANNEL_ID || "").trim(), @@ -433,11 +397,10 @@ async function postToLoopApi( * * @param {string} reportsDir Directory containing `e2e_report_*.json`. * @param {string[]} configuredClusters Clusters expected in the final report. - * @param {Record} reportFallbacks Fallback data by cluster. * @param {{ warning(message: string): void }} core GitHub core API. * @returns {Record[]} Ordered cluster reports. */ -function readReports(reportsDir, configuredClusters, reportFallbacks, core) { +function readReports(reportsDir, configuredClusters, core) { const reportFiles = listMatchingFiles(reportsDir, /^e2e_report_.*\.json$/); const reports = []; @@ -464,10 +427,7 @@ function readReports(reportsDir, configuredClusters, reportFallbacks, core) { for (const clusterName of configuredClusters) { if (!reportsByCluster.has(clusterName)) { - reportsByCluster.set( - clusterName, - createMissingReport(clusterName, reportFallbacks[clusterName]) - ); + reportsByCluster.set(clusterName, createMissingReport(clusterName)); } } @@ -497,15 +457,17 @@ function buildMainMessage(orderedReports) { } const testsReports = orderedReports.filter( - (report) => report.reportKind === "tests" && getReportClusterKey(report) + (report) => isTestResultReport(report) && getReportClusterKey(report) ); - const nonTestReports = orderedReports.filter( - (report) => report.reportKind !== "tests" && getReportClusterKey(report) + const stageFailureReports = orderedReports.filter( + (report) => isClusterFailureReport(report) && getReportClusterKey(report) ); - const stageFailureReports = nonTestReports.filter( - (report) => !isMissingReport(report) + const missingReports = orderedReports.filter( + (report) => + isMissingReport(report) && + !isClusterFailureReport(report) && + getReportClusterKey(report) ); - const missingReports = nonTestReports.filter((report) => isMissingReport(report)); if (testsReports.length > 0) { lines.push("### Test results"); @@ -536,7 +498,10 @@ function buildMainMessage(orderedReports) { for (const report of stageFailureReports) { lines.push( `- ${formatClusterLink(report)}: ${sanitizeListItem( - report.failedStageLabel || report.statusMessage || report.failedStage + (report.clusterStatus && report.clusterStatus.message) || + report.statusMessage || + report.failedStageLabel || + report.failedStage )}` ); } @@ -549,9 +514,17 @@ function buildMainMessage(orderedReports) { lines.push(""); for (const report of missingReports) { + const missingMessage = + report.clusterStatus && report.clusterStatus.status === "missing" + ? report.clusterStatus.message + : report.testStatus && report.testStatus.message; lines.push( `- ${formatClusterLink(report)}: ${sanitizeListItem( - report.failedStageLabel || report.statusMessage || report.failedStage + missingMessage || + (report.clusterStatus && report.clusterStatus.message) || + report.statusMessage || + report.failedStageLabel || + report.failedStage )}` ); } @@ -574,6 +547,9 @@ function hasFailedTests(report) { } return Boolean( + report.testStatus && + (report.testStatus.status === "failure" || + report.testStatus.status === "cancelled") || (report.metrics && report.metrics.failed) || (report.metrics && report.metrics.errors) ); @@ -633,7 +609,10 @@ function buildFailedTestsClusterMessage(report) { } } else { lines.push( - "- No testcase-level failures were collected, but the E2E stage reported failures." + `- ${ + sanitizeListItem(report.testStatus && report.testStatus.message) || + "No testcase-level failures were collected, but the E2E stage reported failures." + }` ); } @@ -648,7 +627,7 @@ function buildFailedTestsClusterMessage(report) { */ function buildThreadMessages(orderedReports) { const testsReports = orderedReports.filter( - (report) => report.reportKind === "tests" + (report) => isTestResultReport(report) ); const failedTestReports = testsReports.filter(hasFailedTests); @@ -656,10 +635,12 @@ function buildThreadMessages(orderedReports) { return []; } - return [ - "### Failed tests", - ...failedTestReports.map(buildFailedTestsClusterMessage), - ]; + return failedTestReports.map((report, index) => { + const clusterMessage = buildFailedTestsClusterMessage(report); + return index === 0 + ? ["### Failed tests", clusterMessage].join("\n\n") + : clusterMessage; + }); } /** @@ -668,7 +649,6 @@ function buildThreadMessages(orderedReports) { * @param {{ * reportsDir: string, * configuredClusters: string[], - * reportFallbacks: Record, * core: { warning(message: string): void } * }} params Message rendering inputs. * @returns {{ @@ -680,13 +660,11 @@ function buildThreadMessages(orderedReports) { function buildMessengerMessages({ reportsDir, configuredClusters, - reportFallbacks, core, }) { const orderedReports = readReports( reportsDir, configuredClusters, - reportFallbacks, core ); const threadMessages = buildThreadMessages(orderedReports); @@ -767,7 +745,8 @@ async function publishToLoop({ message, threadMessages, loop }, core) { * info(message: string): void, * warning(message: string): void, * setOutput(name: string, value: string): void - * } + * }, + * reportsDir?: string * }} params GitHub script dependencies. * @returns {Promise<{ * message: string, @@ -775,12 +754,11 @@ async function publishToLoop({ message, threadMessages, loop }, core) { * threadMessages: string[] * }>} Rendered messages. */ -async function renderMessengerReport({ core }) { +async function renderMessengerReport({ core, reportsDir }) { const config = readMessengerConfigFromEnv(); const { message, threadMessage, threadMessages } = buildMessengerMessages({ - reportsDir: config.reportsDir, + reportsDir: reportsDir || config.reportsDir, configuredClusters: config.configuredClusters, - reportFallbacks: config.reportFallbacks, core, }); @@ -805,5 +783,4 @@ module.exports = renderMessengerReport; module.exports.createMissingReport = createMissingReport; module.exports.buildMessengerMessages = buildMessengerMessages; module.exports.getLoopPostsApiUrl = getLoopPostsApiUrl; -module.exports.readReportFallbacksFromEnv = readReportFallbacksFromEnv; module.exports.readMessengerConfigFromEnv = readMessengerConfigFromEnv; diff --git a/.github/scripts/js/e2e/report/messenger-report.test.js b/.github/scripts/js/e2e/report/messenger-report.test.js index 7a76568aba..7f47424365 100644 --- a/.github/scripts/js/e2e/report/messenger-report.test.js +++ b/.github/scripts/js/e2e/report/messenger-report.test.js @@ -46,18 +46,6 @@ describe("messenger-report", () => { afterEach(() => { delete process.env.REPORTS_DIR; delete process.env.STORAGE_TYPES; - delete process.env.REPORT_FALLBACK_REPLICATED_REPORT_KIND; - delete process.env.REPORT_FALLBACK_REPLICATED_STATUS; - delete process.env.REPORT_FALLBACK_REPLICATED_FAILED_STAGE; - delete process.env.REPORT_FALLBACK_REPLICATED_FAILED_STAGE_LABEL; - delete process.env.REPORT_FALLBACK_REPLICATED_WORKFLOW_RUN_URL; - delete process.env.REPORT_FALLBACK_REPLICATED_BRANCH; - delete process.env.REPORT_FALLBACK_NFS_REPORT_KIND; - delete process.env.REPORT_FALLBACK_NFS_STATUS; - delete process.env.REPORT_FALLBACK_NFS_FAILED_STAGE; - delete process.env.REPORT_FALLBACK_NFS_FAILED_STAGE_LABEL; - delete process.env.REPORT_FALLBACK_NFS_WORKFLOW_RUN_URL; - delete process.env.REPORT_FALLBACK_NFS_BRANCH; delete process.env.LOOP_API_BASE_URL; delete process.env.LOOP_CHANNEL_ID; delete process.env.LOOP_TOKEN; @@ -76,7 +64,6 @@ describe("messenger-report", () => { expect(config).toEqual({ reportsDir: "custom-reports", configuredClusters: ["replicated", "nfs"], - reportFallbacks: {}, loop: { apiUrl: "https://loop.example.invalid/api/v4/posts", channelId: "channel-id", @@ -144,8 +131,7 @@ describe("messenger-report", () => { ); expect(result.message).not.toContain("### Failed tests"); expect(result.threadMessages).toEqual([ - "### Failed tests", - "**replicated**\n\n| Test group |\n|---|\n| fails |", + "### Failed tests\n\n**replicated**\n\n| Test group |\n|---|\n| fails |", ]); expect(result.threadMessage).toContain("### Failed tests"); expect(result.threadMessage).toContain("**replicated**"); @@ -163,7 +149,7 @@ describe("messenger-report", () => { expect(result.message).toContain("### Missing reports"); expect(result.message).toContain( - "- replicated: E2E REPORT ARTIFACT NOT FOUND" + "- replicated: ⚠️ E2E REPORT ARTIFACT NOT FOUND" ); expect(result.threadMessage).toBe(""); expect(result.threadMessages).toEqual([]); @@ -266,8 +252,7 @@ describe("messenger-report", () => { const result = await renderMessengerReport({ core: createCore() }); expect(result.threadMessages).toEqual([ - "### Failed tests", - "**replicated**\n\n| Test group |\n|---|\n| replicated |", + "### Failed tests\n\n**replicated**\n\n| Test group |\n|---|\n| replicated |", "**nfs**\n\n| Test group |\n|---|\n| nfs |", ]); })); @@ -309,8 +294,9 @@ describe("messenger-report", () => { const result = await renderMessengerReport({ core: createCore() }); expect(result.threadMessages).toEqual([ - "### Failed tests", [ + "### Failed tests", + "", "**nfs**", "", "| Test group |", @@ -321,25 +307,46 @@ describe("messenger-report", () => { ]); })); - test("uses workflow fallback metadata for missing cluster report", async () => + test("renders cluster status from downloaded report artifact", async () => withTempDir(async (tempDir) => { + fs.writeFileSync( + path.join(tempDir, "e2e_report_replicated.json"), + JSON.stringify({ + cluster: "replicated", + storageType: "replicated", + branch: "main", + workflowRunUrl: "https://example.invalid/replicated", + clusterStatus: { + status: "failure", + stage: "configure-sdn", + stageLabel: "CONFIGURE SDN", + message: "❌ CONFIGURE SDN FAILED", + reason: "cluster-stage-failed", + }, + testStatus: { + status: "not-run", + reason: "cluster-stage-failed", + message: "E2E tests were not run because cluster setup did not finish", + }, + metrics: { + passed: 0, + failed: 0, + errors: 0, + total: 0, + successRate: 0, + }, + failedTests: [], + }) + ); + process.env.REPORTS_DIR = tempDir; - process.env.STORAGE_TYPES = '["replicated"]'; - process.env.REPORT_FALLBACK_REPLICATED_REPORT_KIND = "stage-failure"; - process.env.REPORT_FALLBACK_REPLICATED_STATUS = "failure"; - process.env.REPORT_FALLBACK_REPLICATED_FAILED_STAGE = "configure-sdn"; - process.env.REPORT_FALLBACK_REPLICATED_FAILED_STAGE_LABEL = - "CONFIGURE SDN"; - process.env.REPORT_FALLBACK_REPLICATED_WORKFLOW_RUN_URL = - "https://example.invalid/replicated"; - process.env.REPORT_FALLBACK_REPLICATED_BRANCH = "main"; const result = await renderMessengerReport({ core: createCore() }); expect(result.message).not.toContain("Branch: `main`"); expect(result.message).toContain("### Cluster failures"); expect(result.message).toContain( - "- [replicated](https://example.invalid/replicated): CONFIGURE SDN" + "- [replicated](https://example.invalid/replicated): ❌ CONFIGURE SDN FAILED" ); expect(result.threadMessage).toBe(""); expect(result.threadMessages).toEqual([]); @@ -347,39 +354,81 @@ describe("messenger-report", () => { test("shows branch line for non-main branches", async () => withTempDir(async (tempDir) => { + fs.writeFileSync( + path.join(tempDir, "e2e_report_replicated.json"), + JSON.stringify({ + cluster: "replicated", + storageType: "replicated", + branch: "release-1.2", + clusterStatus: { + status: "failure", + stage: "configure-sdn", + stageLabel: "CONFIGURE SDN", + message: "❌ CONFIGURE SDN FAILED", + reason: "cluster-stage-failed", + }, + testStatus: { + status: "not-run", + reason: "cluster-stage-failed", + message: "E2E tests were not run because cluster setup did not finish", + }, + metrics: { + passed: 0, + failed: 0, + errors: 0, + total: 0, + successRate: 0, + }, + failedTests: [], + }) + ); + process.env.REPORTS_DIR = tempDir; - process.env.STORAGE_TYPES = '["replicated"]'; - process.env.REPORT_FALLBACK_REPLICATED_REPORT_KIND = "stage-failure"; - process.env.REPORT_FALLBACK_REPLICATED_STATUS = "failure"; - process.env.REPORT_FALLBACK_REPLICATED_FAILED_STAGE = "configure-sdn"; - process.env.REPORT_FALLBACK_REPLICATED_FAILED_STAGE_LABEL = - "CONFIGURE SDN"; - process.env.REPORT_FALLBACK_REPLICATED_WORKFLOW_RUN_URL = - "https://example.invalid/replicated"; - process.env.REPORT_FALLBACK_REPLICATED_BRANCH = "release-1.2"; const result = await renderMessengerReport({ core: createCore() }); expect(result.message).toContain("Branch: `release-1.2`"); })); - test("preserves test-reports-missing fallback from workflow metadata", async () => + test("renders missing test report status from downloaded report artifact", async () => withTempDir(async (tempDir) => { + fs.writeFileSync( + path.join(tempDir, "e2e_report_replicated.json"), + JSON.stringify({ + cluster: "replicated", + storageType: "replicated", + branch: "main", + workflowRunUrl: "https://example.invalid/replicated", + clusterStatus: { + status: "success", + stage: "ready", + stageLabel: "CLUSTER READY", + message: "✅ CLUSTER READY", + reason: "", + }, + testStatus: { + status: "missing", + reason: "ginkgo-report-missing", + message: "⚠️ E2E TEST REPORT NOT FOUND", + }, + metrics: { + passed: 0, + failed: 0, + errors: 0, + total: 0, + successRate: 0, + }, + failedTests: [], + }) + ); + process.env.REPORTS_DIR = tempDir; - process.env.STORAGE_TYPES = '["replicated"]'; - process.env.REPORT_FALLBACK_REPLICATED_REPORT_KIND = "artifact-missing"; - process.env.REPORT_FALLBACK_REPLICATED_STATUS = "missing"; - process.env.REPORT_FALLBACK_REPLICATED_FAILED_STAGE = "artifact-missing"; - process.env.REPORT_FALLBACK_REPLICATED_FAILED_STAGE_LABEL = - "TEST REPORTS NOT FOUND"; - process.env.REPORT_FALLBACK_REPLICATED_WORKFLOW_RUN_URL = - "https://example.invalid/replicated"; const result = await renderMessengerReport({ core: createCore() }); expect(result.message).toContain("### Missing reports"); expect(result.message).toContain( - "- [replicated](https://example.invalid/replicated): TEST REPORTS NOT FOUND" + "- [replicated](https://example.invalid/replicated): ⚠️ E2E TEST REPORT NOT FOUND" ); expect(result.threadMessage).toBe(""); expect(result.threadMessages).toEqual([]); @@ -424,17 +473,12 @@ describe("messenger-report", () => { .mockResolvedValueOnce({ ok: true, status: 201, - text: async () => JSON.stringify({ id: "thread-header-post-id" }), - }) - .mockResolvedValueOnce({ - ok: true, - status: 201, - text: async () => JSON.stringify({ id: "thread-cluster-post-id" }), + text: async () => JSON.stringify({ id: "thread-post-id" }), }); const result = await renderMessengerReport({ core: createCore() }); - expect(global.fetch).toHaveBeenCalledTimes(3); + expect(global.fetch).toHaveBeenCalledTimes(2); expect(global.fetch).toHaveBeenNthCalledWith( 1, "https://loop.example.invalid/api/v4/posts", @@ -452,12 +496,7 @@ describe("messenger-report", () => { }); expect(JSON.parse(global.fetch.mock.calls[1][1].body)).toEqual({ channel_id: "channel-id", - message: "### Failed tests", - root_id: "root-post-id", - }); - expect(JSON.parse(global.fetch.mock.calls[2][1].body)).toEqual({ - channel_id: "channel-id", - message: "**replicated**\n\n| Test group |\n|---|\n| fails |", + message: "### Failed tests\n\n**replicated**\n\n| Test group |\n|---|\n| fails |", root_id: "root-post-id", }); })); diff --git a/.github/workflows/e2e-matrix.yml b/.github/workflows/e2e-matrix.yml index 07bd6e2ad7..d220ad20eb 100644 --- a/.github/workflows/e2e-matrix.yml +++ b/.github/workflows/e2e-matrix.yml @@ -472,8 +472,6 @@ jobs: - e2e-replicated - e2e-nfs if: ${{ always()}} - env: - STORAGE_TYPES: '["replicated", "nfs"]' steps: - uses: actions/checkout@v4 @@ -481,12 +479,10 @@ jobs: uses: actions/setup-node@v6 with: node-version: "20" - cache: npm - cache-dependency-path: .github/scripts/js/package-lock.json - name: Install report script dependencies working-directory: .github/scripts/js - run: npm ci + run: npm install - name: Download E2E report artifacts uses: actions/download-artifact@v5 @@ -501,24 +497,10 @@ jobs: id: render-report uses: actions/github-script@v7 env: - REPORTS_DIR: downloaded-artifacts/ - STORAGE_TYPES: ${{ env.STORAGE_TYPES }} - REPORT_FALLBACK_REPLICATED_REPORT_KIND: ${{ needs.e2e-replicated.outputs.report_kind }} - REPORT_FALLBACK_REPLICATED_STATUS: ${{ needs.e2e-replicated.outputs.status }} - REPORT_FALLBACK_REPLICATED_FAILED_STAGE: ${{ needs.e2e-replicated.outputs.failed_stage }} - REPORT_FALLBACK_REPLICATED_FAILED_STAGE_LABEL: ${{ needs.e2e-replicated.outputs.failed_stage_label }} - REPORT_FALLBACK_REPLICATED_WORKFLOW_RUN_URL: ${{ needs.e2e-replicated.outputs.workflow_run_url }} - REPORT_FALLBACK_REPLICATED_BRANCH: ${{ needs.e2e-replicated.outputs.branch || github.ref_name }} - REPORT_FALLBACK_NFS_REPORT_KIND: ${{ needs.e2e-nfs.outputs.report_kind }} - REPORT_FALLBACK_NFS_STATUS: ${{ needs.e2e-nfs.outputs.status }} - REPORT_FALLBACK_NFS_FAILED_STAGE: ${{ needs.e2e-nfs.outputs.failed_stage }} - REPORT_FALLBACK_NFS_FAILED_STAGE_LABEL: ${{ needs.e2e-nfs.outputs.failed_stage_label }} - REPORT_FALLBACK_NFS_WORKFLOW_RUN_URL: ${{ needs.e2e-nfs.outputs.workflow_run_url }} - REPORT_FALLBACK_NFS_BRANCH: ${{ needs.e2e-nfs.outputs.branch || github.ref_name }} LOOP_API_BASE_URL: ${{ secrets.LOOP_API_BASE_URL }} LOOP_CHANNEL_ID: ${{ secrets.LOOP_CHANNEL_ID }} LOOP_TOKEN: ${{ secrets.LOOP_TOKEN }} with: script: | const renderMessengerReport = require('./.github/scripts/js/e2e/report/messenger-report'); - await renderMessengerReport({core}); + await renderMessengerReport({core, reportsDir: 'downloaded-artifacts/'}); diff --git a/.github/workflows/e2e-reusable-pipeline.yml b/.github/workflows/e2e-reusable-pipeline.yml index bbe1a51233..9162b2d09d 100644 --- a/.github/workflows/e2e-reusable-pipeline.yml +++ b/.github/workflows/e2e-reusable-pipeline.yml @@ -1369,6 +1369,7 @@ jobs: path: | test/e2e/e2e_report_*.json if-no-files-found: ignore + overwrite: true retention-days: 3 - name: Upload resources from failed tests @@ -1405,12 +1406,10 @@ jobs: uses: actions/setup-node@v6 with: node-version: "20" - cache: npm - cache-dependency-path: .github/scripts/js/package-lock.json - name: Install report script dependencies working-directory: .github/scripts/js - run: npm ci + run: npm install - name: Download E2E test results if available uses: actions/download-artifact@v5 @@ -1422,21 +1421,27 @@ jobs: - name: Determine failed stage and prepare report id: determine-stage uses: actions/github-script@v7 - env: - STORAGE_TYPE: ${{ inputs.storage_type }} - E2E_REPORT_DIR: test/e2e - REPORT_FILE: e2e_report_${{ inputs.storage_type }}.json - BRANCH_NAME: ${{ github.head_ref || github.ref_name }} - BOOTSTRAP_RESULT: ${{ needs.bootstrap.result }} - CONFIGURE_SDN_RESULT: ${{ needs.configure-sdn.result }} - CONFIGURE_STORAGE_RESULT: ${{ needs.configure-storage.result }} - CONFIGURE_VIRTUALIZATION_RESULT: ${{ needs.configure-virtualization.result }} - E2E_TEST_RESULT: ${{ needs.e2e-test.result }} - WORKFLOW_RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }} with: script: | const buildClusterReport = require('./.github/scripts/js/e2e/report/cluster-report'); - await buildClusterReport({core, context}); + await buildClusterReport({ + core, + context, + config: { + storageType: ${{ toJson(inputs.storage_type) }}, + reportsDir: 'test/e2e', + reportFile: ${{ toJson(format('e2e_report_{0}.json', inputs.storage_type)) }}, + branchName: ${{ toJson(github.head_ref || github.ref_name) }}, + workflowRunUrl: ${{ toJson(format('{0}/{1}/actions/runs/{2}', github.server_url, github.repository, github.run_id)) }}, + stageResults: { + bootstrap: ${{ toJson(needs.bootstrap.result) }}, + 'configure-sdn': ${{ toJson(needs.configure-sdn.result) }}, + 'storage-setup': ${{ toJson(needs.configure-storage.result) }}, + 'virtualization-setup': ${{ toJson(needs.configure-virtualization.result) }}, + 'e2e-test': ${{ toJson(needs.e2e-test.result) }}, + }, + }, + }); - name: Upload E2E report artifact id: upload-artifact @@ -1444,6 +1449,7 @@ jobs: with: name: e2e-report-${{ inputs.storage_type }}-${{ github.run_id }}-${{ inputs.date_start }} path: ${{ steps.determine-stage.outputs.report_file }} + overwrite: true retention-days: 3 - name: Set artifact name output From d33c1dcf433d377cfb334ec9cd38ab0703804978 Mon Sep 17 00:00:00 2001 From: Nikita Korolev Date: Mon, 4 May 2026 14:46:33 +0300 Subject: [PATCH 04/22] refactor, separate by dirs Signed-off-by: Nikita Korolev --- .../scripts/js/e2e/report/cluster-report.js | 16 +- .../js/e2e/report/cluster-report.test.js | 8 +- .../scripts/js/e2e/report/messenger-report.js | 665 +----------------- .../js/e2e/report/messenger-report.test.js | 2 +- .../scripts/js/e2e/report/messenger/config.js | 79 +++ .../js/e2e/report/messenger/loop-client.js | 132 ++++ .../js/e2e/report/messenger/markdown.js | 203 ++++++ .../scripts/js/e2e/report/messenger/model.js | 194 +++++ .../js/e2e/report/{ => shared}/fs-utils.js | 0 .../e2e/report/{ => shared}/fs-utils.test.js | 0 .../{ => shared}/ginkgo-report-utils.js | 0 11 files changed, 637 insertions(+), 662 deletions(-) create mode 100644 .github/scripts/js/e2e/report/messenger/config.js create mode 100644 .github/scripts/js/e2e/report/messenger/loop-client.js create mode 100644 .github/scripts/js/e2e/report/messenger/markdown.js create mode 100644 .github/scripts/js/e2e/report/messenger/model.js rename .github/scripts/js/e2e/report/{ => shared}/fs-utils.js (100%) rename .github/scripts/js/e2e/report/{ => shared}/fs-utils.test.js (100%) rename .github/scripts/js/e2e/report/{ => shared}/ginkgo-report-utils.js (100%) diff --git a/.github/scripts/js/e2e/report/cluster-report.js b/.github/scripts/js/e2e/report/cluster-report.js index af9ba61f53..3cfc2c9e44 100644 --- a/.github/scripts/js/e2e/report/cluster-report.js +++ b/.github/scripts/js/e2e/report/cluster-report.js @@ -12,16 +12,16 @@ const fs = require("fs"); -const { findSingleMatchingFile } = require("./fs-utils"); -const { parseGinkgoReport } = require("./ginkgo-report-utils"); +const { findSingleMatchingFile } = require("./shared/fs-utils"); +const { parseGinkgoReport } = require("./shared/ginkgo-report-utils"); const stageLabels = { - bootstrap: "BOOTSTRAP CLUSTER", + "bootstrap": "BOOTSTRAP CLUSTER", "configure-sdn": "CONFIGURE SDN", "storage-setup": "STORAGE SETUP", "virtualization-setup": "VIRTUALIZATION SETUP", "e2e-test": "E2E TEST", - ready: "CLUSTER READY", + "ready": "CLUSTER READY", "artifact-missing": "TEST REPORTS NOT FOUND", }; @@ -54,7 +54,7 @@ function escapeRegExp(value) { * workflowRunUrlOverride: string, * branchNameOverride: string, * stageResults: { - * bootstrap: string|undefined, + * "bootstrap": string|undefined, * "configure-sdn": string|undefined, * "storage-setup": string|undefined, * "virtualization-setup": string|undefined, @@ -72,7 +72,7 @@ function readClusterConfigFromEnv(env = process.env) { workflowRunUrlOverride: env.WORKFLOW_RUN_URL || "", branchNameOverride: env.BRANCH_NAME || "", stageResults: { - bootstrap: env.BOOTSTRAP_RESULT, + "bootstrap": env.BOOTSTRAP_RESULT, "configure-sdn": env.CONFIGURE_SDN_RESULT, "storage-setup": env.CONFIGURE_STORAGE_RESULT, "virtualization-setup": env.CONFIGURE_VIRTUALIZATION_RESULT, @@ -150,7 +150,7 @@ function normalizeJobResult(resultValue) { * Builds the cluster setup status from pre-E2E workflow stages. * * @param {{ - * bootstrap: string|undefined, + * "bootstrap": string|undefined, * "configure-sdn": string|undefined, * "storage-setup": string|undefined, * "virtualization-setup": string|undefined @@ -366,7 +366,7 @@ function setReportOutputs(report, reportFile, core) { * workflowRunUrl?: string, * branchName?: string, * stageResults: { - * bootstrap: string|undefined, + * "bootstrap": string|undefined, * "configure-sdn": string|undefined, * "storage-setup": string|undefined, * "virtualization-setup": string|undefined, diff --git a/.github/scripts/js/e2e/report/cluster-report.test.js b/.github/scripts/js/e2e/report/cluster-report.test.js index 80f2c7ec9c..bcd4a7e634 100644 --- a/.github/scripts/js/e2e/report/cluster-report.test.js +++ b/.github/scripts/js/e2e/report/cluster-report.test.js @@ -4,7 +4,7 @@ const path = require("path"); const buildClusterReport = require("./cluster-report"); const { buildClusterStatus } = require("./cluster-report"); -const { parseGinkgoReport } = require("./ginkgo-report-utils"); +const { parseGinkgoReport } = require("./shared/ginkgo-report-utils"); const { readClusterConfigFromEnv } = require("./cluster-report"); /** @@ -204,7 +204,7 @@ describe("cluster-report", () => { workflowRunUrlOverride: "https://example.invalid/run/1", branchNameOverride: "release", stageResults: { - bootstrap: "success", + "bootstrap": "success", "configure-sdn": "failure", "storage-setup": "skipped", "virtualization-setup": "skipped", @@ -216,7 +216,7 @@ describe("cluster-report", () => { test("determines cluster setup status from explicit stage results", () => { expect( buildClusterStatus({ - bootstrap: "success", + "bootstrap": "success", "configure-sdn": "failure", "storage-setup": "skipped", "virtualization-setup": "skipped", @@ -243,7 +243,7 @@ describe("cluster-report", () => { workflowRunUrl: "https://example.invalid/run/explicit", branchName: "feature/report", stageResults: { - bootstrap: "success", + "bootstrap": "success", "configure-sdn": "failure", "storage-setup": "skipped", "virtualization-setup": "skipped", diff --git a/.github/scripts/js/e2e/report/messenger-report.js b/.github/scripts/js/e2e/report/messenger-report.js index cdeb57d96e..09bef43b05 100644 --- a/.github/scripts/js/e2e/report/messenger-report.js +++ b/.github/scripts/js/e2e/report/messenger-report.js @@ -12,384 +12,21 @@ const fs = require("fs"); -const { listMatchingFiles } = require("./fs-utils"); - -const genericArtifactMissingLabel = "E2E REPORT ARTIFACT NOT FOUND"; - -/** - * Builds a user-facing status line for a cluster row or fallback report. - * - * @param {string} status Normalized cluster status. - * @param {string} stageLabel Human-readable stage label. - * @returns {string} Rendered status message. - */ -function buildStatusMessage(status, stageLabel) { - if (status === "cancelled") { - return `⚠️ ${stageLabel} CANCELLED`; - } - - if (status === "failure") { - return `❌ ${stageLabel} FAILED`; - } - - if (status === "missing") { - return `⚠️ ${stageLabel}`; - } - - if (status === "success") { - return "✅ SUCCESS"; - } - - return stageLabel; -} - -/** - * Creates a synthetic cluster report when the expected JSON artifact is absent. - * - * This allows the final messenger message to stay informative even when the - * report-preparation step failed or never produced an artifact. - * - * @param {string} clusterName Cluster or storage name. - * @returns {Record} Synthetic report payload. - */ -function createMissingReport(clusterName) { - return { - schemaVersion: 1, - cluster: clusterName, - storageType: clusterName, - reportKind: "artifact-missing", - status: "missing", - statusMessage: buildStatusMessage("missing", genericArtifactMissingLabel), - failedStage: "artifact-missing", - failedStageLabel: genericArtifactMissingLabel, - branch: "", - workflowRunUrl: "", - clusterStatus: { - status: "missing", - stage: "artifact-missing", - stageLabel: genericArtifactMissingLabel, - message: buildStatusMessage("missing", genericArtifactMissingLabel), - reason: "cluster-report-artifact-missing", - }, - testStatus: { - status: "not-run", - reason: "cluster-report-artifact-missing", - message: "E2E status is unavailable because cluster report artifact was not found", - }, - metrics: { - passed: 0, - failed: 0, - errors: 0, - skipped: 0, - total: 0, - successRate: 0, - }, - failedTests: [], - reportSource: "missing-artifact", - }; -} - -/** - * Escapes markdown table cell content and normalizes whitespace. - * - * @param {any} value Raw cell value. - * @returns {string} Sanitized table cell string. - */ -function sanitizeCell(value) { - return String(value || "—") - .replace(/\|/g, "\\|") - .replace(/\r?\n/g, " ") - .trim(); -} - -/** - * Normalizes markdown list item content to a single trimmed line. - * - * @param {any} value Raw list item value. - * @returns {string} Sanitized list item string. - */ -function sanitizeListItem(value) { - return String(value || "") - .replace(/\r?\n/g, " ") - .trim(); -} - -/** - * Formats a numeric success rate as a percentage string. - * - * @param {number|string} value Raw rate value. - * @returns {string} Formatted percentage. - */ -function formatRate(value) { - const rate = Number(value || 0); - return `${Number.isFinite(rate) ? rate.toFixed(2) : "0.00"}%`; -} - -/** - * Picks a report date from the first report that exposes `startedAt`. - * - * @param {Record[]} reports Available cluster reports. - * @returns {string} ISO date string (`YYYY-MM-DD`). - */ -function getReportDate(reports) { - const datedReport = reports.find((report) => report.startedAt); - if (!datedReport) { - return new Date().toISOString().slice(0, 10); - } - - return String(datedReport.startedAt).slice(0, 10); -} - -/** - * Orders reports by the configured cluster order and then by cluster name. - * - * @param {Record[]} reports Reports to sort. - * @param {string[]} preferredOrder Configured cluster order. - * @returns {Record[]} Sorted reports copy. - */ -function sortReports(reports, preferredOrder) { - const orderMap = new Map(preferredOrder.map((name, index) => [name, index])); - - return [...reports].sort((left, right) => { - const leftKey = left.storageType || left.cluster; - const rightKey = right.storageType || right.cluster; - const leftOrder = orderMap.has(leftKey) - ? orderMap.get(leftKey) - : Number.MAX_SAFE_INTEGER; - const rightOrder = orderMap.has(rightKey) - ? orderMap.get(rightKey) - : Number.MAX_SAFE_INTEGER; - - if (leftOrder !== rightOrder) { - return leftOrder - rightOrder; - } - - return String(left.cluster || left.storageType).localeCompare( - String(right.cluster || right.storageType) - ); - }); -} - -/** - * Renders a cluster name as a markdown link when a workflow URL is available. - * - * @param {Record} report Cluster report payload. - * @returns {string} Markdown link or plain sanitized cluster name. - */ -function formatClusterLink(report) { - const clusterName = sanitizeCell(report.cluster || report.storageType); - return report.workflowRunUrl - ? `[${clusterName}](${report.workflowRunUrl})` - : clusterName; -} - -/** - * Extracts the normalized cluster key from a report payload. - * - * @param {Record} report Cluster report payload. - * @returns {string} Cluster key or an empty string when it is missing. - */ -function getReportClusterKey(report) { - return String(report.storageType || report.cluster || "").trim(); -} - -/** - * Tells whether the report represents a missing artifact rather than a real - * cluster-stage failure. - * - * @param {Record} report Cluster report payload. - * @returns {boolean} True when the report describes a missing artifact. - */ -function isMissingReport(report) { - return ( - (report.testStatus && report.testStatus.status === "missing") || - (report.clusterStatus && report.clusterStatus.status === "missing") || - report.reportKind === "artifact-missing" || - report.failedStage === "artifact-missing" || - report.status === "missing" - ); -} - -/** - * Tells whether the report describes a failed cluster setup stage. - * - * @param {Record} report Cluster report payload. - * @returns {boolean} True for cluster-stage failures. - */ -function isClusterFailureReport(report) { - if (report.clusterStatus) { - return ( - report.clusterStatus.status !== "success" && - report.clusterStatus.status !== "missing" - ); - } - - return report.reportKind !== "tests" && !isMissingReport(report); -} - -/** - * Tells whether the report should be rendered in the E2E test results table. - * - * @param {Record} report Cluster report payload. - * @returns {boolean} True for reports with test status data. - */ -function isTestResultReport(report) { - if (report.clusterStatus && report.clusterStatus.status !== "success") { - return false; - } - - if (report.testStatus) { - return ( - report.testStatus.status !== "not-run" && - report.testStatus.status !== "missing" - ); - } - - return report.reportKind === "tests"; -} - -/** - * Normalizes the configured Loop API base URL to the `/api/v4/posts` endpoint. - * - * @param {string} value Raw Loop API base URL. - * @returns {string} Normalized posts endpoint URL or an empty string. - */ -function normalizeLoopApiBaseUrl(value) { - const trimmedValue = String(value || "") - .trim() - .replace(/\/+$/, ""); - - if (!trimmedValue) { - return ""; - } - - if (trimmedValue.endsWith("/api/v4/posts")) { - return trimmedValue; - } - - if (trimmedValue.endsWith("/api/v4")) { - return `${trimmedValue}/posts`; - } - - return `${trimmedValue}/api/v4/posts`; -} - -/** - * Reads and normalizes the Loop posts API URL from environment variables. - * - * @param {NodeJS.ProcessEnv} [env=process.env] Environment variables source. - * @returns {string} Normalized posts endpoint URL or an empty string. - */ -function getLoopPostsApiUrl(env = process.env) { - return normalizeLoopApiBaseUrl(env.LOOP_API_BASE_URL); -} - -/** - * Parses the configured cluster list passed via workflow environment variables. - * - * @param {string} value JSON-encoded cluster list. - * @returns {string[]} Ordered cluster names. - */ -function parseConfiguredClusters(value) { - const parsedValue = JSON.parse(value || "[]"); - return Array.isArray(parsedValue) ? parsedValue : []; -} - -/** - * Reads messenger configuration from the environment prepared by the workflow. - * - * @param {NodeJS.ProcessEnv} [env=process.env] Environment variables source. - * @returns {{ - * reportsDir: string, - * configuredClusters: string[], - * loop: { - * apiUrl: string, - * channelId: string, - * token: string - * } - * }} Normalized messenger configuration. - */ -function readMessengerConfigFromEnv(env = process.env) { - const configuredClusters = parseConfiguredClusters(env.STORAGE_TYPES); - - return { - reportsDir: env.REPORTS_DIR || "downloaded-artifacts", - configuredClusters, - loop: { - apiUrl: getLoopPostsApiUrl(env), - channelId: String(env.LOOP_CHANNEL_ID || "").trim(), - token: String(env.LOOP_TOKEN || "").trim(), - }, - }; -} - -/** - * Parses a Loop API response body if it is JSON, otherwise returns an empty - * object and emits a warning for diagnostics. - * - * @param {string} responseText Raw response body. - * @param {{ warning(message: string): void }} core GitHub core API. - * @returns {Record} Parsed response payload or an empty object. - */ -function parseLoopApiPayload(responseText, core) { - if (!responseText) { - return {}; - } - - try { - return JSON.parse(responseText); - } catch (error) { - core.warning( - `Loop API returned a non-JSON response body: ${error.message}` - ); - return {}; - } -} - -/** - * Sends a single post to Loop and returns the parsed API payload. - * - * @param {{ - * apiUrl: string, - * channelId: string, - * token: string, - * message: string, - * rootId?: string - * }} request Loop API request payload. - * @param {{ - * info(message: string): void, - * warning(message: string): void - * }} core GitHub core API. - * @returns {Promise>} Parsed Loop API response. - */ -async function postToLoopApi( - { apiUrl, channelId, token, message, rootId }, - core -) { - const response = await fetch(apiUrl, { - method: "POST", - headers: { - Authorization: `Bearer ${token}`, - "Content-Type": "application/json", - }, - body: JSON.stringify({ - channel_id: channelId, - message, - ...(rootId ? { root_id: rootId } : {}), - }), - }); - const responseText = await response.text(); - - if (!response.ok) { - throw new Error( - `Loop API request failed with status ${response.status}: ${responseText}` - ); - } - - const payload = parseLoopApiPayload(responseText, core); - core.info(`Loop API accepted report with status ${response.status}`); - return payload; -} +const { listMatchingFiles } = require("./shared/fs-utils"); +const { publishToLoop } = require("./messenger/loop-client"); +const { + getLoopPostsApiUrl, + readMessengerConfigFromEnv, +} = require("./messenger/config"); +const { + createMissingReport, + getReportClusterKey, + sortReports, +} = require("./messenger/model"); +const { + buildMainMessage, + buildThreadMessages, +} = require("./messenger/markdown"); /** * Loads report JSON files from disk and injects synthetic reports for clusters @@ -431,216 +68,7 @@ function readReports(reportsDir, configuredClusters, core) { } } - const orderedReports = sortReports( - Array.from(reportsByCluster.values()), - configuredClusters - ); - return orderedReports; -} - -/** - * Renders the top-level messenger markdown message. - * - * @param {Record[]} orderedReports Reports ordered for display. - * @returns {string} Main markdown message. - */ -function buildMainMessage(orderedReports) { - const reportDate = getReportDate(orderedReports); - const branches = Array.from( - new Set(orderedReports.map((report) => report.branch).filter(Boolean)) - ); - const lines = [`## DVP | E2E on nested clusters | ${reportDate}`, ""]; - - if (branches.length === 1 && branches[0] !== "main") { - lines.push(`Branch: \`${branches[0]}\``); - lines.push(""); - } - - const testsReports = orderedReports.filter( - (report) => isTestResultReport(report) && getReportClusterKey(report) - ); - const stageFailureReports = orderedReports.filter( - (report) => isClusterFailureReport(report) && getReportClusterKey(report) - ); - const missingReports = orderedReports.filter( - (report) => - isMissingReport(report) && - !isClusterFailureReport(report) && - getReportClusterKey(report) - ); - - if (testsReports.length > 0) { - lines.push("### Test results"); - lines.push(""); - lines.push( - "| Cluster | ✅ Passed | ⏭️ Skipped | ❌ Failed | ⚠️ Errors | Total | Success Rate |" - ); - lines.push("|---|---:|---:|---:|---:|---:|---:|"); - - for (const report of testsReports) { - const metrics = report.metrics || {}; - lines.push( - `| ${formatClusterLink(report)} | ${metrics.passed || 0} | ${ - metrics.skipped || 0 - } | ${metrics.failed || 0} | ${metrics.errors || 0} | ${ - metrics.total || 0 - } | ${formatRate(metrics.successRate)} |` - ); - } - - lines.push(""); - } - - if (stageFailureReports.length > 0) { - lines.push("### Cluster failures"); - lines.push(""); - - for (const report of stageFailureReports) { - lines.push( - `- ${formatClusterLink(report)}: ${sanitizeListItem( - (report.clusterStatus && report.clusterStatus.message) || - report.statusMessage || - report.failedStageLabel || - report.failedStage - )}` - ); - } - - lines.push(""); - } - - if (missingReports.length > 0) { - lines.push("### Missing reports"); - lines.push(""); - - for (const report of missingReports) { - const missingMessage = - report.clusterStatus && report.clusterStatus.status === "missing" - ? report.clusterStatus.message - : report.testStatus && report.testStatus.message; - lines.push( - `- ${formatClusterLink(report)}: ${sanitizeListItem( - missingMessage || - (report.clusterStatus && report.clusterStatus.message) || - report.statusMessage || - report.failedStageLabel || - report.failedStage - )}` - ); - } - - lines.push(""); - } - - return lines.join("\n").trim(); -} - -/** - * Tells whether the report should contribute failed-test details to the thread. - * - * @param {Record} report Cluster report payload. - * @returns {boolean} True when failed-test details should be rendered. - */ -function hasFailedTests(report) { - if (Array.isArray(report.failedTests) && report.failedTests.length > 0) { - return true; - } - - return Boolean( - report.testStatus && - (report.testStatus.status === "failure" || - report.testStatus.status === "cancelled") || - (report.metrics && report.metrics.failed) || - (report.metrics && report.metrics.errors) - ); -} - -/** - * Extracts the top-level test group name from a failed test title. - * - * For Ginkgo titles like `[It] VirtualMachineOperationRestore restores ...`, - * this returns `VirtualMachineOperationRestore`. - * - * @param {string} testName Full failed test name. - * @returns {string} Top-level test group label. - */ -function getFailedTestGroupName(testName) { - const normalizedName = sanitizeListItem(testName).replace(/^\[[^\]]+\]\s*/, ""); - const [groupName] = normalizedName.split(/\s+/, 1); - return groupName || "Unknown"; -} - -/** - * Aggregates failed test names into an ordered unique group list. - * - * @param {string[]} failedTests Failed testcase names. - * @returns {string[]} Ordered unique group names. - */ -function summarizeFailedTestGroups(failedTests) { - const groupNames = []; - - for (const testName of failedTests) { - const groupName = getFailedTestGroupName(testName); - if (!groupNames.includes(groupName)) { - groupNames.push(groupName); - } - } - - return groupNames; -} - -/** - * Builds the thread reply body for a single cluster with failed tests. - * - * @param {Record} report Cluster report payload. - * @returns {string} Cluster-specific failed tests markdown. - */ -function buildFailedTestsClusterMessage(report) { - const clusterName = sanitizeListItem(report.cluster || report.storageType); - const lines = [`**${clusterName}**`]; - - if (Array.isArray(report.failedTests) && report.failedTests.length > 0) { - const failedGroups = summarizeFailedTestGroups(report.failedTests); - lines.push(""); - lines.push("| Test group |"); - lines.push("|---|"); - for (const groupName of failedGroups) { - lines.push(`| ${sanitizeCell(groupName)} |`); - } - } else { - lines.push( - `- ${ - sanitizeListItem(report.testStatus && report.testStatus.message) || - "No testcase-level failures were collected, but the E2E stage reported failures." - }` - ); - } - - return lines.join("\n"); -} - -/** - * Renders thread markdown messages containing failed test names, if any. - * - * @param {Record[]} orderedReports Reports ordered for display. - * @returns {string[]} Thread markdown messages in publish order. - */ -function buildThreadMessages(orderedReports) { - const testsReports = orderedReports.filter( - (report) => isTestResultReport(report) - ); - const failedTestReports = testsReports.filter(hasFailedTests); - - if (failedTestReports.length === 0) { - return []; - } - - return failedTestReports.map((report, index) => { - const clusterMessage = buildFailedTestsClusterMessage(report); - return index === 0 - ? ["### Failed tests", clusterMessage].join("\n\n") - : clusterMessage; - }); + return sortReports(Array.from(reportsByCluster.values()), configuredClusters); } /** @@ -675,67 +103,6 @@ function buildMessengerMessages({ }; } -/** - * Publishes the main report and optional failed-tests thread to Loop. - * - * @param {{ - * message: string, - * threadMessages: string[], - * loop: { - * apiUrl: string, - * channelId: string, - * token: string - * } - * }} params Message payload and Loop credentials. - * @param {{ - * setOutput(name: string, value: string): void, - * info(message: string): void, - * warning(message: string): void - * }} core GitHub core API. - * @returns {Promise} - */ -async function publishToLoop({ message, threadMessages, loop }, core) { - if (!loop.apiUrl && !loop.channelId && !loop.token) { - return; - } - - if (!loop.apiUrl || !loop.channelId || !loop.token) { - throw new Error( - "LOOP_CHANNEL_ID, LOOP_TOKEN, and LOOP_API_BASE_URL are required" - ); - } - - const rootPost = await postToLoopApi( - { - apiUrl: loop.apiUrl, - channelId: loop.channelId, - token: loop.token, - message, - }, - core - ); - - let lastReplyPost = null; - for (const replyMessage of threadMessages) { - lastReplyPost = await postToLoopApi( - { - apiUrl: loop.apiUrl, - channelId: loop.channelId, - token: loop.token, - message: replyMessage, - rootId: rootPost.id, - }, - core - ); - } - - core.setOutput("root_post_id", rootPost.id || ""); - core.setOutput( - "thread_post_id", - lastReplyPost && lastReplyPost.id ? lastReplyPost.id : "" - ); -} - /** * Entry point used by `actions/github-script` to render and optionally publish * the aggregated E2E messenger report. diff --git a/.github/scripts/js/e2e/report/messenger-report.test.js b/.github/scripts/js/e2e/report/messenger-report.test.js index 7f47424365..a439d8e32d 100644 --- a/.github/scripts/js/e2e/report/messenger-report.test.js +++ b/.github/scripts/js/e2e/report/messenger-report.test.js @@ -485,7 +485,7 @@ describe("messenger-report", () => { expect.objectContaining({ method: "POST", headers: expect.objectContaining({ - Authorization: "Bearer loop-token", + "Authorization": "Bearer loop-token", "Content-Type": "application/json", }), }) diff --git a/.github/scripts/js/e2e/report/messenger/config.js b/.github/scripts/js/e2e/report/messenger/config.js new file mode 100644 index 0000000000..86968c33ad --- /dev/null +++ b/.github/scripts/js/e2e/report/messenger/config.js @@ -0,0 +1,79 @@ +/** + * Normalizes the configured Loop API base URL to the `/api/v4/posts` endpoint. + * + * @param {string} value Raw Loop API base URL. + * @returns {string} Normalized posts endpoint URL or an empty string. + */ +function normalizeLoopApiBaseUrl(value) { + const trimmedValue = String(value || "") + .trim() + .replace(/\/+$/, ""); + + if (!trimmedValue) { + return ""; + } + + if (trimmedValue.endsWith("/api/v4/posts")) { + return trimmedValue; + } + + if (trimmedValue.endsWith("/api/v4")) { + return `${trimmedValue}/posts`; + } + + return `${trimmedValue}/api/v4/posts`; +} + +/** + * Reads and normalizes the Loop posts API URL from environment variables. + * + * @param {NodeJS.ProcessEnv} [env=process.env] Environment variables source. + * @returns {string} Normalized posts endpoint URL or an empty string. + */ +function getLoopPostsApiUrl(env = process.env) { + return normalizeLoopApiBaseUrl(env.LOOP_API_BASE_URL); +} + +/** + * Parses the configured cluster list passed via workflow environment variables. + * + * @param {string} value JSON-encoded cluster list. + * @returns {string[]} Ordered cluster names. + */ +function parseConfiguredClusters(value) { + const parsedValue = JSON.parse(value || "[]"); + return Array.isArray(parsedValue) ? parsedValue : []; +} + +/** + * Reads messenger configuration from the environment prepared by the workflow. + * + * @param {NodeJS.ProcessEnv} [env=process.env] Environment variables source. + * @returns {{ + * reportsDir: string, + * configuredClusters: string[], + * loop: { + * apiUrl: string, + * channelId: string, + * token: string + * } + * }} Normalized messenger configuration. + */ +function readMessengerConfigFromEnv(env = process.env) { + const configuredClusters = parseConfiguredClusters(env.STORAGE_TYPES); + + return { + reportsDir: env.REPORTS_DIR || "downloaded-artifacts", + configuredClusters, + loop: { + apiUrl: getLoopPostsApiUrl(env), + channelId: String(env.LOOP_CHANNEL_ID || "").trim(), + token: String(env.LOOP_TOKEN || "").trim(), + }, + }; +} + +module.exports = { + getLoopPostsApiUrl, + readMessengerConfigFromEnv, +}; diff --git a/.github/scripts/js/e2e/report/messenger/loop-client.js b/.github/scripts/js/e2e/report/messenger/loop-client.js new file mode 100644 index 0000000000..c22455245d --- /dev/null +++ b/.github/scripts/js/e2e/report/messenger/loop-client.js @@ -0,0 +1,132 @@ +/** + * Parses a Loop API response body if it is JSON, otherwise returns an empty + * object and emits a warning for diagnostics. + * + * @param {string} responseText Raw response body. + * @param {{ warning(message: string): void }} core GitHub core API. + * @returns {Record} Parsed response payload or an empty object. + */ +function parseLoopApiPayload(responseText, core) { + if (!responseText) { + return {}; + } + + try { + return JSON.parse(responseText); + } catch (error) { + core.warning( + `Loop API returned a non-JSON response body: ${error.message}` + ); + return {}; + } +} + +/** + * Sends a single post to Loop and returns the parsed API payload. + * + * @param {{ + * apiUrl: string, + * channelId: string, + * token: string, + * message: string, + * rootId?: string + * }} request Loop API request payload. + * @param {{ + * info(message: string): void, + * warning(message: string): void + * }} core GitHub core API. + * @returns {Promise>} Parsed Loop API response. + */ +async function postToLoopApi( + { apiUrl, channelId, token, message, rootId }, + core +) { + const response = await fetch(apiUrl, { + method: "POST", + headers: { + "Authorization": `Bearer ${token}`, + "Content-Type": "application/json", + }, + body: JSON.stringify({ + channel_id: channelId, + message, + ...(rootId ? { root_id: rootId } : {}), + }), + }); + const responseText = await response.text(); + + if (!response.ok) { + throw new Error( + `Loop API request failed with status ${response.status}: ${responseText}` + ); + } + + const payload = parseLoopApiPayload(responseText, core); + core.info(`Loop API accepted report with status ${response.status}`); + return payload; +} + +/** + * Publishes the main report and optional failed-tests thread to Loop. + * + * @param {{ + * message: string, + * threadMessages: string[], + * loop: { + * apiUrl: string, + * channelId: string, + * token: string + * } + * }} params Message payload and Loop credentials. + * @param {{ + * setOutput(name: string, value: string): void, + * info(message: string): void, + * warning(message: string): void + * }} core GitHub core API. + * @returns {Promise} + */ +async function publishToLoop({ message, threadMessages, loop }, core) { + if (!loop.apiUrl && !loop.channelId && !loop.token) { + return; + } + + if (!loop.apiUrl || !loop.channelId || !loop.token) { + throw new Error( + "LOOP_CHANNEL_ID, LOOP_TOKEN, and LOOP_API_BASE_URL are required" + ); + } + + const rootPost = await postToLoopApi( + { + apiUrl: loop.apiUrl, + channelId: loop.channelId, + token: loop.token, + message, + }, + core + ); + + let lastReplyPost = null; + for (const replyMessage of threadMessages) { + lastReplyPost = await postToLoopApi( + { + apiUrl: loop.apiUrl, + channelId: loop.channelId, + token: loop.token, + message: replyMessage, + rootId: rootPost.id, + }, + core + ); + } + + core.setOutput("root_post_id", rootPost.id || ""); + core.setOutput( + "thread_post_id", + lastReplyPost && lastReplyPost.id ? lastReplyPost.id : "" + ); +} + +module.exports = { + publishToLoop, +}; diff --git a/.github/scripts/js/e2e/report/messenger/markdown.js b/.github/scripts/js/e2e/report/messenger/markdown.js new file mode 100644 index 0000000000..2a0df87877 --- /dev/null +++ b/.github/scripts/js/e2e/report/messenger/markdown.js @@ -0,0 +1,203 @@ +const { + getReportClusterKey, + getReportDate, + isClusterFailureReport, + isMissingReport, + isTestResultReport, +} = require("./model"); + +function sanitizeCell(value) { + return String(value || "—") + .replace(/\|/g, "\\|") + .replace(/\r?\n/g, " ") + .trim(); +} + +function sanitizeListItem(value) { + return String(value || "") + .replace(/\r?\n/g, " ") + .trim(); +} + +function formatRate(value) { + const rate = Number(value || 0); + return `${Number.isFinite(rate) ? rate.toFixed(2) : "0.00"}%`; +} + +function formatClusterLink(report) { + const clusterName = sanitizeCell(report.cluster || report.storageType); + return report.workflowRunUrl + ? `[${clusterName}](${report.workflowRunUrl})` + : clusterName; +} + +function buildMainMessage(orderedReports) { + const reportDate = getReportDate(orderedReports); + const branches = Array.from( + new Set(orderedReports.map((report) => report.branch).filter(Boolean)) + ); + const lines = [`## DVP | E2E on nested clusters | ${reportDate}`, ""]; + + if (branches.length === 1 && branches[0] !== "main") { + lines.push(`Branch: \`${branches[0]}\``); + lines.push(""); + } + + const testsReports = orderedReports.filter( + (report) => isTestResultReport(report) && getReportClusterKey(report) + ); + const stageFailureReports = orderedReports.filter( + (report) => isClusterFailureReport(report) && getReportClusterKey(report) + ); + const missingReports = orderedReports.filter( + (report) => + isMissingReport(report) && + !isClusterFailureReport(report) && + getReportClusterKey(report) + ); + + if (testsReports.length > 0) { + lines.push("### Test results"); + lines.push(""); + lines.push( + "| Cluster | ✅ Passed | ⏭️ Skipped | ❌ Failed | ⚠️ Errors | Total | Success Rate |" + ); + lines.push("|---|---:|---:|---:|---:|---:|---:|"); + + for (const report of testsReports) { + const metrics = report.metrics || {}; + lines.push( + `| ${formatClusterLink(report)} | ${metrics.passed || 0} | ${ + metrics.skipped || 0 + } | ${metrics.failed || 0} | ${metrics.errors || 0} | ${ + metrics.total || 0 + } | ${formatRate(metrics.successRate)} |` + ); + } + + lines.push(""); + } + + if (stageFailureReports.length > 0) { + lines.push("### Cluster failures"); + lines.push(""); + + for (const report of stageFailureReports) { + lines.push( + `- ${formatClusterLink(report)}: ${sanitizeListItem( + (report.clusterStatus && report.clusterStatus.message) || + report.statusMessage || + report.failedStageLabel || + report.failedStage + )}` + ); + } + + lines.push(""); + } + + if (missingReports.length > 0) { + lines.push("### Missing reports"); + lines.push(""); + + for (const report of missingReports) { + const missingMessage = + report.clusterStatus && report.clusterStatus.status === "missing" + ? report.clusterStatus.message + : report.testStatus && report.testStatus.message; + lines.push( + `- ${formatClusterLink(report)}: ${sanitizeListItem( + missingMessage || + (report.clusterStatus && report.clusterStatus.message) || + report.statusMessage || + report.failedStageLabel || + report.failedStage + )}` + ); + } + + lines.push(""); + } + + return lines.join("\n").trim(); +} + +function hasFailedTests(report) { + if (Array.isArray(report.failedTests) && report.failedTests.length > 0) { + return true; + } + + return Boolean( + report.testStatus && + (report.testStatus.status === "failure" || + report.testStatus.status === "cancelled") || + (report.metrics && report.metrics.failed) || + (report.metrics && report.metrics.errors) + ); +} + +function getFailedTestGroupName(testName) { + const normalizedName = sanitizeListItem(testName).replace(/^\[[^\]]+\]\s*/, ""); + const [groupName] = normalizedName.split(/\s+/, 1); + return groupName || "Unknown"; +} + +function summarizeFailedTestGroups(failedTests) { + const groupNames = []; + + for (const testName of failedTests) { + const groupName = getFailedTestGroupName(testName); + if (!groupNames.includes(groupName)) { + groupNames.push(groupName); + } + } + + return groupNames; +} + +function buildFailedTestsClusterMessage(report) { + const clusterName = sanitizeListItem(report.cluster || report.storageType); + const lines = [`**${clusterName}**`]; + + if (Array.isArray(report.failedTests) && report.failedTests.length > 0) { + const failedGroups = summarizeFailedTestGroups(report.failedTests); + lines.push(""); + lines.push("| Test group |"); + lines.push("|---|"); + for (const groupName of failedGroups) { + lines.push(`| ${sanitizeCell(groupName)} |`); + } + } else { + lines.push( + `- ${ + sanitizeListItem(report.testStatus && report.testStatus.message) || + "No testcase-level failures were collected, but the E2E stage reported failures." + }` + ); + } + + return lines.join("\n"); +} + +function buildThreadMessages(orderedReports) { + const testsReports = orderedReports.filter( + (report) => isTestResultReport(report) + ); + const failedTestReports = testsReports.filter(hasFailedTests); + + if (failedTestReports.length === 0) { + return []; + } + + return failedTestReports.map((report, index) => { + const clusterMessage = buildFailedTestsClusterMessage(report); + return index === 0 + ? ["### Failed tests", clusterMessage].join("\n\n") + : clusterMessage; + }); +} + +module.exports = { + buildMainMessage, + buildThreadMessages, +}; diff --git a/.github/scripts/js/e2e/report/messenger/model.js b/.github/scripts/js/e2e/report/messenger/model.js new file mode 100644 index 0000000000..b2f1f033bb --- /dev/null +++ b/.github/scripts/js/e2e/report/messenger/model.js @@ -0,0 +1,194 @@ +const genericArtifactMissingLabel = "E2E REPORT ARTIFACT NOT FOUND"; + +/** + * Builds a user-facing status line for a cluster row or fallback report. + * + * @param {string} status Normalized cluster status. + * @param {string} stageLabel Human-readable stage label. + * @returns {string} Rendered status message. + */ +function buildStatusMessage(status, stageLabel) { + if (status === "cancelled") { + return `⚠️ ${stageLabel} CANCELLED`; + } + + if (status === "failure") { + return `❌ ${stageLabel} FAILED`; + } + + if (status === "missing") { + return `⚠️ ${stageLabel}`; + } + + if (status === "success") { + return "✅ SUCCESS"; + } + + return stageLabel; +} + +/** + * Creates a synthetic cluster report when the expected JSON artifact is absent. + * + * This allows the final messenger message to stay informative even when the + * report-preparation step failed or never produced an artifact. + * + * @param {string} clusterName Cluster or storage name. + * @returns {Record} Synthetic report payload. + */ +function createMissingReport(clusterName) { + return { + schemaVersion: 1, + cluster: clusterName, + storageType: clusterName, + reportKind: "artifact-missing", + status: "missing", + statusMessage: buildStatusMessage("missing", genericArtifactMissingLabel), + failedStage: "artifact-missing", + failedStageLabel: genericArtifactMissingLabel, + branch: "", + workflowRunUrl: "", + clusterStatus: { + status: "missing", + stage: "artifact-missing", + stageLabel: genericArtifactMissingLabel, + message: buildStatusMessage("missing", genericArtifactMissingLabel), + reason: "cluster-report-artifact-missing", + }, + testStatus: { + status: "not-run", + reason: "cluster-report-artifact-missing", + message: "E2E status is unavailable because cluster report artifact was not found", + }, + metrics: { + passed: 0, + failed: 0, + errors: 0, + skipped: 0, + total: 0, + successRate: 0, + }, + failedTests: [], + reportSource: "missing-artifact", + }; +} + +/** + * Picks a report date from the first report that exposes `startedAt`. + * + * @param {Record[]} reports Available cluster reports. + * @returns {string} ISO date string (`YYYY-MM-DD`). + */ +function getReportDate(reports) { + const datedReport = reports.find((report) => report.startedAt); + if (!datedReport) { + return new Date().toISOString().slice(0, 10); + } + + return String(datedReport.startedAt).slice(0, 10); +} + +/** + * Orders reports by the configured cluster order and then by cluster name. + * + * @param {Record[]} reports Reports to sort. + * @param {string[]} preferredOrder Configured cluster order. + * @returns {Record[]} Sorted reports copy. + */ +function sortReports(reports, preferredOrder) { + const orderMap = new Map(preferredOrder.map((name, index) => [name, index])); + + return [...reports].sort((left, right) => { + const leftKey = left.storageType || left.cluster; + const rightKey = right.storageType || right.cluster; + const leftOrder = orderMap.has(leftKey) + ? orderMap.get(leftKey) + : Number.MAX_SAFE_INTEGER; + const rightOrder = orderMap.has(rightKey) + ? orderMap.get(rightKey) + : Number.MAX_SAFE_INTEGER; + + if (leftOrder !== rightOrder) { + return leftOrder - rightOrder; + } + + return String(left.cluster || left.storageType).localeCompare( + String(right.cluster || right.storageType) + ); + }); +} + +/** + * Extracts the normalized cluster key from a report payload. + * + * @param {Record} report Cluster report payload. + * @returns {string} Cluster key or an empty string when it is missing. + */ +function getReportClusterKey(report) { + return String(report.storageType || report.cluster || "").trim(); +} + +/** + * Tells whether the report represents a missing artifact rather than a real + * cluster-stage failure. + * + * @param {Record} report Cluster report payload. + * @returns {boolean} True when the report describes a missing artifact. + */ +function isMissingReport(report) { + return ( + (report.testStatus && report.testStatus.status === "missing") || + (report.clusterStatus && report.clusterStatus.status === "missing") || + report.reportKind === "artifact-missing" || + report.failedStage === "artifact-missing" || + report.status === "missing" + ); +} + +/** + * Tells whether the report describes a failed cluster setup stage. + * + * @param {Record} report Cluster report payload. + * @returns {boolean} True for cluster-stage failures. + */ +function isClusterFailureReport(report) { + if (report.clusterStatus) { + return ( + report.clusterStatus.status !== "success" && + report.clusterStatus.status !== "missing" + ); + } + + return report.reportKind !== "tests" && !isMissingReport(report); +} + +/** + * Tells whether the report should be rendered in the E2E test results table. + * + * @param {Record} report Cluster report payload. + * @returns {boolean} True for reports with test status data. + */ +function isTestResultReport(report) { + if (report.clusterStatus && report.clusterStatus.status !== "success") { + return false; + } + + if (report.testStatus) { + return ( + report.testStatus.status !== "not-run" && + report.testStatus.status !== "missing" + ); + } + + return report.reportKind === "tests"; +} + +module.exports = { + createMissingReport, + getReportClusterKey, + getReportDate, + isClusterFailureReport, + isMissingReport, + isTestResultReport, + sortReports, +}; diff --git a/.github/scripts/js/e2e/report/fs-utils.js b/.github/scripts/js/e2e/report/shared/fs-utils.js similarity index 100% rename from .github/scripts/js/e2e/report/fs-utils.js rename to .github/scripts/js/e2e/report/shared/fs-utils.js diff --git a/.github/scripts/js/e2e/report/fs-utils.test.js b/.github/scripts/js/e2e/report/shared/fs-utils.test.js similarity index 100% rename from .github/scripts/js/e2e/report/fs-utils.test.js rename to .github/scripts/js/e2e/report/shared/fs-utils.test.js diff --git a/.github/scripts/js/e2e/report/ginkgo-report-utils.js b/.github/scripts/js/e2e/report/shared/ginkgo-report-utils.js similarity index 100% rename from .github/scripts/js/e2e/report/ginkgo-report-utils.js rename to .github/scripts/js/e2e/report/shared/ginkgo-report-utils.js From 7f899726b14af18b13641378e2f18b895200df90 Mon Sep 17 00:00:00 2001 From: Nikita Korolev Date: Tue, 5 May 2026 15:34:02 +0300 Subject: [PATCH 05/22] fix jsdoc Signed-off-by: Nikita Korolev --- .../scripts/js/e2e/report/cluster-report.js | 101 +++++++++--------- .../js/e2e/report/cluster-report.test.js | 29 ++--- .../scripts/js/e2e/report/messenger-report.js | 39 ++++--- .../js/e2e/report/messenger-report.test.js | 2 +- .../js/e2e/report/messenger/loop-client.js | 54 +++++----- .../scripts/js/e2e/report/messenger/model.js | 6 +- .../js/e2e/report/shared/fs-utils.test.js | 2 +- .../e2e/report/shared/ginkgo-report-utils.js | 28 +++-- 8 files changed, 134 insertions(+), 127 deletions(-) diff --git a/.github/scripts/js/e2e/report/cluster-report.js b/.github/scripts/js/e2e/report/cluster-report.js index 3cfc2c9e44..72ec547b6f 100644 --- a/.github/scripts/js/e2e/report/cluster-report.js +++ b/.github/scripts/js/e2e/report/cluster-report.js @@ -32,6 +32,50 @@ const clusterSetupStages = [ "virtualization-setup", ]; +/** + * @typedef {Record} StageResults + */ + +/** + * @typedef {Object} GinkgoMetrics + * @property {number} [failed] + * @property {number} [errors] + */ + +/** + * @typedef {Object} ClusterReportCore + * @property {function(string): void} info + * @property {function(string): void} warning + * @property {function(string, string): void} setOutput + */ + +/** + * @typedef {Object} ClusterReportContext + * @property {string} serverUrl + * @property {{ owner: string, repo: string }} repo + * @property {string|number} runId + * @property {string} [ref] + */ + +/** + * @typedef {Object} ClusterReportConfig + * @property {string} storageType + * @property {string} reportsDir + * @property {string} reportFile + * @property {string} [workflowRunUrl] + * @property {string} [workflowRunUrlOverride] + * @property {string} [branchName] + * @property {string} [branchNameOverride] + * @property {StageResults} stageResults + */ + +/** + * @typedef {Object} ClusterReportParams + * @property {ClusterReportCore} core + * @property {ClusterReportContext} context + * @property {ClusterReportConfig} [config] + */ + /** * Escapes special characters in a string for safe use inside a RegExp source. * @@ -47,20 +91,7 @@ function escapeRegExp(value) { * reusable workflow or the local helper script. * * @param {NodeJS.ProcessEnv} [env=process.env] Environment variables source. - * @returns {{ - * storageType: string, - * reportsDir: string, - * reportFile: string, - * workflowRunUrlOverride: string, - * branchNameOverride: string, - * stageResults: { - * "bootstrap": string|undefined, - * "configure-sdn": string|undefined, - * "storage-setup": string|undefined, - * "virtualization-setup": string|undefined, - * "e2e-test": string|undefined - * } - * }} Normalized cluster report configuration. + * @returns {ClusterReportConfig} Normalized cluster report configuration. */ function readClusterConfigFromEnv(env = process.env) { const storageType = env.STORAGE_TYPE; @@ -149,12 +180,7 @@ function normalizeJobResult(resultValue) { /** * Builds the cluster setup status from pre-E2E workflow stages. * - * @param {{ - * "bootstrap": string|undefined, - * "configure-sdn": string|undefined, - * "storage-setup": string|undefined, - * "virtualization-setup": string|undefined - * }} stageResults Per-stage GitHub Actions results. + * @param {StageResults} stageResults Per-stage GitHub Actions results. * @returns {{ * status: string, * stage: string, @@ -196,10 +222,7 @@ function buildClusterStatus(stageResults) { * @param {string|undefined} testResult Raw E2E job result. * @param {string} reportSource Parsed report source. * @param {{ status: string }} clusterStatus Cluster setup status. - * @param {{ - * failed?: number, - * errors?: number - * }} [metrics={}] Parsed Ginkgo metrics. + * @param {GinkgoMetrics} [metrics={}] Parsed Ginkgo metrics. * @returns {{ * status: string, * reason: string, @@ -331,7 +354,7 @@ function buildLegacyDescriptor(storageType, clusterStatus, testStatus) { * * @param {Record} report Final cluster report payload. * @param {string} reportFile Path to the written JSON report file. - * @param {{ setOutput(name: string, value: string): void }} core GitHub core API. + * @param {ClusterReportCore} core GitHub core API. */ function setReportOutputs(report, reportFile, core) { core.setOutput("report_file", reportFile); @@ -347,33 +370,7 @@ function setReportOutputs(report, reportFile, core) { * Builds a per-cluster JSON report from workflow stage results and an optional * raw Ginkgo JSON report, writes it to disk, and publishes step outputs. * - * @param {{ - * core: { - * info(message: string): void, - * warning(message: string): void, - * setOutput(name: string, value: string): void - * }, - * context: { - * serverUrl: string, - * repo: { owner: string, repo: string }, - * runId: string|number, - * ref?: string - * }, - * config?: { - * storageType: string, - * reportsDir: string, - * reportFile: string, - * workflowRunUrl?: string, - * branchName?: string, - * stageResults: { - * "bootstrap": string|undefined, - * "configure-sdn": string|undefined, - * "storage-setup": string|undefined, - * "virtualization-setup": string|undefined, - * "e2e-test": string|undefined - * } - * } - * }} params GitHub script dependencies. + * @param {ClusterReportParams} params GitHub script dependencies. * @returns {Promise>} Generated cluster report. */ async function buildClusterReport({ core, context, config: explicitConfig }) { diff --git a/.github/scripts/js/e2e/report/cluster-report.test.js b/.github/scripts/js/e2e/report/cluster-report.test.js index bcd4a7e634..d5678cac56 100644 --- a/.github/scripts/js/e2e/report/cluster-report.test.js +++ b/.github/scripts/js/e2e/report/cluster-report.test.js @@ -49,7 +49,7 @@ function createContext() { * Runs a test body inside a temporary directory and removes it afterwards. * * @template T - * @param {(tempDir: string) => Promise|T} testFn Test body. + * @param {function(string): (Promise|T)} testFn Test body. * @returns {Promise} Test result. */ async function withTempDir(testFn) { @@ -78,20 +78,23 @@ function setStageEnv(overrides = {}) { Object.assign(process.env, overrides); } +/** + * @typedef {Object} SpecReportOptions + * @property {string[]} [containerHierarchyTexts] + * @property {Array} [containerHierarchyLabels] + * @property {string} [leafNodeText] + * @property {string} [leafNodeType] + * @property {string[]} [leafNodeLabels] + * @property {string} [state] + * @property {string} [startTime] + * @property {string} [endTime] + * @property {Record|undefined} [failure] + */ + /** * Creates a synthetic Ginkgo spec report for parser tests. * - * @param {{ - * containerHierarchyTexts?: string[], - * containerHierarchyLabels?: Array, - * leafNodeText?: string, - * leafNodeType?: string, - * leafNodeLabels?: string[], - * state?: string, - * startTime?: string, - * endTime?: string, - * failure?: Record|undefined - * }} [options={}] Spec overrides. + * @param {SpecReportOptions} [options={}] Spec overrides. * @returns {Record} Synthetic spec report. */ function createSpecReport({ @@ -125,7 +128,7 @@ function createSpecReport({ /** * Creates a serialized single-suite Ginkgo report for unit tests. * - * @param {{ startedAt: string, specs: Record[] }} params Report contents. + * @param {{ startedAt: string, specs: Array> }} params Report contents. * @returns {string} JSON-serialized report. */ function createGinkgoReport({ startedAt, specs }) { diff --git a/.github/scripts/js/e2e/report/messenger-report.js b/.github/scripts/js/e2e/report/messenger-report.js index 09bef43b05..b6307574bd 100644 --- a/.github/scripts/js/e2e/report/messenger-report.js +++ b/.github/scripts/js/e2e/report/messenger-report.js @@ -28,14 +28,34 @@ const { buildThreadMessages, } = require("./messenger/markdown"); +/** + * @typedef {Object} MessengerReportCore + * @property {function(string): void} warning + * @property {function(string): void} [info] + * @property {function(string, string): void} [setOutput] + */ + +/** + * @typedef {Object} MessengerMessagesParams + * @property {string} reportsDir + * @property {string[]} configuredClusters + * @property {MessengerReportCore} core + */ + +/** + * @typedef {Object} RenderMessengerReportParams + * @property {MessengerReportCore} core + * @property {string} [reportsDir] + */ + /** * Loads report JSON files from disk and injects synthetic reports for clusters * whose artifacts are missing. * * @param {string} reportsDir Directory containing `e2e_report_*.json`. * @param {string[]} configuredClusters Clusters expected in the final report. - * @param {{ warning(message: string): void }} core GitHub core API. - * @returns {Record[]} Ordered cluster reports. + * @param {MessengerReportCore} core GitHub core API. + * @returns {Array>} Ordered cluster reports. */ function readReports(reportsDir, configuredClusters, core) { const reportFiles = listMatchingFiles(reportsDir, /^e2e_report_.*\.json$/); @@ -74,11 +94,7 @@ function readReports(reportsDir, configuredClusters, core) { /** * Reads cluster reports from disk and builds both messenger message bodies. * - * @param {{ - * reportsDir: string, - * configuredClusters: string[], - * core: { warning(message: string): void } - * }} params Message rendering inputs. + * @param {MessengerMessagesParams} params Message rendering inputs. * @returns {{ * message: string, * threadMessage: string, @@ -107,14 +123,7 @@ function buildMessengerMessages({ * Entry point used by `actions/github-script` to render and optionally publish * the aggregated E2E messenger report. * - * @param {{ - * core: { - * info(message: string): void, - * warning(message: string): void, - * setOutput(name: string, value: string): void - * }, - * reportsDir?: string - * }} params GitHub script dependencies. + * @param {RenderMessengerReportParams} params GitHub script dependencies. * @returns {Promise<{ * message: string, * threadMessage: string, diff --git a/.github/scripts/js/e2e/report/messenger-report.test.js b/.github/scripts/js/e2e/report/messenger-report.test.js index a439d8e32d..ba27beab3a 100644 --- a/.github/scripts/js/e2e/report/messenger-report.test.js +++ b/.github/scripts/js/e2e/report/messenger-report.test.js @@ -28,7 +28,7 @@ function createCore() { * Runs a test body inside a temporary directory and removes it afterwards. * * @template T - * @param {(tempDir: string) => Promise|T} testFn Test body. + * @param {function(string): (Promise|T)} testFn Test body. * @returns {Promise} Test result. */ async function withTempDir(testFn) { diff --git a/.github/scripts/js/e2e/report/messenger/loop-client.js b/.github/scripts/js/e2e/report/messenger/loop-client.js index c22455245d..762763a0c5 100644 --- a/.github/scripts/js/e2e/report/messenger/loop-client.js +++ b/.github/scripts/js/e2e/report/messenger/loop-client.js @@ -1,9 +1,32 @@ +/** + * @typedef {Object} LoopClientCore + * @property {function(string): void} warning + * @property {function(string): void} [info] + * @property {function(string, string): void} [setOutput] + */ + +/** + * @typedef {Object} LoopPostRequest + * @property {string} apiUrl + * @property {string} channelId + * @property {string} token + * @property {string} message + * @property {string} [rootId] + */ + +/** + * @typedef {Object} LoopPublishParams + * @property {string} message + * @property {string[]} threadMessages + * @property {{ apiUrl: string, channelId: string, token: string }} loop + */ + /** * Parses a Loop API response body if it is JSON, otherwise returns an empty * object and emits a warning for diagnostics. * * @param {string} responseText Raw response body. - * @param {{ warning(message: string): void }} core GitHub core API. + * @param {LoopClientCore} core GitHub core API. * @returns {Record} Parsed response payload or an empty object. */ function parseLoopApiPayload(responseText, core) { @@ -24,17 +47,8 @@ function parseLoopApiPayload(responseText, core) { /** * Sends a single post to Loop and returns the parsed API payload. * - * @param {{ - * apiUrl: string, - * channelId: string, - * token: string, - * message: string, - * rootId?: string - * }} request Loop API request payload. - * @param {{ - * info(message: string): void, - * warning(message: string): void - * }} core GitHub core API. + * @param {LoopPostRequest} request Loop API request payload. + * @param {LoopClientCore} core GitHub core API. * @returns {Promise>} Parsed Loop API response. */ async function postToLoopApi( @@ -69,20 +83,8 @@ async function postToLoopApi( /** * Publishes the main report and optional failed-tests thread to Loop. * - * @param {{ - * message: string, - * threadMessages: string[], - * loop: { - * apiUrl: string, - * channelId: string, - * token: string - * } - * }} params Message payload and Loop credentials. - * @param {{ - * setOutput(name: string, value: string): void, - * info(message: string): void, - * warning(message: string): void - * }} core GitHub core API. + * @param {LoopPublishParams} params Message payload and Loop credentials. + * @param {LoopClientCore} core GitHub core API. * @returns {Promise} */ async function publishToLoop({ message, threadMessages, loop }, core) { diff --git a/.github/scripts/js/e2e/report/messenger/model.js b/.github/scripts/js/e2e/report/messenger/model.js index b2f1f033bb..79b7add08c 100644 --- a/.github/scripts/js/e2e/report/messenger/model.js +++ b/.github/scripts/js/e2e/report/messenger/model.js @@ -76,7 +76,7 @@ function createMissingReport(clusterName) { /** * Picks a report date from the first report that exposes `startedAt`. * - * @param {Record[]} reports Available cluster reports. + * @param {Array>} reports Available cluster reports. * @returns {string} ISO date string (`YYYY-MM-DD`). */ function getReportDate(reports) { @@ -91,9 +91,9 @@ function getReportDate(reports) { /** * Orders reports by the configured cluster order and then by cluster name. * - * @param {Record[]} reports Reports to sort. + * @param {Array>} reports Reports to sort. * @param {string[]} preferredOrder Configured cluster order. - * @returns {Record[]} Sorted reports copy. + * @returns {Array>} Sorted reports copy. */ function sortReports(reports, preferredOrder) { const orderMap = new Map(preferredOrder.map((name, index) => [name, index])); diff --git a/.github/scripts/js/e2e/report/shared/fs-utils.test.js b/.github/scripts/js/e2e/report/shared/fs-utils.test.js index 7b72021a38..90b91fee4b 100644 --- a/.github/scripts/js/e2e/report/shared/fs-utils.test.js +++ b/.github/scripts/js/e2e/report/shared/fs-utils.test.js @@ -8,7 +8,7 @@ const { listMatchingFiles } = require("./fs-utils"); * Runs a test body inside a temporary directory and removes it afterwards. * * @template T - * @param {(tempDir: string) => Promise|T} testFn Test body. + * @param {function(string): (Promise|T)} testFn Test body. * @returns {Promise} Test result. */ async function withTempDir(testFn) { diff --git a/.github/scripts/js/e2e/report/shared/ginkgo-report-utils.js b/.github/scripts/js/e2e/report/shared/ginkgo-report-utils.js index 1242f68e2f..7e6dda7276 100644 --- a/.github/scripts/js/e2e/report/shared/ginkgo-report-utils.js +++ b/.github/scripts/js/e2e/report/shared/ginkgo-report-utils.js @@ -1,3 +1,13 @@ +/** + * @typedef {Object} GinkgoMetrics + * @property {number} passed + * @property {number} failed + * @property {number} errors + * @property {number} skipped + * @property {number} total + * @property {number} successRate + */ + /** * Normalizes a value into an array. * @@ -90,23 +100,9 @@ function metricKeyForState(state) { * markdown report. * * @param {string} jsonContent Raw JSON content. - * @param {() => { - * passed: number, - * failed: number, - * errors: number, - * skipped: number, - * total: number, - * successRate: number - * }} createZeroMetrics Factory creating a zeroed metrics object. + * @param {function(): GinkgoMetrics} createZeroMetrics Factory creating a zeroed metrics object. * @returns {{ - * metrics: { - * passed: number, - * failed: number, - * errors: number, - * skipped: number, - * total: number, - * successRate: number - * }, + * metrics: GinkgoMetrics, * failedTests: string[], * startedAt: string|null * }} Parsed report payload. From 2ed3f5975a1eaeb33e7a007906d9fa40a288a01d Mon Sep 17 00:00:00 2001 From: Nikita Korolev Date: Tue, 5 May 2026 15:51:53 +0300 Subject: [PATCH 06/22] rm unused code Signed-off-by: Nikita Korolev --- .../scripts/js/e2e/report/cluster-report.js | 47 +----- .../js/e2e/report/cluster-report.test.js | 151 ++++++++---------- .../scripts/js/e2e/report/messenger-report.js | 9 +- .../js/e2e/report/messenger-report.test.js | 2 +- .../js/e2e/report/messenger/markdown.js | 12 ++ 5 files changed, 91 insertions(+), 130 deletions(-) diff --git a/.github/scripts/js/e2e/report/cluster-report.js b/.github/scripts/js/e2e/report/cluster-report.js index 72ec547b6f..3bcaba9dd8 100644 --- a/.github/scripts/js/e2e/report/cluster-report.js +++ b/.github/scripts/js/e2e/report/cluster-report.js @@ -63,9 +63,7 @@ const clusterSetupStages = [ * @property {string} reportsDir * @property {string} reportFile * @property {string} [workflowRunUrl] - * @property {string} [workflowRunUrlOverride] * @property {string} [branchName] - * @property {string} [branchNameOverride] * @property {StageResults} stageResults */ @@ -73,7 +71,7 @@ const clusterSetupStages = [ * @typedef {Object} ClusterReportParams * @property {ClusterReportCore} core * @property {ClusterReportContext} context - * @property {ClusterReportConfig} [config] + * @property {ClusterReportConfig} config */ /** @@ -86,32 +84,6 @@ function escapeRegExp(value) { return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); } -/** - * Reads cluster report configuration from environment variables injected by the - * reusable workflow or the local helper script. - * - * @param {NodeJS.ProcessEnv} [env=process.env] Environment variables source. - * @returns {ClusterReportConfig} Normalized cluster report configuration. - */ -function readClusterConfigFromEnv(env = process.env) { - const storageType = env.STORAGE_TYPE; - - return { - storageType, - reportsDir: env.E2E_REPORT_DIR || "test/e2e", - reportFile: env.REPORT_FILE || `e2e_report_${storageType}.json`, - workflowRunUrlOverride: env.WORKFLOW_RUN_URL || "", - branchNameOverride: env.BRANCH_NAME || "", - stageResults: { - "bootstrap": env.BOOTSTRAP_RESULT, - "configure-sdn": env.CONFIGURE_SDN_RESULT, - "storage-setup": env.CONFIGURE_STORAGE_RESULT, - "virtualization-setup": env.CONFIGURE_VIRTUALIZATION_RESULT, - "e2e-test": env.E2E_TEST_RESULT, - }, - }; -} - /** * Creates a zero-filled metrics object for cluster report defaults. * @@ -372,17 +344,18 @@ function setReportOutputs(report, reportFile, core) { * * @param {ClusterReportParams} params GitHub script dependencies. * @returns {Promise>} Generated cluster report. + * @throws {Error} If `config` is missing or the report file cannot be written. */ -async function buildClusterReport({ core, context, config: explicitConfig }) { - const config = explicitConfig || readClusterConfigFromEnv(); +async function buildClusterReport({ core, context, config } = {}) { + if (!config) { + throw new Error("buildClusterReport requires a config object"); + } + const workflowRunUrl = config.workflowRunUrl || - config.workflowRunUrlOverride || `${context.serverUrl}/${context.repo.owner}/${context.repo.repo}/actions/runs/${context.runId}`; const branchName = - config.branchName || - config.branchNameOverride || - String(context.ref || "").replace(/^refs\/heads\//, ""); + config.branchName || String(context.ref || "").replace(/^refs\/heads\//, ""); const rawReportPattern = new RegExp( `^e2e_report_${escapeRegExp(config.storageType)}_.*\\.json$` ); @@ -470,7 +443,3 @@ async function buildClusterReport({ core, context, config: explicitConfig }) { module.exports = buildClusterReport; module.exports.buildClusterStatus = buildClusterStatus; -module.exports.buildTestStatus = buildTestStatus; -module.exports.parseGinkgoReport = parseGinkgoReport; -module.exports.buildLegacyDescriptor = buildLegacyDescriptor; -module.exports.readClusterConfigFromEnv = readClusterConfigFromEnv; diff --git a/.github/scripts/js/e2e/report/cluster-report.test.js b/.github/scripts/js/e2e/report/cluster-report.test.js index d5678cac56..ab572489fd 100644 --- a/.github/scripts/js/e2e/report/cluster-report.test.js +++ b/.github/scripts/js/e2e/report/cluster-report.test.js @@ -5,7 +5,6 @@ const path = require("path"); const buildClusterReport = require("./cluster-report"); const { buildClusterStatus } = require("./cluster-report"); const { parseGinkgoReport } = require("./shared/ginkgo-report-utils"); -const { readClusterConfigFromEnv } = require("./cluster-report"); /** * Creates a mocked GitHub Actions core object for unit tests. @@ -64,18 +63,26 @@ async function withTempDir(testFn) { } /** - * Seeds environment variables representing workflow stage results. + * Creates explicit cluster report config for unit tests. * - * @param {Record} [overrides={}] Environment overrides. + * @param {Partial>} [overrides={}] Config overrides. + * @returns {Record} Cluster report config. */ -function setStageEnv(overrides = {}) { - process.env.STORAGE_TYPE = "replicated"; - process.env.BOOTSTRAP_RESULT = "success"; - process.env.CONFIGURE_SDN_RESULT = "success"; - process.env.CONFIGURE_STORAGE_RESULT = "success"; - process.env.CONFIGURE_VIRTUALIZATION_RESULT = "success"; - process.env.E2E_TEST_RESULT = "success"; - Object.assign(process.env, overrides); +function createClusterConfig(overrides = {}) { + return { + storageType: "replicated", + reportsDir: "test/e2e", + reportFile: "e2e_report_replicated.json", + ...overrides, + stageResults: { + "bootstrap": "success", + "configure-sdn": "success", + "storage-setup": "success", + "virtualization-setup": "success", + "e2e-test": "success", + ...(overrides.stageResults || {}), + }, + }; } /** @@ -173,47 +180,13 @@ function createZeroMetrics() { } describe("cluster-report", () => { - afterEach(() => { - delete process.env.STORAGE_TYPE; - delete process.env.E2E_REPORT_DIR; - delete process.env.REPORT_FILE; - delete process.env.BRANCH_NAME; - delete process.env.WORKFLOW_RUN_URL; - delete process.env.BOOTSTRAP_RESULT; - delete process.env.CONFIGURE_SDN_RESULT; - delete process.env.CONFIGURE_STORAGE_RESULT; - delete process.env.CONFIGURE_VIRTUALIZATION_RESULT; - delete process.env.E2E_TEST_RESULT; - }); - - test("reads cluster config from env", () => { - const config = readClusterConfigFromEnv({ - STORAGE_TYPE: "replicated", - E2E_REPORT_DIR: "custom-reports", - REPORT_FILE: "custom.json", - WORKFLOW_RUN_URL: "https://example.invalid/run/1", - BRANCH_NAME: "release", - BOOTSTRAP_RESULT: "success", - CONFIGURE_SDN_RESULT: "failure", - CONFIGURE_STORAGE_RESULT: "skipped", - CONFIGURE_VIRTUALIZATION_RESULT: "skipped", - E2E_TEST_RESULT: "skipped", - }); - - expect(config).toEqual({ - storageType: "replicated", - reportsDir: "custom-reports", - reportFile: "custom.json", - workflowRunUrlOverride: "https://example.invalid/run/1", - branchNameOverride: "release", - stageResults: { - "bootstrap": "success", - "configure-sdn": "failure", - "storage-setup": "skipped", - "virtualization-setup": "skipped", - "e2e-test": "skipped", - }, - }); + test("requires explicit config", async () => { + await expect( + buildClusterReport({ + core: createCore(), + context: createContext(), + }) + ).rejects.toThrow("buildClusterReport requires a config object"); }); test("determines cluster setup status from explicit stage results", () => { @@ -305,15 +278,16 @@ describe("cluster-report", () => { ); const reportFile = path.join(tempDir, "report.json"); - setStageEnv({ - E2E_REPORT_DIR: tempDir, - REPORT_FILE: reportFile, + const config = createClusterConfig({ + reportsDir: tempDir, + reportFile, }); const core = createCore(); const report = await buildClusterReport({ core, context: createContext(), + config, }); expect(report.reportKind).toBe("tests"); @@ -388,15 +362,16 @@ describe("cluster-report", () => { ); const reportFile = path.join(tempDir, "report.json"); - setStageEnv({ - E2E_REPORT_DIR: tempDir, - REPORT_FILE: reportFile, + const config = createClusterConfig({ + reportsDir: tempDir, + reportFile, }); await expect( buildClusterReport({ core: createCore(), context: createContext(), + config, }) ).rejects.toThrow( "Expected a single Ginkgo JSON report, but found 2" @@ -413,15 +388,16 @@ describe("cluster-report", () => { fs.writeFileSync(rawReportPath, "{not-valid-json"); const reportFile = path.join(tempDir, "report.json"); - setStageEnv({ - E2E_REPORT_DIR: tempDir, - REPORT_FILE: reportFile, + const config = createClusterConfig({ + reportsDir: tempDir, + reportFile, }); const core = createCore(); const report = await buildClusterReport({ core, context: createContext(), + config, }); expect(report.reportKind).toBe("artifact-missing"); @@ -440,9 +416,9 @@ describe("cluster-report", () => { test("throws a descriptive error when writing the cluster report fails", async () => withTempDir(async (tempDir) => { const reportFile = path.join(tempDir, "report.json"); - setStageEnv({ - E2E_REPORT_DIR: tempDir, - REPORT_FILE: reportFile, + const config = createClusterConfig({ + reportsDir: tempDir, + reportFile, }); const writeSpy = jest @@ -456,6 +432,7 @@ describe("cluster-report", () => { buildClusterReport({ core: createCore(), context: createContext(), + config, }) ).rejects.toThrow( `Unable to write cluster report file ${reportFile}: disk full` @@ -553,18 +530,21 @@ describe("cluster-report", () => { test("reports configure-sdn as the failed pre-E2E phase", async () => withTempDir(async (tempDir) => { const reportFile = path.join(tempDir, "report.json"); - setStageEnv({ - E2E_REPORT_DIR: tempDir, - REPORT_FILE: reportFile, - CONFIGURE_SDN_RESULT: "failure", - CONFIGURE_STORAGE_RESULT: "skipped", - CONFIGURE_VIRTUALIZATION_RESULT: "skipped", - E2E_TEST_RESULT: "skipped", + const config = createClusterConfig({ + reportsDir: tempDir, + reportFile, + stageResults: { + "configure-sdn": "failure", + "storage-setup": "skipped", + "virtualization-setup": "skipped", + "e2e-test": "skipped", + }, }); const report = await buildClusterReport({ core: createCore(), context: createContext(), + config, }); expect(report.reportKind).toBe("stage-failure"); @@ -585,14 +565,15 @@ describe("cluster-report", () => { test("marks missing artifacts when test stage is successful but no reports were found", async () => withTempDir(async (tempDir) => { const reportFile = path.join(tempDir, "report.json"); - setStageEnv({ - E2E_REPORT_DIR: tempDir, - REPORT_FILE: reportFile, + const config = createClusterConfig({ + reportsDir: tempDir, + reportFile, }); const report = await buildClusterReport({ core: createCore(), context: createContext(), + config, }); expect(report.reportKind).toBe("artifact-missing"); @@ -609,15 +590,18 @@ describe("cluster-report", () => { test("keeps cancelled test stage when no reports were found", async () => withTempDir(async (tempDir) => { const reportFile = path.join(tempDir, "report.json"); - setStageEnv({ - E2E_REPORT_DIR: tempDir, - REPORT_FILE: reportFile, - E2E_TEST_RESULT: "cancelled", + const config = createClusterConfig({ + reportsDir: tempDir, + reportFile, + stageResults: { + "e2e-test": "cancelled", + }, }); const report = await buildClusterReport({ core: createCore(), context: createContext(), + config, }); expect(report.reportKind).toBe("tests"); @@ -634,15 +618,18 @@ describe("cluster-report", () => { test("keeps failed test stage when no reports were found", async () => withTempDir(async (tempDir) => { const reportFile = path.join(tempDir, "report.json"); - setStageEnv({ - E2E_REPORT_DIR: tempDir, - REPORT_FILE: reportFile, - E2E_TEST_RESULT: "failure", + const config = createClusterConfig({ + reportsDir: tempDir, + reportFile, + stageResults: { + "e2e-test": "failure", + }, }); const report = await buildClusterReport({ core: createCore(), context: createContext(), + config, }); expect(report.reportKind).toBe("tests"); diff --git a/.github/scripts/js/e2e/report/messenger-report.js b/.github/scripts/js/e2e/report/messenger-report.js index b6307574bd..8edfa3b98e 100644 --- a/.github/scripts/js/e2e/report/messenger-report.js +++ b/.github/scripts/js/e2e/report/messenger-report.js @@ -14,10 +14,7 @@ const fs = require("fs"); const { listMatchingFiles } = require("./shared/fs-utils"); const { publishToLoop } = require("./messenger/loop-client"); -const { - getLoopPostsApiUrl, - readMessengerConfigFromEnv, -} = require("./messenger/config"); +const { readMessengerConfigFromEnv } = require("./messenger/config"); const { createMissingReport, getReportClusterKey, @@ -156,7 +153,3 @@ async function renderMessengerReport({ core, reportsDir }) { } module.exports = renderMessengerReport; -module.exports.createMissingReport = createMissingReport; -module.exports.buildMessengerMessages = buildMessengerMessages; -module.exports.getLoopPostsApiUrl = getLoopPostsApiUrl; -module.exports.readMessengerConfigFromEnv = readMessengerConfigFromEnv; diff --git a/.github/scripts/js/e2e/report/messenger-report.test.js b/.github/scripts/js/e2e/report/messenger-report.test.js index ba27beab3a..32ed15093b 100644 --- a/.github/scripts/js/e2e/report/messenger-report.test.js +++ b/.github/scripts/js/e2e/report/messenger-report.test.js @@ -3,7 +3,7 @@ const os = require("os"); const path = require("path"); const renderMessengerReport = require("./messenger-report"); -const { readMessengerConfigFromEnv } = require("./messenger-report"); +const { readMessengerConfigFromEnv } = require("./messenger/config"); /** * Creates a mocked GitHub Actions core object for unit tests. diff --git a/.github/scripts/js/e2e/report/messenger/markdown.js b/.github/scripts/js/e2e/report/messenger/markdown.js index 2a0df87877..f8ee72559a 100644 --- a/.github/scripts/js/e2e/report/messenger/markdown.js +++ b/.github/scripts/js/e2e/report/messenger/markdown.js @@ -31,6 +31,12 @@ function formatClusterLink(report) { : clusterName; } +/** + * Builds the main E2E messenger report body. + * + * @param {Array>} orderedReports Cluster reports in display order. + * @returns {string} Markdown message body. + */ function buildMainMessage(orderedReports) { const reportDate = getReportDate(orderedReports); const branches = Array.from( @@ -179,6 +185,12 @@ function buildFailedTestsClusterMessage(report) { return lines.join("\n"); } +/** + * Builds optional failed-tests thread messages for clusters with failed tests. + * + * @param {Array>} orderedReports Cluster reports in display order. + * @returns {string[]} Markdown thread message bodies. + */ function buildThreadMessages(orderedReports) { const testsReports = orderedReports.filter( (report) => isTestResultReport(report) From 6c3a3387572c0e8402674de18ab02115235884b2 Mon Sep 17 00:00:00 2001 From: Nikita Korolev Date: Tue, 5 May 2026 16:08:30 +0300 Subject: [PATCH 07/22] rename stagelabels to stagemessage Signed-off-by: Nikita Korolev --- .github/scripts/js/e2e/report/cluster-report.js | 14 +++++++------- .github/workflows/e2e-matrix.yml | 1 + 2 files changed, 8 insertions(+), 7 deletions(-) diff --git a/.github/scripts/js/e2e/report/cluster-report.js b/.github/scripts/js/e2e/report/cluster-report.js index 3bcaba9dd8..4b9149cb52 100644 --- a/.github/scripts/js/e2e/report/cluster-report.js +++ b/.github/scripts/js/e2e/report/cluster-report.js @@ -15,7 +15,7 @@ const fs = require("fs"); const { findSingleMatchingFile } = require("./shared/fs-utils"); const { parseGinkgoReport } = require("./shared/ginkgo-report-utils"); -const stageLabels = { +const stageMessage = { "bootstrap": "BOOTSTRAP CLUSTER", "configure-sdn": "CONFIGURE SDN", "storage-setup": "STORAGE SETUP", @@ -165,7 +165,7 @@ function buildClusterStatus(stageResults) { for (const stageName of clusterSetupStages) { const stageResult = normalizeJobResult(stageResults[stageName]); if (stageResult !== "success") { - const stageLabel = stageLabels[stageName] || stageName; + const stageLabel = stageMessage[stageName] || stageName; return { status: stageResult === "cancelled" ? "cancelled" : "failure", stage: stageName, @@ -182,8 +182,8 @@ function buildClusterStatus(stageResults) { return { status: "success", stage: "ready", - stageLabel: stageLabels.ready, - message: buildStatusMessage("success", stageLabels.ready), + stageLabel: stageMessage.ready, + message: buildStatusMessage("success", stageMessage.ready), reason: "", }; } @@ -202,7 +202,7 @@ function buildClusterStatus(stageResults) { * }} Normalized test status. */ function buildTestStatus(testResult, reportSource, clusterStatus, metrics = {}) { - const stageLabel = stageLabels["e2e-test"]; + const stageLabel = stageMessage["e2e-test"]; if (clusterStatus.status !== "success") { return { @@ -299,7 +299,7 @@ function buildLegacyDescriptor(storageType, clusterStatus, testStatus) { } if (testStatus.status === "missing") { - const stageLabel = stageLabels["artifact-missing"]; + const stageLabel = stageMessage["artifact-missing"]; return { failedStage: "artifact-missing", failedStageLabel: stageLabel, @@ -313,7 +313,7 @@ function buildLegacyDescriptor(storageType, clusterStatus, testStatus) { return { failedStage: testStatus.status === "success" ? "success" : "e2e-test", failedStageLabel: - testStatus.status === "success" ? "SUCCESS" : stageLabels["e2e-test"], + testStatus.status === "success" ? "SUCCESS" : stageMessage["e2e-test"], failedJobName: `E2E test (${storageType})`, reportKind: "tests", status: testStatus.status, diff --git a/.github/workflows/e2e-matrix.yml b/.github/workflows/e2e-matrix.yml index d220ad20eb..7ffe8868ad 100644 --- a/.github/workflows/e2e-matrix.yml +++ b/.github/workflows/e2e-matrix.yml @@ -497,6 +497,7 @@ jobs: id: render-report uses: actions/github-script@v7 env: + STORAGE_TYPES: '["replicated","nfs"]' LOOP_API_BASE_URL: ${{ secrets.LOOP_API_BASE_URL }} LOOP_CHANNEL_ID: ${{ secrets.LOOP_CHANNEL_ID }} LOOP_TOKEN: ${{ secrets.LOOP_TOKEN }} From ae9a720d484b82bd58db45b7f819026decae8dc9 Mon Sep 17 00:00:00 2001 From: Nikita Korolev Date: Tue, 5 May 2026 16:31:57 +0300 Subject: [PATCH 08/22] STORAGE_TYPES to EXPECTED_STORAGE_TYPES Signed-off-by: Nikita Korolev --- .../js/e2e/report/messenger-report.test.js | 22 +++++++++---------- .../scripts/js/e2e/report/messenger/config.js | 2 +- .github/workflows/e2e-matrix.yml | 2 +- 3 files changed, 13 insertions(+), 13 deletions(-) diff --git a/.github/scripts/js/e2e/report/messenger-report.test.js b/.github/scripts/js/e2e/report/messenger-report.test.js index 32ed15093b..2cfe32feb6 100644 --- a/.github/scripts/js/e2e/report/messenger-report.test.js +++ b/.github/scripts/js/e2e/report/messenger-report.test.js @@ -45,7 +45,7 @@ async function withTempDir(testFn) { describe("messenger-report", () => { afterEach(() => { delete process.env.REPORTS_DIR; - delete process.env.STORAGE_TYPES; + delete process.env.EXPECTED_STORAGE_TYPES; delete process.env.LOOP_API_BASE_URL; delete process.env.LOOP_CHANNEL_ID; delete process.env.LOOP_TOKEN; @@ -55,7 +55,7 @@ describe("messenger-report", () => { test("reads normalized messenger config from env", () => { const config = readMessengerConfigFromEnv({ REPORTS_DIR: "custom-reports", - STORAGE_TYPES: '["replicated","nfs"]', + EXPECTED_STORAGE_TYPES: '["replicated","nfs"]', LOOP_API_BASE_URL: "https://loop.example.invalid/api/v4/", LOOP_CHANNEL_ID: " channel-id ", LOOP_TOKEN: " token ", @@ -117,7 +117,7 @@ describe("messenger-report", () => { ); process.env.REPORTS_DIR = tempDir; - process.env.STORAGE_TYPES = '["replicated","nfs"]'; + process.env.EXPECTED_STORAGE_TYPES = '["replicated","nfs"]'; const result = await renderMessengerReport({ core: createCore() }); @@ -143,7 +143,7 @@ describe("messenger-report", () => { test("creates artifact-missing entry for absent cluster report", async () => withTempDir(async (tempDir) => { process.env.REPORTS_DIR = tempDir; - process.env.STORAGE_TYPES = '["replicated"]'; + process.env.EXPECTED_STORAGE_TYPES = '["replicated"]'; const result = await renderMessengerReport({ core: createCore() }); @@ -189,7 +189,7 @@ describe("messenger-report", () => { ); process.env.REPORTS_DIR = tempDir; - process.env.STORAGE_TYPES = '["nfs"]'; + process.env.EXPECTED_STORAGE_TYPES = '["nfs"]'; const core = createCore(); const result = await renderMessengerReport({ core }); @@ -247,7 +247,7 @@ describe("messenger-report", () => { ); process.env.REPORTS_DIR = tempDir; - process.env.STORAGE_TYPES = '["replicated","nfs"]'; + process.env.EXPECTED_STORAGE_TYPES = '["replicated","nfs"]'; const result = await renderMessengerReport({ core: createCore() }); @@ -289,7 +289,7 @@ describe("messenger-report", () => { ); process.env.REPORTS_DIR = tempDir; - process.env.STORAGE_TYPES = '["nfs"]'; + process.env.EXPECTED_STORAGE_TYPES = '["nfs"]'; const result = await renderMessengerReport({ core: createCore() }); @@ -458,7 +458,7 @@ describe("messenger-report", () => { ); process.env.REPORTS_DIR = tempDir; - process.env.STORAGE_TYPES = '["replicated"]'; + process.env.EXPECTED_STORAGE_TYPES = '["replicated"]'; process.env.LOOP_API_BASE_URL = "https://loop.example.invalid"; process.env.LOOP_CHANNEL_ID = "channel-id"; process.env.LOOP_TOKEN = "loop-token"; @@ -525,7 +525,7 @@ describe("messenger-report", () => { ); process.env.REPORTS_DIR = tempDir; - process.env.STORAGE_TYPES = '["replicated"]'; + process.env.EXPECTED_STORAGE_TYPES = '["replicated"]'; process.env.LOOP_API_BASE_URL = "https://loop.example.invalid"; process.env.LOOP_CHANNEL_ID = "channel-id"; process.env.LOOP_TOKEN = "loop-token"; @@ -572,7 +572,7 @@ describe("messenger-report", () => { ); process.env.REPORTS_DIR = tempDir; - process.env.STORAGE_TYPES = '["replicated"]'; + process.env.EXPECTED_STORAGE_TYPES = '["replicated"]'; process.env.LOOP_API_BASE_URL = "https://loop.example.invalid"; process.env.LOOP_CHANNEL_ID = "channel-id"; process.env.LOOP_TOKEN = "loop-token"; @@ -619,7 +619,7 @@ describe("messenger-report", () => { ); process.env.REPORTS_DIR = tempDir; - process.env.STORAGE_TYPES = '["replicated"]'; + process.env.EXPECTED_STORAGE_TYPES = '["replicated"]'; process.env.LOOP_API_BASE_URL = "https://loop.example.invalid"; process.env.LOOP_CHANNEL_ID = "channel-id"; process.env.LOOP_TOKEN = "loop-token"; diff --git a/.github/scripts/js/e2e/report/messenger/config.js b/.github/scripts/js/e2e/report/messenger/config.js index 86968c33ad..3e69869aff 100644 --- a/.github/scripts/js/e2e/report/messenger/config.js +++ b/.github/scripts/js/e2e/report/messenger/config.js @@ -60,7 +60,7 @@ function parseConfiguredClusters(value) { * }} Normalized messenger configuration. */ function readMessengerConfigFromEnv(env = process.env) { - const configuredClusters = parseConfiguredClusters(env.STORAGE_TYPES); + const configuredClusters = parseConfiguredClusters(env.EXPECTED_STORAGE_TYPES); return { reportsDir: env.REPORTS_DIR || "downloaded-artifacts", diff --git a/.github/workflows/e2e-matrix.yml b/.github/workflows/e2e-matrix.yml index 7ffe8868ad..93453d1bd7 100644 --- a/.github/workflows/e2e-matrix.yml +++ b/.github/workflows/e2e-matrix.yml @@ -497,7 +497,7 @@ jobs: id: render-report uses: actions/github-script@v7 env: - STORAGE_TYPES: '["replicated","nfs"]' + EXPECTED_STORAGE_TYPES: '["replicated","nfs"]' LOOP_API_BASE_URL: ${{ secrets.LOOP_API_BASE_URL }} LOOP_CHANNEL_ID: ${{ secrets.LOOP_CHANNEL_ID }} LOOP_TOKEN: ${{ secrets.LOOP_TOKEN }} From 951d27f2515211cdd29dad5e8898f226c3e5197b Mon Sep 17 00:00:00 2001 From: Nikita Korolev Date: Tue, 5 May 2026 18:22:12 +0300 Subject: [PATCH 09/22] resolve comments Signed-off-by: Nikita Korolev --- .../scripts/js/e2e/report/cluster-report.js | 20 +++++++++---------- .../js/e2e/report/cluster-report.test.js | 12 +++++++++++ .../js/e2e/report/messenger-report.test.js | 12 +++++++++++ .../scripts/js/e2e/report/messenger/config.js | 12 +++++++++++ .../js/e2e/report/messenger/loop-client.js | 12 +++++++++++ .../js/e2e/report/messenger/markdown.js | 12 +++++++++++ .../scripts/js/e2e/report/messenger/model.js | 12 +++++++++++ .../scripts/js/e2e/report/shared/fs-utils.js | 12 +++++++++++ .../js/e2e/report/shared/fs-utils.test.js | 12 +++++++++++ .../e2e/report/shared/ginkgo-report-utils.js | 12 +++++++++++ .github/workflows/e2e-matrix.yml | 9 --------- .github/workflows/e2e-reusable-pipeline.yml | 9 --------- 12 files changed, 118 insertions(+), 28 deletions(-) diff --git a/.github/scripts/js/e2e/report/cluster-report.js b/.github/scripts/js/e2e/report/cluster-report.js index 4b9149cb52..1d139c9b5a 100644 --- a/.github/scripts/js/e2e/report/cluster-report.js +++ b/.github/scripts/js/e2e/report/cluster-report.js @@ -272,7 +272,7 @@ function buildTestStatus(testResult, reportSource, clusterStatus, metrics = {}) } /** - * Determines which legacy status fields should be exposed as step outputs. + * Builds flat summary fields derived from cluster and test statuses. * * @param {string} storageType Storage backend name. * @param {{ status: string, stage: string, stageLabel: string, message: string }} clusterStatus Cluster setup status. @@ -284,9 +284,9 @@ function buildTestStatus(testResult, reportSource, clusterStatus, metrics = {}) * reportKind: string, * status: string, * statusMessage: string - * }} Legacy descriptor. + * }} Report summary descriptor. */ -function buildLegacyDescriptor(storageType, clusterStatus, testStatus) { +function buildReportSummary(storageType, clusterStatus, testStatus) { if (clusterStatus.status !== "success") { return { failedStage: clusterStatus.stage, @@ -398,7 +398,7 @@ async function buildClusterReport({ core, context, config } = {}) { clusterStatus, parsedReport.metrics ); - const legacyDescriptor = buildLegacyDescriptor( + const reportSummary = buildReportSummary( config.storageType, clusterStatus, testStatus @@ -408,12 +408,12 @@ async function buildClusterReport({ core, context, config } = {}) { schemaVersion: 1, cluster: config.storageType, storageType: config.storageType, - reportKind: legacyDescriptor.reportKind, - status: legacyDescriptor.status, - statusMessage: legacyDescriptor.statusMessage, - failedStage: legacyDescriptor.failedStage, - failedStageLabel: legacyDescriptor.failedStageLabel, - failedJobName: legacyDescriptor.failedJobName, + reportKind: reportSummary.reportKind, + status: reportSummary.status, + statusMessage: reportSummary.statusMessage, + failedStage: reportSummary.failedStage, + failedStageLabel: reportSummary.failedStageLabel, + failedJobName: reportSummary.failedJobName, workflowRunId: String(context.runId), workflowRunUrl, branch: branchName, diff --git a/.github/scripts/js/e2e/report/cluster-report.test.js b/.github/scripts/js/e2e/report/cluster-report.test.js index ab572489fd..fef8e3fd87 100644 --- a/.github/scripts/js/e2e/report/cluster-report.test.js +++ b/.github/scripts/js/e2e/report/cluster-report.test.js @@ -1,3 +1,15 @@ +// Copyright 2026 Flant JSC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// http://www.apache.org/licenses/LICENSE-2.0 +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + const fs = require("fs"); const os = require("os"); const path = require("path"); diff --git a/.github/scripts/js/e2e/report/messenger-report.test.js b/.github/scripts/js/e2e/report/messenger-report.test.js index 2cfe32feb6..c1f81b1905 100644 --- a/.github/scripts/js/e2e/report/messenger-report.test.js +++ b/.github/scripts/js/e2e/report/messenger-report.test.js @@ -1,3 +1,15 @@ +// Copyright 2026 Flant JSC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// http://www.apache.org/licenses/LICENSE-2.0 +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + const fs = require("fs"); const os = require("os"); const path = require("path"); diff --git a/.github/scripts/js/e2e/report/messenger/config.js b/.github/scripts/js/e2e/report/messenger/config.js index 3e69869aff..5ceb71a494 100644 --- a/.github/scripts/js/e2e/report/messenger/config.js +++ b/.github/scripts/js/e2e/report/messenger/config.js @@ -1,3 +1,15 @@ +// Copyright 2026 Flant JSC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// http://www.apache.org/licenses/LICENSE-2.0 +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + /** * Normalizes the configured Loop API base URL to the `/api/v4/posts` endpoint. * diff --git a/.github/scripts/js/e2e/report/messenger/loop-client.js b/.github/scripts/js/e2e/report/messenger/loop-client.js index 762763a0c5..3a9d594e30 100644 --- a/.github/scripts/js/e2e/report/messenger/loop-client.js +++ b/.github/scripts/js/e2e/report/messenger/loop-client.js @@ -1,3 +1,15 @@ +// Copyright 2026 Flant JSC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// http://www.apache.org/licenses/LICENSE-2.0 +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + /** * @typedef {Object} LoopClientCore * @property {function(string): void} warning diff --git a/.github/scripts/js/e2e/report/messenger/markdown.js b/.github/scripts/js/e2e/report/messenger/markdown.js index f8ee72559a..9f27b23593 100644 --- a/.github/scripts/js/e2e/report/messenger/markdown.js +++ b/.github/scripts/js/e2e/report/messenger/markdown.js @@ -1,3 +1,15 @@ +// Copyright 2026 Flant JSC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// http://www.apache.org/licenses/LICENSE-2.0 +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + const { getReportClusterKey, getReportDate, diff --git a/.github/scripts/js/e2e/report/messenger/model.js b/.github/scripts/js/e2e/report/messenger/model.js index 79b7add08c..78ce864723 100644 --- a/.github/scripts/js/e2e/report/messenger/model.js +++ b/.github/scripts/js/e2e/report/messenger/model.js @@ -1,3 +1,15 @@ +// Copyright 2026 Flant JSC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// http://www.apache.org/licenses/LICENSE-2.0 +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + const genericArtifactMissingLabel = "E2E REPORT ARTIFACT NOT FOUND"; /** diff --git a/.github/scripts/js/e2e/report/shared/fs-utils.js b/.github/scripts/js/e2e/report/shared/fs-utils.js index 104c4b67c8..7cb7a7e650 100644 --- a/.github/scripts/js/e2e/report/shared/fs-utils.js +++ b/.github/scripts/js/e2e/report/shared/fs-utils.js @@ -1,3 +1,15 @@ +// Copyright 2026 Flant JSC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// http://www.apache.org/licenses/LICENSE-2.0 +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + const fs = require("fs"); const path = require("path"); diff --git a/.github/scripts/js/e2e/report/shared/fs-utils.test.js b/.github/scripts/js/e2e/report/shared/fs-utils.test.js index 90b91fee4b..0b504089b8 100644 --- a/.github/scripts/js/e2e/report/shared/fs-utils.test.js +++ b/.github/scripts/js/e2e/report/shared/fs-utils.test.js @@ -1,3 +1,15 @@ +// Copyright 2026 Flant JSC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// http://www.apache.org/licenses/LICENSE-2.0 +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + const fs = require("fs"); const os = require("os"); const path = require("path"); diff --git a/.github/scripts/js/e2e/report/shared/ginkgo-report-utils.js b/.github/scripts/js/e2e/report/shared/ginkgo-report-utils.js index 7e6dda7276..557f8c3b78 100644 --- a/.github/scripts/js/e2e/report/shared/ginkgo-report-utils.js +++ b/.github/scripts/js/e2e/report/shared/ginkgo-report-utils.js @@ -1,3 +1,15 @@ +// Copyright 2026 Flant JSC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// http://www.apache.org/licenses/LICENSE-2.0 +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + /** * @typedef {Object} GinkgoMetrics * @property {number} passed diff --git a/.github/workflows/e2e-matrix.yml b/.github/workflows/e2e-matrix.yml index 93453d1bd7..e4dadb39be 100644 --- a/.github/workflows/e2e-matrix.yml +++ b/.github/workflows/e2e-matrix.yml @@ -475,15 +475,6 @@ jobs: steps: - uses: actions/checkout@v4 - - name: Setup Node.js for report scripts - uses: actions/setup-node@v6 - with: - node-version: "20" - - - name: Install report script dependencies - working-directory: .github/scripts/js - run: npm install - - name: Download E2E report artifacts uses: actions/download-artifact@v5 continue-on-error: true diff --git a/.github/workflows/e2e-reusable-pipeline.yml b/.github/workflows/e2e-reusable-pipeline.yml index 9162b2d09d..62d6728a3d 100644 --- a/.github/workflows/e2e-reusable-pipeline.yml +++ b/.github/workflows/e2e-reusable-pipeline.yml @@ -1402,15 +1402,6 @@ jobs: steps: - uses: actions/checkout@v4 - - name: Setup Node.js for report scripts - uses: actions/setup-node@v6 - with: - node-version: "20" - - - name: Install report script dependencies - working-directory: .github/scripts/js - run: npm install - - name: Download E2E test results if available uses: actions/download-artifact@v5 continue-on-error: true From 5094052e68e03bc5dd619f6799aa90b881050b8a Mon Sep 17 00:00:00 2001 From: Nikita Korolev Date: Tue, 5 May 2026 18:57:34 +0300 Subject: [PATCH 10/22] refactor 2 Signed-off-by: Nikita Korolev --- .../scripts/js/e2e/report/cluster-report.js | 432 ++++++------------ .../js/e2e/report/cluster-report.test.js | 82 +++- .../scripts/js/e2e/report/messenger-report.js | 21 +- .../js/e2e/report/messenger-report.test.js | 11 +- .../scripts/js/e2e/report/messenger/config.js | 4 +- .../js/e2e/report/messenger/loop-client.js | 2 +- .../js/e2e/report/messenger/markdown.js | 92 ++-- .../scripts/js/e2e/report/messenger/model.js | 102 +---- .../scripts/js/e2e/report/shared/fs-utils.js | 6 +- .../js/e2e/report/shared/fs-utils.test.js | 8 +- .../e2e/report/shared/ginkgo-report-utils.js | 4 +- .../js/e2e/report/shared/report-model.js | 254 ++++++++++ .github/workflows/e2e-reusable-pipeline.yml | 25 +- 13 files changed, 571 insertions(+), 472 deletions(-) create mode 100644 .github/scripts/js/e2e/report/shared/report-model.js diff --git a/.github/scripts/js/e2e/report/cluster-report.js b/.github/scripts/js/e2e/report/cluster-report.js index 1d139c9b5a..0ac3653fae 100644 --- a/.github/scripts/js/e2e/report/cluster-report.js +++ b/.github/scripts/js/e2e/report/cluster-report.js @@ -14,23 +14,12 @@ const fs = require("fs"); const { findSingleMatchingFile } = require("./shared/fs-utils"); const { parseGinkgoReport } = require("./shared/ginkgo-report-utils"); - -const stageMessage = { - "bootstrap": "BOOTSTRAP CLUSTER", - "configure-sdn": "CONFIGURE SDN", - "storage-setup": "STORAGE SETUP", - "virtualization-setup": "VIRTUALIZATION SETUP", - "e2e-test": "E2E TEST", - "ready": "CLUSTER READY", - "artifact-missing": "TEST REPORTS NOT FOUND", -}; - -const clusterSetupStages = [ - "bootstrap", - "configure-sdn", - "storage-setup", - "virtualization-setup", -]; +const { + buildClusterStatus, + buildReportSummary, + buildTestStatus, + zeroMetrics, +} = require("./shared/report-model"); /** * @typedef {Record} StageResults @@ -71,253 +60,147 @@ const clusterSetupStages = [ * @typedef {Object} ClusterReportParams * @property {ClusterReportCore} core * @property {ClusterReportContext} context - * @property {ClusterReportConfig} config + * @property {ClusterReportConfig} [config] */ -/** - * Escapes special characters in a string for safe use inside a RegExp source. - * - * @param {string} value Raw string value. - * @returns {string} Escaped RegExp fragment. - */ function escapeRegExp(value) { return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); } -/** - * Creates a zero-filled metrics object for cluster report defaults. - * - * @returns {{ - * passed: number, - * failed: number, - * errors: number, - * skipped: number, - * total: number, - * successRate: number - * }} Zeroed metrics payload. - */ -function zeroMetrics() { +function readClusterReportConfigFromEnv(env = process.env) { + const storageType = String(env.STORAGE_TYPE || "").trim(); + return { - passed: 0, - failed: 0, - errors: 0, - skipped: 0, - total: 0, - successRate: 0, + storageType, + reportsDir: env.REPORTS_DIR || "test/e2e", + reportFile: env.REPORT_FILE || `e2e_report_${storageType}.json`, + workflowRunUrl: String(env.WORKFLOW_RUN_URL || "").trim(), + branchName: String(env.BRANCH_NAME || "").trim(), + stageResults: { + bootstrap: env.BOOTSTRAP_RESULT, + "configure-sdn": env.CONFIGURE_SDN_RESULT, + "storage-setup": env.STORAGE_SETUP_RESULT, + "virtualization-setup": env.VIRTUALIZATION_SETUP_RESULT, + "e2e-test": env.E2E_TEST_RESULT, + }, }; } -/** - * Builds a user-facing status line for a workflow stage. - * - * @param {string} status Normalized stage status. - * @param {string} stageLabel Human-readable stage label. - * @returns {string} Rendered status message. - */ -function buildStatusMessage(status, stageLabel) { - if (status === "success") { - return `✅ ${stageLabel}`; - } - - if (status === "cancelled") { - return `⚠️ ${stageLabel} CANCELLED`; +function requireClusterReportConfig(config) { + if (!config.storageType) { + throw new Error("buildClusterReport requires storageType"); } - if (status === "missing") { - return `⚠️ ${stageLabel}`; - } - - if (status === "not-run") { - return `⚠️ ${stageLabel} NOT RUN`; - } - - return `❌ ${stageLabel} FAILED`; -} - -/** - * Normalizes a GitHub Actions job result into the report status vocabulary. - * - * @param {string|undefined} resultValue Raw GitHub Actions result value. - * @returns {"success"|"failure"|"cancelled"|"skipped"} Normalized result. - */ -function normalizeJobResult(resultValue) { - const result = String(resultValue || "success").trim(); - if (result === "cancelled" || result === "skipped" || result === "success") { - return result; + if (!config.reportsDir) { + throw new Error("buildClusterReport requires reportsDir"); } - return "failure"; -} - -/** - * Builds the cluster setup status from pre-E2E workflow stages. - * - * @param {StageResults} stageResults Per-stage GitHub Actions results. - * @returns {{ - * status: string, - * stage: string, - * stageLabel: string, - * message: string, - * reason: string - * }} Normalized cluster setup status. - */ -function buildClusterStatus(stageResults) { - for (const stageName of clusterSetupStages) { - const stageResult = normalizeJobResult(stageResults[stageName]); - if (stageResult !== "success") { - const stageLabel = stageMessage[stageName] || stageName; - return { - status: stageResult === "cancelled" ? "cancelled" : "failure", - stage: stageName, - stageLabel, - message: buildStatusMessage(stageResult, stageLabel), - reason: - stageResult === "cancelled" - ? "cluster-stage-cancelled" - : "cluster-stage-failed", - }; - } + if (!config.reportFile) { + throw new Error("buildClusterReport requires reportFile"); } return { - status: "success", - stage: "ready", - stageLabel: stageMessage.ready, - message: buildStatusMessage("success", stageMessage.ready), - reason: "", + ...config, + stageResults: config.stageResults || {}, }; } -/** - * Builds E2E test status from test job result and Ginkgo report availability. - * - * @param {string|undefined} testResult Raw E2E job result. - * @param {string} reportSource Parsed report source. - * @param {{ status: string }} clusterStatus Cluster setup status. - * @param {GinkgoMetrics} [metrics={}] Parsed Ginkgo metrics. - * @returns {{ - * status: string, - * reason: string, - * message: string - * }} Normalized test status. - */ -function buildTestStatus(testResult, reportSource, clusterStatus, metrics = {}) { - const stageLabel = stageMessage["e2e-test"]; - - if (clusterStatus.status !== "success") { - return { - status: "not-run", - reason: "cluster-stage-failed", - message: "E2E tests were not run because cluster setup did not finish", - }; +function getWorkflowRunUrl(config, context) { + if (config.workflowRunUrl) { + return config.workflowRunUrl; } - const normalizedResult = normalizeJobResult(testResult); + return `${context.serverUrl}/${context.repo.owner}/${context.repo.repo}/actions/runs/${context.runId}`; +} - if (reportSource === "ginkgo-json") { - const hasReportedFailures = - Number(metrics.failed || 0) > 0 || Number(metrics.errors || 0) > 0; - const status = - normalizedResult === "success" && hasReportedFailures - ? "failure" - : normalizedResult; +function getBranchName(config, context) { + return ( + config.branchName || String(context.ref || "").replace(/^refs\/heads\//, "") + ); +} - return { - status, - reason: status === "success" ? "" : "ginkgo-failed", - message: - status === "success" - ? "✅ E2E TESTS PASSED" - : buildStatusMessage(status, stageLabel), - }; - } +function findGinkgoReport(config) { + const rawReportPattern = new RegExp( + `^e2e_report_${escapeRegExp(config.storageType)}_.*\\.json$` + ); - if (reportSource === "ginkgo-json-invalid") { - return { - status: "missing", - reason: "ginkgo-report-invalid", - message: "⚠️ E2E TEST REPORT IS INVALID", - }; - } + return findSingleMatchingFile( + config.reportsDir, + rawReportPattern, + "Ginkgo JSON report" + ); +} - if (normalizedResult === "success") { +function parseGinkgoReportFile(rawReportPath, core) { + if (!rawReportPath) { return { - status: "missing", - reason: "ginkgo-report-missing", - message: "⚠️ E2E TEST REPORT NOT FOUND", + metrics: zeroMetrics(), + failedTests: [], + startedAt: null, + source: "empty", }; } - if (normalizedResult === "cancelled") { + core.info(`Found Ginkgo JSON report: ${rawReportPath}`); + try { return { - status: "cancelled", - reason: "e2e-cancelled", - message: buildStatusMessage("cancelled", stageLabel), + ...parseGinkgoReport(fs.readFileSync(rawReportPath, "utf8"), zeroMetrics), + source: "ginkgo-json", }; - } - - if (normalizedResult === "skipped") { + } catch (error) { + core.warning( + `Unable to parse Ginkgo JSON report ${rawReportPath}: ${error.message}` + ); return { - status: "not-run", - reason: "e2e-skipped", - message: buildStatusMessage("not-run", stageLabel), + metrics: zeroMetrics(), + failedTests: [], + startedAt: null, + source: "ginkgo-json-invalid", }; } - - return { - status: "failure", - reason: "ginkgo-report-missing", - message: "❌ E2E TESTS FAILED, GINKGO REPORT NOT FOUND", - }; } -/** - * Builds flat summary fields derived from cluster and test statuses. - * - * @param {string} storageType Storage backend name. - * @param {{ status: string, stage: string, stageLabel: string, message: string }} clusterStatus Cluster setup status. - * @param {{ status: string, message: string }} testStatus Test status. - * @returns {{ - * failedStage: string, - * failedStageLabel: string, - * failedJobName: string, - * reportKind: string, - * status: string, - * statusMessage: string - * }} Report summary descriptor. - */ -function buildReportSummary(storageType, clusterStatus, testStatus) { - if (clusterStatus.status !== "success") { - return { - failedStage: clusterStatus.stage, - failedStageLabel: clusterStatus.stageLabel, - failedJobName: `${clusterStatus.stageLabel} (${storageType})`, - reportKind: "stage-failure", - status: clusterStatus.status, - statusMessage: clusterStatus.message, - }; - } - - if (testStatus.status === "missing") { - const stageLabel = stageMessage["artifact-missing"]; - return { - failedStage: "artifact-missing", - failedStageLabel: stageLabel, - failedJobName: `E2E test (${storageType})`, - reportKind: "artifact-missing", - status: "missing", - statusMessage: testStatus.message, - }; - } +function buildReportPayload({ + config, + context, + workflowRunUrl, + branchName, + parsedReport, + rawReportPath, +}) { + const clusterStatus = buildClusterStatus(config.stageResults); + const testStatus = buildTestStatus( + config.stageResults["e2e-test"], + parsedReport.source, + clusterStatus, + parsedReport.metrics + ); + const reportSummary = buildReportSummary( + config.storageType, + clusterStatus, + testStatus + ); return { - failedStage: testStatus.status === "success" ? "success" : "e2e-test", - failedStageLabel: - testStatus.status === "success" ? "SUCCESS" : stageMessage["e2e-test"], - failedJobName: `E2E test (${storageType})`, - reportKind: "tests", - status: testStatus.status, - statusMessage: testStatus.message, + schemaVersion: 1, + cluster: config.storageType, + storageType: config.storageType, + reportKind: reportSummary.reportKind, + status: reportSummary.status, + statusMessage: reportSummary.statusMessage, + failedStage: reportSummary.failedStage, + failedStageLabel: reportSummary.failedStageLabel, + failedJobName: reportSummary.failedJobName, + workflowRunId: String(context.runId), + workflowRunUrl, + branch: branchName, + clusterStatus, + testStatus, + startedAt: parsedReport.startedAt, + metrics: parsedReport.metrics, + failedTests: parsedReport.failedTests, + sourceReport: rawReportPath, + reportSource: parsedReport.source, }; } @@ -344,98 +227,46 @@ function setReportOutputs(report, reportFile, core) { * * @param {ClusterReportParams} params GitHub script dependencies. * @returns {Promise>} Generated cluster report. - * @throws {Error} If `config` is missing or the report file cannot be written. + * @throws {Error} If config is incomplete or the report file cannot be written. */ async function buildClusterReport({ core, context, config } = {}) { - if (!config) { - throw new Error("buildClusterReport requires a config object"); - } - - const workflowRunUrl = - config.workflowRunUrl || - `${context.serverUrl}/${context.repo.owner}/${context.repo.repo}/actions/runs/${context.runId}`; - const branchName = - config.branchName || String(context.ref || "").replace(/^refs\/heads\//, ""); - const rawReportPattern = new RegExp( - `^e2e_report_${escapeRegExp(config.storageType)}_.*\\.json$` + const resolvedConfig = requireClusterReportConfig( + config || readClusterReportConfigFromEnv() ); - const rawReportPath = findSingleMatchingFile( - config.reportsDir, - rawReportPattern, - "Ginkgo JSON report" - ); - const clusterStatus = buildClusterStatus(config.stageResults); - let parsedReport = { - metrics: zeroMetrics(), - failedTests: [], - startedAt: null, - source: "empty", - }; + const workflowRunUrl = getWorkflowRunUrl(resolvedConfig, context); + const branchName = getBranchName(resolvedConfig, context); + const rawReportPath = findGinkgoReport(resolvedConfig); - if (rawReportPath) { - core.info(`Found Ginkgo JSON report: ${rawReportPath}`); - try { - parsedReport = { - ...parseGinkgoReport(fs.readFileSync(rawReportPath, "utf8"), zeroMetrics), - source: "ginkgo-json", - }; - } catch (error) { - parsedReport.source = "ginkgo-json-invalid"; - core.warning( - `Unable to parse Ginkgo JSON report ${rawReportPath}: ${error.message}` - ); - } - } else { + if (!rawReportPath) { core.warning( - `Ginkgo JSON report was not found for ${config.storageType} under ${config.reportsDir}` + `Ginkgo JSON report was not found for ${resolvedConfig.storageType} under ${resolvedConfig.reportsDir}` ); } - const testStatus = buildTestStatus( - config.stageResults["e2e-test"], - parsedReport.source, - clusterStatus, - parsedReport.metrics - ); - const reportSummary = buildReportSummary( - config.storageType, - clusterStatus, - testStatus - ); - - const report = { - schemaVersion: 1, - cluster: config.storageType, - storageType: config.storageType, - reportKind: reportSummary.reportKind, - status: reportSummary.status, - statusMessage: reportSummary.statusMessage, - failedStage: reportSummary.failedStage, - failedStageLabel: reportSummary.failedStageLabel, - failedJobName: reportSummary.failedJobName, - workflowRunId: String(context.runId), + const parsedReport = parseGinkgoReportFile(rawReportPath, core); + const report = buildReportPayload({ + config: resolvedConfig, + context, workflowRunUrl, - branch: branchName, - clusterStatus, - testStatus, - startedAt: parsedReport.startedAt, - metrics: parsedReport.metrics, - failedTests: parsedReport.failedTests, - sourceReport: rawReportPath, - reportSource: parsedReport.source, - }; + branchName, + parsedReport, + rawReportPath, + }); try { - fs.writeFileSync(config.reportFile, `${JSON.stringify(report, null, 2)}\n`); + fs.writeFileSync( + resolvedConfig.reportFile, + `${JSON.stringify(report, null, 2)}\n` + ); } catch (error) { throw new Error( - `Unable to write cluster report file ${config.reportFile}: ${error.message}` + `Unable to write cluster report file ${resolvedConfig.reportFile}: ${error.message}` ); } - setReportOutputs(report, config.reportFile, core); - core.info(`Created report file: ${config.reportFile}`); + setReportOutputs(report, resolvedConfig.reportFile, core); + core.info(`Created report file: ${resolvedConfig.reportFile}`); core.info(JSON.stringify(report, null, 2)); return report; @@ -443,3 +274,4 @@ async function buildClusterReport({ core, context, config } = {}) { module.exports = buildClusterReport; module.exports.buildClusterStatus = buildClusterStatus; +module.exports.readClusterReportConfigFromEnv = readClusterReportConfigFromEnv; diff --git a/.github/scripts/js/e2e/report/cluster-report.test.js b/.github/scripts/js/e2e/report/cluster-report.test.js index fef8e3fd87..9c0380ca40 100644 --- a/.github/scripts/js/e2e/report/cluster-report.test.js +++ b/.github/scripts/js/e2e/report/cluster-report.test.js @@ -15,8 +15,9 @@ const os = require("os"); const path = require("path"); const buildClusterReport = require("./cluster-report"); -const { buildClusterStatus } = require("./cluster-report"); +const { readClusterReportConfigFromEnv } = require("./cluster-report"); const { parseGinkgoReport } = require("./shared/ginkgo-report-utils"); +const { buildClusterStatus } = require("./shared/report-model"); /** * Creates a mocked GitHub Actions core object for unit tests. @@ -87,7 +88,7 @@ function createClusterConfig(overrides = {}) { reportFile: "e2e_report_replicated.json", ...overrides, stageResults: { - "bootstrap": "success", + bootstrap: "success", "configure-sdn": "success", "storage-setup": "success", "virtualization-setup": "success", @@ -192,19 +193,32 @@ function createZeroMetrics() { } describe("cluster-report", () => { - test("requires explicit config", async () => { + afterEach(() => { + delete process.env.STORAGE_TYPE; + delete process.env.REPORTS_DIR; + delete process.env.REPORT_FILE; + delete process.env.WORKFLOW_RUN_URL; + delete process.env.BRANCH_NAME; + delete process.env.BOOTSTRAP_RESULT; + delete process.env.CONFIGURE_SDN_RESULT; + delete process.env.STORAGE_SETUP_RESULT; + delete process.env.VIRTUALIZATION_SETUP_RESULT; + delete process.env.E2E_TEST_RESULT; + }); + + test("requires storage type when config is absent", async () => { await expect( buildClusterReport({ core: createCore(), context: createContext(), }) - ).rejects.toThrow("buildClusterReport requires a config object"); + ).rejects.toThrow("buildClusterReport requires storageType"); }); test("determines cluster setup status from explicit stage results", () => { expect( buildClusterStatus({ - "bootstrap": "success", + bootstrap: "success", "configure-sdn": "failure", "storage-setup": "skipped", "virtualization-setup": "skipped", @@ -231,7 +245,7 @@ describe("cluster-report", () => { workflowRunUrl: "https://example.invalid/run/explicit", branchName: "feature/report", stageResults: { - "bootstrap": "success", + bootstrap: "success", "configure-sdn": "failure", "storage-setup": "skipped", "virtualization-setup": "skipped", @@ -241,13 +255,53 @@ describe("cluster-report", () => { }); expect(report.cluster).toBe("nfs"); - expect(report.workflowRunUrl).toBe("https://example.invalid/run/explicit"); + expect(report.workflowRunUrl).toBe( + "https://example.invalid/run/explicit" + ); expect(report.branch).toBe("feature/report"); expect(report.clusterStatus).toMatchObject({ status: "failure", stage: "configure-sdn", }); - expect(JSON.parse(fs.readFileSync(reportFile, "utf8")).cluster).toBe("nfs"); + expect(JSON.parse(fs.readFileSync(reportFile, "utf8")).cluster).toBe( + "nfs" + ); + })); + + test("builds report from environment config", async () => + withTempDir(async (tempDir) => { + const reportFile = path.join(tempDir, "env-report.json"); + process.env.STORAGE_TYPE = "replicated"; + process.env.REPORTS_DIR = tempDir; + process.env.REPORT_FILE = reportFile; + process.env.WORKFLOW_RUN_URL = "https://example.invalid/run/from-env"; + process.env.BRANCH_NAME = "feature/from-env"; + process.env.BOOTSTRAP_RESULT = "success"; + process.env.CONFIGURE_SDN_RESULT = "success"; + process.env.STORAGE_SETUP_RESULT = "success"; + process.env.VIRTUALIZATION_SETUP_RESULT = "success"; + process.env.E2E_TEST_RESULT = "success"; + + expect(readClusterReportConfigFromEnv()).toMatchObject({ + storageType: "replicated", + reportsDir: tempDir, + reportFile, + branchName: "feature/from-env", + }); + + const report = await buildClusterReport({ + core: createCore(), + context: createContext(), + }); + + expect(report.cluster).toBe("replicated"); + expect(report.workflowRunUrl).toBe( + "https://example.invalid/run/from-env" + ); + expect(report.branch).toBe("feature/from-env"); + expect(JSON.parse(fs.readFileSync(reportFile, "utf8")).cluster).toBe( + "replicated" + ); })); test("marks Ginkgo JSON with failed specs as failed", async () => @@ -362,14 +416,18 @@ describe("cluster-report", () => { firstReportPath, createGinkgoReport({ startedAt: "2026-04-15T09:30:44Z", - specs: [createSpecReport({ leafNodeText: "old pass", state: "passed" })], + specs: [ + createSpecReport({ leafNodeText: "old pass", state: "passed" }), + ], }) ); fs.writeFileSync( secondReportPath, createGinkgoReport({ startedAt: "2026-04-16T09:30:44Z", - specs: [createSpecReport({ leafNodeText: "latest pass", state: "passed" })], + specs: [ + createSpecReport({ leafNodeText: "latest pass", state: "passed" }), + ], }) ); @@ -385,9 +443,7 @@ describe("cluster-report", () => { context: createContext(), config, }) - ).rejects.toThrow( - "Expected a single Ginkgo JSON report, but found 2" - ); + ).rejects.toThrow("Expected a single Ginkgo JSON report, but found 2"); expect(fs.existsSync(reportFile)).toBe(false); })); diff --git a/.github/scripts/js/e2e/report/messenger-report.js b/.github/scripts/js/e2e/report/messenger-report.js index 8edfa3b98e..b2814f1ffd 100644 --- a/.github/scripts/js/e2e/report/messenger-report.js +++ b/.github/scripts/js/e2e/report/messenger-report.js @@ -71,7 +71,9 @@ function readReports(reportsDir, configuredClusters, core) { const clusterName = getReportClusterKey(report); if (!clusterName) { core.warning( - `Skipping report without cluster name from ${report.sourceReport || "parsed JSON payload"}` + `Skipping report without cluster name from ${ + report.sourceReport || "parsed JSON payload" + }` ); continue; } @@ -98,16 +100,8 @@ function readReports(reportsDir, configuredClusters, core) { * threadMessages: string[] * }} Rendered markdown payloads. */ -function buildMessengerMessages({ - reportsDir, - configuredClusters, - core, -}) { - const orderedReports = readReports( - reportsDir, - configuredClusters, - core - ); +function buildMessengerMessages({ reportsDir, configuredClusters, core }) { + const orderedReports = readReports(reportsDir, configuredClusters, core); const threadMessages = buildThreadMessages(orderedReports); return { message: buildMainMessage(orderedReports), @@ -141,10 +135,7 @@ async function renderMessengerReport({ core, reportsDir }) { core.setOutput("thread_messages", JSON.stringify(threadMessages)); try { - await publishToLoop( - { message, threadMessages, loop: config.loop }, - core - ); + await publishToLoop({ message, threadMessages, loop: config.loop }, core); } catch (error) { core.warning(`Unable to deliver report to Loop API: ${error.message}`); } diff --git a/.github/scripts/js/e2e/report/messenger-report.test.js b/.github/scripts/js/e2e/report/messenger-report.test.js index c1f81b1905..757e7600d1 100644 --- a/.github/scripts/js/e2e/report/messenger-report.test.js +++ b/.github/scripts/js/e2e/report/messenger-report.test.js @@ -338,7 +338,8 @@ describe("messenger-report", () => { testStatus: { status: "not-run", reason: "cluster-stage-failed", - message: "E2E tests were not run because cluster setup did not finish", + message: + "E2E tests were not run because cluster setup did not finish", }, metrics: { passed: 0, @@ -382,7 +383,8 @@ describe("messenger-report", () => { testStatus: { status: "not-run", reason: "cluster-stage-failed", - message: "E2E tests were not run because cluster setup did not finish", + message: + "E2E tests were not run because cluster setup did not finish", }, metrics: { passed: 0, @@ -497,7 +499,7 @@ describe("messenger-report", () => { expect.objectContaining({ method: "POST", headers: expect.objectContaining({ - "Authorization": "Bearer loop-token", + Authorization: "Bearer loop-token", "Content-Type": "application/json", }), }) @@ -508,7 +510,8 @@ describe("messenger-report", () => { }); expect(JSON.parse(global.fetch.mock.calls[1][1].body)).toEqual({ channel_id: "channel-id", - message: "### Failed tests\n\n**replicated**\n\n| Test group |\n|---|\n| fails |", + message: + "### Failed tests\n\n**replicated**\n\n| Test group |\n|---|\n| fails |", root_id: "root-post-id", }); })); diff --git a/.github/scripts/js/e2e/report/messenger/config.js b/.github/scripts/js/e2e/report/messenger/config.js index 5ceb71a494..bb9fc59751 100644 --- a/.github/scripts/js/e2e/report/messenger/config.js +++ b/.github/scripts/js/e2e/report/messenger/config.js @@ -72,7 +72,9 @@ function parseConfiguredClusters(value) { * }} Normalized messenger configuration. */ function readMessengerConfigFromEnv(env = process.env) { - const configuredClusters = parseConfiguredClusters(env.EXPECTED_STORAGE_TYPES); + const configuredClusters = parseConfiguredClusters( + env.EXPECTED_STORAGE_TYPES + ); return { reportsDir: env.REPORTS_DIR || "downloaded-artifacts", diff --git a/.github/scripts/js/e2e/report/messenger/loop-client.js b/.github/scripts/js/e2e/report/messenger/loop-client.js index 3a9d594e30..d06d413dc5 100644 --- a/.github/scripts/js/e2e/report/messenger/loop-client.js +++ b/.github/scripts/js/e2e/report/messenger/loop-client.js @@ -70,7 +70,7 @@ async function postToLoopApi( const response = await fetch(apiUrl, { method: "POST", headers: { - "Authorization": `Bearer ${token}`, + Authorization: `Bearer ${token}`, "Content-Type": "application/json", }, body: JSON.stringify({ diff --git a/.github/scripts/js/e2e/report/messenger/markdown.js b/.github/scripts/js/e2e/report/messenger/markdown.js index 9f27b23593..072c9cf4cc 100644 --- a/.github/scripts/js/e2e/report/messenger/markdown.js +++ b/.github/scripts/js/e2e/report/messenger/markdown.js @@ -43,24 +43,7 @@ function formatClusterLink(report) { : clusterName; } -/** - * Builds the main E2E messenger report body. - * - * @param {Array>} orderedReports Cluster reports in display order. - * @returns {string} Markdown message body. - */ -function buildMainMessage(orderedReports) { - const reportDate = getReportDate(orderedReports); - const branches = Array.from( - new Set(orderedReports.map((report) => report.branch).filter(Boolean)) - ); - const lines = [`## DVP | E2E on nested clusters | ${reportDate}`, ""]; - - if (branches.length === 1 && branches[0] !== "main") { - lines.push(`Branch: \`${branches[0]}\``); - lines.push(""); - } - +function splitReportsBySection(orderedReports) { const testsReports = orderedReports.filter( (report) => isTestResultReport(report) && getReportClusterKey(report) ); @@ -74,6 +57,26 @@ function buildMainMessage(orderedReports) { getReportClusterKey(report) ); + return { + testsReports, + stageFailureReports, + missingReports, + }; +} + +function renderBranchLine(orderedReports) { + const branches = Array.from( + new Set(orderedReports.map((report) => report.branch).filter(Boolean)) + ); + + return branches.length === 1 && branches[0] !== "main" + ? [`Branch: \`${branches[0]}\``, ""] + : []; +} + +function renderTestResultsSection(testsReports) { + const lines = []; + if (testsReports.length > 0) { lines.push("### Test results"); lines.push(""); @@ -96,6 +99,12 @@ function buildMainMessage(orderedReports) { lines.push(""); } + return lines; +} + +function renderClusterFailuresSection(stageFailureReports) { + const lines = []; + if (stageFailureReports.length > 0) { lines.push("### Cluster failures"); lines.push(""); @@ -114,6 +123,12 @@ function buildMainMessage(orderedReports) { lines.push(""); } + return lines; +} + +function renderMissingReportsSection(missingReports) { + const lines = []; + if (missingReports.length > 0) { lines.push("### Missing reports"); lines.push(""); @@ -137,6 +152,28 @@ function buildMainMessage(orderedReports) { lines.push(""); } + return lines; +} + +/** + * Builds the main E2E messenger report body. + * + * @param {Array>} orderedReports Cluster reports in display order. + * @returns {string} Markdown message body. + */ +function buildMainMessage(orderedReports) { + const reportDate = getReportDate(orderedReports); + const { testsReports, stageFailureReports, missingReports } = + splitReportsBySection(orderedReports); + const lines = [ + `## DVP | E2E on nested clusters | ${reportDate}`, + "", + ...renderBranchLine(orderedReports), + ...renderTestResultsSection(testsReports), + ...renderClusterFailuresSection(stageFailureReports), + ...renderMissingReportsSection(missingReports), + ]; + return lines.join("\n").trim(); } @@ -146,16 +183,19 @@ function hasFailedTests(report) { } return Boolean( - report.testStatus && + (report.testStatus && (report.testStatus.status === "failure" || - report.testStatus.status === "cancelled") || - (report.metrics && report.metrics.failed) || + report.testStatus.status === "cancelled")) || + (report.metrics && report.metrics.failed) || (report.metrics && report.metrics.errors) ); } function getFailedTestGroupName(testName) { - const normalizedName = sanitizeListItem(testName).replace(/^\[[^\]]+\]\s*/, ""); + const normalizedName = sanitizeListItem(testName).replace( + /^\[[^\]]+\]\s*/, + "" + ); const [groupName] = normalizedName.split(/\s+/, 1); return groupName || "Unknown"; } @@ -173,7 +213,7 @@ function summarizeFailedTestGroups(failedTests) { return groupNames; } -function buildFailedTestsClusterMessage(report) { +function renderFailedTestsThreadMessage(report) { const clusterName = sanitizeListItem(report.cluster || report.storageType); const lines = [`**${clusterName}**`]; @@ -204,8 +244,8 @@ function buildFailedTestsClusterMessage(report) { * @returns {string[]} Markdown thread message bodies. */ function buildThreadMessages(orderedReports) { - const testsReports = orderedReports.filter( - (report) => isTestResultReport(report) + const testsReports = orderedReports.filter((report) => + isTestResultReport(report) ); const failedTestReports = testsReports.filter(hasFailedTests); @@ -214,7 +254,7 @@ function buildThreadMessages(orderedReports) { } return failedTestReports.map((report, index) => { - const clusterMessage = buildFailedTestsClusterMessage(report); + const clusterMessage = renderFailedTestsThreadMessage(report); return index === 0 ? ["### Failed tests", clusterMessage].join("\n\n") : clusterMessage; diff --git a/.github/scripts/js/e2e/report/messenger/model.js b/.github/scripts/js/e2e/report/messenger/model.js index 78ce864723..2bf631d8c8 100644 --- a/.github/scripts/js/e2e/report/messenger/model.js +++ b/.github/scripts/js/e2e/report/messenger/model.js @@ -10,34 +10,15 @@ // See the License for the specific language governing permissions and // limitations under the License. -const genericArtifactMissingLabel = "E2E REPORT ARTIFACT NOT FOUND"; - -/** - * Builds a user-facing status line for a cluster row or fallback report. - * - * @param {string} status Normalized cluster status. - * @param {string} stageLabel Human-readable stage label. - * @returns {string} Rendered status message. - */ -function buildStatusMessage(status, stageLabel) { - if (status === "cancelled") { - return `⚠️ ${stageLabel} CANCELLED`; - } - - if (status === "failure") { - return `❌ ${stageLabel} FAILED`; - } - - if (status === "missing") { - return `⚠️ ${stageLabel}`; - } - - if (status === "success") { - return "✅ SUCCESS"; - } +const { + buildStatusMessage, + isClusterFailureReport, + isMissingReport, + isTestResultReport, + zeroMetrics, +} = require("../shared/report-model"); - return stageLabel; -} +const genericArtifactMissingLabel = "E2E REPORT ARTIFACT NOT FOUND"; /** * Creates a synthetic cluster report when the expected JSON artifact is absent. @@ -70,16 +51,10 @@ function createMissingReport(clusterName) { testStatus: { status: "not-run", reason: "cluster-report-artifact-missing", - message: "E2E status is unavailable because cluster report artifact was not found", - }, - metrics: { - passed: 0, - failed: 0, - errors: 0, - skipped: 0, - total: 0, - successRate: 0, + message: + "E2E status is unavailable because cluster report artifact was not found", }, + metrics: zeroMetrics(), failedTests: [], reportSource: "missing-artifact", }; @@ -140,61 +115,6 @@ function getReportClusterKey(report) { return String(report.storageType || report.cluster || "").trim(); } -/** - * Tells whether the report represents a missing artifact rather than a real - * cluster-stage failure. - * - * @param {Record} report Cluster report payload. - * @returns {boolean} True when the report describes a missing artifact. - */ -function isMissingReport(report) { - return ( - (report.testStatus && report.testStatus.status === "missing") || - (report.clusterStatus && report.clusterStatus.status === "missing") || - report.reportKind === "artifact-missing" || - report.failedStage === "artifact-missing" || - report.status === "missing" - ); -} - -/** - * Tells whether the report describes a failed cluster setup stage. - * - * @param {Record} report Cluster report payload. - * @returns {boolean} True for cluster-stage failures. - */ -function isClusterFailureReport(report) { - if (report.clusterStatus) { - return ( - report.clusterStatus.status !== "success" && - report.clusterStatus.status !== "missing" - ); - } - - return report.reportKind !== "tests" && !isMissingReport(report); -} - -/** - * Tells whether the report should be rendered in the E2E test results table. - * - * @param {Record} report Cluster report payload. - * @returns {boolean} True for reports with test status data. - */ -function isTestResultReport(report) { - if (report.clusterStatus && report.clusterStatus.status !== "success") { - return false; - } - - if (report.testStatus) { - return ( - report.testStatus.status !== "not-run" && - report.testStatus.status !== "missing" - ); - } - - return report.reportKind === "tests"; -} - module.exports = { createMissingReport, getReportClusterKey, diff --git a/.github/scripts/js/e2e/report/shared/fs-utils.js b/.github/scripts/js/e2e/report/shared/fs-utils.js index 7cb7a7e650..9cd45616e1 100644 --- a/.github/scripts/js/e2e/report/shared/fs-utils.js +++ b/.github/scripts/js/e2e/report/shared/fs-utils.js @@ -67,9 +67,9 @@ function findSingleMatchingFile(dirPath, filePattern, description = "file") { if (matchingFiles.length > 1) { throw new Error( - `Expected a single ${description}, but found ${matchingFiles.length}: ${matchingFiles.join( - ", " - )}` + `Expected a single ${description}, but found ${ + matchingFiles.length + }: ${matchingFiles.join(", ")}` ); } diff --git a/.github/scripts/js/e2e/report/shared/fs-utils.test.js b/.github/scripts/js/e2e/report/shared/fs-utils.test.js index 0b504089b8..0ab496fbbe 100644 --- a/.github/scripts/js/e2e/report/shared/fs-utils.test.js +++ b/.github/scripts/js/e2e/report/shared/fs-utils.test.js @@ -49,9 +49,11 @@ describe("fs-utils", () => { test("throws a descriptive error when a directory cannot be scanned", async () => withTempDir((tempDir) => { - const readdirSpy = jest.spyOn(fs, "readdirSync").mockImplementation(() => { - throw new Error("permission denied"); - }); + const readdirSpy = jest + .spyOn(fs, "readdirSync") + .mockImplementation(() => { + throw new Error("permission denied"); + }); try { expect(() => listMatchingFiles(tempDir, /\.json$/)).toThrow( diff --git a/.github/scripts/js/e2e/report/shared/ginkgo-report-utils.js b/.github/scripts/js/e2e/report/shared/ginkgo-report-utils.js index 557f8c3b78..af8c5e5ad6 100644 --- a/.github/scripts/js/e2e/report/shared/ginkgo-report-utils.js +++ b/.github/scripts/js/e2e/report/shared/ginkgo-report-utils.js @@ -90,7 +90,9 @@ function formatSpecName(specReport) { * @returns {"passed"|"failed"|"errors"|"skipped"} Metrics key. */ function metricKeyForState(state) { - const normalizedState = String(state || "").trim().toLowerCase(); + const normalizedState = String(state || "") + .trim() + .toLowerCase(); if (normalizedState === "passed") { return "passed"; diff --git a/.github/scripts/js/e2e/report/shared/report-model.js b/.github/scripts/js/e2e/report/shared/report-model.js new file mode 100644 index 0000000000..9ad5aac5cf --- /dev/null +++ b/.github/scripts/js/e2e/report/shared/report-model.js @@ -0,0 +1,254 @@ +// Copyright 2026 Flant JSC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// http://www.apache.org/licenses/LICENSE-2.0 +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +const stageMessage = { + bootstrap: "BOOTSTRAP CLUSTER", + "configure-sdn": "CONFIGURE SDN", + "storage-setup": "STORAGE SETUP", + "virtualization-setup": "VIRTUALIZATION SETUP", + "e2e-test": "E2E TEST", + ready: "CLUSTER READY", + "artifact-missing": "TEST REPORTS NOT FOUND", +}; + +const clusterSetupStages = [ + "bootstrap", + "configure-sdn", + "storage-setup", + "virtualization-setup", +]; + +function zeroMetrics() { + return { + passed: 0, + failed: 0, + errors: 0, + skipped: 0, + total: 0, + successRate: 0, + }; +} + +function buildStatusMessage(status, stageLabel) { + if (status === "success") { + return `✅ ${stageLabel}`; + } + + if (status === "cancelled") { + return `⚠️ ${stageLabel} CANCELLED`; + } + + if (status === "missing") { + return `⚠️ ${stageLabel}`; + } + + if (status === "not-run") { + return `⚠️ ${stageLabel} NOT RUN`; + } + + return `❌ ${stageLabel} FAILED`; +} + +function normalizeJobResult(resultValue) { + const result = String(resultValue || "success").trim(); + if (result === "cancelled" || result === "skipped" || result === "success") { + return result; + } + + return "failure"; +} + +function buildClusterStatus(stageResults) { + for (const stageName of clusterSetupStages) { + const stageResult = normalizeJobResult(stageResults[stageName]); + if (stageResult !== "success") { + const stageLabel = stageMessage[stageName] || stageName; + return { + status: stageResult === "cancelled" ? "cancelled" : "failure", + stage: stageName, + stageLabel, + message: buildStatusMessage(stageResult, stageLabel), + reason: + stageResult === "cancelled" + ? "cluster-stage-cancelled" + : "cluster-stage-failed", + }; + } + } + + return { + status: "success", + stage: "ready", + stageLabel: stageMessage.ready, + message: buildStatusMessage("success", stageMessage.ready), + reason: "", + }; +} + +function buildTestStatus( + testResult, + reportSource, + clusterStatus, + metrics = {} +) { + const stageLabel = stageMessage["e2e-test"]; + + if (clusterStatus.status !== "success") { + return { + status: "not-run", + reason: "cluster-stage-failed", + message: "E2E tests were not run because cluster setup did not finish", + }; + } + + const normalizedResult = normalizeJobResult(testResult); + + if (reportSource === "ginkgo-json") { + const hasReportedFailures = + Number(metrics.failed || 0) > 0 || Number(metrics.errors || 0) > 0; + const status = + normalizedResult === "success" && hasReportedFailures + ? "failure" + : normalizedResult; + + return { + status, + reason: status === "success" ? "" : "ginkgo-failed", + message: + status === "success" + ? "✅ E2E TESTS PASSED" + : buildStatusMessage(status, stageLabel), + }; + } + + if (reportSource === "ginkgo-json-invalid") { + return { + status: "missing", + reason: "ginkgo-report-invalid", + message: "⚠️ E2E TEST REPORT IS INVALID", + }; + } + + if (normalizedResult === "success") { + return { + status: "missing", + reason: "ginkgo-report-missing", + message: "⚠️ E2E TEST REPORT NOT FOUND", + }; + } + + if (normalizedResult === "cancelled") { + return { + status: "cancelled", + reason: "e2e-cancelled", + message: buildStatusMessage("cancelled", stageLabel), + }; + } + + if (normalizedResult === "skipped") { + return { + status: "not-run", + reason: "e2e-skipped", + message: buildStatusMessage("not-run", stageLabel), + }; + } + + return { + status: "failure", + reason: "ginkgo-report-missing", + message: "❌ E2E TESTS FAILED, GINKGO REPORT NOT FOUND", + }; +} + +function buildReportSummary(storageType, clusterStatus, testStatus) { + if (clusterStatus.status !== "success") { + return { + failedStage: clusterStatus.stage, + failedStageLabel: clusterStatus.stageLabel, + failedJobName: `${clusterStatus.stageLabel} (${storageType})`, + reportKind: "stage-failure", + status: clusterStatus.status, + statusMessage: clusterStatus.message, + }; + } + + if (testStatus.status === "missing") { + const stageLabel = stageMessage["artifact-missing"]; + return { + failedStage: "artifact-missing", + failedStageLabel: stageLabel, + failedJobName: `E2E test (${storageType})`, + reportKind: "artifact-missing", + status: "missing", + statusMessage: testStatus.message, + }; + } + + return { + failedStage: testStatus.status === "success" ? "success" : "e2e-test", + failedStageLabel: + testStatus.status === "success" ? "SUCCESS" : stageMessage["e2e-test"], + failedJobName: `E2E test (${storageType})`, + reportKind: "tests", + status: testStatus.status, + statusMessage: testStatus.message, + }; +} + +function isMissingReport(report) { + return ( + (report.testStatus && report.testStatus.status === "missing") || + (report.clusterStatus && report.clusterStatus.status === "missing") || + report.reportKind === "artifact-missing" || + report.failedStage === "artifact-missing" || + report.status === "missing" + ); +} + +function isClusterFailureReport(report) { + if (report.clusterStatus) { + return ( + report.clusterStatus.status !== "success" && + report.clusterStatus.status !== "missing" + ); + } + + return report.reportKind !== "tests" && !isMissingReport(report); +} + +function isTestResultReport(report) { + if (report.clusterStatus && report.clusterStatus.status !== "success") { + return false; + } + + if (report.testStatus) { + return ( + report.testStatus.status !== "not-run" && + report.testStatus.status !== "missing" + ); + } + + return report.reportKind === "tests"; +} + +module.exports = { + buildClusterStatus, + buildReportSummary, + buildStatusMessage, + buildTestStatus, + isClusterFailureReport, + isMissingReport, + isTestResultReport, + normalizeJobResult, + stageMessage, + zeroMetrics, +}; diff --git a/.github/workflows/e2e-reusable-pipeline.yml b/.github/workflows/e2e-reusable-pipeline.yml index 62d6728a3d..84171a1b66 100644 --- a/.github/workflows/e2e-reusable-pipeline.yml +++ b/.github/workflows/e2e-reusable-pipeline.yml @@ -1412,26 +1412,23 @@ jobs: - name: Determine failed stage and prepare report id: determine-stage uses: actions/github-script@v7 + env: + STORAGE_TYPE: ${{ inputs.storage_type }} + REPORTS_DIR: test/e2e + REPORT_FILE: ${{ format('e2e_report_{0}.json', inputs.storage_type) }} + BRANCH_NAME: ${{ github.head_ref || github.ref_name }} + WORKFLOW_RUN_URL: ${{ format('{0}/{1}/actions/runs/{2}', github.server_url, github.repository, github.run_id) }} + BOOTSTRAP_RESULT: ${{ needs.bootstrap.result }} + CONFIGURE_SDN_RESULT: ${{ needs.configure-sdn.result }} + STORAGE_SETUP_RESULT: ${{ needs.configure-storage.result }} + VIRTUALIZATION_SETUP_RESULT: ${{ needs.configure-virtualization.result }} + E2E_TEST_RESULT: ${{ needs.e2e-test.result }} with: script: | const buildClusterReport = require('./.github/scripts/js/e2e/report/cluster-report'); await buildClusterReport({ core, context, - config: { - storageType: ${{ toJson(inputs.storage_type) }}, - reportsDir: 'test/e2e', - reportFile: ${{ toJson(format('e2e_report_{0}.json', inputs.storage_type)) }}, - branchName: ${{ toJson(github.head_ref || github.ref_name) }}, - workflowRunUrl: ${{ toJson(format('{0}/{1}/actions/runs/{2}', github.server_url, github.repository, github.run_id)) }}, - stageResults: { - bootstrap: ${{ toJson(needs.bootstrap.result) }}, - 'configure-sdn': ${{ toJson(needs.configure-sdn.result) }}, - 'storage-setup': ${{ toJson(needs.configure-storage.result) }}, - 'virtualization-setup': ${{ toJson(needs.configure-virtualization.result) }}, - 'e2e-test': ${{ toJson(needs.e2e-test.result) }}, - }, - }, }); - name: Upload E2E report artifact From 8ecb7c508cb1f230f3891a5f8b87960894d0bc86 Mon Sep 17 00:00:00 2001 From: Nikita Korolev Date: Tue, 5 May 2026 19:35:53 +0300 Subject: [PATCH 11/22] resolve comments from v Signed-off-by: Nikita Korolev --- .../scripts/js/e2e/report/cluster-report.js | 79 +++++++++++++------ .../js/e2e/report/cluster-report.test.js | 55 ++++++++----- .../js/e2e/report/messenger-report.test.js | 8 +- .../scripts/js/e2e/report/messenger/config.js | 10 ++- .github/workflows/e2e-matrix.yml | 3 +- .github/workflows/e2e-reusable-pipeline.yml | 48 +---------- 6 files changed, 106 insertions(+), 97 deletions(-) diff --git a/.github/scripts/js/e2e/report/cluster-report.js b/.github/scripts/js/e2e/report/cluster-report.js index 0ac3653fae..07e57f92ed 100644 --- a/.github/scripts/js/e2e/report/cluster-report.js +++ b/.github/scripts/js/e2e/report/cluster-report.js @@ -51,8 +51,6 @@ const { * @property {string} storageType * @property {string} reportsDir * @property {string} reportFile - * @property {string} [workflowRunUrl] - * @property {string} [branchName] * @property {StageResults} stageResults */ @@ -60,9 +58,18 @@ const { * @typedef {Object} ClusterReportParams * @property {ClusterReportCore} core * @property {ClusterReportContext} context + * @property {any} [github] * @property {ClusterReportConfig} [config] */ +const workflowStageJobs = { + bootstrap: "Bootstrap cluster", + "configure-sdn": "Configure SDN", + "storage-setup": "Configure storage", + "virtualization-setup": "Configure Virtualization", + "e2e-test": "E2E test", +}; + function escapeRegExp(value) { return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); } @@ -74,15 +81,6 @@ function readClusterReportConfigFromEnv(env = process.env) { storageType, reportsDir: env.REPORTS_DIR || "test/e2e", reportFile: env.REPORT_FILE || `e2e_report_${storageType}.json`, - workflowRunUrl: String(env.WORKFLOW_RUN_URL || "").trim(), - branchName: String(env.BRANCH_NAME || "").trim(), - stageResults: { - bootstrap: env.BOOTSTRAP_RESULT, - "configure-sdn": env.CONFIGURE_SDN_RESULT, - "storage-setup": env.STORAGE_SETUP_RESULT, - "virtualization-setup": env.VIRTUALIZATION_SETUP_RESULT, - "e2e-test": env.E2E_TEST_RESULT, - }, }; } @@ -105,18 +103,47 @@ function requireClusterReportConfig(config) { }; } -function getWorkflowRunUrl(config, context) { - if (config.workflowRunUrl) { - return config.workflowRunUrl; +function getWorkflowRunUrl(context) { + return `${context.serverUrl}/${context.repo.owner}/${context.repo.repo}/actions/runs/${context.runId}`; +} + +function getBranchName(context) { + return String(context.ref || "").replace(/^refs\/heads\//, ""); +} + +async function listWorkflowRunJobs(github, context) { + if (!github || !github.rest || !github.rest.actions) { + throw new Error("buildClusterReport requires github client"); } - return `${context.serverUrl}/${context.repo.owner}/${context.repo.repo}/actions/runs/${context.runId}`; + const params = { + owner: context.repo.owner, + repo: context.repo.repo, + run_id: context.runId, + per_page: 100, + }; + + const response = await github.rest.actions.listJobsForWorkflowRun(params); + return response.data.jobs || []; } -function getBranchName(config, context) { - return ( - config.branchName || String(context.ref || "").replace(/^refs\/heads\//, "") - ); +async function readStageResultsFromWorkflowRun(github, context, core) { + const jobs = await listWorkflowRunJobs(github, context); + const jobsByName = new Map(jobs.map((job) => [job.name, job])); + const stageResults = {}; + + for (const [stageName, jobName] of Object.entries(workflowStageJobs)) { + const job = jobsByName.get(jobName); + if (!job) { + core.warning(`Unable to find workflow job "${jobName}" for E2E report`); + stageResults[stageName] = "skipped"; + continue; + } + + stageResults[stageName] = job.conclusion || "skipped"; + } + + return stageResults; } function findGinkgoReport(config) { @@ -229,13 +256,21 @@ function setReportOutputs(report, reportFile, core) { * @returns {Promise>} Generated cluster report. * @throws {Error} If config is incomplete or the report file cannot be written. */ -async function buildClusterReport({ core, context, config } = {}) { +async function buildClusterReport({ core, context, github, config } = {}) { const resolvedConfig = requireClusterReportConfig( config || readClusterReportConfigFromEnv() ); - const workflowRunUrl = getWorkflowRunUrl(resolvedConfig, context); - const branchName = getBranchName(resolvedConfig, context); + if (!config || !config.stageResults) { + resolvedConfig.stageResults = await readStageResultsFromWorkflowRun( + github, + context, + core + ); + } + + const workflowRunUrl = getWorkflowRunUrl(context); + const branchName = getBranchName(context); const rawReportPath = findGinkgoReport(resolvedConfig); if (!rawReportPath) { diff --git a/.github/scripts/js/e2e/report/cluster-report.test.js b/.github/scripts/js/e2e/report/cluster-report.test.js index 9c0380ca40..e0d40e23ec 100644 --- a/.github/scripts/js/e2e/report/cluster-report.test.js +++ b/.github/scripts/js/e2e/report/cluster-report.test.js @@ -57,6 +57,29 @@ function createContext() { }; } +/** + * Creates a minimal GitHub API client mock for workflow job discovery. + * + * @param {Record} jobConclusions Job conclusion by job name. + * @returns {Record} Mocked GitHub client. + */ +function createGithub(jobConclusions) { + const jobs = Object.entries(jobConclusions).map(([name, conclusion]) => ({ + name, + conclusion, + })); + + return { + rest: { + actions: { + listJobsForWorkflowRun: jest.fn().mockResolvedValue({ + data: { jobs }, + }), + }, + }, + }; +} + /** * Runs a test body inside a temporary directory and removes it afterwards. * @@ -197,13 +220,6 @@ describe("cluster-report", () => { delete process.env.STORAGE_TYPE; delete process.env.REPORTS_DIR; delete process.env.REPORT_FILE; - delete process.env.WORKFLOW_RUN_URL; - delete process.env.BRANCH_NAME; - delete process.env.BOOTSTRAP_RESULT; - delete process.env.CONFIGURE_SDN_RESULT; - delete process.env.STORAGE_SETUP_RESULT; - delete process.env.VIRTUALIZATION_SETUP_RESULT; - delete process.env.E2E_TEST_RESULT; }); test("requires storage type when config is absent", async () => { @@ -242,8 +258,6 @@ describe("cluster-report", () => { storageType: "nfs", reportsDir: tempDir, reportFile, - workflowRunUrl: "https://example.invalid/run/explicit", - branchName: "feature/report", stageResults: { bootstrap: "success", "configure-sdn": "failure", @@ -256,9 +270,9 @@ describe("cluster-report", () => { expect(report.cluster).toBe("nfs"); expect(report.workflowRunUrl).toBe( - "https://example.invalid/run/explicit" + "https://github.com/test/repo/actions/runs/12345" ); - expect(report.branch).toBe("feature/report"); + expect(report.branch).toBe("main"); expect(report.clusterStatus).toMatchObject({ status: "failure", stage: "configure-sdn", @@ -274,31 +288,30 @@ describe("cluster-report", () => { process.env.STORAGE_TYPE = "replicated"; process.env.REPORTS_DIR = tempDir; process.env.REPORT_FILE = reportFile; - process.env.WORKFLOW_RUN_URL = "https://example.invalid/run/from-env"; - process.env.BRANCH_NAME = "feature/from-env"; - process.env.BOOTSTRAP_RESULT = "success"; - process.env.CONFIGURE_SDN_RESULT = "success"; - process.env.STORAGE_SETUP_RESULT = "success"; - process.env.VIRTUALIZATION_SETUP_RESULT = "success"; - process.env.E2E_TEST_RESULT = "success"; expect(readClusterReportConfigFromEnv()).toMatchObject({ storageType: "replicated", reportsDir: tempDir, reportFile, - branchName: "feature/from-env", }); const report = await buildClusterReport({ core: createCore(), context: createContext(), + github: createGithub({ + "Bootstrap cluster": "success", + "Configure SDN": "success", + "Configure storage": "success", + "Configure Virtualization": "success", + "E2E test": "success", + }), }); expect(report.cluster).toBe("replicated"); expect(report.workflowRunUrl).toBe( - "https://example.invalid/run/from-env" + "https://github.com/test/repo/actions/runs/12345" ); - expect(report.branch).toBe("feature/from-env"); + expect(report.branch).toBe("main"); expect(JSON.parse(fs.readFileSync(reportFile, "utf8")).cluster).toBe( "replicated" ); diff --git a/.github/scripts/js/e2e/report/messenger-report.test.js b/.github/scripts/js/e2e/report/messenger-report.test.js index 757e7600d1..e76cd4cced 100644 --- a/.github/scripts/js/e2e/report/messenger-report.test.js +++ b/.github/scripts/js/e2e/report/messenger-report.test.js @@ -67,7 +67,6 @@ describe("messenger-report", () => { test("reads normalized messenger config from env", () => { const config = readMessengerConfigFromEnv({ REPORTS_DIR: "custom-reports", - EXPECTED_STORAGE_TYPES: '["replicated","nfs"]', LOOP_API_BASE_URL: "https://loop.example.invalid/api/v4/", LOOP_CHANNEL_ID: " channel-id ", LOOP_TOKEN: " token ", @@ -84,6 +83,13 @@ describe("messenger-report", () => { }); }); + test("uses default configured clusters when env override is absent", () => { + const config = readMessengerConfigFromEnv({}); + + expect(config.configuredClusters).toEqual(["replicated", "nfs"]); + expect(config.reportsDir).toBe("downloaded-artifacts"); + }); + test("renders test results, stage failures, and per-cluster thread replies", async () => withTempDir(async (tempDir) => { fs.writeFileSync( diff --git a/.github/scripts/js/e2e/report/messenger/config.js b/.github/scripts/js/e2e/report/messenger/config.js index bb9fc59751..003fd69a84 100644 --- a/.github/scripts/js/e2e/report/messenger/config.js +++ b/.github/scripts/js/e2e/report/messenger/config.js @@ -57,8 +57,10 @@ function parseConfiguredClusters(value) { return Array.isArray(parsedValue) ? parsedValue : []; } +const defaultConfiguredClusters = ["replicated", "nfs"]; + /** - * Reads messenger configuration from the environment prepared by the workflow. + * Reads messenger configuration from the environment. * * @param {NodeJS.ProcessEnv} [env=process.env] Environment variables source. * @returns {{ @@ -72,9 +74,9 @@ function parseConfiguredClusters(value) { * }} Normalized messenger configuration. */ function readMessengerConfigFromEnv(env = process.env) { - const configuredClusters = parseConfiguredClusters( - env.EXPECTED_STORAGE_TYPES - ); + const configuredClusters = env.EXPECTED_STORAGE_TYPES + ? parseConfiguredClusters(env.EXPECTED_STORAGE_TYPES) + : defaultConfiguredClusters; return { reportsDir: env.REPORTS_DIR || "downloaded-artifacts", diff --git a/.github/workflows/e2e-matrix.yml b/.github/workflows/e2e-matrix.yml index e4dadb39be..b1ee551ee7 100644 --- a/.github/workflows/e2e-matrix.yml +++ b/.github/workflows/e2e-matrix.yml @@ -488,11 +488,10 @@ jobs: id: render-report uses: actions/github-script@v7 env: - EXPECTED_STORAGE_TYPES: '["replicated","nfs"]' LOOP_API_BASE_URL: ${{ secrets.LOOP_API_BASE_URL }} LOOP_CHANNEL_ID: ${{ secrets.LOOP_CHANNEL_ID }} LOOP_TOKEN: ${{ secrets.LOOP_TOKEN }} with: script: | const renderMessengerReport = require('./.github/scripts/js/e2e/report/messenger-report'); - await renderMessengerReport({core, reportsDir: 'downloaded-artifacts/'}); + await renderMessengerReport({core}); diff --git a/.github/workflows/e2e-reusable-pipeline.yml b/.github/workflows/e2e-reusable-pipeline.yml index 84171a1b66..db85cc2ac7 100644 --- a/.github/workflows/e2e-reusable-pipeline.yml +++ b/.github/workflows/e2e-reusable-pipeline.yml @@ -122,29 +122,6 @@ on: required: true BOOTSTRAP_DEV_PROXY: required: true - outputs: - artifact-name: - description: "Name of the uploaded artifact with E2E report" - value: ${{ jobs.prepare-report.outputs.artifact-name }} - report_kind: - description: "E2E report kind for the cluster" - value: ${{ jobs.prepare-report.outputs.report_kind }} - status: - description: "E2E report status for the cluster" - value: ${{ jobs.prepare-report.outputs.status }} - failed_stage: - description: "Failed or final stage name for the cluster" - value: ${{ jobs.prepare-report.outputs.failed_stage }} - failed_stage_label: - description: "Human-readable failed or final stage label for the cluster" - value: ${{ jobs.prepare-report.outputs.failed_stage_label }} - workflow_run_url: - description: "Workflow run URL for the cluster pipeline" - value: ${{ jobs.prepare-report.outputs.workflow_run_url }} - branch: - description: "Branch used for the cluster pipeline" - value: ${{ jobs.prepare-report.outputs.branch }} - env: BRANCH: ${{ inputs.branch }} VIRTUALIZATION_TAG: ${{ inputs.virtualization_tag }} @@ -1391,14 +1368,6 @@ jobs: - configure-virtualization - e2e-test if: always() - outputs: - artifact-name: ${{ steps.set-artifact-name.outputs.artifact-name }} - report_kind: ${{ steps.determine-stage.outputs.report_kind }} - status: ${{ steps.determine-stage.outputs.status }} - failed_stage: ${{ steps.determine-stage.outputs.failed_stage }} - failed_stage_label: ${{ steps.determine-stage.outputs.failed_stage_label }} - workflow_run_url: ${{ steps.determine-stage.outputs.workflow_run_url }} - branch: ${{ steps.determine-stage.outputs.branch }} steps: - uses: actions/checkout@v4 @@ -1414,21 +1383,13 @@ jobs: uses: actions/github-script@v7 env: STORAGE_TYPE: ${{ inputs.storage_type }} - REPORTS_DIR: test/e2e - REPORT_FILE: ${{ format('e2e_report_{0}.json', inputs.storage_type) }} - BRANCH_NAME: ${{ github.head_ref || github.ref_name }} - WORKFLOW_RUN_URL: ${{ format('{0}/{1}/actions/runs/{2}', github.server_url, github.repository, github.run_id) }} - BOOTSTRAP_RESULT: ${{ needs.bootstrap.result }} - CONFIGURE_SDN_RESULT: ${{ needs.configure-sdn.result }} - STORAGE_SETUP_RESULT: ${{ needs.configure-storage.result }} - VIRTUALIZATION_SETUP_RESULT: ${{ needs.configure-virtualization.result }} - E2E_TEST_RESULT: ${{ needs.e2e-test.result }} with: script: | const buildClusterReport = require('./.github/scripts/js/e2e/report/cluster-report'); await buildClusterReport({ core, context, + github, }); - name: Upload E2E report artifact @@ -1440,13 +1401,6 @@ jobs: overwrite: true retention-days: 3 - - name: Set artifact name output - id: set-artifact-name - run: | - ARTIFACT_NAME="e2e-report-${{ inputs.storage_type }}-${{ github.run_id }}-${{ inputs.date_start }}" - echo "artifact-name=$ARTIFACT_NAME" >> $GITHUB_OUTPUT - echo "[INFO] Artifact name: $ARTIFACT_NAME" - undeploy-cluster: name: Undeploy cluster runs-on: ubuntu-latest From 9a245a38b71d5cc08982ba69dc4263d781723011 Mon Sep 17 00:00:00 2001 From: Nikita Korolev Date: Tue, 5 May 2026 19:49:53 +0300 Subject: [PATCH 12/22] refactor getting url job Signed-off-by: Nikita Korolev --- .../scripts/js/e2e/report/cluster-report.js | 67 ++++++++++++++++--- .../js/e2e/report/cluster-report.test.js | 53 ++++++++++++--- 2 files changed, 102 insertions(+), 18 deletions(-) diff --git a/.github/scripts/js/e2e/report/cluster-report.js b/.github/scripts/js/e2e/report/cluster-report.js index 07e57f92ed..98cc6e4f36 100644 --- a/.github/scripts/js/e2e/report/cluster-report.js +++ b/.github/scripts/js/e2e/report/cluster-report.js @@ -25,6 +25,10 @@ const { * @typedef {Record} StageResults */ +/** + * @typedef {Record} StageUrls + */ + /** * @typedef {Object} GinkgoMetrics * @property {number} [failed] @@ -52,6 +56,7 @@ const { * @property {string} reportsDir * @property {string} reportFile * @property {StageResults} stageResults + * @property {StageUrls} [stageJobUrls] */ /** @@ -70,6 +75,11 @@ const workflowStageJobs = { "e2e-test": "E2E test", }; +const workflowPipelineJobs = { + replicated: "E2E Pipeline (Replicated)", + nfs: "E2E Pipeline (NFS)", +}; + function escapeRegExp(value) { return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); } @@ -100,6 +110,7 @@ function requireClusterReportConfig(config) { return { ...config, stageResults: config.stageResults || {}, + stageJobUrls: config.stageJobUrls || {}, }; } @@ -123,17 +134,32 @@ async function listWorkflowRunJobs(github, context) { per_page: 100, }; + if (github.paginate) { + return github.paginate(github.rest.actions.listJobsForWorkflowRun, params); + } + const response = await github.rest.actions.listJobsForWorkflowRun(params); return response.data.jobs || []; } -async function readStageResultsFromWorkflowRun(github, context, core) { +function findWorkflowJob(jobs, storageType, jobName) { + const pipelineJobName = workflowPipelineJobs[storageType]; + const nestedJobName = pipelineJobName ? `${pipelineJobName} / ${jobName}` : ""; + + return ( + jobs.find((job) => job.name === nestedJobName) || + jobs.find((job) => job.name === jobName) || + jobs.find((job) => String(job.name || "").endsWith(` / ${jobName}`)) + ); +} + +async function readStageDetailsFromWorkflowRun(github, context, config, core) { const jobs = await listWorkflowRunJobs(github, context); - const jobsByName = new Map(jobs.map((job) => [job.name, job])); const stageResults = {}; + const stageJobUrls = {}; for (const [stageName, jobName] of Object.entries(workflowStageJobs)) { - const job = jobsByName.get(jobName); + const job = findWorkflowJob(jobs, config.storageType, jobName); if (!job) { core.warning(`Unable to find workflow job "${jobName}" for E2E report`); stageResults[stageName] = "skipped"; @@ -141,9 +167,10 @@ async function readStageResultsFromWorkflowRun(github, context, core) { } stageResults[stageName] = job.conclusion || "skipped"; + stageJobUrls[stageName] = job.html_url || ""; } - return stageResults; + return { stageResults, stageJobUrls }; } function findGinkgoReport(config) { @@ -190,7 +217,7 @@ function parseGinkgoReportFile(rawReportPath, core) { function buildReportPayload({ config, context, - workflowRunUrl, + fallbackWorkflowRunUrl, branchName, parsedReport, rawReportPath, @@ -207,6 +234,11 @@ function buildReportPayload({ clusterStatus, testStatus ); + const workflowRunUrl = getReportJobUrl( + reportSummary, + config.stageJobUrls, + fallbackWorkflowRunUrl + ); return { schemaVersion: 1, @@ -231,6 +263,22 @@ function buildReportPayload({ }; } +function getReportJobUrl( + reportSummary, + stageJobUrls = {}, + fallbackWorkflowRunUrl +) { + if (reportSummary.failedStage && stageJobUrls[reportSummary.failedStage]) { + return stageJobUrls[reportSummary.failedStage]; + } + + if (stageJobUrls["e2e-test"]) { + return stageJobUrls["e2e-test"]; + } + + return fallbackWorkflowRunUrl; +} + /** * Exposes the generated report fields as GitHub Actions step outputs. * @@ -262,14 +310,17 @@ async function buildClusterReport({ core, context, github, config } = {}) { ); if (!config || !config.stageResults) { - resolvedConfig.stageResults = await readStageResultsFromWorkflowRun( + const stageDetails = await readStageDetailsFromWorkflowRun( github, context, + resolvedConfig, core ); + resolvedConfig.stageResults = stageDetails.stageResults; + resolvedConfig.stageJobUrls = stageDetails.stageJobUrls; } - const workflowRunUrl = getWorkflowRunUrl(context); + const fallbackWorkflowRunUrl = getWorkflowRunUrl(context); const branchName = getBranchName(context); const rawReportPath = findGinkgoReport(resolvedConfig); @@ -283,7 +334,7 @@ async function buildClusterReport({ core, context, github, config } = {}) { const report = buildReportPayload({ config: resolvedConfig, context, - workflowRunUrl, + fallbackWorkflowRunUrl, branchName, parsedReport, rawReportPath, diff --git a/.github/scripts/js/e2e/report/cluster-report.test.js b/.github/scripts/js/e2e/report/cluster-report.test.js index e0d40e23ec..07a28fa767 100644 --- a/.github/scripts/js/e2e/report/cluster-report.test.js +++ b/.github/scripts/js/e2e/report/cluster-report.test.js @@ -64,10 +64,15 @@ function createContext() { * @returns {Record} Mocked GitHub client. */ function createGithub(jobConclusions) { - const jobs = Object.entries(jobConclusions).map(([name, conclusion]) => ({ - name, - conclusion, - })); + const jobs = Object.entries(jobConclusions).map( + ([name, conclusion], index) => ({ + name, + conclusion, + html_url: `https://github.com/test/repo/actions/runs/12345/job/${ + index + 1 + }`, + }) + ); return { rest: { @@ -299,17 +304,17 @@ describe("cluster-report", () => { core: createCore(), context: createContext(), github: createGithub({ - "Bootstrap cluster": "success", - "Configure SDN": "success", - "Configure storage": "success", - "Configure Virtualization": "success", - "E2E test": "success", + "E2E Pipeline (Replicated) / Bootstrap cluster": "success", + "E2E Pipeline (Replicated) / Configure SDN": "success", + "E2E Pipeline (Replicated) / Configure storage": "success", + "E2E Pipeline (Replicated) / Configure Virtualization": "success", + "E2E Pipeline (Replicated) / E2E test": "success", }), }); expect(report.cluster).toBe("replicated"); expect(report.workflowRunUrl).toBe( - "https://github.com/test/repo/actions/runs/12345" + "https://github.com/test/repo/actions/runs/12345/job/5" ); expect(report.branch).toBe("main"); expect(JSON.parse(fs.readFileSync(reportFile, "utf8")).cluster).toBe( @@ -317,6 +322,34 @@ describe("cluster-report", () => { ); })); + test("links report to the matching failed workflow job", async () => + withTempDir(async (tempDir) => { + const reportFile = path.join(tempDir, "env-report.json"); + process.env.STORAGE_TYPE = "nfs"; + process.env.REPORTS_DIR = tempDir; + process.env.REPORT_FILE = reportFile; + + const report = await buildClusterReport({ + core: createCore(), + context: createContext(), + github: createGithub({ + "E2E Pipeline (NFS) / Bootstrap cluster": "success", + "E2E Pipeline (NFS) / Configure SDN": "failure", + "E2E Pipeline (NFS) / Configure storage": "skipped", + "E2E Pipeline (NFS) / Configure Virtualization": "skipped", + "E2E Pipeline (NFS) / E2E test": "skipped", + }), + }); + + expect(report.clusterStatus).toMatchObject({ + status: "failure", + stage: "configure-sdn", + }); + expect(report.workflowRunUrl).toBe( + "https://github.com/test/repo/actions/runs/12345/job/2" + ); + })); + test("marks Ginkgo JSON with failed specs as failed", async () => withTempDir(async (tempDir) => { const rawReportPath = path.join( From 8202fc2cefc9246b81f783605ee398c4c37c5cf5 Mon Sep 17 00:00:00 2001 From: Nikita Korolev Date: Tue, 5 May 2026 19:56:11 +0300 Subject: [PATCH 13/22] sort array Signed-off-by: Nikita Korolev --- .github/scripts/js/e2e/report/messenger/markdown.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/scripts/js/e2e/report/messenger/markdown.js b/.github/scripts/js/e2e/report/messenger/markdown.js index 072c9cf4cc..8631b7ca84 100644 --- a/.github/scripts/js/e2e/report/messenger/markdown.js +++ b/.github/scripts/js/e2e/report/messenger/markdown.js @@ -169,9 +169,9 @@ function buildMainMessage(orderedReports) { `## DVP | E2E on nested clusters | ${reportDate}`, "", ...renderBranchLine(orderedReports), - ...renderTestResultsSection(testsReports), ...renderClusterFailuresSection(stageFailureReports), ...renderMissingReportsSection(missingReports), + ...renderTestResultsSection(testsReports), ]; return lines.join("\n").trim(); From 51b5ae850c7bfb74eeda3cce01af78faa69fb4ea Mon Sep 17 00:00:00 2001 From: Nikita Korolev Date: Tue, 5 May 2026 20:06:40 +0300 Subject: [PATCH 14/22] resolve comment for parseGinkgoReport Signed-off-by: Nikita Korolev --- .../scripts/js/e2e/report/cluster-report.js | 2 +- .../js/e2e/report/cluster-report.test.js | 26 +------------------ .../e2e/report/shared/ginkgo-report-utils.js | 7 ++--- 3 files changed, 6 insertions(+), 29 deletions(-) diff --git a/.github/scripts/js/e2e/report/cluster-report.js b/.github/scripts/js/e2e/report/cluster-report.js index 98cc6e4f36..a638d3451b 100644 --- a/.github/scripts/js/e2e/report/cluster-report.js +++ b/.github/scripts/js/e2e/report/cluster-report.js @@ -198,7 +198,7 @@ function parseGinkgoReportFile(rawReportPath, core) { core.info(`Found Ginkgo JSON report: ${rawReportPath}`); try { return { - ...parseGinkgoReport(fs.readFileSync(rawReportPath, "utf8"), zeroMetrics), + ...parseGinkgoReport(fs.readFileSync(rawReportPath, "utf8")), source: "ginkgo-json", }; } catch (error) { diff --git a/.github/scripts/js/e2e/report/cluster-report.test.js b/.github/scripts/js/e2e/report/cluster-report.test.js index 07a28fa767..6764c8f6fa 100644 --- a/.github/scripts/js/e2e/report/cluster-report.test.js +++ b/.github/scripts/js/e2e/report/cluster-report.test.js @@ -197,29 +197,6 @@ function createGinkgoReport({ startedAt, specs }) { ); } -/** - * Creates a zero-filled metrics object for parser tests. - * - * @returns {{ - * passed: number, - * failed: number, - * errors: number, - * skipped: number, - * total: number, - * successRate: number - * }} Zeroed metrics payload. - */ -function createZeroMetrics() { - return { - passed: 0, - failed: 0, - errors: 0, - skipped: 0, - total: 0, - successRate: 0, - }; -} - describe("cluster-report", () => { afterEach(() => { delete process.env.STORAGE_TYPE; @@ -622,8 +599,7 @@ describe("cluster-report", () => { createGinkgoReport({ startedAt: "2026-04-28T03:11:27.708387575Z", specs, - }), - createZeroMetrics + }) ); expect(parsed.metrics).toEqual({ diff --git a/.github/scripts/js/e2e/report/shared/ginkgo-report-utils.js b/.github/scripts/js/e2e/report/shared/ginkgo-report-utils.js index af8c5e5ad6..509da9f146 100644 --- a/.github/scripts/js/e2e/report/shared/ginkgo-report-utils.js +++ b/.github/scripts/js/e2e/report/shared/ginkgo-report-utils.js @@ -10,6 +10,8 @@ // See the License for the specific language governing permissions and // limitations under the License. +const { zeroMetrics } = require("./report-model"); + /** * @typedef {Object} GinkgoMetrics * @property {number} passed @@ -114,16 +116,15 @@ function metricKeyForState(state) { * markdown report. * * @param {string} jsonContent Raw JSON content. - * @param {function(): GinkgoMetrics} createZeroMetrics Factory creating a zeroed metrics object. * @returns {{ * metrics: GinkgoMetrics, * failedTests: string[], * startedAt: string|null * }} Parsed report payload. */ -function parseGinkgoReport(jsonContent, createZeroMetrics) { +function parseGinkgoReport(jsonContent) { const suites = toArray(JSON.parse(jsonContent)); - const metrics = createZeroMetrics(); + const metrics = zeroMetrics(); const failedTests = []; const startedAt = suites.find((suite) => suite && suite.StartTime)?.StartTime || null; From 8097f16a4e5191fd7e67907765b0add2eae78408 Mon Sep 17 00:00:00 2001 From: Nikita Korolev Date: Wed, 6 May 2026 12:16:01 +0300 Subject: [PATCH 15/22] refactor and fix Signed-off-by: Nikita Korolev --- .../scripts/js/e2e/report/messenger-report.js | 75 +++++++++++++------ .../js/e2e/report/messenger-report.test.js | 11 ++- .../scripts/js/e2e/report/messenger/config.js | 15 +++- .../js/e2e/report/messenger/markdown.js | 26 ++----- .../scripts/js/e2e/report/messenger/model.js | 31 -------- .../e2e/report/shared/ginkgo-report-utils.js | 8 +- .../js/e2e/report/shared/report-model.js | 4 +- .github/workflows/e2e-matrix.yml | 1 + 8 files changed, 85 insertions(+), 86 deletions(-) diff --git a/.github/scripts/js/e2e/report/messenger-report.js b/.github/scripts/js/e2e/report/messenger-report.js index b2814f1ffd..d9e0c69c3d 100644 --- a/.github/scripts/js/e2e/report/messenger-report.js +++ b/.github/scripts/js/e2e/report/messenger-report.js @@ -11,6 +11,7 @@ // limitations under the License. const fs = require("fs"); +const path = require("path"); const { listMatchingFiles } = require("./shared/fs-utils"); const { publishToLoop } = require("./messenger/loop-client"); @@ -18,7 +19,6 @@ const { readMessengerConfigFromEnv } = require("./messenger/config"); const { createMissingReport, getReportClusterKey, - sortReports, } = require("./messenger/model"); const { buildMainMessage, @@ -45,10 +45,30 @@ const { * @property {string} [reportsDir] */ +/** + * Derives a cluster key from a report file path using the naming convention + * `e2e_report_[_suffix].json`. + * + * This is a fallback used only when the report JSON itself does not contain + * `storageType` or `cluster` fields, which should not happen in normal CI runs + * because `buildClusterReport` validates both fields before writing the file. + * + * @param {string} reportFile Absolute or relative path to the report file. + * @returns {string} Extracted storage type, or an empty string when not parseable. + */ +function clusterKeyFromFilename(reportFile) { + const match = path.basename(reportFile).match(/^e2e_report_([^_.]+)/); + return match ? match[1] : ""; +} + /** * Loads report JSON files from disk and injects synthetic reports for clusters * whose artifacts are missing. * + * The result is ordered as follows: + * 1. Configured clusters in their declared order (missing ones get synthetic reports). + * 2. Any extra clusters found on disk, sorted alphabetically. + * * @param {string} reportsDir Directory containing `e2e_report_*.json`. * @param {string[]} configuredClusters Clusters expected in the final report. * @param {MessengerReportCore} core GitHub core API. @@ -56,38 +76,51 @@ const { */ function readReports(reportsDir, configuredClusters, core) { const reportFiles = listMatchingFiles(reportsDir, /^e2e_report_.*\.json$/); - const reports = []; + const reportsByCluster = new Map(); for (const reportFile of reportFiles) { try { - reports.push(JSON.parse(fs.readFileSync(reportFile, "utf8"))); + const report = JSON.parse(fs.readFileSync(reportFile, "utf8")); + const reportKey = getReportClusterKey(report); + const clusterName = reportKey || clusterKeyFromFilename(reportFile); + if (!clusterName) { + core.warning( + `Skipping report with no identifiable cluster name: ${reportFile}` + ); + continue; + } + if (!reportKey) { + core.warning( + `Report ${reportFile} is missing storageType/cluster fields; using filename-derived key "${clusterName}"` + ); + } + // Ensure the report object carries the cluster identity used for rendering. + const resolvedReport = reportKey + ? report + : { ...report, storageType: clusterName, cluster: clusterName }; + reportsByCluster.set(clusterName, resolvedReport); } catch (error) { core.warning(`Unable to parse ${reportFile}: ${error.message}`); } } - const reportsByCluster = new Map(); - for (const report of reports) { - const clusterName = getReportClusterKey(report); - if (!clusterName) { - core.warning( - `Skipping report without cluster name from ${ - report.sourceReport || "parsed JSON payload" - }` - ); - continue; - } - - reportsByCluster.set(clusterName, report); - } + // Configured clusters first, in declared order; missing ones get synthetic reports. + const result = configuredClusters.map( + (name) => reportsByCluster.get(name) ?? createMissingReport(name) + ); - for (const clusterName of configuredClusters) { - if (!reportsByCluster.has(clusterName)) { - reportsByCluster.set(clusterName, createMissingReport(clusterName)); + // Any extra clusters not in the configured list, sorted alphabetically. + const extras = []; + for (const [key, report] of reportsByCluster) { + if (!configuredClusters.includes(key)) { + extras.push(report); } } + extras.sort((a, b) => + getReportClusterKey(a).localeCompare(getReportClusterKey(b)) + ); - return sortReports(Array.from(reportsByCluster.values()), configuredClusters); + return [...result, ...extras]; } /** diff --git a/.github/scripts/js/e2e/report/messenger-report.test.js b/.github/scripts/js/e2e/report/messenger-report.test.js index e76cd4cced..e67d831699 100644 --- a/.github/scripts/js/e2e/report/messenger-report.test.js +++ b/.github/scripts/js/e2e/report/messenger-report.test.js @@ -173,10 +173,10 @@ describe("messenger-report", () => { expect(result.threadMessages).toEqual([]); })); - test("skips invalid reports without cluster identity", async () => + test("derives cluster key from filename when storageType/cluster fields are absent", async () => withTempDir(async (tempDir) => { fs.writeFileSync( - path.join(tempDir, "e2e_report_invalid.json"), + path.join(tempDir, "e2e_report_extra.json"), JSON.stringify({ reportKind: "stage-failure", failedStage: "configure-sdn", @@ -212,11 +212,14 @@ describe("messenger-report", () => { const core = createCore(); const result = await renderMessengerReport({ core }); + // "nfs" is in the configured list and is rendered normally. expect(result.message).toContain("### Test results"); - expect(result.message).not.toContain("### Cluster failures"); expect(result.message).not.toContain("- —:"); + // "extra" is not in the configured list; it is appended as an extra cluster failure. + expect(result.message).toContain("### Cluster failures"); + expect(result.message).toContain("- extra: CONFIGURE SDN"); expect(core.warning).toHaveBeenCalledWith( - "Skipping report without cluster name from parsed JSON payload" + expect.stringContaining('missing storageType/cluster fields; using filename-derived key "extra"') ); })); diff --git a/.github/scripts/js/e2e/report/messenger/config.js b/.github/scripts/js/e2e/report/messenger/config.js index 003fd69a84..7ae09075aa 100644 --- a/.github/scripts/js/e2e/report/messenger/config.js +++ b/.github/scripts/js/e2e/report/messenger/config.js @@ -46,19 +46,26 @@ function getLoopPostsApiUrl(env = process.env) { return normalizeLoopApiBaseUrl(env.LOOP_API_BASE_URL); } +// Fallback used only when EXPECTED_STORAGE_TYPES is not set (e.g. local runs or tests). +// In CI the list is passed explicitly via the EXPECTED_STORAGE_TYPES env variable. +const defaultConfiguredClusters = ["replicated", "nfs"]; + /** * Parses the configured cluster list passed via workflow environment variables. + * Returns the default cluster list when the value is absent or contains invalid JSON. * * @param {string} value JSON-encoded cluster list. * @returns {string[]} Ordered cluster names. */ function parseConfiguredClusters(value) { - const parsedValue = JSON.parse(value || "[]"); - return Array.isArray(parsedValue) ? parsedValue : []; + try { + const parsed = JSON.parse(value || "[]"); + return Array.isArray(parsed) ? parsed : defaultConfiguredClusters; + } catch { + return defaultConfiguredClusters; + } } -const defaultConfiguredClusters = ["replicated", "nfs"]; - /** * Reads messenger configuration from the environment. * diff --git a/.github/scripts/js/e2e/report/messenger/markdown.js b/.github/scripts/js/e2e/report/messenger/markdown.js index 8631b7ca84..f8a98f89ed 100644 --- a/.github/scripts/js/e2e/report/messenger/markdown.js +++ b/.github/scripts/js/e2e/report/messenger/markdown.js @@ -134,17 +134,12 @@ function renderMissingReportsSection(missingReports) { lines.push(""); for (const report of missingReports) { - const missingMessage = - report.clusterStatus && report.clusterStatus.status === "missing" - ? report.clusterStatus.message - : report.testStatus && report.testStatus.message; lines.push( `- ${formatClusterLink(report)}: ${sanitizeListItem( - missingMessage || + report.statusMessage || + (report.testStatus && report.testStatus.message) || (report.clusterStatus && report.clusterStatus.message) || - report.statusMessage || - report.failedStageLabel || - report.failedStage + report.failedStageLabel )}` ); } @@ -183,9 +178,7 @@ function hasFailedTests(report) { } return Boolean( - (report.testStatus && - (report.testStatus.status === "failure" || - report.testStatus.status === "cancelled")) || + (report.testStatus && report.testStatus.status === "failure") || (report.metrics && report.metrics.failed) || (report.metrics && report.metrics.errors) ); @@ -201,16 +194,7 @@ function getFailedTestGroupName(testName) { } function summarizeFailedTestGroups(failedTests) { - const groupNames = []; - - for (const testName of failedTests) { - const groupName = getFailedTestGroupName(testName); - if (!groupNames.includes(groupName)) { - groupNames.push(groupName); - } - } - - return groupNames; + return [...new Set(failedTests.map(getFailedTestGroupName))]; } function renderFailedTestsThreadMessage(report) { diff --git a/.github/scripts/js/e2e/report/messenger/model.js b/.github/scripts/js/e2e/report/messenger/model.js index 2bf631d8c8..e3ca960c29 100644 --- a/.github/scripts/js/e2e/report/messenger/model.js +++ b/.github/scripts/js/e2e/report/messenger/model.js @@ -75,36 +75,6 @@ function getReportDate(reports) { return String(datedReport.startedAt).slice(0, 10); } -/** - * Orders reports by the configured cluster order and then by cluster name. - * - * @param {Array>} reports Reports to sort. - * @param {string[]} preferredOrder Configured cluster order. - * @returns {Array>} Sorted reports copy. - */ -function sortReports(reports, preferredOrder) { - const orderMap = new Map(preferredOrder.map((name, index) => [name, index])); - - return [...reports].sort((left, right) => { - const leftKey = left.storageType || left.cluster; - const rightKey = right.storageType || right.cluster; - const leftOrder = orderMap.has(leftKey) - ? orderMap.get(leftKey) - : Number.MAX_SAFE_INTEGER; - const rightOrder = orderMap.has(rightKey) - ? orderMap.get(rightKey) - : Number.MAX_SAFE_INTEGER; - - if (leftOrder !== rightOrder) { - return leftOrder - rightOrder; - } - - return String(left.cluster || left.storageType).localeCompare( - String(right.cluster || right.storageType) - ); - }); -} - /** * Extracts the normalized cluster key from a report payload. * @@ -122,5 +92,4 @@ module.exports = { isClusterFailureReport, isMissingReport, isTestResultReport, - sortReports, }; diff --git a/.github/scripts/js/e2e/report/shared/ginkgo-report-utils.js b/.github/scripts/js/e2e/report/shared/ginkgo-report-utils.js index 509da9f146..dfdb2d7aea 100644 --- a/.github/scripts/js/e2e/report/shared/ginkgo-report-utils.js +++ b/.github/scripts/js/e2e/report/shared/ginkgo-report-utils.js @@ -43,12 +43,14 @@ function toArray(value) { * @returns {string[]} Flattened unique labels. */ function flattenLabels(labelGroups) { + const seen = new Set(); const labels = []; for (const group of toArray(labelGroups)) { for (const label of toArray(group)) { const normalizedLabel = String(label || "").trim(); - if (normalizedLabel && !labels.includes(normalizedLabel)) { + if (normalizedLabel && !seen.has(normalizedLabel)) { + seen.add(normalizedLabel); labels.push(normalizedLabel); } } @@ -70,10 +72,10 @@ function formatSpecName(specReport) { .map((part) => String(part || "").trim()) .filter(Boolean); const leafText = String(specReport.LeafNodeText || "").trim(); - const labels = [ + const labels = [...new Set([ ...flattenLabels(specReport.ContainerHierarchyLabels), ...flattenLabels(specReport.LeafNodeLabels), - ].filter((label, index, array) => array.indexOf(label) === index); + ])]; const labelSuffix = labels.map((label) => `[${label}]`).join(" "); const body = [...hierarchyParts, leafText].filter(Boolean).join(" "); diff --git a/.github/scripts/js/e2e/report/shared/report-model.js b/.github/scripts/js/e2e/report/shared/report-model.js index 9ad5aac5cf..fe1658e0e6 100644 --- a/.github/scripts/js/e2e/report/shared/report-model.js +++ b/.github/scripts/js/e2e/report/shared/report-model.js @@ -11,12 +11,12 @@ // limitations under the License. const stageMessage = { - bootstrap: "BOOTSTRAP CLUSTER", + "bootstrap": "BOOTSTRAP CLUSTER", "configure-sdn": "CONFIGURE SDN", "storage-setup": "STORAGE SETUP", "virtualization-setup": "VIRTUALIZATION SETUP", "e2e-test": "E2E TEST", - ready: "CLUSTER READY", + "ready": "CLUSTER READY", "artifact-missing": "TEST REPORTS NOT FOUND", }; diff --git a/.github/workflows/e2e-matrix.yml b/.github/workflows/e2e-matrix.yml index b1ee551ee7..2995043e3d 100644 --- a/.github/workflows/e2e-matrix.yml +++ b/.github/workflows/e2e-matrix.yml @@ -488,6 +488,7 @@ jobs: id: render-report uses: actions/github-script@v7 env: + EXPECTED_STORAGE_TYPES: '["replicated","nfs"]' LOOP_API_BASE_URL: ${{ secrets.LOOP_API_BASE_URL }} LOOP_CHANNEL_ID: ${{ secrets.LOOP_CHANNEL_ID }} LOOP_TOKEN: ${{ secrets.LOOP_TOKEN }} From c93828b448968c5d5813b7c2040ab5a8dc4a2d54 Mon Sep 17 00:00:00 2001 From: Nikita Korolev Date: Wed, 6 May 2026 12:57:27 +0300 Subject: [PATCH 16/22] use job name to determine status in reusable wf Signed-off-by: Nikita Korolev --- .github/scripts/js/e2e/report/cluster-report.js | 11 +++-------- .github/scripts/js/e2e/report/cluster-report.test.js | 4 ++++ .github/workflows/e2e-matrix.yml | 2 ++ .github/workflows/e2e-reusable-pipeline.yml | 6 ++++++ 4 files changed, 15 insertions(+), 8 deletions(-) diff --git a/.github/scripts/js/e2e/report/cluster-report.js b/.github/scripts/js/e2e/report/cluster-report.js index a638d3451b..26dcec2792 100644 --- a/.github/scripts/js/e2e/report/cluster-report.js +++ b/.github/scripts/js/e2e/report/cluster-report.js @@ -75,11 +75,6 @@ const workflowStageJobs = { "e2e-test": "E2E test", }; -const workflowPipelineJobs = { - replicated: "E2E Pipeline (Replicated)", - nfs: "E2E Pipeline (NFS)", -}; - function escapeRegExp(value) { return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); } @@ -89,6 +84,7 @@ function readClusterReportConfigFromEnv(env = process.env) { return { storageType, + pipelineJobName: String(env.PIPELINE_JOB_NAME || "").trim(), reportsDir: env.REPORTS_DIR || "test/e2e", reportFile: env.REPORT_FILE || `e2e_report_${storageType}.json`, }; @@ -142,8 +138,7 @@ async function listWorkflowRunJobs(github, context) { return response.data.jobs || []; } -function findWorkflowJob(jobs, storageType, jobName) { - const pipelineJobName = workflowPipelineJobs[storageType]; +function findWorkflowJob(jobs, pipelineJobName, jobName) { const nestedJobName = pipelineJobName ? `${pipelineJobName} / ${jobName}` : ""; return ( @@ -159,7 +154,7 @@ async function readStageDetailsFromWorkflowRun(github, context, config, core) { const stageJobUrls = {}; for (const [stageName, jobName] of Object.entries(workflowStageJobs)) { - const job = findWorkflowJob(jobs, config.storageType, jobName); + const job = findWorkflowJob(jobs, config.pipelineJobName, jobName); if (!job) { core.warning(`Unable to find workflow job "${jobName}" for E2E report`); stageResults[stageName] = "skipped"; diff --git a/.github/scripts/js/e2e/report/cluster-report.test.js b/.github/scripts/js/e2e/report/cluster-report.test.js index 6764c8f6fa..73c0596d8f 100644 --- a/.github/scripts/js/e2e/report/cluster-report.test.js +++ b/.github/scripts/js/e2e/report/cluster-report.test.js @@ -202,6 +202,7 @@ describe("cluster-report", () => { delete process.env.STORAGE_TYPE; delete process.env.REPORTS_DIR; delete process.env.REPORT_FILE; + delete process.env.PIPELINE_JOB_NAME; }); test("requires storage type when config is absent", async () => { @@ -268,11 +269,13 @@ describe("cluster-report", () => { withTempDir(async (tempDir) => { const reportFile = path.join(tempDir, "env-report.json"); process.env.STORAGE_TYPE = "replicated"; + process.env.PIPELINE_JOB_NAME = "E2E Pipeline (Replicated)"; process.env.REPORTS_DIR = tempDir; process.env.REPORT_FILE = reportFile; expect(readClusterReportConfigFromEnv()).toMatchObject({ storageType: "replicated", + pipelineJobName: "E2E Pipeline (Replicated)", reportsDir: tempDir, reportFile, }); @@ -303,6 +306,7 @@ describe("cluster-report", () => { withTempDir(async (tempDir) => { const reportFile = path.join(tempDir, "env-report.json"); process.env.STORAGE_TYPE = "nfs"; + process.env.PIPELINE_JOB_NAME = "E2E Pipeline (NFS)"; process.env.REPORTS_DIR = tempDir; process.env.REPORT_FILE = reportFile; diff --git a/.github/workflows/e2e-matrix.yml b/.github/workflows/e2e-matrix.yml index 2995043e3d..80144a7003 100644 --- a/.github/workflows/e2e-matrix.yml +++ b/.github/workflows/e2e-matrix.yml @@ -420,6 +420,7 @@ jobs: uses: ./.github/workflows/e2e-reusable-pipeline.yml with: storage_type: replicated + pipeline_job_name: "E2E Pipeline (Replicated)" nested_storageclass_name: nested-thin-r1 nested_cluster_network_name: cn-4006-for-e2e-test branch: main @@ -446,6 +447,7 @@ jobs: uses: ./.github/workflows/e2e-reusable-pipeline.yml with: storage_type: nfs + pipeline_job_name: "E2E Pipeline (NFS)" nested_storageclass_name: nfs nested_cluster_network_name: cn-4006-for-e2e-test branch: main diff --git a/.github/workflows/e2e-reusable-pipeline.yml b/.github/workflows/e2e-reusable-pipeline.yml index db85cc2ac7..196d7cbdeb 100644 --- a/.github/workflows/e2e-reusable-pipeline.yml +++ b/.github/workflows/e2e-reusable-pipeline.yml @@ -113,6 +113,11 @@ on: type: string default: "https://mirror.hetzner.com/ubuntu/packages" description: "APT mirror base URL (without trailing slash)" + pipeline_job_name: + required: false + type: string + default: "" + description: "Display name of the calling pipeline job in the parent workflow (e.g. 'E2E Pipeline (Replicated)'). Used to resolve per-stage job URLs in the report." secrets: DEV_REGISTRY_DOCKER_CFG: required: true @@ -1383,6 +1388,7 @@ jobs: uses: actions/github-script@v7 env: STORAGE_TYPE: ${{ inputs.storage_type }} + PIPELINE_JOB_NAME: ${{ inputs.pipeline_job_name }} with: script: | const buildClusterReport = require('./.github/scripts/js/e2e/report/cluster-report'); From c92560568769bcf81a9591b2bc1aa306513ca924 Mon Sep 17 00:00:00 2001 From: Nikita Korolev <141920865+universal-itengineer@users.noreply.github.com> Date: Thu, 7 May 2026 18:52:40 +0300 Subject: [PATCH 17/22] Update .github/scripts/js/e2e/report/messenger/config.js Co-authored-by: Ivan Mikheykin Signed-off-by: Nikita Korolev <141920865+universal-itengineer@users.noreply.github.com> --- .github/scripts/js/e2e/report/messenger/config.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/scripts/js/e2e/report/messenger/config.js b/.github/scripts/js/e2e/report/messenger/config.js index 7ae09075aa..c61ed73a51 100644 --- a/.github/scripts/js/e2e/report/messenger/config.js +++ b/.github/scripts/js/e2e/report/messenger/config.js @@ -59,7 +59,7 @@ const defaultConfiguredClusters = ["replicated", "nfs"]; */ function parseConfiguredClusters(value) { try { - const parsed = JSON.parse(value || "[]"); + const parsed = JSON.parse(value || "{}"); return Array.isArray(parsed) ? parsed : defaultConfiguredClusters; } catch { return defaultConfiguredClusters; From 21ec4a537c73dc80577f75ed444721e680140940 Mon Sep 17 00:00:00 2001 From: Nikita Korolev <141920865+universal-itengineer@users.noreply.github.com> Date: Thu, 7 May 2026 18:52:59 +0300 Subject: [PATCH 18/22] Update .github/scripts/js/e2e/report/messenger/markdown.js Co-authored-by: Maksim Fedotov Signed-off-by: Nikita Korolev <141920865+universal-itengineer@users.noreply.github.com> --- .github/scripts/js/e2e/report/messenger/markdown.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/scripts/js/e2e/report/messenger/markdown.js b/.github/scripts/js/e2e/report/messenger/markdown.js index f8a98f89ed..75aa427452 100644 --- a/.github/scripts/js/e2e/report/messenger/markdown.js +++ b/.github/scripts/js/e2e/report/messenger/markdown.js @@ -161,7 +161,7 @@ function buildMainMessage(orderedReports) { const { testsReports, stageFailureReports, missingReports } = splitReportsBySection(orderedReports); const lines = [ - `## DVP | E2E on nested clusters | ${reportDate}`, + `## :dvp: DVP | E2E on nested clusters | ${reportDate}`, "", ...renderBranchLine(orderedReports), ...renderClusterFailuresSection(stageFailureReports), From 5cf1899248301063f1fc2a5cdba84572c9098602 Mon Sep 17 00:00:00 2001 From: Nikita Korolev Date: Thu, 7 May 2026 18:52:29 +0300 Subject: [PATCH 19/22] resolve comments Signed-off-by: Nikita Korolev --- .../scripts/js/e2e/report/cluster-report.js | 14 ++--- .../scripts/js/e2e/report/messenger-report.js | 55 +++++-------------- .../js/e2e/report/messenger-report.test.js | 31 ++++++++--- .../scripts/js/e2e/report/messenger/config.js | 51 ++++++++++------- .../js/e2e/report/messenger/loop-client.js | 14 +---- .../e2e/report/shared/ginkgo-report-utils.js | 10 +++- .../js/e2e/report/shared/report-model.js | 26 +++++++++ 7 files changed, 108 insertions(+), 93 deletions(-) diff --git a/.github/scripts/js/e2e/report/cluster-report.js b/.github/scripts/js/e2e/report/cluster-report.js index 26dcec2792..acd144f196 100644 --- a/.github/scripts/js/e2e/report/cluster-report.js +++ b/.github/scripts/js/e2e/report/cluster-report.js @@ -15,9 +15,11 @@ const fs = require("fs"); const { findSingleMatchingFile } = require("./shared/fs-utils"); const { parseGinkgoReport } = require("./shared/ginkgo-report-utils"); const { + archivedReportPattern, buildClusterStatus, buildReportSummary, buildTestStatus, + reportFileName, zeroMetrics, } = require("./shared/report-model"); @@ -68,17 +70,13 @@ const { */ const workflowStageJobs = { - bootstrap: "Bootstrap cluster", + "bootstrap": "Bootstrap cluster", "configure-sdn": "Configure SDN", "storage-setup": "Configure storage", "virtualization-setup": "Configure Virtualization", "e2e-test": "E2E test", }; -function escapeRegExp(value) { - return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); -} - function readClusterReportConfigFromEnv(env = process.env) { const storageType = String(env.STORAGE_TYPE || "").trim(); @@ -86,7 +84,7 @@ function readClusterReportConfigFromEnv(env = process.env) { storageType, pipelineJobName: String(env.PIPELINE_JOB_NAME || "").trim(), reportsDir: env.REPORTS_DIR || "test/e2e", - reportFile: env.REPORT_FILE || `e2e_report_${storageType}.json`, + reportFile: env.REPORT_FILE || reportFileName(storageType), }; } @@ -169,9 +167,7 @@ async function readStageDetailsFromWorkflowRun(github, context, config, core) { } function findGinkgoReport(config) { - const rawReportPattern = new RegExp( - `^e2e_report_${escapeRegExp(config.storageType)}_.*\\.json$` - ); + const rawReportPattern = archivedReportPattern(config.storageType); return findSingleMatchingFile( config.reportsDir, diff --git a/.github/scripts/js/e2e/report/messenger-report.js b/.github/scripts/js/e2e/report/messenger-report.js index d9e0c69c3d..b24df29a41 100644 --- a/.github/scripts/js/e2e/report/messenger-report.js +++ b/.github/scripts/js/e2e/report/messenger-report.js @@ -11,10 +11,10 @@ // limitations under the License. const fs = require("fs"); -const path = require("path"); const { listMatchingFiles } = require("./shared/fs-utils"); -const { publishToLoop } = require("./messenger/loop-client"); +const { REPORT_FILE_PATTERN } = require("./shared/report-model"); +const { makeThreadedReportInLoop } = require("./messenger/loop-client"); const { readMessengerConfigFromEnv } = require("./messenger/config"); const { createMissingReport, @@ -45,22 +45,6 @@ const { * @property {string} [reportsDir] */ -/** - * Derives a cluster key from a report file path using the naming convention - * `e2e_report_[_suffix].json`. - * - * This is a fallback used only when the report JSON itself does not contain - * `storageType` or `cluster` fields, which should not happen in normal CI runs - * because `buildClusterReport` validates both fields before writing the file. - * - * @param {string} reportFile Absolute or relative path to the report file. - * @returns {string} Extracted storage type, or an empty string when not parseable. - */ -function clusterKeyFromFilename(reportFile) { - const match = path.basename(reportFile).match(/^e2e_report_([^_.]+)/); - return match ? match[1] : ""; -} - /** * Loads report JSON files from disk and injects synthetic reports for clusters * whose artifacts are missing. @@ -75,32 +59,21 @@ function clusterKeyFromFilename(reportFile) { * @returns {Array>} Ordered cluster reports. */ function readReports(reportsDir, configuredClusters, core) { - const reportFiles = listMatchingFiles(reportsDir, /^e2e_report_.*\.json$/); + const reportFiles = listMatchingFiles(reportsDir, REPORT_FILE_PATTERN); const reportsByCluster = new Map(); for (const reportFile of reportFiles) { try { const report = JSON.parse(fs.readFileSync(reportFile, "utf8")); - const reportKey = getReportClusterKey(report); - const clusterName = reportKey || clusterKeyFromFilename(reportFile); + const clusterName = getReportClusterKey(report); if (!clusterName) { - core.warning( - `Skipping report with no identifiable cluster name: ${reportFile}` - ); - continue; - } - if (!reportKey) { - core.warning( - `Report ${reportFile} is missing storageType/cluster fields; using filename-derived key "${clusterName}"` - ); + // cluster-report.js always writes storageType; a missing key means + // the file is corrupt or was not produced by this pipeline. + throw new Error(`report is missing storageType/cluster fields`); } - // Ensure the report object carries the cluster identity used for rendering. - const resolvedReport = reportKey - ? report - : { ...report, storageType: clusterName, cluster: clusterName }; - reportsByCluster.set(clusterName, resolvedReport); + reportsByCluster.set(clusterName, report); } catch (error) { - core.warning(`Unable to parse ${reportFile}: ${error.message}`); + core.warning(`Unable to load ${reportFile}: ${error.message}`); } } @@ -167,10 +140,12 @@ async function renderMessengerReport({ core, reportsDir }) { core.setOutput("thread_message", threadMessage); core.setOutput("thread_messages", JSON.stringify(threadMessages)); - try { - await publishToLoop({ message, threadMessages, loop: config.loop }, core); - } catch (error) { - core.warning(`Unable to deliver report to Loop API: ${error.message}`); + if (config.loop) { + try { + await makeThreadedReportInLoop({ message, threadMessages, loop: config.loop }, core); + } catch (error) { + core.warning(`Unable to deliver report to Loop API: ${error.message}`); + } } return { message, threadMessage, threadMessages }; diff --git a/.github/scripts/js/e2e/report/messenger-report.test.js b/.github/scripts/js/e2e/report/messenger-report.test.js index e67d831699..e57639d88c 100644 --- a/.github/scripts/js/e2e/report/messenger-report.test.js +++ b/.github/scripts/js/e2e/report/messenger-report.test.js @@ -83,6 +83,21 @@ describe("messenger-report", () => { }); }); + test("returns null loop config when no Loop credentials are set", () => { + const config = readMessengerConfigFromEnv({}); + + expect(config.loop).toBeNull(); + }); + + test("throws when Loop credentials are only partially configured", () => { + expect(() => + readMessengerConfigFromEnv({ + LOOP_API_BASE_URL: "https://loop.example.invalid", + // LOOP_CHANNEL_ID and LOOP_TOKEN intentionally absent + }) + ).toThrow("LOOP_CHANNEL_ID, LOOP_TOKEN, and LOOP_API_BASE_URL are required"); + }); + test("uses default configured clusters when env override is absent", () => { const config = readMessengerConfigFromEnv({}); @@ -173,15 +188,16 @@ describe("messenger-report", () => { expect(result.threadMessages).toEqual([]); })); - test("derives cluster key from filename when storageType/cluster fields are absent", async () => + test("warns and skips report files that are missing storageType/cluster fields", async () => withTempDir(async (tempDir) => { fs.writeFileSync( - path.join(tempDir, "e2e_report_extra.json"), + path.join(tempDir, "e2e_report_corrupt.json"), JSON.stringify({ reportKind: "stage-failure", failedStage: "configure-sdn", failedStageLabel: "CONFIGURE SDN", status: "failure", + // no storageType / cluster fields }) ); @@ -212,14 +228,13 @@ describe("messenger-report", () => { const core = createCore(); const result = await renderMessengerReport({ core }); - // "nfs" is in the configured list and is rendered normally. + // The valid "nfs" report is still rendered normally. expect(result.message).toContain("### Test results"); - expect(result.message).not.toContain("- —:"); - // "extra" is not in the configured list; it is appended as an extra cluster failure. - expect(result.message).toContain("### Cluster failures"); - expect(result.message).toContain("- extra: CONFIGURE SDN"); + // The corrupt file is dropped; no phantom entry appears in the output. + expect(result.message).not.toContain("corrupt"); + // A warning is emitted so the problem is visible in CI logs. expect(core.warning).toHaveBeenCalledWith( - expect.stringContaining('missing storageType/cluster fields; using filename-derived key "extra"') + expect.stringContaining("report is missing storageType/cluster fields") ); })); diff --git a/.github/scripts/js/e2e/report/messenger/config.js b/.github/scripts/js/e2e/report/messenger/config.js index c61ed73a51..9c4767da06 100644 --- a/.github/scripts/js/e2e/report/messenger/config.js +++ b/.github/scripts/js/e2e/report/messenger/config.js @@ -36,16 +36,6 @@ function normalizeLoopApiBaseUrl(value) { return `${trimmedValue}/api/v4/posts`; } -/** - * Reads and normalizes the Loop posts API URL from environment variables. - * - * @param {NodeJS.ProcessEnv} [env=process.env] Environment variables source. - * @returns {string} Normalized posts endpoint URL or an empty string. - */ -function getLoopPostsApiUrl(env = process.env) { - return normalizeLoopApiBaseUrl(env.LOOP_API_BASE_URL); -} - // Fallback used only when EXPECTED_STORAGE_TYPES is not set (e.g. local runs or tests). // In CI the list is passed explicitly via the EXPECTED_STORAGE_TYPES env variable. const defaultConfiguredClusters = ["replicated", "nfs"]; @@ -66,6 +56,33 @@ function parseConfiguredClusters(value) { } } +/** + * Reads Loop credentials from the environment. + * + * Returns `null` when none of the Loop variables are set, indicating that the + * messenger integration is intentionally disabled (e.g. local runs or forks). + * Throws when only some variables are present — that is always a configuration + * mistake and should surface as an error rather than a silent no-op. + * + * @param {NodeJS.ProcessEnv} [env=process.env] Environment variables source. + * @returns {{ apiUrl: string, channelId: string, token: string } | null} + */ +function readLoopConfig(env = process.env) { + const apiUrl = normalizeLoopApiBaseUrl(env.LOOP_API_BASE_URL); + const channelId = String(env.LOOP_CHANNEL_ID || "").trim(); + const token = String(env.LOOP_TOKEN || "").trim(); + + if (!apiUrl && !channelId && !token) { + return null; + } + if (!apiUrl || !channelId || !token) { + throw new Error( + "LOOP_CHANNEL_ID, LOOP_TOKEN, and LOOP_API_BASE_URL are required" + ); + } + return { apiUrl, channelId, token }; +} + /** * Reads messenger configuration from the environment. * @@ -73,11 +90,7 @@ function parseConfiguredClusters(value) { * @returns {{ * reportsDir: string, * configuredClusters: string[], - * loop: { - * apiUrl: string, - * channelId: string, - * token: string - * } + * loop: { apiUrl: string, channelId: string, token: string } | null * }} Normalized messenger configuration. */ function readMessengerConfigFromEnv(env = process.env) { @@ -88,15 +101,11 @@ function readMessengerConfigFromEnv(env = process.env) { return { reportsDir: env.REPORTS_DIR || "downloaded-artifacts", configuredClusters, - loop: { - apiUrl: getLoopPostsApiUrl(env), - channelId: String(env.LOOP_CHANNEL_ID || "").trim(), - token: String(env.LOOP_TOKEN || "").trim(), - }, + loop: readLoopConfig(env), }; } module.exports = { - getLoopPostsApiUrl, + readLoopConfig, readMessengerConfigFromEnv, }; diff --git a/.github/scripts/js/e2e/report/messenger/loop-client.js b/.github/scripts/js/e2e/report/messenger/loop-client.js index d06d413dc5..5c7bf8251c 100644 --- a/.github/scripts/js/e2e/report/messenger/loop-client.js +++ b/.github/scripts/js/e2e/report/messenger/loop-client.js @@ -99,17 +99,7 @@ async function postToLoopApi( * @param {LoopClientCore} core GitHub core API. * @returns {Promise} */ -async function publishToLoop({ message, threadMessages, loop }, core) { - if (!loop.apiUrl && !loop.channelId && !loop.token) { - return; - } - - if (!loop.apiUrl || !loop.channelId || !loop.token) { - throw new Error( - "LOOP_CHANNEL_ID, LOOP_TOKEN, and LOOP_API_BASE_URL are required" - ); - } - +async function makeThreadedReportInLoop({ message, threadMessages, loop }, core) { const rootPost = await postToLoopApi( { apiUrl: loop.apiUrl, @@ -142,5 +132,5 @@ async function publishToLoop({ message, threadMessages, loop }, core) { } module.exports = { - publishToLoop, + makeThreadedReportInLoop, }; diff --git a/.github/scripts/js/e2e/report/shared/ginkgo-report-utils.js b/.github/scripts/js/e2e/report/shared/ginkgo-report-utils.js index dfdb2d7aea..c97bbf6539 100644 --- a/.github/scripts/js/e2e/report/shared/ginkgo-report-utils.js +++ b/.github/scripts/js/e2e/report/shared/ginkgo-report-utils.js @@ -93,7 +93,7 @@ function formatSpecName(specReport) { * @param {string} state Raw `SpecReport.State` value. * @returns {"passed"|"failed"|"errors"|"skipped"} Metrics key. */ -function metricKeyForState(state) { +function getMetricKeyForState(state) { const normalizedState = String(state || "") .trim() .toLowerCase(); @@ -133,12 +133,16 @@ function parseGinkgoReport(jsonContent) { for (const suite of suites) { for (const specReport of toArray(suite && suite.SpecReports)) { + // SpecReports can contain suite-level setup/teardown entries + // (BeforeSuite, AfterSuite, etc.) in addition to regular specs. + // `Specify` is a pure alias for `It` and serializes to the same + // "It" value. We only count actual spec nodes in the metrics. if (String(specReport && specReport.LeafNodeType) !== "It") { continue; } metrics.total += 1; - const metricKey = metricKeyForState(specReport.State); + const metricKey = getMetricKeyForState(specReport.State); metrics[metricKey] += 1; if (metricKey === "failed" || metricKey === "errors") { @@ -165,7 +169,7 @@ function parseGinkgoReport(jsonContent) { module.exports = { flattenLabels, formatSpecName, - metricKeyForState, + getMetricKeyForState, parseGinkgoReport, toArray, }; diff --git a/.github/scripts/js/e2e/report/shared/report-model.js b/.github/scripts/js/e2e/report/shared/report-model.js index fe1658e0e6..12f0159366 100644 --- a/.github/scripts/js/e2e/report/shared/report-model.js +++ b/.github/scripts/js/e2e/report/shared/report-model.js @@ -10,6 +10,29 @@ // See the License for the specific language governing permissions and // limitations under the License. +/** Matches every `e2e_report_*.json` file produced by the pipeline. */ +const REPORT_FILE_PATTERN = /^e2e_report_.*\.json$/; + +/** + * Returns the canonical report file name for a given storage type. + * @param {string} storageType + * @returns {string} + */ +function reportFileName(storageType) { + return `e2e_report_${storageType}.json`; +} + +/** + * Returns a regex that matches dated archive copies of a report file, + * e.g. `e2e_report_replicated_2026-04-15.json`. + * @param {string} storageType + * @returns {RegExp} + */ +function archivedReportPattern(storageType) { + const escaped = storageType.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); + return new RegExp(`^e2e_report_${escaped}_.*\\.json$`); +} + const stageMessage = { "bootstrap": "BOOTSTRAP CLUSTER", "configure-sdn": "CONFIGURE SDN", @@ -241,6 +264,7 @@ function isTestResultReport(report) { } module.exports = { + archivedReportPattern, buildClusterStatus, buildReportSummary, buildStatusMessage, @@ -249,6 +273,8 @@ module.exports = { isMissingReport, isTestResultReport, normalizeJobResult, + REPORT_FILE_PATTERN, + reportFileName, stageMessage, zeroMetrics, }; From baaf7ea73fefb2294b8d56116edd3ce459feec5e Mon Sep 17 00:00:00 2001 From: Nikita Korolev Date: Thu, 7 May 2026 19:23:51 +0300 Subject: [PATCH 20/22] fix issues Signed-off-by: Nikita Korolev --- .../scripts/js/e2e/report/cluster-report.js | 8 +---- .../scripts/js/e2e/report/messenger-report.js | 3 +- .../js/e2e/report/messenger-report.test.js | 19 ++++++----- .../scripts/js/e2e/report/messenger/config.js | 5 +-- .../js/e2e/report/messenger/loop-client.js | 6 ++++ .../scripts/js/e2e/report/shared/fs-utils.js | 34 +++++++++---------- .../e2e/report/shared/ginkgo-report-utils.js | 4 --- .../js/e2e/report/shared/report-model.js | 18 +++++++--- 8 files changed, 54 insertions(+), 43 deletions(-) diff --git a/.github/scripts/js/e2e/report/cluster-report.js b/.github/scripts/js/e2e/report/cluster-report.js index acd144f196..352dfb23c9 100644 --- a/.github/scripts/js/e2e/report/cluster-report.js +++ b/.github/scripts/js/e2e/report/cluster-report.js @@ -31,12 +31,6 @@ const { * @typedef {Record} StageUrls */ -/** - * @typedef {Object} GinkgoMetrics - * @property {number} [failed] - * @property {number} [errors] - */ - /** * @typedef {Object} ClusterReportCore * @property {function(string): void} info @@ -55,6 +49,7 @@ const { /** * @typedef {Object} ClusterReportConfig * @property {string} storageType + * @property {string} pipelineJobName * @property {string} reportsDir * @property {string} reportFile * @property {StageResults} stageResults @@ -350,5 +345,4 @@ async function buildClusterReport({ core, context, github, config } = {}) { } module.exports = buildClusterReport; -module.exports.buildClusterStatus = buildClusterStatus; module.exports.readClusterReportConfigFromEnv = readClusterReportConfigFromEnv; diff --git a/.github/scripts/js/e2e/report/messenger-report.js b/.github/scripts/js/e2e/report/messenger-report.js index b24df29a41..ff46d175d1 100644 --- a/.github/scripts/js/e2e/report/messenger-report.js +++ b/.github/scripts/js/e2e/report/messenger-report.js @@ -83,9 +83,10 @@ function readReports(reportsDir, configuredClusters, core) { ); // Any extra clusters not in the configured list, sorted alphabetically. + const configuredSet = new Set(configuredClusters); const extras = []; for (const [key, report] of reportsByCluster) { - if (!configuredClusters.includes(key)) { + if (!configuredSet.has(key)) { extras.push(report); } } diff --git a/.github/scripts/js/e2e/report/messenger-report.test.js b/.github/scripts/js/e2e/report/messenger-report.test.js index e57639d88c..98e2095336 100644 --- a/.github/scripts/js/e2e/report/messenger-report.test.js +++ b/.github/scripts/js/e2e/report/messenger-report.test.js @@ -540,7 +540,7 @@ describe("messenger-report", () => { }); })); - test("tolerates an empty Loop API response body", async () => + test("warns when Loop API returns an empty response body (no post id)", async () => withTempDir(async (tempDir) => { fs.writeFileSync( path.join(tempDir, "e2e_report_replicated.json"), @@ -578,16 +578,16 @@ describe("messenger-report", () => { await renderMessengerReport({ core }); + // Empty body → no post id → thread replies cannot be sent → warning emitted. expect(global.fetch).toHaveBeenCalledTimes(1); - expect(core.warning).not.toHaveBeenCalledWith( - expect.stringContaining("Unable to deliver report to Loop API") + expect(core.warning).toHaveBeenCalledWith( + expect.stringContaining("Loop API did not return a post id") ); + // Report outputs are still set because the message was built before sending. expect(core.setOutput).toHaveBeenCalledWith("thread_messages", "[]"); - expect(core.setOutput).toHaveBeenCalledWith("root_post_id", ""); - expect(core.setOutput).toHaveBeenCalledWith("thread_post_id", ""); })); - test("tolerates an invalid JSON Loop API response body", async () => + test("warns when Loop API returns a non-JSON response body (no post id)", async () => withTempDir(async (tempDir) => { fs.writeFileSync( path.join(tempDir, "e2e_report_replicated.json"), @@ -625,13 +625,16 @@ describe("messenger-report", () => { await renderMessengerReport({ core }); + // Non-JSON body → parse warning → no post id → delivery warning. expect(global.fetch).toHaveBeenCalledTimes(1); expect(core.warning).toHaveBeenCalledWith( expect.stringContaining("Loop API returned a non-JSON response body") ); + expect(core.warning).toHaveBeenCalledWith( + expect.stringContaining("Loop API did not return a post id") + ); + // Report outputs are still set because the message was built before sending. expect(core.setOutput).toHaveBeenCalledWith("thread_messages", "[]"); - expect(core.setOutput).toHaveBeenCalledWith("root_post_id", ""); - expect(core.setOutput).toHaveBeenCalledWith("thread_post_id", ""); })); test("logs readable Loop API errors for failed responses", async () => diff --git a/.github/scripts/js/e2e/report/messenger/config.js b/.github/scripts/js/e2e/report/messenger/config.js index 9c4767da06..12fa7dc659 100644 --- a/.github/scripts/js/e2e/report/messenger/config.js +++ b/.github/scripts/js/e2e/report/messenger/config.js @@ -42,9 +42,10 @@ const defaultConfiguredClusters = ["replicated", "nfs"]; /** * Parses the configured cluster list passed via workflow environment variables. - * Returns the default cluster list when the value is absent or contains invalid JSON. + * Returns the default cluster list when the value is absent, is not valid JSON, + * or parses to a non-array value (e.g. an object `{}`). * - * @param {string} value JSON-encoded cluster list. + * @param {string} value JSON-encoded array of cluster names, e.g. '["replicated","nfs"]'. * @returns {string[]} Ordered cluster names. */ function parseConfiguredClusters(value) { diff --git a/.github/scripts/js/e2e/report/messenger/loop-client.js b/.github/scripts/js/e2e/report/messenger/loop-client.js index 5c7bf8251c..b30b62b003 100644 --- a/.github/scripts/js/e2e/report/messenger/loop-client.js +++ b/.github/scripts/js/e2e/report/messenger/loop-client.js @@ -110,6 +110,12 @@ async function makeThreadedReportInLoop({ message, threadMessages, loop }, core) core ); + if (!rootPost.id) { + throw new Error( + "Loop API did not return a post id; thread replies cannot be attached" + ); + } + let lastReplyPost = null; for (const replyMessage of threadMessages) { lastReplyPost = await postToLoopApi( diff --git a/.github/scripts/js/e2e/report/shared/fs-utils.js b/.github/scripts/js/e2e/report/shared/fs-utils.js index 9cd45616e1..1b66b7a3ff 100644 --- a/.github/scripts/js/e2e/report/shared/fs-utils.js +++ b/.github/scripts/js/e2e/report/shared/fs-utils.js @@ -13,17 +13,9 @@ const fs = require("fs"); const path = require("path"); -/** - * Recursively collects files whose base name matches the provided pattern. - * - * @param {string} dirPath Directory to scan. - * @param {RegExp} filePattern Regular expression applied to file names. - * @param {string[]} [files=[]] Accumulator used during recursion. - * @returns {string[]} Matching file paths. - */ -function listMatchingFiles(dirPath, filePattern, files = []) { +function collectMatchingFiles(dirPath, filePattern, acc) { if (!fs.existsSync(dirPath)) { - return files; + return; } let entries; @@ -38,16 +30,24 @@ function listMatchingFiles(dirPath, filePattern, files = []) { for (const entry of entries) { const fullPath = path.join(dirPath, entry.name); if (entry.isDirectory()) { - listMatchingFiles(fullPath, filePattern, files); - continue; - } - - if (filePattern.test(entry.name)) { - files.push(fullPath); + collectMatchingFiles(fullPath, filePattern, acc); + } else if (filePattern.test(entry.name)) { + acc.push(fullPath); } } +} - return files; +/** + * Recursively collects files whose base name matches the provided pattern. + * + * @param {string} dirPath Directory to scan. + * @param {RegExp} filePattern Regular expression applied to file names. + * @returns {string[]} Matching file paths. + */ +function listMatchingFiles(dirPath, filePattern) { + const acc = []; + collectMatchingFiles(dirPath, filePattern, acc); + return acc; } /** diff --git a/.github/scripts/js/e2e/report/shared/ginkgo-report-utils.js b/.github/scripts/js/e2e/report/shared/ginkgo-report-utils.js index c97bbf6539..5cec91699b 100644 --- a/.github/scripts/js/e2e/report/shared/ginkgo-report-utils.js +++ b/.github/scripts/js/e2e/report/shared/ginkgo-report-utils.js @@ -167,9 +167,5 @@ function parseGinkgoReport(jsonContent) { } module.exports = { - flattenLabels, - formatSpecName, - getMetricKeyForState, parseGinkgoReport, - toArray, }; diff --git a/.github/scripts/js/e2e/report/shared/report-model.js b/.github/scripts/js/e2e/report/shared/report-model.js index 12f0159366..7b4fd42934 100644 --- a/.github/scripts/js/e2e/report/shared/report-model.js +++ b/.github/scripts/js/e2e/report/shared/report-model.js @@ -70,6 +70,10 @@ function buildStatusMessage(status, stageLabel) { return `⚠️ ${stageLabel} CANCELLED`; } + if (status === "skipped") { + return `⚠️ ${stageLabel} SKIPPED`; + } + if (status === "missing") { return `⚠️ ${stageLabel}`; } @@ -95,15 +99,23 @@ function buildClusterStatus(stageResults) { const stageResult = normalizeJobResult(stageResults[stageName]); if (stageResult !== "success") { const stageLabel = stageMessage[stageName] || stageName; + const status = + stageResult === "cancelled" + ? "cancelled" + : stageResult === "skipped" + ? "skipped" + : "failure"; return { - status: stageResult === "cancelled" ? "cancelled" : "failure", + status, stage: stageName, stageLabel, message: buildStatusMessage(stageResult, stageLabel), reason: stageResult === "cancelled" ? "cluster-stage-cancelled" - : "cluster-stage-failed", + : stageResult === "skipped" + ? "cluster-stage-skipped" + : "cluster-stage-failed", }; } } @@ -272,9 +284,7 @@ module.exports = { isClusterFailureReport, isMissingReport, isTestResultReport, - normalizeJobResult, REPORT_FILE_PATTERN, reportFileName, - stageMessage, zeroMetrics, }; From 7ef0d4b66e42200fe235db0cc9ba46ecfa42b11b Mon Sep 17 00:00:00 2001 From: Nikita Korolev Date: Fri, 8 May 2026 16:47:20 +0300 Subject: [PATCH 21/22] use needs from json forn gthub actions Signed-off-by: Nikita Korolev --- .../scripts/js/e2e/report/cluster-report.js | 65 +++++++------- .../js/e2e/report/cluster-report.test.js | 84 +++++++++++++++++-- .github/workflows/e2e-reusable-pipeline.yml | 1 + 3 files changed, 113 insertions(+), 37 deletions(-) diff --git a/.github/scripts/js/e2e/report/cluster-report.js b/.github/scripts/js/e2e/report/cluster-report.js index 352dfb23c9..637a0e4c54 100644 --- a/.github/scripts/js/e2e/report/cluster-report.js +++ b/.github/scripts/js/e2e/report/cluster-report.js @@ -64,13 +64,13 @@ const { * @property {ClusterReportConfig} [config] */ -const workflowStageJobs = { - "bootstrap": "Bootstrap cluster", - "configure-sdn": "Configure SDN", - "storage-setup": "Configure storage", - "virtualization-setup": "Configure Virtualization", - "e2e-test": "E2E test", -}; +const workflowStages = [ + { name: "bootstrap", displayName: "Bootstrap cluster", needsJobId: "bootstrap" }, + { name: "configure-sdn", displayName: "Configure SDN", needsJobId: "configure-sdn" }, + { name: "storage-setup", displayName: "Configure storage", needsJobId: "configure-storage" }, + { name: "virtualization-setup", displayName: "Configure Virtualization", needsJobId: "configure-virtualization" }, + { name: "e2e-test", displayName: "E2E test", needsJobId: "e2e-test" }, +]; function readClusterReportConfigFromEnv(env = process.env) { const storageType = String(env.STORAGE_TYPE || "").trim(); @@ -96,11 +96,7 @@ function requireClusterReportConfig(config) { throw new Error("buildClusterReport requires reportFile"); } - return { - ...config, - stageResults: config.stageResults || {}, - stageJobUrls: config.stageJobUrls || {}, - }; + return { ...config }; } function getWorkflowRunUrl(context) { @@ -141,24 +137,35 @@ function findWorkflowJob(jobs, pipelineJobName, jobName) { ); } -async function readStageDetailsFromWorkflowRun(github, context, config, core) { - const jobs = await listWorkflowRunJobs(github, context); +function readStageResultsFromEnv(env = process.env) { + let needs = {}; + try { + needs = JSON.parse(env.NEEDS_CONTEXT || "{}"); + } catch { + // malformed JSON — treat all stages as skipped + } + const stageResults = {}; + for (const { name, needsJobId } of workflowStages) { + stageResults[name] = String((needs[needsJobId] || {}).result || "").trim() || "skipped"; + } + return stageResults; +} + +async function readStageJobUrlsFromApi(github, context, config, core) { + const jobs = await listWorkflowRunJobs(github, context); const stageJobUrls = {}; - for (const [stageName, jobName] of Object.entries(workflowStageJobs)) { - const job = findWorkflowJob(jobs, config.pipelineJobName, jobName); - if (!job) { - core.warning(`Unable to find workflow job "${jobName}" for E2E report`); - stageResults[stageName] = "skipped"; - continue; + for (const { name, displayName } of workflowStages) { + const job = findWorkflowJob(jobs, config.pipelineJobName, displayName); + if (job) { + stageJobUrls[name] = job.html_url || ""; + } else { + core.warning(`Unable to find workflow job "${displayName}" for E2E report`); } - - stageResults[stageName] = job.conclusion || "skipped"; - stageJobUrls[stageName] = job.html_url || ""; } - return { stageResults, stageJobUrls }; + return stageJobUrls; } function findGinkgoReport(config) { @@ -295,15 +302,17 @@ async function buildClusterReport({ core, context, github, config } = {}) { config || readClusterReportConfigFromEnv() ); - if (!config || !config.stageResults) { - const stageDetails = await readStageDetailsFromWorkflowRun( + if (!resolvedConfig.stageResults) { + resolvedConfig.stageResults = readStageResultsFromEnv(); + } + + if (!resolvedConfig.stageJobUrls && github) { + resolvedConfig.stageJobUrls = await readStageJobUrlsFromApi( github, context, resolvedConfig, core ); - resolvedConfig.stageResults = stageDetails.stageResults; - resolvedConfig.stageJobUrls = stageDetails.stageJobUrls; } const fallbackWorkflowRunUrl = getWorkflowRunUrl(context); diff --git a/.github/scripts/js/e2e/report/cluster-report.test.js b/.github/scripts/js/e2e/report/cluster-report.test.js index 73c0596d8f..a989df760d 100644 --- a/.github/scripts/js/e2e/report/cluster-report.test.js +++ b/.github/scripts/js/e2e/report/cluster-report.test.js @@ -203,6 +203,7 @@ describe("cluster-report", () => { delete process.env.REPORTS_DIR; delete process.env.REPORT_FILE; delete process.env.PIPELINE_JOB_NAME; + delete process.env.NEEDS_CONTEXT; }); test("requires storage type when config is absent", async () => { @@ -272,6 +273,13 @@ describe("cluster-report", () => { process.env.PIPELINE_JOB_NAME = "E2E Pipeline (Replicated)"; process.env.REPORTS_DIR = tempDir; process.env.REPORT_FILE = reportFile; + process.env.NEEDS_CONTEXT = JSON.stringify({ + "bootstrap": { result: "success" }, + "configure-sdn": { result: "success" }, + "configure-storage": { result: "success" }, + "configure-virtualization": { result: "success" }, + "e2e-test": { result: "success" }, + }); expect(readClusterReportConfigFromEnv()).toMatchObject({ storageType: "replicated", @@ -283,18 +291,11 @@ describe("cluster-report", () => { const report = await buildClusterReport({ core: createCore(), context: createContext(), - github: createGithub({ - "E2E Pipeline (Replicated) / Bootstrap cluster": "success", - "E2E Pipeline (Replicated) / Configure SDN": "success", - "E2E Pipeline (Replicated) / Configure storage": "success", - "E2E Pipeline (Replicated) / Configure Virtualization": "success", - "E2E Pipeline (Replicated) / E2E test": "success", - }), }); expect(report.cluster).toBe("replicated"); expect(report.workflowRunUrl).toBe( - "https://github.com/test/repo/actions/runs/12345/job/5" + "https://github.com/test/repo/actions/runs/12345" ); expect(report.branch).toBe("main"); expect(JSON.parse(fs.readFileSync(reportFile, "utf8")).cluster).toBe( @@ -302,13 +303,50 @@ describe("cluster-report", () => { ); })); - test("links report to the matching failed workflow job", async () => + test("reads stage results from env vars", async () => withTempDir(async (tempDir) => { const reportFile = path.join(tempDir, "env-report.json"); process.env.STORAGE_TYPE = "nfs"; process.env.PIPELINE_JOB_NAME = "E2E Pipeline (NFS)"; process.env.REPORTS_DIR = tempDir; process.env.REPORT_FILE = reportFile; + process.env.NEEDS_CONTEXT = JSON.stringify({ + "bootstrap": { result: "success" }, + "configure-sdn": { result: "failure" }, + "configure-storage": { result: "skipped" }, + "configure-virtualization": { result: "skipped" }, + "e2e-test": { result: "skipped" }, + }); + + const report = await buildClusterReport({ + core: createCore(), + context: createContext(), + }); + + expect(report.clusterStatus).toMatchObject({ + status: "failure", + stage: "configure-sdn", + }); + // No github — falls back to workflow run URL + expect(report.workflowRunUrl).toBe( + "https://github.com/test/repo/actions/runs/12345" + ); + })); + + test("fetches job URLs from GitHub API", async () => + withTempDir(async (tempDir) => { + const reportFile = path.join(tempDir, "env-report.json"); + process.env.STORAGE_TYPE = "nfs"; + process.env.PIPELINE_JOB_NAME = "E2E Pipeline (NFS)"; + process.env.REPORTS_DIR = tempDir; + process.env.REPORT_FILE = reportFile; + process.env.NEEDS_CONTEXT = JSON.stringify({ + "bootstrap": { result: "success" }, + "configure-sdn": { result: "failure" }, + "configure-storage": { result: "skipped" }, + "configure-virtualization": { result: "skipped" }, + "e2e-test": { result: "skipped" }, + }); const report = await buildClusterReport({ core: createCore(), @@ -326,11 +364,39 @@ describe("cluster-report", () => { status: "failure", stage: "configure-sdn", }); + // github provided — URL points to the specific failed job expect(report.workflowRunUrl).toBe( "https://github.com/test/repo/actions/runs/12345/job/2" ); })); + test("works without github (no job URLs)", async () => + withTempDir(async (tempDir) => { + const reportFile = path.join(tempDir, "env-report.json"); + process.env.STORAGE_TYPE = "replicated"; + process.env.REPORTS_DIR = tempDir; + process.env.REPORT_FILE = reportFile; + process.env.NEEDS_CONTEXT = JSON.stringify({ + "bootstrap": { result: "success" }, + "configure-sdn": { result: "success" }, + "configure-storage": { result: "success" }, + "configure-virtualization": { result: "success" }, + "e2e-test": { result: "success" }, + }); + + const report = await buildClusterReport({ + core: createCore(), + context: createContext(), + // no github + }); + + expect(report.cluster).toBe("replicated"); + // stageJobUrls is empty — falls back to workflow run URL + expect(report.workflowRunUrl).toBe( + "https://github.com/test/repo/actions/runs/12345" + ); + })); + test("marks Ginkgo JSON with failed specs as failed", async () => withTempDir(async (tempDir) => { const rawReportPath = path.join( diff --git a/.github/workflows/e2e-reusable-pipeline.yml b/.github/workflows/e2e-reusable-pipeline.yml index 196d7cbdeb..2df5763ebc 100644 --- a/.github/workflows/e2e-reusable-pipeline.yml +++ b/.github/workflows/e2e-reusable-pipeline.yml @@ -1389,6 +1389,7 @@ jobs: env: STORAGE_TYPE: ${{ inputs.storage_type }} PIPELINE_JOB_NAME: ${{ inputs.pipeline_job_name }} + NEEDS_CONTEXT: ${{ toJSON(needs) }} with: script: | const buildClusterReport = require('./.github/scripts/js/e2e/report/cluster-report'); From ceb1ed74895cf2eda2ed4ca8c40da25a9b6bae87 Mon Sep 17 00:00:00 2001 From: Nikita Korolev Date: Fri, 8 May 2026 19:09:43 +0300 Subject: [PATCH 22/22] resolve comments cluster-report Signed-off-by: Nikita Korolev --- .github/scripts/js/e2e/report/cluster-report.test.js | 6 +++--- .github/scripts/js/e2e/report/messenger-report.test.js | 8 ++++---- .github/scripts/js/e2e/report/shared/report-model.js | 9 ++------- 3 files changed, 9 insertions(+), 14 deletions(-) diff --git a/.github/scripts/js/e2e/report/cluster-report.test.js b/.github/scripts/js/e2e/report/cluster-report.test.js index a989df760d..88f7cba81a 100644 --- a/.github/scripts/js/e2e/report/cluster-report.test.js +++ b/.github/scripts/js/e2e/report/cluster-report.test.js @@ -227,7 +227,7 @@ describe("cluster-report", () => { status: "failure", stage: "configure-sdn", stageLabel: "CONFIGURE SDN", - reason: "cluster-stage-failed", + reason: "cluster-stage-failure", }); }); @@ -714,11 +714,11 @@ describe("cluster-report", () => { expect(report.clusterStatus).toMatchObject({ status: "failure", stage: "configure-sdn", - reason: "cluster-stage-failed", + reason: "cluster-stage-failure", }); expect(report.testStatus).toMatchObject({ status: "not-run", - reason: "cluster-stage-failed", + reason: "cluster-stage-failure", }); })); diff --git a/.github/scripts/js/e2e/report/messenger-report.test.js b/.github/scripts/js/e2e/report/messenger-report.test.js index 98e2095336..5d22d3073f 100644 --- a/.github/scripts/js/e2e/report/messenger-report.test.js +++ b/.github/scripts/js/e2e/report/messenger-report.test.js @@ -357,11 +357,11 @@ describe("messenger-report", () => { stage: "configure-sdn", stageLabel: "CONFIGURE SDN", message: "❌ CONFIGURE SDN FAILED", - reason: "cluster-stage-failed", + reason: "cluster-stage-failure", }, testStatus: { status: "not-run", - reason: "cluster-stage-failed", + reason: "cluster-stage-failure", message: "E2E tests were not run because cluster setup did not finish", }, @@ -402,11 +402,11 @@ describe("messenger-report", () => { stage: "configure-sdn", stageLabel: "CONFIGURE SDN", message: "❌ CONFIGURE SDN FAILED", - reason: "cluster-stage-failed", + reason: "cluster-stage-failure", }, testStatus: { status: "not-run", - reason: "cluster-stage-failed", + reason: "cluster-stage-failure", message: "E2E tests were not run because cluster setup did not finish", }, diff --git a/.github/scripts/js/e2e/report/shared/report-model.js b/.github/scripts/js/e2e/report/shared/report-model.js index 7b4fd42934..d0f713c098 100644 --- a/.github/scripts/js/e2e/report/shared/report-model.js +++ b/.github/scripts/js/e2e/report/shared/report-model.js @@ -110,12 +110,7 @@ function buildClusterStatus(stageResults) { stage: stageName, stageLabel, message: buildStatusMessage(stageResult, stageLabel), - reason: - stageResult === "cancelled" - ? "cluster-stage-cancelled" - : stageResult === "skipped" - ? "cluster-stage-skipped" - : "cluster-stage-failed", + reason: `cluster-stage-${status}`, }; } } @@ -140,7 +135,7 @@ function buildTestStatus( if (clusterStatus.status !== "success") { return { status: "not-run", - reason: "cluster-stage-failed", + reason: `cluster-stage-${clusterStatus.status}`, message: "E2E tests were not run because cluster setup did not finish", }; }