Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 6 additions & 1 deletion .github/workflows/build.yml
Original file line number Diff line number Diff line change
Expand Up @@ -352,7 +352,12 @@ jobs:
if: matrix.os == 'linux'
env:
SONARQUBE_CLI_DISABLE_SENTRY: '1'
run: bun test --coverage --coverage-reporter=lcov --coverage-dir=tests/coverage/reports/unit ./tests/unit/
COVERAGE_RAW_UNIT_DIR: tests/coverage/reports/raw-unit
run: bun test --preload ./tests/coverage/preload-instrumenter.ts ./tests/unit/

- name: Generate unit lcov from Istanbul data (Linux)
if: matrix.os == 'linux'
run: bun build-scripts/report-coverage.ts

- name: Upload unit coverage (Linux)
if: matrix.os == 'linux'
Expand Down
11 changes: 11 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,17 @@ Before writing a test, find an existing spec for the same command area and follo

Each test creates a fresh `TestHarness` and disposes it in `afterEach`. The harness runs the compiled binary in a fully isolated environment (temp dir, fake keychain, fake servers). For fine-grained state setup beyond `withAuth`, use `harness.state()` builder (see `tests/integration/harness/environment-builder.ts`). For git hook tests, use `initGitRepo` / `stageFile` from `tests/integration/specs/hook/git-test-helpers.ts`.

### Coverage pipeline

Both unit and integration tests use **Istanbul** (`istanbul-lib-instrument`) for coverage — **not** Bun's native `--coverage` flag.

- **Unit**: `tests/coverage/preload-instrumenter.ts` is a Bun preload that instruments every `src/**/*.ts` file via `istanbul-lib-instrument` and writes a unique per-worker `coverage-<timestamp>-<pid>.json` to `COVERAGE_RAW_UNIT_DIR` on exit (each `bun test` worker runs in its own process). The `test:coverage` script sets `COVERAGE_RAW_UNIT_DIR=tests/coverage/reports/raw-unit`.
- **Integration**: `build-scripts/build-coverage-binary.ts` builds an instrumented binary; each integration test run writes raw JSON to `tests/coverage/reports/raw/`.
- **Merge**: `build-scripts/report-coverage.ts` reads both raw dirs (skipping any that are absent or empty) and writes `tests/coverage/reports/unit/lcov.info` and `tests/coverage/reports/integration/lcov.info` via `istanbul-reports`.
- **Clear**: `build-scripts/clear-coverage-raw.ts` removes both raw dirs (`raw/` and `raw-unit/`) before a fresh run.

Never switch unit tests back to `bun test --coverage`; the inaccurate LCOV will reintroduce false uncovered lines in SonarQube.

## Documentation

When adding, removing, or changing commands, scripts, or project structure, update `CLAUDE.md`, and `AGENTS.md` to reflect the change before finishing.
Expand Down
14 changes: 8 additions & 6 deletions build-scripts/clear-coverage-raw.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,11 +28,13 @@

import { existsSync, rmSync } from 'node:fs';

import { COVERAGE_RAW_DIR } from '../tests/coverage/paths.js';
import { COVERAGE_RAW_DIR, COVERAGE_UNIT_RAW_DIR } from '../tests/coverage/paths.js';

