diff --git a/.github/workflows/test-coverage.yml b/.github/workflows/test-coverage.yml
new file mode 100644
index 0000000..4f92de4
--- /dev/null
+++ b/.github/workflows/test-coverage.yml
@@ -0,0 +1,126 @@
+name: Tests & Coverage
+
+on:
+ pull_request:
+ branches: [main]
+
+jobs:
+ test:
+ runs-on: ubuntu-latest
+ permissions:
+ contents: write
+ pull-requests: write
+
+ steps:
+ - uses: actions/checkout@v6
+ with:
+ fetch-depth: 0
+ ref: ${{ github.event.pull_request.head.sha }}
+
+ - uses: pnpm/action-setup@v3
+ with:
+ version: 9
+
+ - uses: actions/setup-node@v4
+ with:
+ node-version: 20
+ cache: 'pnpm'
+
+ - run: pnpm install --frozen-lockfile
+
+ - name: Install diff-cover
+ run: pip install diff-cover
+
+ - name: Run coverage
+ run: pnpm run coverage
+
+ - name: Fix lcov paths
+ run: |
+ if [ -f "coverage/lcov.info" ]; then
+ sed -i "s|^SF:|SF:|g" coverage/lcov.info
+ fi
+
+ - name: Run diff-cover analysis
+ id: diff_cover
+ run: |
+ echo "## 📊 Unit Test Coverage" > comment.md
+ echo "" >> comment.md
+
+ COVERAGE_THRESHOLD=80
+ all_passed=true
+
+ overall_coverage="N/A"
+ if [ -f "coverage/coverage-summary.json" ]; then
+ overall_coverage=$(jq -r '.total.lines.pct' "coverage/coverage-summary.json")
+ fi
+
+ diff-cover coverage/lcov.info \
+ --compare-branch=origin/${{ github.event.pull_request.base.ref }} \
+ --diff-range-notation=... \
+ --json-report diff-coverage.json \
+ --fail-under=0 || true
+
+ echo "**Overall coverage**: ${overall_coverage}%" >> comment.md
+ echo "" >> comment.md
+
+ if [ -f "diff-coverage.json" ]; then
+ total_lines=$(jq -r '.total_num_lines' diff-coverage.json)
+
+ if [ "$total_lines" != "0" ] && [ "$total_lines" != "null" ]; then
+ percent=$(jq -r '.total_percent_covered' diff-coverage.json)
+ violations=$(jq -r '.total_num_violations' diff-coverage.json)
+ covered=$((total_lines - violations))
+
+ if [ "$percent" -lt "$COVERAGE_THRESHOLD" ]; then
+ all_passed=false
+ fi
+
+ if [ "$percent" = "100" ]; then
+ emoji="✅"
+ status="PASS"
+ elif [ "$percent" -ge "$COVERAGE_THRESHOLD" ]; then
+ emoji="⚠️"
+ status="PASS"
+ else
+ emoji="❌"
+ status="FAIL"
+ fi
+
+ echo "**Diff coverage**: $emoji ${percent}% (${covered}/${total_lines} lines) - $status" >> comment.md
+ echo "" >> comment.md
+
+ if [ "$violations" != "0" ]; then
+ echo "" >> comment.md
+ echo "🔍 Uncovered lines in diff
" >> comment.md
+ echo "" >> comment.md
+ echo '```' >> comment.md
+ jq -r '.src_stats | to_entries[] | .key as $file | .value.violation_lines[] | "\($file):\(.)"' diff-coverage.json >> comment.md
+ echo '```' >> comment.md
+ echo "" >> comment.md
+ echo " " >> comment.md
+ fi
+ else
+ echo "✨ No new testable lines in this PR" >> comment.md
+ fi
+ fi
+
+ echo "---" >> comment.md
+ echo "" >> comment.md
+ if [ "$all_passed" = true ]; then
+ echo "✅ **Coverage threshold met** (≥${COVERAGE_THRESHOLD}%)" >> comment.md
+ else
+ echo "❌ **Coverage threshold not met** (required: ≥${COVERAGE_THRESHOLD}%)" >> comment.md
+ fi
+
+ echo "all_passed=$all_passed" >> $GITHUB_OUTPUT
+
+ - name: Comment PR with coverage
+ uses: marocchino/sticky-pull-request-comment@v2
+ with:
+ path: comment.md
+
+ - name: Fail if coverage is below threshold
+ if: steps.diff_cover.outputs.all_passed == 'false'
+ run: |
+ echo "❌ Coverage check failed: Coverage below 80%"
+ exit 1
\ No newline at end of file
diff --git a/src/core/patterns.ts b/src/core/scan/patterns.ts
similarity index 100%
rename from src/core/patterns.ts
rename to src/core/scan/patterns.ts
diff --git a/src/core/scan/scanFile.ts b/src/core/scan/scanFile.ts
index 297ae5a..ee7d198 100644
--- a/src/core/scan/scanFile.ts
+++ b/src/core/scan/scanFile.ts
@@ -1,6 +1,6 @@
import path from 'path';
import type { EnvUsage, ScanOptions } from '../../config/types.js';
-import { ENV_PATTERNS } from '../patterns.js';
+import { ENV_PATTERNS } from './patterns.js';
import { hasIgnoreComment } from '../security/secretDetectors.js';
import { normalizePath } from '../helpers/normalizePath.js';
diff --git a/src/services/fileWalker.ts b/src/services/fileWalker.ts
index c7263a0..51df981 100644
--- a/src/services/fileWalker.ts
+++ b/src/services/fileWalker.ts
@@ -4,7 +4,7 @@ import fsSync from 'fs';
import {
DEFAULT_INCLUDE_EXTENSIONS,
DEFAULT_EXCLUDE_PATTERNS,
-} from '../core/patterns.js';
+} from '../core/scan/patterns.js';
/**
* Options for finding files
diff --git a/src/services/scanCodebase.ts b/src/services/scanCodebase.ts
index 4d3fa6b..8bf554f 100644
--- a/src/services/scanCodebase.ts
+++ b/src/services/scanCodebase.ts
@@ -5,7 +5,7 @@ import {
detectSecretsInSource,
type SecretFinding,
} from '../core/security/secretDetectors.js';
-import { DEFAULT_EXCLUDE_PATTERNS } from '../core/patterns.js';
+import { DEFAULT_EXCLUDE_PATTERNS } from '../core/scan/patterns.js';
import { scanFile } from '../core/scan/scanFile.js';
import { findFiles } from './fileWalker.js';
import { printProgress } from '../ui/scan/printProgress.js';
diff --git a/test/unit/commands/scanUsage.test.ts b/test/unit/commands/scanUsage.test.ts
index 980e097..011bd2f 100644
--- a/test/unit/commands/scanUsage.test.ts
+++ b/test/unit/commands/scanUsage.test.ts
@@ -4,6 +4,7 @@ import type {
ScanResult,
} from '../../../src/config/types.js';
import { type SecretFinding } from '../../../src/core/security/secretDetectors.js';
+import { promptNoEnvScenario } from '../../../src/commands/prompts/promptNoEnvScenario.js';
vi.mock('../../../src/services/scanCodebase.js', () => ({
scanCodebase: vi.fn(),
@@ -52,7 +53,7 @@ vi.mock('../../../src/core/security/exampleSecretDetector.js', () => ({
]),
}));
-vi.mock('../../../src/services/prompts/promptNoEnvScenario.js', () => ({
+vi.mock('../../../src/commands/prompts/promptNoEnvScenario.js', () => ({
promptNoEnvScenario: vi.fn(),
}));
@@ -65,7 +66,6 @@ import { printMissingExample } from '../../../src/ui/scan/printMissingExample.js
import { printComparisonError } from '../../../src/ui/scan/printComparisonError.js';
describe('scanUsage', () => {
-
const dummySecret: SecretFinding = {
file: 'file.ts',
line: 1,
@@ -107,6 +107,8 @@ describe('scanUsage', () => {
vi.mocked(scanCodebase).mockResolvedValue({ ...baseScanResult });
vi.mocked(determineComparisonFile).mockResolvedValue({ type: 'none' });
vi.mocked(printScanResult).mockReturnValue({ exitWithError: false });
+ vi.mocked(printMissingExample).mockReturnValue(false);
+ vi.mocked(promptNoEnvScenario).mockResolvedValue({ compareFile: undefined });
});
it('returns early when example missing in CI mode', async () => {
@@ -170,4 +172,280 @@ describe('scanUsage', () => {
expect(result.exitWithError).toBe(true);
});
-});
\ No newline at end of file
+
+it('skips prompt when type is none and isCiMode is true', async () => {
+ vi.mocked(determineComparisonFile).mockResolvedValue({ type: 'none' });
+
+ const result = await scanUsage({ ...baseOpts, isCiMode: true });
+
+ expect(promptNoEnvScenario).not.toHaveBeenCalled();
+ expect(result.exitWithError).toBe(false);
+});
+
+it('skips prompt when type is none and json is true', async () => {
+ vi.mocked(determineComparisonFile).mockResolvedValue({ type: 'none' });
+
+ const result = await scanUsage({ ...baseOpts, json: true });
+
+ expect(promptNoEnvScenario).not.toHaveBeenCalled();
+ expect(result.exitWithError).toBe(false);
+});
+
+ it('calls promptNoEnvScenario when type is none and not CI/json', async () => {
+ vi.mocked(promptNoEnvScenario).mockResolvedValue({
+ compareFile: { path: '/env/.env', name: '.env' },
+ });
+ vi.mocked(determineComparisonFile).mockResolvedValue({ type: 'none' });
+ vi.mocked(processComparisonFile).mockReturnValue({
+ scanResult: { ...baseScanResult },
+ comparedAgainst: '.env',
+ fix: { fixApplied: false, removedDuplicates: [], addedEnv: [], gitignoreUpdated: false },
+ } as any);
+
+ await scanUsage({ ...baseOpts, isCiMode: false, json: false });
+
+ expect(promptNoEnvScenario).toHaveBeenCalled();
+ expect(processComparisonFile).toHaveBeenCalled();
+});
+
+ it('sets frameworkWarnings on scanResult when frameworkValidator returns results', async () => {
+ const { frameworkValidator } =
+ await import('../../../src/core/frameworks/frameworkValidator.js');
+ vi.mocked(frameworkValidator).mockReturnValue([
+ {
+ variable: 'API_KEY',
+ reason: 'exposed',
+ file: 'app.ts',
+ line: 1,
+ framework: 'nextjs',
+ },
+ ]);
+ vi.mocked(determineComparisonFile).mockResolvedValue({ type: 'none' });
+
+ await scanUsage({ ...baseOpts, isCiMode: true });
+
+ expect(printScanResult).toHaveBeenCalledWith(
+ expect.objectContaining({
+ frameworkWarnings: expect.arrayContaining([
+ expect.objectContaining({ variable: 'API_KEY' }),
+ ]),
+ }),
+ expect.anything(),
+ expect.anything(),
+ expect.anything(),
+ );
+ });
+
+ it('continues when comparison error has shouldExit false', async () => {
+ vi.mocked(determineComparisonFile).mockResolvedValue({
+ type: 'found',
+ file: { path: '/env/.env', name: '.env' },
+ });
+ vi.mocked(processComparisonFile).mockReturnValue({
+ error: { message: 'soft error', shouldExit: false },
+ } as any);
+ vi.mocked(printComparisonError).mockReturnValue({ exit: false });
+
+ const result = await scanUsage(baseOpts);
+
+ expect(result.exitWithError).toBe(false);
+ });
+
+ it('transfers uppercaseWarnings, expireWarnings and inconsistentNamingWarnings to scanResult', async () => {
+ vi.mocked(determineComparisonFile).mockResolvedValue({
+ type: 'found',
+ file: { path: '/env/.env', name: '.env' },
+ });
+ vi.mocked(processComparisonFile).mockReturnValue({
+ scanResult: { ...baseScanResult },
+ comparedAgainst: '.env',
+ fix: {
+ fixApplied: false,
+ removedDuplicates: [],
+ addedEnv: [],
+ gitignoreUpdated: false,
+ },
+ uppercaseWarnings: [{ key: 'myKey', suggestion: 'MY_KEY' }],
+ expireWarnings: [{ key: 'OLD_KEY', date: '2024-01-01', daysLeft: -10 }],
+ inconsistentNamingWarnings: [{ key1: 'A', key2: 'B', suggestion: 'A_B' }],
+ } as any);
+
+ await scanUsage(baseOpts);
+
+ expect(printScanResult).toHaveBeenCalledWith(
+ expect.objectContaining({
+ uppercaseWarnings: expect.arrayContaining([
+ expect.objectContaining({ key: 'myKey' }),
+ ]),
+ expireWarnings: expect.arrayContaining([
+ expect.objectContaining({ key: 'OLD_KEY' }),
+ ]),
+ inconsistentNamingWarnings: expect.arrayContaining([
+ expect.objectContaining({ key1: 'A' }),
+ ]),
+ }),
+ expect.anything(),
+ expect.anything(),
+ expect.anything(),
+ );
+ });
+
+ it('sets exampleWarnings when comparedAgainst matches DEFAULT_EXAMPLE_FILE', async () => {
+ const { DEFAULT_EXAMPLE_FILE } =
+ await import('../../../src/config/constants.js');
+
+ vi.mocked(determineComparisonFile).mockResolvedValue({
+ type: 'found',
+ file: { path: '/env/.env.example', name: DEFAULT_EXAMPLE_FILE },
+ });
+ vi.mocked(processComparisonFile).mockReturnValue({
+ scanResult: { ...baseScanResult },
+ comparedAgainst: DEFAULT_EXAMPLE_FILE,
+ exampleFull: { SECRET: 'abc123' },
+ fix: {
+ fixApplied: false,
+ removedDuplicates: [],
+ addedEnv: [],
+ gitignoreUpdated: false,
+ },
+ } as any);
+
+ await scanUsage(baseOpts);
+
+ const { detectSecretsInExample } =
+ await import('../../../src/core/security/exampleSecretDetector.js');
+ expect(detectSecretsInExample).toHaveBeenCalledWith({ SECRET: 'abc123' });
+ });
+
+ it('does not set exampleWarnings when comparedAgainst is not the default example file', async () => {
+ vi.mocked(determineComparisonFile).mockResolvedValue({
+ type: 'found',
+ file: { path: '/env/.env', name: '.env' },
+ });
+ vi.mocked(processComparisonFile).mockReturnValue({
+ scanResult: { ...baseScanResult },
+ comparedAgainst: '.env',
+ exampleFull: { SECRET: 'abc123' },
+ fix: {
+ fixApplied: false,
+ removedDuplicates: [],
+ addedEnv: [],
+ gitignoreUpdated: false,
+ },
+ } as any);
+
+ await scanUsage(baseOpts);
+
+ const { detectSecretsInExample } =
+ await import('../../../src/core/security/exampleSecretDetector.js');
+ expect(detectSecretsInExample).not.toHaveBeenCalled();
+ });
+
+ it('filters out commented usages before processing', async () => {
+ vi.mocked(scanCodebase).mockResolvedValue({
+ ...baseScanResult,
+ used: [
+ {
+ variable: 'A',
+ file: 'f.ts',
+ line: 1,
+ column: 0,
+ pattern: 'process.env',
+ context: '// process.env.A',
+ },
+ {
+ variable: 'B',
+ file: 'f.ts',
+ line: 2,
+ column: 0,
+ pattern: 'process.env',
+ context: 'process.env.B',
+ },
+ ],
+ });
+ vi.mocked(determineComparisonFile).mockResolvedValue({ type: 'none' });
+
+ await scanUsage({ ...baseOpts, isCiMode: true });
+
+ expect(printScanResult).toHaveBeenCalledWith(
+ expect.objectContaining({
+ used: expect.arrayContaining([
+ expect.objectContaining({ variable: 'B' }),
+ ]),
+ }),
+ expect.anything(),
+ expect.anything(),
+ expect.anything(),
+ );
+ expect(printScanResult).toHaveBeenCalledWith(
+ expect.objectContaining({
+ used: expect.not.arrayContaining([
+ expect.objectContaining({ variable: 'A' }),
+ ]),
+ }),
+ expect.anything(),
+ expect.anything(),
+ expect.anything(),
+ );
+ });
+
+ it('returns exitWithError true in JSON strict mode for each warning type', async () => {
+ const cases: Partial[] = [
+ { duplicates: { env: [{ key: 'A', count: 2 }] } },
+ { duplicates: { example: [{ key: 'B', count: 2 }] } },
+ {
+ secrets: [
+ {
+ file: 'f.ts',
+ line: 1,
+ kind: 'pattern',
+ message: 'x',
+ snippet: 'y',
+ severity: 'low',
+ },
+ ],
+ },
+ {
+ frameworkWarnings: [
+ {
+ variable: 'A',
+ reason: 'r',
+ file: 'f.ts',
+ line: 1,
+ framework: 'nextjs',
+ },
+ ],
+ },
+ {
+ logged: [
+ {
+ variable: 'A',
+ file: 'f.ts',
+ line: 1,
+ column: 0,
+ pattern: 'process.env',
+ context: '',
+ },
+ ],
+ },
+ { uppercaseWarnings: [{ key: 'a', suggestion: 'A' }] },
+ { expireWarnings: [{ key: 'K', date: '2020-01-01', daysLeft: -100 }] },
+ {
+ inconsistentNamingWarnings: [
+ { key1: 'A', key2: 'B', suggestion: 'A_B' },
+ ],
+ },
+ ];
+
+ for (const extra of cases) {
+ vi.mocked(scanCodebase).mockResolvedValue({
+ ...baseScanResult,
+ ...extra,
+ });
+
+ const result = await scanUsage({ ...baseOpts, json: true, strict: true });
+
+ expect(result.exitWithError).toBe(true);
+ }
+ });
+});
diff --git a/test/unit/core/scan/patterns.test.ts b/test/unit/core/scan/patterns.test.ts
index e1a7d8e..ddc18d4 100644
--- a/test/unit/core/scan/patterns.test.ts
+++ b/test/unit/core/scan/patterns.test.ts
@@ -19,33 +19,33 @@ describe('scanFile - Pattern Detection', () => {
it('detects standard dot notation: process.env.MY_KEY', () => {
const code = 'const val = process.env.MY_KEY;';
const result = scanFile('test.js', code, baseOpts);
-
+
expect(result).toHaveLength(1);
expect(result[0]).toMatchObject({
variable: 'MY_KEY',
- pattern: 'process.env'
+ pattern: 'process.env',
});
});
it('detects bracket notation with double quotes: process.env["MY_KEY"]', () => {
const code = 'const val = process.env["MY_KEY"];';
const result = scanFile('test.js', code, baseOpts);
-
+
expect(result).toHaveLength(1);
expect(result[0]).toMatchObject({
variable: 'MY_KEY',
- pattern: 'process.env'
+ pattern: 'process.env',
});
});
- it('detects bracket notation with single quotes: process.env[\'MY_KEY\']', () => {
+ it("detects bracket notation with single quotes: process.env['MY_KEY']", () => {
const code = "const val = process.env['MY_KEY'];";
const result = scanFile('test.js', code, baseOpts);
-
+
expect(result).toHaveLength(1);
expect(result[0]).toMatchObject({
variable: 'MY_KEY',
- pattern: 'process.env'
+ pattern: 'process.env',
});
});
});
@@ -54,42 +54,43 @@ describe('scanFile - Pattern Detection', () => {
it('detects simple destructuring: const { MY_KEY } = process.env', () => {
const code = 'const { MY_KEY } = process.env;';
const result = scanFile('test.js', code, baseOpts);
-
+
expect(result).toHaveLength(1);
expect(result[0]).toMatchObject({
variable: 'MY_KEY',
- pattern: 'process.env'
+ pattern: 'process.env',
});
});
it('detects aliased destructuring: const { MY_KEY: alias } = process.env', () => {
const code = 'const { MY_KEY: alias } = process.env;';
const result = scanFile('test.js', code, baseOpts);
-
+
expect(result).toHaveLength(1);
expect(result[0]).toMatchObject({
variable: 'MY_KEY',
- pattern: 'process.env'
+ pattern: 'process.env',
});
});
it('detects destructuring with default values: const { MY_KEY = "val" } = process.env', () => {
const code = 'const { MY_KEY = "default" } = process.env;';
const result = scanFile('test.js', code, baseOpts);
-
+
expect(result).toHaveLength(1);
expect(result[0]).toMatchObject({
variable: 'MY_KEY',
- pattern: 'process.env'
+ pattern: 'process.env',
});
});
it('detects multiple mixed destructuring', () => {
- const code = 'const { MY_KEY, OTHER_KEY: alias, THIRD_KEY = "fallback" } = process.env;';
+ const code =
+ 'const { MY_KEY, OTHER_KEY: alias, THIRD_KEY = "fallback" } = process.env;';
const result = scanFile('test.js', code, baseOpts);
-
+
expect(result).toHaveLength(3);
- const variables = result.map(u => u.variable).sort();
+ const variables = result.map((u) => u.variable).sort();
expect(variables).toEqual(['MY_KEY', 'OTHER_KEY', 'THIRD_KEY']);
});
@@ -101,11 +102,26 @@ describe('scanFile - Pattern Detection', () => {
} = process.env;
`;
const result = scanFile('test.js', code, baseOpts);
-
+
expect(result).toHaveLength(2);
- const variables = result.map(u => u.variable).sort();
+ const variables = result.map((u) => u.variable).sort();
expect(variables).toEqual(['MY_KEY', 'OTHER_KEY']);
});
+
+ it('handles empty destructuring gracefully', () => {
+ const code = 'const {} = process.env;';
+ const result = scanFile('test.js', code, baseOpts);
+
+ expect(result).toHaveLength(0);
+ });
+
+ it('handles complex whitespace and empty parts in destructuring', () => {
+ const code = 'const { KEY_1, , KEY_2 } = process.env;';
+ const result = scanFile('test.js', code, baseOpts);
+
+ expect(result).toHaveLength(2);
+ expect(result.map((r) => r.variable).sort()).toEqual(['KEY_1', 'KEY_2']);
+ });
});
describe('import.meta.env Dot and Bracket Notation', () => {
@@ -116,7 +132,7 @@ describe('scanFile - Pattern Detection', () => {
expect(result).toHaveLength(1);
expect(result[0]).toMatchObject({
variable: 'MY_KEY',
- pattern: 'import.meta.env'
+ pattern: 'import.meta.env',
});
});
@@ -127,18 +143,18 @@ describe('scanFile - Pattern Detection', () => {
expect(result).toHaveLength(1);
expect(result[0]).toMatchObject({
variable: 'MY_KEY',
- pattern: 'import.meta.env'
+ pattern: 'import.meta.env',
});
});
- it('detects bracket notation with single quotes: import.meta.env[\'MY_KEY\']', () => {
+ it("detects bracket notation with single quotes: import.meta.env['MY_KEY']", () => {
const code = "const val = import.meta.env['MY_KEY'];";
const result = scanFile('test.js', code, baseOpts);
expect(result).toHaveLength(1);
expect(result[0]).toMatchObject({
variable: 'MY_KEY',
- pattern: 'import.meta.env'
+ pattern: 'import.meta.env',
});
});
});
diff --git a/test/unit/services/processComparisonFile.test.ts b/test/unit/services/processComparisonFile.test.ts
index 41c9750..247465f 100644
--- a/test/unit/services/processComparisonFile.test.ts
+++ b/test/unit/services/processComparisonFile.test.ts
@@ -58,6 +58,7 @@ vi.mock('../../../src/core/detectInconsistentNaming.js', () => ({
import { processComparisonFile } from '../../../src/services/processComparisonFile.js';
import { applyFixes } from '../../../src/core/fixEnv.js';
+import { parseEnvFile } from '../../../src/core/parseEnv.js';
describe('processComparisonFile', () => {
const baseScanResult: ScanResult = {
@@ -83,6 +84,7 @@ describe('processComparisonFile', () => {
const baseOpts: ScanUsageOptions = {
cwd: '/root',
+ examplePath: '.env.example',
include: [],
exclude: [],
ignore: [],
@@ -166,4 +168,104 @@ describe('processComparisonFile', () => {
expect(result.scanResult.duplicates?.env).toBeDefined();
});
+
+ it('Will Load .env.example trough examplePath', () => {
+ const exampleFile: ComparisonFile = {
+ path: '/env/.env.example',
+ name: '.env.example',
+ };
+ const result = processComparisonFile(baseScanResult, exampleFile, {
+ ...baseOpts,
+ examplePath: '.env.example',
+ });
+
+ expect(result.comparedAgainst).toBe('.env.example');
+ });
+
+ it('returns error result when file cannot be read', () => {
+ vi.mocked(parseEnvFile).mockImplementationOnce(() => {
+ throw new Error('ENOENT: no such file or directory');
+ });
+
+ const result = processComparisonFile(baseScanResult, compareFile, baseOpts);
+
+ expect(result.error).toBeDefined();
+ expect(result.error?.message).toContain('Could not read .env');
+ expect(result.error?.shouldExit).toBe(false);
+ });
+
+ it('sets shouldExit true on error when isCiMode is enabled', () => {
+ vi.mocked(parseEnvFile).mockImplementationOnce(() => {
+ throw new Error('ENOENT: no such file or directory');
+ });
+
+ const result = processComparisonFile(baseScanResult, compareFile, {
+ ...baseOpts,
+ isCiMode: true,
+ });
+
+ expect(result.error?.shouldExit).toBe(true);
+ });
+
+ it('works without examplePath option', () => {
+ const opts: ScanUsageOptions = { ...baseOpts, examplePath: undefined };
+
+ const result = processComparisonFile(baseScanResult, compareFile, opts);
+
+ expect(result.error).toBeUndefined();
+ expect(result.exampleFull).toBeUndefined();
+ });
+
+ it('skips example duplicate check when examplePath equals compareFile path', async () => {
+ // resolveFromCwd returns the compareFile path → same file → skip
+ const { resolveFromCwd } =
+ await import('../../../src/core/helpers/resolveFromCwd.js');
+ vi.mocked(resolveFromCwd).mockReturnValue(compareFile.path);
+
+ const result = processComparisonFile(baseScanResult, compareFile, {
+ ...baseOpts,
+ allowDuplicates: false,
+ examplePath: '.env.example',
+ });
+
+ // dupsEnv still found, but dupsEx should be empty because same file
+ expect(result.dupsEx).toHaveLength(0);
+ });
+
+ it('does not clear state when fix returns changed=false', () => {
+ vi.mocked(applyFixes).mockReturnValueOnce({
+ changed: false,
+ result: {
+ removedDuplicates: [],
+ addedEnv: [],
+ gitignoreUpdated: false,
+ },
+ });
+
+ const result = processComparisonFile(baseScanResult, compareFile, {
+ ...baseOpts,
+ fix: true,
+ allowDuplicates: false,
+ });
+
+ expect(result.fix.fixApplied).toBe(false);
+ // duplicates should still be present on scanResult
+ expect(result.scanResult.duplicates?.env).toBeDefined();
+ });
+
+ it('initialises scanResult.duplicates if it was undefined before writing', () => {
+ const scanWithNoDuplicates: ScanResult = {
+ ...baseScanResult,
+ duplicates: undefined as any,
+ };
+
+ const result = processComparisonFile(scanWithNoDuplicates, compareFile, {
+ ...baseOpts,
+ allowDuplicates: false,
+ fix: false,
+ });
+
+ expect(result.scanResult.duplicates).toBeDefined();
+ expect(result.scanResult.duplicates?.env).toBeDefined();
+ });
});
diff --git a/vitest.config.ts b/vitest.config.ts
index 009f5a2..c2f4e0f 100644
--- a/vitest.config.ts
+++ b/vitest.config.ts
@@ -7,7 +7,7 @@ export default defineConfig({
hookTimeout: 50000,
coverage: {
provider: 'v8',
- reporter: ['text', 'lcov', 'html'],
+ reporter: ['text', 'lcov', 'json-summary'],
include: ['src/**/*.{ts,tsx}'],
reportsDirectory: './coverage',
},