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', },