if (existsSync(COVERAGE_RAW_DIR)) {
rmSync(COVERAGE_RAW_DIR, { recursive: true, force: true });
console.log(`Cleared: ${COVERAGE_RAW_DIR}`);
} else {
console.log(`Nothing to clear at: ${COVERAGE_RAW_DIR}`);
for (const dir of [COVERAGE_RAW_DIR, COVERAGE_UNIT_RAW_DIR]) {
if (existsSync(dir)) {
rmSync(dir, { recursive: true, force: true });
console.log(`Cleared: ${dir}`);
} else {
console.log(`Nothing to clear at: ${dir}`);
}
}
72 changes: 47 additions & 25 deletions build-scripts/report-coverage.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,11 +19,19 @@
*/

/**
* Reads all Istanbul JSON files from tests/coverage/reports/raw/, merges
* them, and generates tests/coverage/reports/integration/lcov.info.
* Reads Istanbul JSON files from the raw dirs and generates LCOV reports.
*
* SonarQube is configured to read both this file and the unit lcov
* (tests/coverage/reports/unit/lcov.info) separately via
* Integration: tests/coverage/reports/raw/ → tests/coverage/reports/integration/lcov.info
* Unit: tests/coverage/reports/raw-unit/ → tests/coverage/reports/unit/lcov.info
*
* Each section is processed only when its raw dir exists and is non-empty,
* so the script can be called from either the unit-tests job (only unit raw
* data present) or the integration job (only integration raw data present),
* or both in the full test:coverage local run.
*
* At least one raw dir must have data, or the script exits with an error.
*
* SonarQube is configured to read both lcov files via
* sonar.javascript.lcov.reportPaths in sonar-project.properties.
*
* Run via: bun build-scripts/report-coverage.ts
Expand All @@ -36,29 +44,43 @@ import { type CoverageMapData, createCoverageMap } from 'istanbul-lib-coverage';
import { createContext } from 'istanbul-lib-report';
import reports from 'istanbul-reports';

import { COVERAGE_INTEGRATION_REPORT_DIR, COVERAGE_RAW_DIR } from '../tests/coverage/paths.js';

if (!existsSync(COVERAGE_RAW_DIR)) {
console.error(`No integration coverage data found at ${COVERAGE_RAW_DIR}`);
console.error('Run the integration tests first with the coverage binary.');
process.exit(1);
}
import {
COVERAGE_INTEGRATION_REPORT_DIR,
COVERAGE_RAW_DIR,
COVERAGE_UNIT_RAW_DIR,
COVERAGE_UNIT_REPORT_DIR,
} from '../tests/coverage/paths.js';

const jsonFiles = readdirSync(COVERAGE_RAW_DIR).filter((f) => f.endsWith('.json'));
if (jsonFiles.length === 0) {
console.error(`No JSON files found in ${COVERAGE_RAW_DIR}`);
process.exit(1);
function processRawDir(rawDir: string, reportDir: string, label: string): boolean {
if (!existsSync(rawDir)) {
console.log(`No ${label} raw coverage dir found at ${rawDir}, skipping.`);
return false;
}
const jsonFiles = readdirSync(rawDir).filter((f) => f.endsWith('.json'));
if (jsonFiles.length === 0) {
console.log(`No JSON files found in ${rawDir}, skipping ${label} lcov.`);
return false;
}
console.log(`Processing ${jsonFiles.length} ${label} coverage file(s)...`);
const coverageMap = createCoverageMap({});
for (const file of jsonFiles) {
const data = JSON.parse(readFileSync(join(rawDir, file), 'utf-8')) as CoverageMapData;
coverageMap.merge(data);
}
const ctx = createContext({ coverageMap, dir: reportDir });
reports.create('lcov').execute(ctx);
console.log(`${label} lcov written to ${reportDir}/lcov.info`);
return true;
}

console.log(`Processing ${jsonFiles.length} integration coverage file(s)...`);
const wroteIntegration = processRawDir(
COVERAGE_RAW_DIR,
COVERAGE_INTEGRATION_REPORT_DIR,
'integration',
);
const wroteUnit = processRawDir(COVERAGE_UNIT_RAW_DIR, COVERAGE_UNIT_REPORT_DIR, 'unit');

const coverageMap = createCoverageMap({});
for (const file of jsonFiles) {
const data = JSON.parse(readFileSync(join(COVERAGE_RAW_DIR, file), 'utf-8')) as CoverageMapData;
coverageMap.merge(data);
if (!wroteIntegration && !wroteUnit) {
console.error('No coverage data found in either raw dir. Run tests with coverage first.');
process.exit(1);
}

const ctx = createContext({ coverageMap, dir: COVERAGE_INTEGRATION_REPORT_DIR });
reports.create('lcov').execute(ctx);

console.log(`Integration lcov written to ${COVERAGE_INTEGRATION_REPORT_DIR}/lcov.info`);
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@
"test:integration": "bun test ./tests/integration/",
"test:all": "bun run test:unit && bun run test:integration",
"test:e2e": "bun test ./tests/e2e/",
"test:coverage": "bun build:binary && bun build-scripts/build-coverage-binary.ts && bun build-scripts/setup-integration-resources.ts && bun build-scripts/clear-coverage-raw.ts && bun test --coverage --coverage-reporter=lcov --coverage-dir=tests/coverage/reports/unit ./tests/unit/ && SONARQUBE_CLI_USE_COVERAGE=1 bun test ./tests/integration/ && bun build-scripts/report-coverage.ts",
"test:coverage": "bun build:binary && bun build-scripts/build-coverage-binary.ts && bun build-scripts/setup-integration-resources.ts && bun build-scripts/clear-coverage-raw.ts && COVERAGE_RAW_UNIT_DIR=tests/coverage/reports/raw-unit SONARQUBE_CLI_DISABLE_SENTRY=1 bun test --preload ./tests/coverage/preload-instrumenter.ts ./tests/unit/ && SONARQUBE_CLI_USE_COVERAGE=1 bun test ./tests/integration/ && bun build-scripts/report-coverage.ts",
"lint": "eslint src/ tests/ build-scripts/",
"lint:fix": "eslint src/ tests/ build-scripts/ --fix",
"format": "prettier --write \"src/**/*.ts\" \"tests/**/*.ts\" \"build-scripts/**/*.ts\"",
Expand Down
13 changes: 2 additions & 11 deletions tests/coverage/index-coverage.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,21 +27,12 @@
//
// Only used when building the coverage-instrumented binary.

import { mkdirSync, writeFileSync } from 'node:fs';
import { dirname } from 'node:path';
import { serializeCoverageToFile } from './utils.js';

const coverageOutputFile = process.env.COVERAGE_OUTPUT_FILE;
if (coverageOutputFile) {
process.on('exit', () => {
const cov = (globalThis as Record<string, unknown>).__coverage__;
if (cov) {
try {
mkdirSync(dirname(coverageOutputFile), { recursive: true });
writeFileSync(coverageOutputFile, JSON.stringify(cov));
} catch {
// best-effort: do not crash the process over coverage serialization
}
}
serializeCoverageToFile(coverageOutputFile);
});
}

Expand Down
2 changes: 2 additions & 0 deletions tests/coverage/paths.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,3 +31,5 @@ export const COVERAGE_INTEGRATION_REPORT_DIR = join(
'reports',
'integration',
);
export const COVERAGE_UNIT_RAW_DIR = join(PROJECT_ROOT, 'tests', 'coverage', 'reports', 'raw-unit');
export const COVERAGE_UNIT_REPORT_DIR = join(PROJECT_ROOT, 'tests', 'coverage', 'reports', 'unit');
86 changes: 86 additions & 0 deletions tests/coverage/preload-instrumenter.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
/*
* SonarQube CLI
* Copyright (C) SonarSource Sàrl
* mailto:info AT sonarsource DOT com
*
* This program is free software; you can redistribute it and/or
* modify it under the terms of the GNU Lesser General Public
* License as published by the Free Software Foundation; either
* version 3 of the License, or (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
* Lesser General Public License for more details.
*
* You should have received a copy of the GNU Lesser General Public License
* along with this program; if not, write to the Free Software Foundation,
* Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
*/

// Bun preload script for Istanbul-based unit test coverage.
//
// When COVERAGE_RAW_UNIT_DIR is set, this script registers a Bun plugin that
// instruments every src/**/*.ts file with istanbul-lib-instrument before it is
// loaded, then serializes globalThis.__coverage__ to a unique JSON file inside
// COVERAGE_RAW_UNIT_DIR via a global afterAll hook.
//
// bun test runs each test file in a separate worker process; process.on('exit')
// does not fire in Bun 1.x workers, so we use afterAll from bun:test instead.
// Using a unique output path per worker (PID + timestamp) ensures workers don't
// race on the same file, mirroring how the integration harness generates filenames.
//
// Usage:
// COVERAGE_RAW_UNIT_DIR=tests/coverage/reports/raw-unit \
// bun test --preload ./tests/coverage/preload-instrumenter.ts ./tests/unit/

import { join } from 'node:path';

import { afterAll } from 'bun:test';
import { createInstrumenter } from 'istanbul-lib-instrument';

import { serializeCoverageToFile } from './utils.js';

const coverageRawUnitDir = process.env.COVERAGE_RAW_UNIT_DIR;

if (coverageRawUnitDir) {
const PROJECT_ROOT = join(import.meta.dir, '../..');
const SRC_DIR = join(PROJECT_ROOT, 'src') + '/';

const instrumenter = createInstrumenter({
esModules: true,
preserveComments: true,
parserPlugins: ['typescript'],
produceSourceMap: false,
});

Bun.plugin({
name: 'istanbul-instrumenter',
setup(build) {
build.onLoad({ filter: /\.ts$/ }, async (args) => {
const source = await Bun.file(args.path).text();

// Only instrument files under src/ — pass others through unchanged.
if (!args.path.startsWith(SRC_DIR)) {
return { contents: source, loader: 'ts' };
}

try {
const instrumented = instrumenter.instrumentSync(source, args.path);
return { contents: instrumented, loader: 'ts' };
} catch (err) {
process.stderr.write(
`[istanbul] warning: failed to instrument ${args.path}: ${String(err)}\n`,
);
return { contents: source, loader: 'ts' };
}
});
},
});

afterAll(() => {
const unique = `${Date.now()}-${process.pid}`;
const outputFile = join(coverageRawUnitDir, `coverage-${unique}.json`);
serializeCoverageToFile(outputFile);
});
Comment thread
nquinquenel marked this conversation as resolved.
}
42 changes: 42 additions & 0 deletions tests/coverage/utils.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
/*
* SonarQube CLI
* Copyright (C) SonarSource Sàrl
* mailto:info AT sonarsource DOT com
*
* This program is free software; you can redistribute it and/or
* modify it under the terms of the GNU Lesser General Public
* License as published by the Free Software Foundation; either
* version 3 of the License, or (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
* Lesser General Public License for more details.
*
* You should have received a copy of the GNU Lesser General Public License
* along with this program; if not, write to the Free Software Foundation,
* Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
*/

// Shared Istanbul coverage serialization helpers used by both
// tests/coverage/index-coverage.ts (integration binary) and
// tests/coverage/preload-instrumenter.ts (unit test preload).

import { mkdirSync, writeFileSync } from 'node:fs';
import { dirname } from 'node:path';

/**
* Writes globalThis.__coverage__ to the given file path.
* Creates parent directories as needed. No-ops silently on any error so that
* coverage serialization never crashes the process or test suite.
*/
export function serializeCoverageToFile(outputPath: string): void {
const cov = (globalThis as Record<string, unknown>).__coverage__;
if (!cov) return;
try {
mkdirSync(dirname(outputPath), { recursive: true });
writeFileSync(outputPath, JSON.stringify(cov));
} catch {
// best-effort: do not crash the process over coverage serialization
}
}
Loading