import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest' import type { ChildProcess } from 'child_process' import { EventEmitter } from 'events' import { Readable } from 'stream' import { logger } from './src/logger.js' const mockSpawn = vi.fn() const mockExec = vi.fn() const mockExistsSync = vi.fn() const mockRealpathSync = vi.fn((path: string) => path) const mockMkdirSync = vi.fn() const mockRmSync = vi.fn() const mockReaddirSync = vi.fn(() => [] as string[]) const mockReadFileSync = vi.fn() const mockReaddir = vi.fn() const mockReadFile = vi.fn() const mockStat = vi.fn() const mockCreateReadStream = vi.fn() const mockAppendFile = vi.fn() const mockWriteFile = vi.fn() const mockMkdir = vi.fn() const mockRm = vi.fn() const mockInput = vi.fn() const mockConfirm = vi.fn() const mockCreateHttpServer = vi.fn() const mockFetch = vi.fn() const mockSpinner = { start: vi.fn().mockReturnThis(), succeed: vi.fn().mockReturnThis(), fail: vi.fn().mockReturnThis(), stop: vi.fn().mockReturnThis(), } const mockOra = vi.fn().mockReturnValue(mockSpinner) function expectNpmDevInstalls( mockSpawn: ReturnType, cwd: string, screenciVersion = '0.0.32', includePlaywrightCli = true ) { const npmInstallCalls = mockSpawn.mock.calls.filter( (call: unknown[]) => call[0] === 'npm' && Array.isArray(call[1]) && call[1][0] === 'install' && call[1][1] === '--save-dev' && call[2] && typeof call[2] === 'object' && 'cwd' in (call[2] as Record) && (call[2] as { cwd?: string }).cwd === cwd && 'stdio' in (call[2] as Record) && (call[2] as { stdio?: string }).stdio === 'pipe' ) const expectedPackages = [ `@playwright/test@^1.59.0`, `screenci@${screenciVersion}`, '@types/node@^25.9.1', ...(includePlaywrightCli ? ['@playwright/cli@latest'] : []), ] expect(npmInstallCalls).toEqual( expect.arrayContaining( expectedPackages.map((pkg) => [ 'npm', ['install', '--save-dev', pkg], expect.objectContaining({ cwd, stdio: 'pipe' }), ]) ) ) } function expectPnpmDevInstalls( mockSpawn: ReturnType, cwd: string, screenciVersion = '0.0.32', includePlaywrightCli = true ) { const pnpmInstallCalls = mockSpawn.mock.calls.filter( (call: unknown[]) => call[0] === 'pnpm' && Array.isArray(call[1]) && call[1][0] === 'add' && call[1][1] === '--save-dev' && call[2] && typeof call[2] === 'object' && 'cwd' in (call[2] as Record) && (call[2] as { cwd?: string }).cwd === cwd && 'stdio' in (call[2] as Record) && (call[2] as { stdio?: string }).stdio === 'pipe' ) const expectedPackages = [ ['add', '--save-dev', `@playwright/test@^1.59.0`], [ 'add', '--save-dev', '--allow-build=ffmpeg-static', `screenci@${screenciVersion}`, ], ['add', '--save-dev', '@types/node@^25.9.1'], ...(includePlaywrightCli ? [['add', '--save-dev', '@playwright/cli@latest']] : []), ] expect(pnpmInstallCalls).toEqual( expect.arrayContaining( expectedPackages.map((args) => [ 'pnpm', args, expect.objectContaining({ cwd, stdio: 'pipe' }), ]) ) ) } function expectYarnDevInstalls( mockSpawn: ReturnType, cwd: string, screenciVersion = '0.0.32', includePlaywrightCli = true ) { const yarnInstallCalls = mockSpawn.mock.calls.filter( (call: unknown[]) => call[0] === 'yarn' && Array.isArray(call[1]) && call[1][0] === 'add' && call[1][1] === '--dev' && call[2] && typeof call[2] === 'object' && 'cwd' in (call[2] as Record) && (call[2] as { cwd?: string }).cwd === cwd && 'stdio' in (call[2] as Record) && (call[2] as { stdio?: string }).stdio === 'pipe' ) const expectedPackages = [ ['add', '--dev', `@playwright/test@^1.59.0`], ['add', '--dev', `screenci@${screenciVersion}`], ['add', '--dev', '@types/node@^25.9.1'], ...(includePlaywrightCli ? [['add', '--dev', '@playwright/cli@latest']] : []), ] expect(yarnInstallCalls).toEqual( expect.arrayContaining( expectedPackages.map((args) => [ 'yarn', args, expect.objectContaining({ cwd, stdio: 'pipe' }), ]) ) ) } vi.mock('child_process', () => ({ spawn: mockSpawn, exec: mockExec, createReadStream: mockCreateReadStream, default: { spawn: mockSpawn, exec: mockExec, createReadStream: mockCreateReadStream, }, })) vi.mock('fs', () => ({ createReadStream: mockCreateReadStream, existsSync: mockExistsSync, realpathSync: mockRealpathSync, mkdirSync: mockMkdirSync, rmSync: mockRmSync, readdirSync: mockReaddirSync, readFileSync: mockReadFileSync, default: { createReadStream: mockCreateReadStream, existsSync: mockExistsSync, realpathSync: mockRealpathSync, mkdirSync: mockMkdirSync, rmSync: mockRmSync, readdirSync: mockReaddirSync, readFileSync: mockReadFileSync, }, })) vi.mock('fs/promises', () => ({ appendFile: mockAppendFile, rm: mockRm, readdir: mockReaddir, readFile: mockReadFile, stat: mockStat, writeFile: mockWriteFile, mkdir: mockMkdir, default: { appendFile: mockAppendFile, rm: mockRm, readdir: mockReaddir, readFile: mockReadFile, stat: mockStat, writeFile: mockWriteFile, mkdir: mockMkdir, }, })) vi.mock('@inquirer/prompts', () => ({ input: mockInput, confirm: mockConfirm, })) vi.mock('ora', () => ({ default: mockOra, })) vi.mock('http', () => ({ createServer: mockCreateHttpServer, default: { createServer: mockCreateHttpServer }, })) describe('CLI', () => { let mockChildProcess: EventEmitter let loggerErrorSpy: ReturnType let loggerInfoSpy: ReturnType let loggerWarnSpy: ReturnType let processExitSpy: ReturnType let loadEnvFileSpy: ReturnType | undefined let originalArgv: string[] let originalEnv: NodeJS.ProcessEnv let originalFetch: typeof global.fetch let originalLoadEnvFile: ((path: string | URL) => void) | undefined beforeEach(() => { // Reset all mocks (clearAllMocks only clears call history, not Once queues; // mockReset also clears return values/implementations including Once queue) vi.clearAllMocks() mockSpawn.mockReset() mockAppendFile.mockResolvedValue(undefined) mockWriteFile.mockResolvedValue(undefined) mockMkdir.mockResolvedValue(undefined) mockRm.mockResolvedValue(undefined) mockReaddir.mockResolvedValue([]) mockReadFileSync.mockImplementation(() => { if (process.env.VITE_APP_BASE_URL === undefined) { process.env.VITE_APP_BASE_URL = 'https://env-file.example.com' } return 'VITE_APP_BASE_URL=https://env-file.example.com\n' }) mockReadFile.mockImplementation(async (path: string | URL) => { if (String(path).endsWith('screenci.config.ts')) { return "export default defineConfig({ projectName: 'Test Project' })" } if (String(path).endsWith('package.json')) { return JSON.stringify({ version: '0.0.32' }) } return '' }) mockStat.mockResolvedValue({ size: 4 }) mockCreateReadStream.mockImplementation(() => { const stream = new Readable({ read() {} }) process.nextTick(() => { stream.push('data') stream.push(null) }) return stream }) // Default inquirer responses mockInput.mockImplementation( async (options?: { default?: string }) => options?.default ?? '' ) mockConfirm.mockResolvedValue(false) // Restore ora mock return value after clearAllMocks mockOra.mockReturnValue(mockSpinner) mockSpinner.start.mockReturnThis() mockSpinner.succeed.mockReturnThis() mockSpinner.fail.mockReturnThis() mockSpinner.stop.mockReturnThis() // Store original values originalArgv = process.argv originalEnv = { ...process.env } originalFetch = global.fetch originalLoadEnvFile = ( process as NodeJS.Process & { loadEnvFile?: (path: string | URL) => void } ).loadEnvFile delete process.env.npm_config_user_agent // Mock child process (unref needed for openBrowser's detached spawn) mockChildProcess = Object.assign(new EventEmitter(), { unref: vi.fn(), stdout: new EventEmitter(), stderr: new EventEmitter(), }) mockSpawn.mockReturnValue(mockChildProcess as unknown as ChildProcess) // Mock file system mockExistsSync.mockReturnValue(true) // Default http server mock: does not resolve (login not triggered by default) mockCreateHttpServer.mockReturnValue({ listen: vi.fn(), close: vi.fn(), address: vi.fn().mockReturnValue({ port: 12345 }), on: vi.fn(), }) // Mock logger methods loggerErrorSpy = vi.spyOn(logger, 'error').mockImplementation(() => {}) loggerInfoSpy = vi.spyOn(logger, 'info').mockImplementation(() => {}) loggerWarnSpy = vi.spyOn(logger, 'warn').mockImplementation(() => {}) // Mock process.exit processExitSpy = vi.spyOn(process, 'exit').mockImplementation((() => { throw new Error('process.exit called') }) as unknown as (code?: string | number | null | undefined) => never) if (typeof originalLoadEnvFile === 'function') { loadEnvFileSpy = vi .spyOn( process as NodeJS.Process & { loadEnvFile?: (path: string | URL) => void }, 'loadEnvFile' ) .mockImplementation((path?: string | URL) => { if ( String(path).endsWith('.env') && process.env.VITE_APP_BASE_URL === undefined ) { process.env.VITE_APP_BASE_URL = 'https://env-file.example.com' } }) } else { loadEnvFileSpy = undefined ;( process as NodeJS.Process & { loadEnvFile?: (path: string | URL) => void } ).loadEnvFile = undefined } global.fetch = mockFetch as typeof global.fetch mockFetch.mockResolvedValue({ ok: true, status: 200, json: vi.fn().mockResolvedValue({}), text: vi.fn().mockResolvedValue(''), }) }) afterEach(() => { // Restore original values process.argv = originalArgv process.env = originalEnv global.fetch = originalFetch ;( process as NodeJS.Process & { loadEnvFile?: (path: string | URL) => void } ).loadEnvFile = originalLoadEnvFile // Restore spies loggerErrorSpy?.mockRestore() loggerInfoSpy?.mockRestore() loggerWarnSpy?.mockRestore() processExitSpy?.mockRestore() loadEnvFileSpy?.mockRestore() }) describe('command list', () => { it('lists the available CLI commands when none is provided', async () => { process.argv = ['node', 'cli.js'] const { main } = await import('./cli') await expect(main()).rejects.toThrow('process.exit called') expect(loggerErrorSpy).toHaveBeenCalledWith( expect.stringContaining('test, preview, export, info') ) // The browser sign-in `login` command is gone from the OTP-based flow, so // it must not be advertised as a top-level command. const availableCommandsCall = loggerErrorSpy.mock.calls.find( (call) => typeof call[0] === 'string' && (call[0] as string).startsWith('Available commands:') ) expect(availableCommandsCall).toBeDefined() expect(availableCommandsCall?.[0]).not.toContain('login') }) }) describe('requireScreenCISecret', () => { it('returns the secret and prod api url when SCREENCI_SECRET is set', async () => { process.env.SCREENCI_SECRET = 'secret-from-env' const { requireScreenCISecret } = await import('./cli') const result = await requireScreenCISecret() expect(result.secret).toBe('secret-from-env') expect(result.apiUrl).toBe('https://api.screenci.com') expect(result.resolvedConfigPath).toContain('screenci.config.ts') }) it('logs the secrets url and exits non-zero when SCREENCI_SECRET is missing', async () => { delete process.env.SCREENCI_SECRET const { requireScreenCISecret } = await import('./cli') await expect(requireScreenCISecret()).rejects.toThrow( 'process.exit called' ) expect(loggerErrorSpy).toHaveBeenCalledWith( expect.stringContaining('SCREENCI_SECRET') ) expect(loggerErrorSpy).toHaveBeenCalledWith( expect.stringContaining('/secrets') ) expect(processExitSpy).toHaveBeenCalledWith(1) }) }) describe('detectInteractiveSession', () => { it('is interactive when both streams are a TTY and no automation flag is set', async () => { const { detectInteractiveSession } = await import('./cli') expect( detectInteractiveSession({}, { isTTY: true }, { isTTY: true }) ).toBe(true) }) it('is not interactive without a TTY', async () => { const { detectInteractiveSession } = await import('./cli') expect( detectInteractiveSession({}, { isTTY: false }, { isTTY: false }) ).toBe(false) }) it('is not interactive under CI', async () => { const { detectInteractiveSession } = await import('./cli') expect( detectInteractiveSession( { CI: 'true' }, { isTTY: true }, { isTTY: true } ) ).toBe(false) }) it('is not interactive when SCREENCI_NONINTERACTIVE=1', async () => { const { detectInteractiveSession } = await import('./cli') expect( detectInteractiveSession( { SCREENCI_NONINTERACTIVE: '1' }, { isTTY: true }, { isTTY: true } ) ).toBe(false) }) }) })