diff --git a/src/cli/run.ts b/src/cli/run.ts index 8e84346..f8f8847 100644 --- a/src/cli/run.ts +++ b/src/cli/run.ts @@ -4,7 +4,7 @@ import path from 'path'; import { normalizeOptions } from '../config/options.js'; import { discoverEnvFiles } from '../services/envDiscovery.js'; import { envPairing } from '../services/envPairing.js'; -import { ensureFilesOrPrompt } from '../commands/ensureFilesOrPrompt.js'; +import { promptEnsureFiles } from '../commands/prompts/promptEnsureFiles.js'; import { compareMany } from '../commands/compare.js'; import type { CompareJsonEntry, @@ -78,6 +78,7 @@ async function runScanMode(opts: Options): Promise { showUnused: opts.showUnused, showStats: opts.showStats, isCiMode: opts.isCiMode, + isYesMode: opts.isYesMode, secrets: opts.secrets, strict: opts.strict, ignoreUrls: opts.ignoreUrls, @@ -164,7 +165,7 @@ async function runAutoDiscoveryComparison(opts: Options): Promise { }); // Ensure required files exist or prompt to create them - const initResult = await ensureFilesOrPrompt({ + const initResult = await promptEnsureFiles({ cwd: discovery.cwd, primaryEnv: discovery.primaryEnv, primaryExample: discovery.primaryExample, @@ -211,7 +212,7 @@ async function handleMissingFiles( return { shouldExit: true, exitWithError: true }; } else { // Interactive mode - try to prompt for file creation - const result = await ensureFilesOrPrompt({ + const result = await promptEnsureFiles({ cwd: opts.cwd, primaryEnv: envFlag, primaryExample: exampleFlag, diff --git a/src/commands/ensureFilesOrPrompt.ts b/src/commands/prompts/promptEnsureFiles.ts similarity index 92% rename from src/commands/ensureFilesOrPrompt.ts rename to src/commands/prompts/promptEnsureFiles.ts index e45347c..6a64fb6 100644 --- a/src/commands/ensureFilesOrPrompt.ts +++ b/src/commands/prompts/promptEnsureFiles.ts @@ -1,9 +1,9 @@ import fs from 'fs'; import path from 'path'; -import { confirmYesNo } from '../ui/prompts.js'; -import { warnIfEnvNotIgnored } from '../services/git.js'; -import { printPrompt } from '../ui/compare/printPrompt.js'; -import { DEFAULT_ENV_FILE } from '../config/constants.js'; +import { confirmYesNo } from '../../ui/prompts.js'; +import { warnIfEnvNotIgnored } from '../../services/git.js'; +import { printPrompt } from '../../ui/compare/printPrompt.js'; +import { DEFAULT_ENV_FILE } from '../../config/constants.js'; /** * Result of ensureFilesOrPrompt function @@ -31,7 +31,7 @@ interface EnsureFilesArgs { * @param args - The arguments for the function. * @returns An object indicating whether a file was created or if the process should exit. */ -export async function ensureFilesOrPrompt( +export async function promptEnsureFiles( args: EnsureFilesArgs, ): Promise { const { cwd, primaryEnv, primaryExample, isYesMode, isCiMode } = args; diff --git a/src/commands/prompts/promptNoEnvScenario.ts b/src/commands/prompts/promptNoEnvScenario.ts new file mode 100644 index 0000000..0e243f2 --- /dev/null +++ b/src/commands/prompts/promptNoEnvScenario.ts @@ -0,0 +1,43 @@ +import fs from 'fs'; +import path from 'path'; +import { confirmYesNo } from '../../ui/prompts.js'; +import { DEFAULT_ENV_FILE } from '../../config/constants.js'; +import type { ScanUsageOptions, ComparisonFile } from '../../config/types.js'; + +/** + * Prompts the user to create a .env file if none are found in scan usage + * @param opts - Scan configuration options + * @returns The path and name of the created .env file, or undefined if none created + */ +export async function promptNoEnvScenario( + opts: ScanUsageOptions, +): Promise<{ compareFile: ComparisonFile | undefined }> { + if (opts.isCiMode) { + return { compareFile: undefined }; + } + console.log(); + const shouldCreate = opts.isYesMode + ? true + : await confirmYesNo( + "You don't have any .env files. Do you want to create a .env?", + { + isCiMode: opts.isCiMode ?? false, + isYesMode: opts.isYesMode ?? false, + }, + ); + + if (!shouldCreate) { + return { compareFile: undefined }; + } + + const envPath = path.resolve(opts.cwd, DEFAULT_ENV_FILE); + + fs.writeFileSync(envPath, ''); + + return { + compareFile: { + path: envPath, + name: DEFAULT_ENV_FILE, + }, + }; +} diff --git a/src/commands/scanUsage.ts b/src/commands/scanUsage.ts index 07c2051..e335bc5 100644 --- a/src/commands/scanUsage.ts +++ b/src/commands/scanUsage.ts @@ -4,6 +4,7 @@ import type { EnvUsage, ExitResult, ScanResult, + ComparisonFile, } from '../config/types.js'; import { determineComparisonFile } from '../core/scan/determineComparisonFile.js'; import { printScanResult } from '../services/printScanResult.js'; @@ -15,6 +16,7 @@ import { hasIgnoreComment } from '../core/security/secretDetectors.js'; import { frameworkValidator } from '../core/frameworks/frameworkValidator.js'; import { detectSecretsInExample } from '../core/security/exampleSecretDetector.js'; import { DEFAULT_EXAMPLE_FILE } from '../config/constants.js'; +import { promptNoEnvScenario } from './prompts/promptNoEnvScenario.js'; /** * Scans the codebase for environment variable usage and compares it with @@ -62,9 +64,8 @@ export async function scanUsage(opts: ScanUsageOptions): Promise { } // Determine which file to compare against - const compareFile = determineComparisonFile(opts); + const comparisonResolution = await determineComparisonFile(opts); let comparedAgainst = ''; - let duplicatesFound = false; // Store fix information for consolidated display let fixApplied = false; @@ -72,6 +73,20 @@ export async function scanUsage(opts: ScanUsageOptions): Promise { let removedDuplicates: string[] = []; let gitignoreUpdated = false; + // Handle no env file found scenario + let compareFile: ComparisonFile | undefined; + + if (comparisonResolution.type === 'found') { + compareFile = comparisonResolution.file; + } else if ( + comparisonResolution.type === 'none' && + !opts.isCiMode && + !opts.json + ) { + const promptResult = await promptNoEnvScenario(opts); + compareFile = promptResult.compareFile; + } + // If comparing against a file, process it // fx: if the scan is comparing against .env.example, it will check for missing keys there if (compareFile) { @@ -87,9 +102,6 @@ export async function scanUsage(opts: ScanUsageOptions): Promise { } else { scanResult = result.scanResult; comparedAgainst = result.comparedAgainst; - if (result.duplicatesFound) { - duplicatesFound = result.duplicatesFound; - } fixApplied = result.fixApplied; removedDuplicates = result.removedDuplicates; fixedKeys = result.addedEnv; @@ -104,7 +116,10 @@ export async function scanUsage(opts: ScanUsageOptions): Promise { scanResult.inconsistentNamingWarnings = result.inconsistentNamingWarnings; } - if (result.exampleFull && result.comparedAgainst === DEFAULT_EXAMPLE_FILE) { + if ( + result.exampleFull && + result.comparedAgainst === DEFAULT_EXAMPLE_FILE + ) { scanResult.exampleWarnings = detectSecretsInExample(result.exampleFull); } } diff --git a/src/config/types.ts b/src/config/types.ts index ae7c630..cdb6b0f 100644 --- a/src/config/types.ts +++ b/src/config/types.ts @@ -171,6 +171,7 @@ export interface ScanUsageOptions extends ScanOptions { showUnused?: boolean; showStats?: boolean; isCiMode?: boolean; + isYesMode?: boolean; allowDuplicates?: boolean; strict?: boolean; uppercaseKeys?: boolean; @@ -341,3 +342,11 @@ export interface Discovery { exampleFlag: string | null; alreadyWarnedMissingEnv: boolean; } + +/** + * Resolved comparison file with absolute path and display name. + */ +export interface ComparisonFile { + path: string; + name: string; +}; diff --git a/src/core/scan/determineComparisonFile.ts b/src/core/scan/determineComparisonFile.ts index d7cf05c..9d68b73 100644 --- a/src/core/scan/determineComparisonFile.ts +++ b/src/core/scan/determineComparisonFile.ts @@ -1,38 +1,44 @@ import fs from 'fs'; import path from 'path'; -import type { ScanUsageOptions } from '../../config/types.js'; +import type { ScanUsageOptions, ComparisonFile } from '../../config/types.js'; import { resolveFromCwd } from '../helpers/resolveFromCwd.js'; import { DEFAULT_ENV_CANDIDATES } from '../../config/constants.js'; +import { normalizePath } from '../helpers/normalizePath.js'; /** - * Resolved comparison file with absolute path and display name. + * Result of determining the comparison file, either found with details or none */ -type ComparisonFile = { - path: string; - name: string; -}; +type ComparisonResolution = + | { type: 'found'; file: ComparisonFile } + | { type: 'none' }; /** * Determines which file to use for comparison based on provided options * @param {ScanUsageOptions} opts - Scan configuration options * @returns Comparison file info with absolute path and basename, or undefined if not found */ -export function determineComparisonFile( +export async function determineComparisonFile( opts: ScanUsageOptions, -): ComparisonFile | undefined { +): Promise { // Priority: explicit flags first, then auto-discovery if (opts.examplePath) { const p = resolveFromCwd(opts.cwd, opts.examplePath); if (fs.existsSync(p)) { - return { path: p, name: path.basename(opts.examplePath) }; + return { + type: 'found', + file: { path: normalizePath(p), name: path.basename(opts.examplePath) }, + }; } } if (opts.envPath) { const p = resolveFromCwd(opts.cwd, opts.envPath); if (fs.existsSync(p)) { - return { path: p, name: path.basename(opts.envPath) }; + return { + type: 'found', + file: { path: normalizePath(p), name: path.basename(opts.envPath) }, + }; } } @@ -40,9 +46,12 @@ export function determineComparisonFile( for (const candidate of DEFAULT_ENV_CANDIDATES) { const fullPath = path.resolve(opts.cwd, candidate); if (fs.existsSync(fullPath)) { - return { path: fullPath, name: candidate }; + return { + type: 'found', + file: { path: normalizePath(fullPath), name: candidate }, + }; } } - return undefined; + return { type: 'none' }; } diff --git a/test/e2e/cli.autoscan.e2e.test.ts b/test/e2e/cli.autoscan.e2e.test.ts index 8f7be78..31bd74f 100644 --- a/test/e2e/cli.autoscan.e2e.test.ts +++ b/test/e2e/cli.autoscan.e2e.test.ts @@ -342,6 +342,7 @@ describe('no-flag autoscan', () => { const cwd = tmpDir(); fs.mkdirSync(path.join(cwd, 'src'), { recursive: true }); + fs.writeFileSync(path.join(cwd, '.env'), 'API_KEY=12345\n'); fs.writeFileSync( path.join(cwd, 'src', 'index.ts'), `const apiKey = process.env.API_KEY;`, @@ -376,3 +377,93 @@ describe('no-flag autoscan', () => { expect(res.stdout.trim().startsWith('{')).toBe(true); }); }); + +describe('It will prompt to ask to create .env file is no .env files are found', () => { + it('should create .env file automatically with --yes flag when none found', () => { + const cwd = tmpDir(); + + fs.mkdirSync(path.join(cwd, 'src'), { recursive: true }); + fs.writeFileSync( + path.join(cwd, 'src', 'index.ts'), + `const apiKey = process.env.API_KEY;`, + ); + + const res = runCli(cwd, ['--yes']); + + expect(res.status).toBe(1); // fails because API_KEY is missing + expect(fs.existsSync(path.join(cwd, '.env'))).toBe(true); + }); + + it('should not prompt in CI mode when no .env file found', () => { + const cwd = tmpDir(); + + fs.mkdirSync(path.join(cwd, 'src'), { recursive: true }); + fs.writeFileSync( + path.join(cwd, 'src', 'index.ts'), + `const apiKey = process.env.API_KEY;`, + ); + + const res = runCli(cwd, ['--ci']); + + expect(res.status).toBe(0); + expect(res.stdout).not.toContain('Do you want to create a .env?'); + expect(res.stdout).not.toContain('Created empty .env'); + expect(fs.existsSync(path.join(cwd, '.env'))).toBe(false); + }); + + it('should not create .env if already exists', () => { + const cwd = tmpDir(); + + fs.mkdirSync(path.join(cwd, 'src'), { recursive: true }); + fs.writeFileSync(path.join(cwd, '.env'), 'EXISTING_KEY=value\n'); + fs.writeFileSync( + path.join(cwd, 'src', 'index.ts'), + `const apiKey = process.env.API_KEY;`, + ); + + const res = runCli(cwd, ['--yes']); + + expect(res.status).toBe(1); + expect(res.stdout).not.toContain('Created empty .env'); + const envContent = fs.readFileSync(path.join(cwd, '.env'), 'utf-8'); + expect(envContent).toBe('EXISTING_KEY=value\n'); + }); + + it('should not create a new .env file when --yes and --json flags are used together', () => { + const cwd = tmpDir(); + + fs.mkdirSync(path.join(cwd, 'src'), { recursive: true }); + fs.writeFileSync( + path.join(cwd, 'src', 'index.ts'), + `const apiKey = process.env.API_KEY;`, + ); + + const res = runCli(cwd, ['--yes', '--json']); + + expect(res.status).toBe(0); + expect(res.stdout).not.toContain('Created empty .env'); + expect(fs.existsSync(path.join(cwd, '.env'))).toBe(false); + + // Should still output JSON + const lines = res.stdout.split('\n'); + const jsonLine = lines.find(line => line.trim().startsWith('{')); + expect(jsonLine).toBeDefined(); + }); + + it('should create .env in correct working directory', () => { + const cwd = tmpDir(); + const subdir = path.join(cwd, 'subfolder'); + + fs.mkdirSync(path.join(subdir, 'src'), { recursive: true }); + fs.writeFileSync( + path.join(subdir, 'src', 'index.ts'), + `const apiKey = process.env.API_KEY;`, + ); + + const res = runCli(subdir, ['--yes']); + + expect(res.status).toBe(1); // fails because API_KEY is missing + expect(fs.existsSync(path.join(subdir, '.env'))).toBe(true); + expect(fs.existsSync(path.join(cwd, '.env'))).toBe(false); + }); +}); diff --git a/test/unit/commands/ensureFilesOrPrompt.test.ts b/test/unit/commands/ensureFilesOrPrompt.test.ts index 6280486..d03c07b 100644 --- a/test/unit/commands/ensureFilesOrPrompt.test.ts +++ b/test/unit/commands/ensureFilesOrPrompt.test.ts @@ -2,7 +2,7 @@ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; import fs from 'fs'; import path from 'path'; import os from 'os'; -import { ensureFilesOrPrompt } from '../../../src/commands/ensureFilesOrPrompt.js'; +import { promptEnsureFiles } from '../../../src/commands/prompts/promptEnsureFiles.js'; // ---- mocks ---- vi.mock('../../../src/ui/prompts.js', () => ({ @@ -26,7 +26,7 @@ vi.mock('../../../src/services/git.js', () => ({ import { confirmYesNo } from '../../../src/ui/prompts.js'; import { printPrompt } from '../../../src/ui/compare/printPrompt.js'; -describe('ensureFilesOrPrompt', () => { +describe('promptEnsureFiles', () => { let cwd: string; beforeEach(() => { @@ -43,7 +43,7 @@ describe('ensureFilesOrPrompt', () => { (confirmYesNo as any).mockResolvedValue(true); - await ensureFilesOrPrompt({ + await promptEnsureFiles({ cwd, primaryEnv: '.env', primaryExample: '.env.example', @@ -59,7 +59,7 @@ describe('ensureFilesOrPrompt', () => { }); it('exits when neither .env nor .env.example exists', async () => { - const result = await ensureFilesOrPrompt({ + const result = await promptEnsureFiles({ cwd, primaryEnv: '.env', primaryExample: '.env.example', @@ -80,7 +80,7 @@ describe('ensureFilesOrPrompt', () => { (confirmYesNo as any).mockResolvedValue(false); - const result = await ensureFilesOrPrompt({ + const result = await promptEnsureFiles({ cwd, primaryEnv: '.env', primaryExample: '.env.example', @@ -96,7 +96,7 @@ describe('ensureFilesOrPrompt', () => { it('does not exit when another .env* file exists', async () => { fs.writeFileSync(path.join(cwd, '.env.local'), 'FOO=bar'); - const result = await ensureFilesOrPrompt({ + const result = await promptEnsureFiles({ cwd, primaryEnv: '.env', primaryExample: '.env.example', @@ -113,7 +113,7 @@ describe('ensureFilesOrPrompt', () => { (confirmYesNo as any).mockResolvedValue(false); - const result = await ensureFilesOrPrompt({ + const result = await promptEnsureFiles({ cwd, primaryEnv: '.env', primaryExample: '.env.example', @@ -133,7 +133,7 @@ describe('ensureFilesOrPrompt', () => { it('does not create .env in CI mode when missing', async () => { fs.writeFileSync(path.join(cwd, '.env.example'), 'FOO=bar'); - const result = await ensureFilesOrPrompt({ + const result = await promptEnsureFiles({ cwd, primaryEnv: '.env', primaryExample: '.env.example', @@ -153,7 +153,7 @@ describe('ensureFilesOrPrompt', () => { it('does not warn again if alreadyWarnedMissingEnv is true', async () => { fs.writeFileSync(path.join(cwd, '.env.example'), 'FOO=bar'); - await ensureFilesOrPrompt({ + await promptEnsureFiles({ cwd, primaryEnv: '.env', primaryExample: '.env.example', @@ -168,7 +168,7 @@ describe('ensureFilesOrPrompt', () => { it('does not warn again if alreadyWarnedMissingEnv is true for .env.example', async () => { fs.writeFileSync(path.join(cwd, '.env'), 'FOO=bar'); - const result = await ensureFilesOrPrompt({ + const result = await promptEnsureFiles({ cwd, primaryEnv: '.env', primaryExample: '.env.example', @@ -188,7 +188,7 @@ describe('ensureFilesOrPrompt', () => { it('does not create .env.example in CI mode when missing', async () => { fs.writeFileSync(path.join(cwd, '.env'), 'FOO=bar'); - const result = await ensureFilesOrPrompt({ + const result = await promptEnsureFiles({ cwd, primaryEnv: '.env', primaryExample: '.env.example', @@ -208,7 +208,7 @@ describe('ensureFilesOrPrompt', () => { it('creates .env from .env.example when --yes is set', async () => { fs.writeFileSync(path.join(cwd, '.env.example'), 'FOO=bar'); - const result = await ensureFilesOrPrompt({ + const result = await promptEnsureFiles({ cwd, primaryEnv: '.env', primaryExample: '.env.example', @@ -234,7 +234,7 @@ BAZ=123 `, ); - const result = await ensureFilesOrPrompt({ + const result = await promptEnsureFiles({ cwd, primaryEnv: '.env', primaryExample: '.env.example', diff --git a/test/unit/commands/prompts/promptNoEnvScenario.test.ts b/test/unit/commands/prompts/promptNoEnvScenario.test.ts new file mode 100644 index 0000000..e0b4f1e --- /dev/null +++ b/test/unit/commands/prompts/promptNoEnvScenario.test.ts @@ -0,0 +1,150 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { promptNoEnvScenario } from '../../../../src/commands/prompts/promptNoEnvScenario.js'; +import type { ScanUsageOptions } from '../../../../src/config/types.js'; +import * as prompts from '../../../../src/ui/prompts.js'; +import fs from 'fs'; +import path from 'path'; + +vi.mock('fs'); +vi.mock('../../../../src/ui/prompts.js'); + +describe('promptNoEnvScenario', () => { + const mockWriteFileSync = vi.mocked(fs.writeFileSync); + const mockConfirmYesNo = vi.mocked(prompts.confirmYesNo); + const mockConsoleLog = vi.spyOn(console, 'log'); + + const baseOpts: ScanUsageOptions = { + cwd: '/project', + include: [], + exclude: [], + ignore: [], + ignoreRegex: [], + secrets: false, + json: false, + }; + + beforeEach(() => { + mockWriteFileSync.mockClear(); + mockConfirmYesNo.mockClear(); + mockConsoleLog.mockClear(); + }); + + afterEach(() => { + vi.clearAllMocks(); + }); + + it('returns undefined compareFile when in CI mode', async () => { + const opts: ScanUsageOptions = { + ...baseOpts, + isCiMode: true, + }; + + const result = await promptNoEnvScenario(opts); + + expect(result).toEqual({ compareFile: undefined }); + expect(mockConfirmYesNo).not.toHaveBeenCalled(); + expect(mockWriteFileSync).not.toHaveBeenCalled(); + }); + + it('creates .env file automatically when in yes mode', async () => { + const opts: ScanUsageOptions = { + ...baseOpts, + isYesMode: true, + }; + + const result = await promptNoEnvScenario(opts); + + const expectedPath = path.resolve('/project', '.env'); + + expect(result).toEqual({ + compareFile: { + path: expectedPath, + name: '.env', + }, + }); + expect(mockConfirmYesNo).not.toHaveBeenCalled(); + expect(mockWriteFileSync).toHaveBeenCalledWith(expectedPath, ''); + }); + + it('prompts user and creates .env file when user confirms', async () => { + mockConfirmYesNo.mockResolvedValue(true); + + const opts: ScanUsageOptions = { + ...baseOpts, + isCiMode: false, + isYesMode: false, + }; + + const result = await promptNoEnvScenario(opts); + + const expectedPath = path.resolve('/project', '.env'); + + expect(mockConfirmYesNo).toHaveBeenCalledWith( + "You don't have any .env files. Do you want to create a .env?", + { isCiMode: false, isYesMode: false }, + ); + expect(result).toEqual({ + compareFile: { + path: expectedPath, + name: '.env', + }, + }); + expect(mockWriteFileSync).toHaveBeenCalledWith(expectedPath, ''); + }); + + it('returns undefined compareFile when user declines', async () => { + mockConfirmYesNo.mockResolvedValue(false); + + const opts: ScanUsageOptions = { + ...baseOpts, + isCiMode: false, + isYesMode: false, + }; + + const result = await promptNoEnvScenario(opts); + + expect(mockConfirmYesNo).toHaveBeenCalledWith( + "You don't have any .env files. Do you want to create a .env?", + { isCiMode: false, isYesMode: false }, + ); + expect(result).toEqual({ compareFile: undefined }); + expect(mockWriteFileSync).not.toHaveBeenCalled(); + }); + + it('handles different cwd paths correctly', async () => { + const opts: ScanUsageOptions = { + ...baseOpts, + cwd: '/custom/project/path', + isYesMode: true, + }; + + const result = await promptNoEnvScenario(opts); + + const expectedPath = path.resolve('/custom/project/path', '.env'); + + expect(result).toEqual({ + compareFile: { + path: expectedPath, + name: '.env', + }, + }); + expect(mockWriteFileSync).toHaveBeenCalledWith(expectedPath, ''); + }); + + it('defaults isCiMode and isYesMode to false when undefined in prompt call', async () => { + mockConfirmYesNo.mockResolvedValue(true); + + const opts: ScanUsageOptions = { + ...baseOpts, + isCiMode: undefined, + isYesMode: undefined, + }; + + await promptNoEnvScenario(opts); + + expect(mockConfirmYesNo).toHaveBeenCalledWith( + "You don't have any .env files. Do you want to create a .env?", + { isCiMode: false, isYesMode: false }, + ); + }); +}); diff --git a/test/unit/core/scan/determineComparisonFile.test.ts b/test/unit/core/scan/determineComparisonFile.test.ts index 9598d85..92753c0 100644 --- a/test/unit/core/scan/determineComparisonFile.test.ts +++ b/test/unit/core/scan/determineComparisonFile.test.ts @@ -1,7 +1,9 @@ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; import { determineComparisonFile } from '../../../../src/core/scan/determineComparisonFile.js'; import type { ScanUsageOptions } from '../../../../src/config/types.js'; +import { normalizePath } from '../../../../src/core/helpers/normalizePath.js'; import fs from 'fs'; +import path from 'path'; vi.mock('fs'); @@ -26,7 +28,8 @@ describe('determineComparisonFile', () => { vi.clearAllMocks(); }); - it('returns examplePath when it exists', () => { + it('returns examplePath when it exists', async () => { + const expectedPath = normalizePath(path.resolve('/project/.env.example')); mockExistsSync.mockReturnValue(true); const opts: ScanUsageOptions = { @@ -34,16 +37,20 @@ describe('determineComparisonFile', () => { examplePath: '.env.example', }; - const result = determineComparisonFile(opts); + const result = await determineComparisonFile(opts); expect(result).toEqual({ - path: '/project/.env.example', - name: '.env.example', + type: 'found', + file: { + path: expectedPath, + name: '.env.example', + }, }); - expect(mockExistsSync).toHaveBeenCalledWith('/project/.env.example'); + expect(mockExistsSync).toHaveBeenCalled(); }); - it('returns envPath when it exists and no examplePath provided', () => { + it('returns envPath when it exists and no examplePath provided', async () => { + const expectedPath = normalizePath(path.resolve('/project/.env.local')); mockExistsSync.mockReturnValue(true); const opts: ScanUsageOptions = { @@ -51,16 +58,20 @@ describe('determineComparisonFile', () => { envPath: '.env.local', }; - const result = determineComparisonFile(opts); + const result = await determineComparisonFile(opts); expect(result).toEqual({ - path: '/project/.env.local', - name: '.env.local', + type: 'found', + file: { + path: expectedPath, + name: '.env.local', + }, }); - expect(mockExistsSync).toHaveBeenCalledWith('/project/.env.local'); + expect(mockExistsSync).toHaveBeenCalled(); }); - it('prioritizes examplePath over envPath when both exist', () => { + it('prioritizes examplePath over envPath when both exist', async () => { + const expectedPath = normalizePath(path.resolve('/project/.env.example')); mockExistsSync.mockReturnValue(true); const opts: ScanUsageOptions = { @@ -69,18 +80,21 @@ describe('determineComparisonFile', () => { envPath: '.env', }; - const result = determineComparisonFile(opts); + const result = await determineComparisonFile(opts); expect(result).toEqual({ - path: '/project/.env.example', - name: '.env.example', + type: 'found', + file: { + path: expectedPath, + name: '.env.example', + }, }); - expect(mockExistsSync).toHaveBeenCalledWith('/project/.env.example'); - expect(mockExistsSync).not.toHaveBeenCalledWith('/project/.env'); + expect(mockExistsSync).toHaveBeenCalled(); }); - it('falls back to envPath when examplePath does not exist', () => { - mockExistsSync.mockImplementation((p) => p === '/project/.env'); + it('falls back to envPath when examplePath does not exist', async () => { + const expectedPath = path.resolve('/project/.env'); + mockExistsSync.mockImplementation((p) => p === expectedPath); const opts: ScanUsageOptions = { ...baseOpts, @@ -88,50 +102,62 @@ describe('determineComparisonFile', () => { envPath: '.env', }; - const result = determineComparisonFile(opts); + const result = await determineComparisonFile(opts); expect(result).toEqual({ - path: '/project/.env', - name: '.env', + type: 'found', + file: { + path: normalizePath(expectedPath), + name: '.env', + }, }); }); - it('falls back to auto-discovery when envPath does not exist', () => { - mockExistsSync.mockImplementation((p) => p === '/project/.env'); + it('falls back to auto-discovery when envPath does not exist', async () => { + const expectedPath = path.resolve('/project/.env'); + mockExistsSync.mockImplementation((p) => p === expectedPath); const opts: ScanUsageOptions = { ...baseOpts, envPath: '.env.missing', }; - const result = determineComparisonFile(opts); + const result = await determineComparisonFile(opts); expect(result).toEqual({ - path: '/project/.env', - name: '.env', + type: 'found', + file: { + path: normalizePath(expectedPath), + name: '.env', + }, }); }); - it('auto-discovers first available candidate when no paths provided', () => { - mockExistsSync.mockImplementation((p) => p === '/project/.env.local'); + it('auto-discovers first available candidate when no paths provided', async () => { + const expectedPath = path.resolve('/project/.env.local'); + mockExistsSync.mockImplementation((p) => p === expectedPath); - const result = determineComparisonFile(baseOpts); + const result = await determineComparisonFile(baseOpts); expect(result).toEqual({ - path: '/project/.env.local', - name: '.env.local', + type: 'found', + file: { + path: normalizePath(expectedPath), + name: '.env.local', + }, }); }); - it('returns undefined when no files exist', () => { + it('returns type none when no files exist', async () => { mockExistsSync.mockReturnValue(false); - const result = determineComparisonFile(baseOpts); + const result = await determineComparisonFile(baseOpts); - expect(result).toBeUndefined(); + expect(result).toEqual({ type: 'none' }); }); - it('resolves relative paths correctly', () => { + it('resolves relative paths correctly', async () => { + const expectedPath = normalizePath(path.resolve('/home/user/project/config/.env.example')); mockExistsSync.mockReturnValue(true); const opts: ScanUsageOptions = { @@ -140,28 +166,36 @@ describe('determineComparisonFile', () => { examplePath: 'config/.env.example', }; - const result = determineComparisonFile(opts); + const result = await determineComparisonFile(opts); expect(result).toEqual({ - path: '/home/user/project/config/.env.example', - name: '.env.example', + type: 'found', + file: { + path: expectedPath, + name: '.env.example', + }, }); }); - it('handles absolute paths correctly', () => { + it('handles absolute paths correctly', async () => { + const absolutePath = '/absolute/path/.env.example'; + const expectedPath = normalizePath(absolutePath); mockExistsSync.mockReturnValue(true); const opts: ScanUsageOptions = { ...baseOpts, - examplePath: '/absolute/path/.env.example', + examplePath: absolutePath, }; - const result = determineComparisonFile(opts); + const result = await determineComparisonFile(opts); expect(result).toEqual({ - path: '/absolute/path/.env.example', - name: '.env.example', + type: 'found', + file: { + path: expectedPath, + name: '.env.example', + }, }); - expect(mockExistsSync).toHaveBeenCalledWith('/absolute/path/.env.example'); + expect(mockExistsSync).toHaveBeenCalled(); }); });