Skip to content
Merged
27 changes: 22 additions & 5 deletions .github/workflows/build.yml
Original file line number Diff line number Diff line change
Expand Up @@ -295,9 +295,16 @@ jobs:
bun run sentry:upload-sourcemaps

test-and-scan:
name: Run Tests and Scan with SonarQube
runs-on: github-ubuntu-latest-m
name: Run Tests and Scan with SonarQube - ${{ matrix.os }}
runs-on: ${{ matrix.runner }}
needs: [prepare]
strategy:
matrix:
include:
- os: linux
runner: github-ubuntu-latest-m
- os: windows
runner: warp-custom-windows-2022-s
steps:
- uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8
with:
Expand All @@ -320,7 +327,7 @@ jobs:
development/artifactory/token/{REPO_OWNER_NAME_DASH}-qa-deployer access_token | ARTIFACTORY_DEPLOY_PASSWORD;

- name: Install libsecret (Linux)
if: ${{ runner.os == 'linux' }}
if: matrix.os == 'linux'
run: sudo apt-get update && sudo apt-get install -y libsecret-1-0

- name: Cache Bun dependencies
Expand All @@ -343,10 +350,20 @@ jobs:
- name: Check linting
run: bun lint

- name: Run all tests
- name: Run all tests (Windows)
if: matrix.os == 'windows'
run: bun test:all

- name: Run all tests with coverage (Linux)
if: matrix.os == 'linux'
run: bun test:coverage

- name: Analyze on SonarQubeCloud
- name: Run e2e tests
if: github.ref_name == github.event.repository.default_branch || startsWith(github.ref_name, 'branch-')
run: bun test:e2e

