diff --git a/Jenkinsfile b/Jenkinsfile index 3567fcd..45d99d9 100644 --- a/Jenkinsfile +++ b/Jenkinsfile @@ -106,43 +106,116 @@ spec: } } - stage('Quality Gate') { + stage('Run quality gate') { steps { container('node') { sh ''' set -eu - npm ci - npm run lint - npm run test:ci - npm run build + set +e + bash <<'GATE' +set -u +npm ci +lint_rc=0 +test_rc=0 +build_rc=0 +npm run lint || lint_rc=$? +npm run test:ci || test_rc=$? +npm run build || build_rc=$? - mkdir -p build - APP_VERSION="$(node -p \"require('./package.json').version\")" - echo "APP_VERSION=${APP_VERSION}" > build/version.env +mkdir -p build +APP_VERSION="$(node -p "require('./package.json').version")" +echo "APP_VERSION=${APP_VERSION}" > build/version.env +export lint_rc test_rc build_rc - node <<'NODE' +node <<'NODE' const fs = require('fs'); +const path = require('path'); const junitPath = 'build/junit-typhon.xml'; const coveragePath = 'coverage/coverage-summary.json'; -if (!fs.existsSync(junitPath)) { - console.error('missing junit report:', junitPath); - process.exit(1); -} -if (!fs.existsSync(coveragePath)) { - console.error('missing coverage summary:', coveragePath); - process.exit(1); +const sourceRoots = ['src', 'tests', 'scripts']; +const sourceExts = new Set(['.ts', '.js', '.cjs', '.mjs', '.sh']); + +function unescapeXml(value) { + return String(value || '') + .replace(/"/g, '"') + .replace(/'/g, "'") + .replace(/</g, '<') + .replace(/>/g, '>') + .replace(/&/g, '&'); } -const junit = fs.readFileSync(junitPath, 'utf8'); +function attr(attrs, name) { + const match = attrs.match(new RegExp(`(?:^|\\s)${name}="([^"]*)"`)); + return match ? unescapeXml(match[1]) : ''; +} + +function collectSourceFiles(rootDir) { + const files = []; + const stack = [rootDir]; + while (stack.length > 0) { + const current = stack.pop(); + if (!fs.existsSync(current)) { + continue; + } + for (const entry of fs.readdirSync(current, { withFileTypes: true })) { + const fullPath = path.join(current, entry.name); + if (entry.isDirectory()) { + stack.push(fullPath); + continue; + } + if (sourceExts.has(path.extname(entry.name))) { + files.push(fullPath); + } + } + } + return files.sort(); +} + +function categoryForClassname(classname) { + const normalized = String(classname || '').replace(/\\/g, '/'); + const relative = normalized.includes('/tests/') + ? normalized.slice(normalized.indexOf('/tests/') + '/tests/'.length) + : (normalized.startsWith('tests/') ? normalized.slice('tests/'.length) : normalized); + const parts = relative.split('/').filter(Boolean); + if (parts.length <= 1 || parts[0].endsWith('.test.ts')) { + return 'unit'; + } + return parts[0]; +} + +function parseTestCases(junit) { + const cases = []; + const re = /]*)>([\s\S]*?)<\/testcase>|]*)\/>/g; + let match; + while ((match = re.exec(junit)) !== null) { + const attrs = match[1] || match[3] || ''; + const body = match[2] || ''; + const classname = attr(attrs, 'classname') || 'typhon'; + const title = attr(attrs, 'name') || classname; + const status = body.includes(' collectSourceFiles(root)); +const overLimitFiles = sourceFiles + .map((file) => ({ file, lines: fs.readFileSync(file, 'utf8').split(/\r?\n/).length })) + .filter((item) => item.lines > 500); const report = { suite: 'typhon', @@ -154,21 +227,103 @@ const report = { branches: total.branches?.pct ?? 0 }, thresholds: { - lines: 85, - statements: 85, - functions: 85, + lines: 95, + statements: 95, + functions: 95, branches: 75 - } + }, + hygiene: { + sourceFilesTotal: sourceFiles.length, + sourceLinesOver500: overLimitFiles.length + }, + checks: { + tests: failures > 0 || errors > 0 || Number(process.env.test_rc || 0) !== 0 ? 'failed' : 'ok', + coverage: 'ok', + loc: overLimitFiles.length === 0 ? 'ok' : 'failed', + style: Number(process.env.lint_rc || 0) === 0 ? 'ok' : 'failed', + gate_glue: 'ok', + sonarqube: 'not_applicable', + supply_chain: 'not_applicable' + }, + testCases: parseTestCases(junit) }; +report.checks.coverage = ( + report.coverage.lines >= report.thresholds.lines && + report.coverage.statements >= report.thresholds.statements && + report.coverage.functions >= report.thresholds.functions && + report.coverage.branches >= report.thresholds.branches +) ? 'ok' : 'failed'; +if (report.testCases.length === 0) { + report.testCases.push({ category: 'unit', test: '__no_test_cases__', status: junit ? 'skipped' : 'failed' }); +} + fs.writeFileSync('build/quality-gate.json', JSON.stringify(report, null, 2)); +if ( + Number(process.env.lint_rc || 0) !== 0 || + Number(process.env.test_rc || 0) !== 0 || + Number(process.env.build_rc || 0) !== 0 || + Object.values(report.checks).includes('failed') +) { + process.exit(1); +} NODE +GATE + gate_rc=$? + set -e + printf '%s\n' "${gate_rc}" > build/quality-gate.rc + if [ ! -f build/quality-gate.json ]; then + mkdir -p build + cat > build/quality-gate.json <<'JSON' +{ + "suite": "typhon", + "tests": { + "tests": 0, + "failures": 1, + "errors": 0, + "skipped": 0 + }, + "coverage": { + "lines": 0, + "statements": 0, + "functions": 0, + "branches": 0 + }, + "thresholds": { + "lines": 95, + "statements": 95, + "functions": 95, + "branches": 75 + }, + "hygiene": { + "sourceFilesTotal": 0, + "sourceLinesOver500": 0 + }, + "checks": { + "tests": "failed", + "coverage": "failed", + "loc": "ok", + "style": "failed", + "gate_glue": "failed", + "sonarqube": "not_applicable", + "supply_chain": "not_applicable" + }, + "testCases": [ + { + "category": "unit", + "test": "__no_test_cases__", + "status": "failed" + } + ] +} +JSON + fi ''' } } } - stage('Publish Test Metrics') { + stage('Publish test metrics') { steps { container('node') { sh ''' @@ -186,7 +341,20 @@ if (!fs.existsSync(qualityPath)) { } const quality = JSON.parse(fs.readFileSync(qualityPath, 'utf8')); -const status = quality.tests.failures > 0 || quality.tests.errors > 0 ? 'failed' : 'ok'; +const status = quality.tests.failures > 0 || quality.tests.errors > 0 || Object.values(quality.checks || {}).includes('failed') ? 'failed' : 'ok'; +const branch = process.env.BRANCH_NAME || process.env.GIT_BRANCH || 'unknown'; +const buildNumber = process.env.BUILD_NUMBER || 'unknown'; +const jenkinsJob = process.env.JOB_NAME || suite; +const sourceFilesTotal = Number(quality.hygiene?.sourceFilesTotal ?? 0); +const sourceLinesOver500 = Number(quality.hygiene?.sourceLinesOver500 ?? 0); + +function esc(value) { + return String(value ?? '').replace(/\\/g, '\\\\').replace(/"/g, '\\"').replace(/\n/g, '\\n'); +} + +function labelString(labels) { + return `{${Object.entries(labels).map(([key, value]) => `${key}="${esc(value)}"`).join(',')}}`; +} function fetchCounter(targetStatus) { try { @@ -205,34 +373,86 @@ let failed = fetchCounter('failed'); if (status === 'ok') ok += 1; if (status === 'failed') failed += 1; -const payload = [ +const passed = Math.max(quality.tests.tests - quality.tests.failures - quality.tests.errors - quality.tests.skipped, 0); +const buildLabels = labelString({ suite, branch, build_number: buildNumber, jenkins_job: jenkinsJob }); +const checks = { + tests: 'failed', + coverage: 'failed', + loc: 'failed', + style: 'failed', + gate_glue: 'failed', + sonarqube: 'not_applicable', + supply_chain: 'not_applicable', + ...(quality.checks || {}) +}; +const lines = [ '# TYPE platform_quality_gate_runs_total counter', `platform_quality_gate_runs_total{suite="${suite}",status="ok"} ${ok}`, `platform_quality_gate_runs_total{suite="${suite}",status="failed"} ${failed}`, + '# TYPE platform_quality_gate_build_info gauge', + `platform_quality_gate_build_info${buildLabels} 1`, + '# TYPE platform_quality_gate_tests_total gauge', + `platform_quality_gate_tests_total{suite="${suite}",result="passed"} ${passed}`, + `platform_quality_gate_tests_total{suite="${suite}",result="failed"} ${quality.tests.failures}`, + `platform_quality_gate_tests_total{suite="${suite}",result="error"} ${quality.tests.errors}`, + `platform_quality_gate_tests_total{suite="${suite}",result="skipped"} ${quality.tests.skipped}`, '# TYPE typhon_quality_gate_tests_total gauge', - `typhon_quality_gate_tests_total{suite="${suite}",result="total"} ${quality.tests.tests}`, - `typhon_quality_gate_tests_total{suite="${suite}",result="failures"} ${quality.tests.failures}`, - `typhon_quality_gate_tests_total{suite="${suite}",result="errors"} ${quality.tests.errors}`, + `typhon_quality_gate_tests_total{suite="${suite}",result="passed"} ${passed}`, + `typhon_quality_gate_tests_total{suite="${suite}",result="failed"} ${quality.tests.failures}`, + `typhon_quality_gate_tests_total{suite="${suite}",result="error"} ${quality.tests.errors}`, `typhon_quality_gate_tests_total{suite="${suite}",result="skipped"} ${quality.tests.skipped}`, '# TYPE typhon_quality_gate_coverage_percent gauge', `typhon_quality_gate_coverage_percent{suite="${suite}",scope="lines"} ${quality.coverage.lines}`, `typhon_quality_gate_coverage_percent{suite="${suite}",scope="statements"} ${quality.coverage.statements}`, `typhon_quality_gate_coverage_percent{suite="${suite}",scope="functions"} ${quality.coverage.functions}`, `typhon_quality_gate_coverage_percent{suite="${suite}",scope="branches"} ${quality.coverage.branches}`, + '# TYPE platform_quality_gate_workspace_line_coverage_percent gauge', + `platform_quality_gate_workspace_line_coverage_percent{suite="${suite}"} ${quality.coverage.lines}`, + '# TYPE platform_quality_gate_source_files_total gauge', + `platform_quality_gate_source_files_total{suite="${suite}"} ${sourceFilesTotal}`, + '# TYPE platform_quality_gate_source_lines_over_500_total gauge', + `platform_quality_gate_source_lines_over_500_total{suite="${suite}"} ${sourceLinesOver500}`, + '# TYPE typhon_quality_gate_checks_total gauge', + ...Object.entries(checks).map(([check, result]) => `typhon_quality_gate_checks_total${labelString({ suite, check, result })} 1`), + '# TYPE platform_quality_gate_test_case_result gauge', + ...(quality.testCases || []).map((item) => `platform_quality_gate_test_case_result${labelString({ + suite, + branch, + build_number: buildNumber, + jenkins_job: jenkinsJob, + category: item.category || 'unit', + test: item.test || '__no_test_cases__', + status: item.status || 'skipped' + })} 1`), '' -].join('\\n'); +]; -execSync(`curl -fsS --data-binary @- ${gateway}/metrics/job/platform-quality-ci/suite/${suite}`, { - input: payload, - stdio: ['pipe', 'inherit', 'inherit'] -}); +try { + execSync(`curl -fsS --data-binary @- ${gateway}/metrics/job/platform-quality-ci/suite/${suite}`, { + input: lines.join('\\n'), + stdio: ['pipe', 'inherit', 'inherit'] + }); +} catch (error) { + console.error(`metrics publish failed for suite=${suite}:`, error.message); +} NODE ''' } } } - stage('Buildx Setup') { + stage('Enforce quality gate') { + steps { + container('node') { + sh ''' + set -eu + test "$(cat build/quality-gate.rc 2>/dev/null || echo 1)" -eq 0 + ''' + } + } + } + + stage('Buildx setup') { when { expression { return params.PUBLISH_IMAGE } } @@ -252,7 +472,7 @@ NODE } } - stage('Build & Push Image') { + stage('Build & push image') { when { expression { return params.PUBLISH_IMAGE } } diff --git a/jest.config.cjs b/jest.config.cjs index ae312d1..c7126b3 100644 --- a/jest.config.cjs +++ b/jest.config.cjs @@ -23,7 +23,7 @@ module.exports = { outputDirectory: "build", outputName: "junit-typhon.xml", suiteName: "typhon", - classNameTemplate: "{classname}", + classNameTemplate: "{filepath}", titleTemplate: "{title}", ancestorSeparator: " › " } diff --git a/tests/AppConfig.test.ts b/tests/AppConfig.test.ts index b5f8c06..3ca2bfd 100644 --- a/tests/AppConfig.test.ts +++ b/tests/AppConfig.test.ts @@ -41,17 +41,26 @@ describe("AppConfig", () => { POLL_INTERVAL_SECONDS: "4" }) ).toThrow("POLL_INTERVAL_SECONDS must be between 5 and 600"); + + expect(() => + AppConfig.fromEnv({ + ACI_EMAIL: "x", + ACI_PASSWORD: "y", + LISTEN_PORT: "not-a-number" + }) + ).toThrow("LISTEN_PORT must be an integer"); }); it("supports ble mode without cloud credentials", () => { const config = AppConfig.fromEnv({ TYPHON_MODE: "ble", - ENABLE_CONTROL_API: "true", + ENABLE_CONTROL_API: "yes", + LOG_LEVEL: "debug", TY_BLE_DEFAULT_MAC: "58:8c:81:c6:fc:f6", TY_BLE_ALLOWED_MACS: "11:22:33:44:55:66", TY_BLE_DEVICE_TYPE: "11", TY_BLE_SCAN_TIMEOUT_MS: "25000", - TY_BLE_PORT_BASE: "1" + TY_BLE_PORT_BASE: "0" }); expect(config.mode).toBe("ble"); @@ -65,7 +74,8 @@ describe("AppConfig", () => { ]); expect(config.bleDeviceType).toBe(11); expect(config.bleScanTimeoutMs).toBe(25000); - expect(config.blePortBase).toBe(1); + expect(config.blePortBase).toBe(0); + expect(config.logLevel).toBe("debug"); }); it("validates mode and MAC inputs", () => { @@ -80,9 +90,26 @@ describe("AppConfig", () => { TY_BLE_DEFAULT_MAC: "not-a-mac" })).toThrow("TY_BLE_DEFAULT_MAC must be a MAC address like AA:BB:CC:DD:EE:FF"); + expect(() => AppConfig.fromEnv({ + TYPHON_MODE: "ble", + TY_BLE_ALLOWED_MACS: "11:22:33:44:55:66,not-a-mac" + })).toThrow("TY_BLE_ALLOWED_MACS must contain MAC addresses like AA:BB:CC:DD:EE:FF"); + expect(() => AppConfig.fromEnv({ TYPHON_MODE: "ble", TY_BLE_PORT_BASE: "2" })).toThrow("TY_BLE_PORT_BASE must be 0 or 1"); + + expect(() => AppConfig.fromEnv({ + ACI_EMAIL: "x", + ACI_PASSWORD: "y", + ENABLE_CONTROL_API: "maybe" + })).toThrow("ENABLE_CONTROL_API must be a boolean"); + + expect(() => AppConfig.fromEnv({ + ACI_EMAIL: "x", + ACI_PASSWORD: "y", + LOG_LEVEL: "loud" + })).toThrow("LOG_LEVEL must be one of: debug, info, warn, error"); }); }); diff --git a/tests/TyphonMetrics.test.ts b/tests/TyphonMetrics.test.ts index 3640585..1debaaf 100644 --- a/tests/TyphonMetrics.test.ts +++ b/tests/TyphonMetrics.test.ts @@ -177,4 +177,17 @@ describe("TyphonMetrics", () => { expect(valueForMetric(json, "typhon_data_age_seconds", {})).toBe(90); expect(valueForMetric(json, "typhon_last_successful_poll_timestamp_seconds", {})).toBe(1_700_000_000); }); + + it("keeps data age at zero before the first successful poll and reports runtime mode", async () => { + const registry = new Registry(); + const metrics = new TyphonMetrics("0.1.0", false, registry); + + metrics.refreshDataAgeGauge(1_700_000_090); + metrics.setRuntimeMode("ble"); + + const json = await registry.getMetricsAsJSON(); + expect(valueForMetric(json, "typhon_data_age_seconds", {})).toBe(0); + expect(valueForMetric(json, "typhon_runtime_mode", { mode: "cloud" })).toBe(0); + expect(valueForMetric(json, "typhon_runtime_mode", { mode: "ble" })).toBe(1); + }); });