UNPKG

sln-opener

Version:

Simple command line tool for opening visual studio solutions from their current directory. Useful if you work from the command prommpt using tools like git-posh.

411 lines (320 loc) 15 kB
jest.mock('child_process'); jest.mock('readline'); jest.mock('../config'); describe('mainMenu.js', () => { let mainMenu; const mockIdePaths = { vsPath: 'C:\\Program Files\\Visual Studio\\2022\\Community\\Common7\\IDE', riderPath: 'C:\\Users\\User\\AppData\\Local\\JetBrains\\Toolbox\\apps\\Rider\\ch-0\\233.11235.16\\bin' }; const vsSolution = { path: 'C:\\Projects\\Project1\\Project1.sln', ext: '.sln', type: 'vs', ide: 'Visual Studio', executable: 'devenv.exe' }; const riderSolution = { path: 'C:\\Projects\\Project2\\Project2.iml', ext: '.iml', type: 'jetbrains', ide: 'JetBrains Rider', executable: 'rider.exe' }; const mockMenuItems = [vsSolution, riderSolution]; let mockInterface; beforeEach(() => { jest.resetModules(); jest.clearAllMocks(); jest.spyOn(console, 'log').mockImplementation(() => {}); jest.spyOn(process, 'exit').mockImplementation(() => {}); mockInterface = { question: jest.fn(), close: jest.fn() }; const mockReadline = require('readline'); mockReadline.createInterface.mockReturnValue(mockInterface); const mockConfig = require('../config'); mockConfig.getRecentSolutions.mockReturnValue([]); mockConfig.formatTimeAgo.mockReturnValue('2h ago'); mockConfig.addToRecentSolutions.mockImplementation(() => {}); }); afterEach(() => { console.log.mockRestore(); process.exit.mockRestore(); }); describe('loadSolution', () => { it('should show error when no solutions found', () => { mainMenu = require('../mainMenu'); mainMenu.loadSolution([], mockIdePaths); expect(console.log).toHaveBeenCalledWith(expect.stringContaining('no solutions')); }); it('should open single solution directly without showing a menu', () => { const { spawn } = require('child_process'); const mockSubprocess = { on: jest.fn().mockReturnThis(), unref: jest.fn() }; spawn.mockReturnValue(mockSubprocess); mainMenu = require('../mainMenu'); mainMenu.loadSolution([vsSolution], mockIdePaths); expect(spawn).toHaveBeenCalledWith( expect.stringContaining('devenv.exe'), [vsSolution.path], expect.objectContaining({ detached: true }) ); const mockReadline = require('readline'); expect(mockReadline.createInterface).not.toHaveBeenCalled(); }); it('should create readline interface for multiple solutions', () => { mainMenu = require('../mainMenu'); mainMenu.loadSolution(mockMenuItems, mockIdePaths); const mockReadline = require('readline'); expect(mockReadline.createInterface).toHaveBeenCalled(); }); it('should prompt with search question on load', () => { mainMenu = require('../mainMenu'); mainMenu.loadSolution(mockMenuItems, mockIdePaths); expect(mockInterface.question).toHaveBeenCalledWith( expect.stringContaining('Search solutions'), expect.any(Function) ); }); }); describe('Menu display and selection', () => { it('should display numbered menu including (All) option when Enter pressed', () => { mainMenu = require('../mainMenu'); mainMenu.loadSolution(mockMenuItems, mockIdePaths); // Simulate pressing Enter (show all) const searchCallback = mockInterface.question.mock.calls[0][1]; searchCallback(''); const logged = console.log.mock.calls.map(c => c[0]).join('\n'); expect(logged).toContain('1. (All)'); expect(logged).toContain('Project1.sln'); expect(logged).toContain('Project2.iml'); }); it('should display IDE labels next to solution names', () => { mainMenu = require('../mainMenu'); mainMenu.loadSolution(mockMenuItems, mockIdePaths); const searchCallback = mockInterface.question.mock.calls[0][1]; searchCallback(''); const logged = console.log.mock.calls.map(c => c[0]).join('\n'); expect(logged).toContain('[Visual Studio]'); expect(logged).toContain('[JetBrains Rider]'); }); it('should exit when Exit option is selected', () => { mainMenu = require('../mainMenu'); mainMenu.loadSolution(mockMenuItems, mockIdePaths); // Press Enter to show all solutions first mockInterface.question.mock.calls[0][1](''); // Select exit (last numbered option) const selectCallback = mockInterface.question.mock.calls[1][1]; selectCallback('4'); // (All) + 2 solutions + Exit = option 4 expect(process.exit).toHaveBeenCalled(); }); it('should show error on invalid menu selection', () => { mainMenu = require('../mainMenu'); mainMenu.loadSolution(mockMenuItems, mockIdePaths); mockInterface.question.mock.calls[0][1](''); const selectCallback = mockInterface.question.mock.calls[1][1]; selectCallback('99'); expect(console.log).toHaveBeenCalledWith(expect.stringContaining('Invalid selection')); }); it('should trigger new search when Enter pressed at selection prompt', () => { mainMenu = require('../mainMenu'); mainMenu.loadSolution(mockMenuItems, mockIdePaths); mockInterface.question.mock.calls[0][1](''); const selectCallback = mockInterface.question.mock.calls[1][1]; selectCallback(''); // Should have called question again (new search prompt) expect(mockInterface.question.mock.calls.length).toBeGreaterThan(2); }); }); describe('Search and filter', () => { it('should filter solutions by name', () => { mainMenu = require('../mainMenu'); mainMenu.loadSolution(mockMenuItems, mockIdePaths); const searchCallback = mockInterface.question.mock.calls[0][1]; searchCallback('project1'); const logged = console.log.mock.calls.map(c => c[0]).join('\n'); expect(logged).toContain('Project1.sln'); expect(logged).not.toContain('Project2.iml'); }); it('should show no-results message when search has no matches', () => { mainMenu = require('../mainMenu'); mainMenu.loadSolution(mockMenuItems, mockIdePaths); const searchCallback = mockInterface.question.mock.calls[0][1]; searchCallback('zzznomatch'); expect(console.log).toHaveBeenCalledWith(expect.stringContaining('No solutions found')); }); it('should show (Clear Filter) option when a filter is active', () => { mainMenu = require('../mainMenu'); mainMenu.loadSolution(mockMenuItems, mockIdePaths); mockInterface.question.mock.calls[0][1]('project1'); // Now show the menu with filter active const selectCallback = mockInterface.question.mock.calls[1][1]; // Enter to trigger new search selectCallback(''); // Re-search with empty string clears filter const searchCallback2 = mockInterface.question.mock.calls[2][1]; searchCallback2('project'); // filter again const logged = console.log.mock.calls.map(c => c[0]).join('\n'); expect(logged).toContain('(Clear Filter)'); }); }); describe('Opening solutions', () => { it('should spawn devenv.exe for Visual Studio solutions', () => { const { spawn } = require('child_process'); const mockSubprocess = { on: jest.fn().mockReturnThis(), unref: jest.fn() }; spawn.mockReturnValue(mockSubprocess); mainMenu = require('../mainMenu'); mainMenu.loadSolution(mockMenuItems, mockIdePaths); mockInterface.question.mock.calls[0][1](''); // Option 2 = Project1.sln (after (All)) mockInterface.question.mock.calls[1][1]('2'); expect(spawn).toHaveBeenCalledWith( expect.stringContaining('devenv.exe'), [vsSolution.path], expect.objectContaining({ detached: true }) ); }); it('should spawn rider.exe for JetBrains Rider solutions', () => { const { spawn } = require('child_process'); const mockSubprocess = { on: jest.fn().mockReturnThis(), unref: jest.fn() }; spawn.mockReturnValue(mockSubprocess); mainMenu = require('../mainMenu'); mainMenu.loadSolution(mockMenuItems, mockIdePaths); mockInterface.question.mock.calls[0][1](''); // Option 3 = Project2.iml mockInterface.question.mock.calls[1][1]('3'); expect(spawn).toHaveBeenCalledWith( expect.stringContaining('rider.exe'), [riderSolution.path], expect.objectContaining({ detached: true }) ); }); it('should open all solutions when option 1 is selected', () => { const { spawn } = require('child_process'); const mockSubprocess = { on: jest.fn().mockReturnThis(), unref: jest.fn() }; spawn.mockReturnValue(mockSubprocess); mainMenu = require('../mainMenu'); mainMenu.loadSolution(mockMenuItems, mockIdePaths); mockInterface.question.mock.calls[0][1](''); mockInterface.question.mock.calls[1][1]('1'); expect(spawn).toHaveBeenCalledTimes(2); }); it('should show error when IDE is not configured for solution type', () => { mainMenu = require('../mainMenu'); mainMenu.loadSolution(mockMenuItems, { vsPath: '', riderPath: '' }); mockInterface.question.mock.calls[0][1](''); mockInterface.question.mock.calls[1][1]('2'); // VS solution, but vsPath empty expect(console.log).toHaveBeenCalledWith(expect.stringContaining('IDE not configured')); }); it('should record solution in recent history after opening', () => { const { spawn } = require('child_process'); const mockSubprocess = { on: jest.fn().mockReturnThis(), unref: jest.fn() }; spawn.mockReturnValue(mockSubprocess); const mockConfig = require('../config'); mainMenu = require('../mainMenu'); mainMenu.loadSolution([vsSolution], mockIdePaths); expect(mockConfig.addToRecentSolutions).toHaveBeenCalledWith(vsSolution); }); it('should register error handler on spawned subprocess', () => { const { spawn } = require('child_process'); const mockSubprocess = { on: jest.fn().mockReturnThis(), unref: jest.fn() }; spawn.mockReturnValue(mockSubprocess); mainMenu = require('../mainMenu'); mainMenu.loadSolution([vsSolution], mockIdePaths); expect(mockSubprocess.on).toHaveBeenCalledWith('error', expect.any(Function)); }); it('should detach the subprocess so it runs independently', () => { const { spawn } = require('child_process'); const mockSubprocess = { on: jest.fn().mockReturnThis(), unref: jest.fn() }; spawn.mockReturnValue(mockSubprocess); mainMenu = require('../mainMenu'); mainMenu.loadSolution([vsSolution], mockIdePaths); expect(spawn).toHaveBeenCalledWith( expect.any(String), expect.any(Array), expect.objectContaining({ detached: true, stdio: 'ignore' }) ); expect(mockSubprocess.unref).toHaveBeenCalled(); }); }); describe('Recent solutions', () => { it('should display recent solutions section when history exists', () => { const mockConfig = require('../config'); mockConfig.getRecentSolutions.mockReturnValue([ { path: vsSolution.path, timestamp: Date.now() - 7200000, ide: 'Visual Studio' } ]); mainMenu = require('../mainMenu'); mainMenu.loadSolution(mockMenuItems, mockIdePaths); mockInterface.question.mock.calls[0][1](''); const logged = console.log.mock.calls.map(c => c[0]).join('\n'); expect(logged).toContain('RECENT'); }); it('should not display recent section when no history', () => { const mockConfig = require('../config'); mockConfig.getRecentSolutions.mockReturnValue([]); mainMenu = require('../mainMenu'); mainMenu.loadSolution(mockMenuItems, mockIdePaths); mockInterface.question.mock.calls[0][1](''); const logged = console.log.mock.calls.map(c => c[0]).join('\n'); expect(logged).not.toContain('RECENT'); }); }); describe('IDE Grouping', () => { it('should handle mixed IDE types in menu without throwing', () => { mainMenu = require('../mainMenu'); expect(() => mainMenu.loadSolution(mockMenuItems, mockIdePaths)).not.toThrow(); }); }); describe('selectedIDE override', () => { it('should use selectedIDE to open VS solution with VS when selectedIDE is "vs"', () => { const { spawn } = require('child_process'); const mockSubprocess = { on: jest.fn().mockReturnThis(), unref: jest.fn() }; spawn.mockReturnValue(mockSubprocess); mainMenu = require('../mainMenu'); mainMenu.loadSolution([vsSolution], mockIdePaths, 'vs'); expect(spawn).toHaveBeenCalledWith( expect.stringContaining('devenv.exe'), [vsSolution.path], expect.objectContaining({ detached: true }) ); }); it('should override file-type detection and open jetbrains solution with VS when selectedIDE is "vs"', () => { const { spawn } = require('child_process'); const mockSubprocess = { on: jest.fn().mockReturnThis(), unref: jest.fn() }; spawn.mockReturnValue(mockSubprocess); mainMenu = require('../mainMenu'); // riderSolution has type 'jetbrains' but selectedIDE forces VS mainMenu.loadSolution([riderSolution], mockIdePaths, 'vs'); expect(spawn).toHaveBeenCalledWith( expect.stringContaining('devenv.exe'), [riderSolution.path], expect.objectContaining({ detached: true }) ); }); it('should use selectedIDE "rider" even for VS solution files', () => { const { spawn } = require('child_process'); const mockSubprocess = { on: jest.fn().mockReturnThis(), unref: jest.fn() }; spawn.mockReturnValue(mockSubprocess); mainMenu = require('../mainMenu'); mainMenu.loadSolution([vsSolution], mockIdePaths, 'rider'); expect(spawn).toHaveBeenCalledWith( expect.stringContaining('rider.exe'), [vsSolution.path], expect.objectContaining({ detached: true }) ); }); it('should fall back to auto-detection when selectedIDE is null', () => { const { spawn } = require('child_process'); const mockSubprocess = { on: jest.fn().mockReturnThis(), unref: jest.fn() }; spawn.mockReturnValue(mockSubprocess); mainMenu = require('../mainMenu'); mainMenu.loadSolution([riderSolution], mockIdePaths, null); expect(spawn).toHaveBeenCalledWith( expect.stringContaining('rider.exe'), [riderSolution.path], expect.objectContaining({ detached: true }) ); }); }); });