- name: Analyze on SonarQubeCloud (Linux)
if: matrix.os == 'linux'
env:
SONAR_HOST_URL: ${{ fromJSON(steps.secrets.outputs.vault).SONAR_URL }}
SONAR_TOKEN: ${{ fromJSON(steps.secrets.outputs.vault).SONAR_TOKEN }}
Expand Down
3 changes: 2 additions & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ bun run typecheck # tsc --noEmit
bun run test:unit # All unit tests
bun run test:integration # All integration tests, no coverage (local development)
bun run test:all # Unit + integration
bun run test:e2e # E2E tests (install scripts, requires network)
bun run test:coverage # Full merged lcov report (unit + integration, slow)
```

Expand Down Expand Up @@ -55,7 +56,7 @@ Try to get inspiration from other tests to follow the same structure.

- Unit tests: `tests/unit/` — run with `bun test:unit`
- Integration tests: `tests/integration/` — require env vars. They are using a harness to help set up tests and make assertions. Run with `bun test:integration`.
- E2E tests: `tests/e2e/` — end-to-end tests to verify full integration with external systems. Run with `bun test ./tests/e2e/`.
- E2E tests: `tests/e2e/` — end-to-end tests to verify full integration with external systems. Run with `bun test:e2e`.
- The UI module has a built-in mock system (`src/ui/mock.ts`) — use it instead of mocking stdout directly.

## Documentation
Expand Down
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@
"pretest:integration": "bun build:binary && bun build-scripts/setup-integration-resources.ts",
"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",
"lint": "eslint src/ tests/",
"lint:fix": "eslint src/ tests/ --fix",
Expand Down
67 changes: 62 additions & 5 deletions tests/e2e/install-scripts.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,16 +18,20 @@
* Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
*/

// Real-network test for install.sh — validates URL path structure on binaries.sonarsource.com
// Real-network tests for install scripts — validates URL path structure on binaries.sonarsource.com

import { describe, it, expect, beforeEach, afterEach } from 'bun:test';
import { describe, it, expect, beforeEach, afterEach, setDefaultTimeout } from 'bun:test';
import { mkdtempSync, rmSync, existsSync } from 'node:fs';
import { join } from 'node:path';
import { tmpdir } from 'node:os';

// PowerShell cold-start in afterEach needs more than the default 5s hook timeout
setDefaultTimeout(30_000);

const scriptDir = join(import.meta.dir, '../../user-scripts');
const isWindows = process.platform === 'win32';

describe.if(process.platform !== 'win32')('install.sh (network)', () => {
describe.if(!isWindows)('install.sh (network)', () => {
let tempHome: string;

beforeEach(() => {
Expand All @@ -39,7 +43,7 @@ describe.if(process.platform !== 'win32')('install.sh (network)', () => {
});

it(
'downloads and installs sonar CLI binary on the current platform',
'downloads and installs sonar CLI binary on Unix',
() => {
const scriptPath = join(scriptDir, 'install.sh');

Expand All @@ -60,6 +64,59 @@ describe.if(process.platform !== 'win32')('install.sh (network)', () => {
expect(helpProc.exitCode).toBe(0);
expect(helpOutput).toContain('sonar');
},
{ timeout: 120000 },
{ timeout: 120_000 },
);
});

function removeFromUserPath(dir: string) {
const escaped = dir.replace(/'/g, "''");
Bun.spawnSync([
'powershell',
'-NoProfile',
'-Command',
`$p = [Environment]::GetEnvironmentVariable('PATH','User'); if ($p) { $entries = ($p -split ';') | Where-Object { $_ -ne '${escaped}' }; [Environment]::SetEnvironmentVariable('PATH', ($entries -join ';'), 'User') }`,
]);
}

describe.if(isWindows)('install.ps1 (network)', () => {
let tempLocalAppData: string;
let installDir: string;

beforeEach(() => {
tempLocalAppData = mkdtempSync(join(tmpdir(), 'sonar-install-test-'));
installDir = join(tempLocalAppData, 'sonarqube-cli', 'bin');
});

afterEach(() => {
removeFromUserPath(installDir);
rmSync(tempLocalAppData, { recursive: true, force: true });
});

it(
'downloads and installs sonar CLI binary on Windows',
() => {
const scriptPath = join(scriptDir, 'install.ps1');

const proc = Bun.spawnSync(
['powershell', '-NoProfile', '-ExecutionPolicy', 'Bypass', '-File', scriptPath],
{
env: { ...process.env, LOCALAPPDATA: tempLocalAppData },
},
);

const installStderr = new TextDecoder().decode(proc.stderr);
expect(proc.exitCode, `install.ps1 failed:\n${installStderr}`).toBe(0);

const binaryPath = join(installDir, 'sonar.exe');
expect(existsSync(binaryPath)).toBe(true);

const helpProc = Bun.spawnSync([binaryPath, '--help'], {
env: { ...process.env, LOCALAPPDATA: tempLocalAppData },
});
const helpOutput = new TextDecoder().decode(helpProc.stdout);
expect(helpProc.exitCode).toBe(0);
expect(helpOutput).toContain('sonar');
},
{ timeout: 240_000 },
);
});
7 changes: 6 additions & 1 deletion tests/integration/harness/cli-runner.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,9 +24,14 @@ import { existsSync, mkdirSync } from 'node:fs';
import { join } from 'node:path';
import { COVERAGE_BINARY, COVERAGE_RAW_DIR } from '../../coverage/paths.js';
import type { CliResult } from './types.js';
import { IS_WINDOWS } from './platform';

const PROJECT_ROOT = join(import.meta.dir, '../../..');
const DEFAULT_BINARY = join(PROJECT_ROOT, 'dist', 'sonarqube-cli');
const DEFAULT_BINARY = join(
PROJECT_ROOT,
'dist',
IS_WINDOWS ? 'sonarqube-cli.exe' : 'sonarqube-cli',
);
const DEFAULT_TIMEOUT_MS = 30000;

function getBinaryPath(coverageMode: boolean): string {
Expand Down
6 changes: 6 additions & 0 deletions tests/integration/harness/file.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,8 @@
// Declarative builder for test file system fixtures

import { existsSync, readFileSync, statSync } from 'node:fs';
import { extname } from 'node:path';
import { IS_WINDOWS } from './platform';

export class File {
public readonly path: string;
Expand All @@ -42,6 +44,10 @@ export class File {
}

get isExecutable(): boolean {
if (IS_WINDOWS) {
const executableExts = ['.exe', '.cmd', '.bat', '.com', '.ps1'];
return executableExts.includes(extname(this.path).toLowerCase());
}
const stats = statSync(this.path);
return !!(stats.mode & 0o100);
}
Expand Down
21 changes: 13 additions & 8 deletions tests/integration/harness/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,12 +20,14 @@

// TestHarness — main entry point for integration tests

import { mkdirSync, rmSync } from 'node:fs';
import { mkdirSync } from 'node:fs';
import { rm } from 'node:fs/promises';
import { join } from 'node:path';
import { tmpdir } from 'node:os';
import { runCli } from './cli-runner.js';
import { EnvironmentBuilder } from './environment-builder.js';
import { Dir } from './dir';
import { buildHomeEnv } from './platform';
import { FakeSonarQubeServer, FakeSonarQubeServerBuilder } from './fake-sonarqube-server.js';
import { FakeBinariesServer, FakeBinariesServerBuilder } from './fake-binaries-server.js';
import type { CliResult, RunOptions } from './types.js';
Expand All @@ -39,6 +41,7 @@ export {
} from './fake-sonarqube-server.js';
export { FakeBinariesServer, FakeBinariesServerBuilder } from './fake-binaries-server.js';
export type { CliResult, RunOptions, RecordedRequest } from './types.js';
export { IS_WINDOWS, SCRIPT_EXT, hookScriptName, hookScriptPath, normalizePath } from './platform';

export class TestHarness {
private readonly tempDir: Dir;
Expand Down Expand Up @@ -151,11 +154,6 @@ export class TestHarness {
if (val !== undefined) systemVars[key] = val;
}

const homeEnv: Record<string, string> =
process.platform === 'win32'
? { USERPROFILE: this.userHome.path }
: { HOME: this.userHome.path };

const activeBinariesServer = this.binariesServers.at(-1);
const fakeBinariesEnv: Record<string, string> = activeBinariesServer
? { SONARQUBE_CLI_BINARIES_URL: activeBinariesServer.baseUrl() }
Expand All @@ -176,7 +174,7 @@ export class TestHarness {
CI: 'true',
...this._extraEnv,
...(options?.extraEnv ?? {}),
...homeEnv,
...buildHomeEnv(this.userHome.path),
};

return runCli(command, env, {
Expand All @@ -199,6 +197,13 @@ export class TestHarness {
}),
),
);
rmSync(this.tempDir.path, { recursive: true, force: true });
await rm(this.tempDir.path, {
recursive: true,
force: true,
maxRetries: 5,
retryDelay: 1000,
}).catch(() => {
/* best-effort: temp dirs are cleaned up by the OS */
});
}
}
54 changes: 54 additions & 0 deletions tests/integration/harness/platform.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
/*
* SonarQube CLI
* Copyright (C) 2026 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.
*/

export const normalizePath = (p: string): string => p.replaceAll('\\', '/');

export const IS_WINDOWS = process.platform === 'win32';
export const SCRIPT_EXT = IS_WINDOWS ? '.ps1' : '.sh';

/**
* Build the HOME-related env vars needed to override a user's home directory.
* On Windows both USERPROFILE and HOME are set because Git for Windows runs
* hooks in MSYS2 bash, which derives HOME from HOMEDRIVE+HOMEPATH when HOME
* is unset.
*/
export function buildHomeEnv(homePath: string): Record<string, string> {
return IS_WINDOWS ? { USERPROFILE: homePath, HOME: homePath } : { HOME: homePath };
}

/**
* Get the name of a hook script file (with extension)
*/
export function hookScriptName(name: string): string {
return `${name}${SCRIPT_EXT}`;
}

/**
* Extract the script path from a hook command string.
* On Windows commands are wrapped as `powershell -NoProfile -File <path>`;
* this strips that prefix. Always normalizes to forward slashes.
*/
export function hookScriptPath(command: string): string {
const powershellPrefix = 'powershell -NoProfile -File ';
const path = command.startsWith(powershellPrefix)
? command.slice(powershellPrefix.length)
: command;
return normalizePath(path);
}
Loading
Loading