UNPKG

apisurf

Version:

Analyze API surface changes between npm package versions to catch breaking changes

307 lines (306 loc) 12.8 kB
import { describe, it, expect, jest, beforeEach } from '@jest/globals'; // Create mocks const mockParseApiSurface = jest.fn(); const mockParseTypeScriptDefinitions = jest.fn(); const mockExecSync = jest.fn(); const mockFs = { existsSync: jest.fn(), mkdirSync: jest.fn(), rmSync: jest.fn(), readFileSync: jest.fn(), readdirSync: jest.fn(), mkdtempSync: jest.fn(), writeFileSync: jest.fn(), copyFileSync: jest.fn(), promises: { readFile: jest.fn() } }; const mockOs = { tmpdir: jest.fn() }; const mockPath = { join: jest.fn(), basename: jest.fn() }; // Mock dependencies jest.unstable_mockModule('../utilities/parseApiSurface.js', () => ({ parseApiSurface: mockParseApiSurface })); jest.unstable_mockModule('./parseTypeScriptDefinitions.js', () => ({ parseTypeScriptDefinitions: mockParseTypeScriptDefinitions })); jest.unstable_mockModule('fs', () => mockFs); jest.unstable_mockModule('child_process', () => ({ execSync: mockExecSync })); jest.unstable_mockModule('os', () => mockOs); jest.unstable_mockModule('path', () => mockPath); // Import after mocking const { analyzeNpmPackageVersions, createNpmPackageAnalyzer } = await import('./analyzeNpmPackageVersions.js'); describe('analyzeNpmPackageVersions', () => { beforeEach(() => { jest.clearAllMocks(); // Setup default mocks mockOs.tmpdir.mockReturnValue('/tmp'); mockFs.mkdtempSync.mockReturnValue('/tmp/apisurf-test'); mockPath.join.mockImplementation((...segments) => segments.join('/')); mockFs.mkdirSync.mockImplementation(() => { }); mockFs.writeFileSync.mockImplementation(() => { }); mockFs.existsSync.mockReturnValue(true); mockFs.readFileSync.mockReturnValue(JSON.stringify({ name: 'test-package', version: '1.0.0' })); mockFs.rmSync.mockImplementation(() => { }); mockExecSync.mockImplementation(() => Buffer.from('')); }); it('should download and analyze two package versions', async () => { const mockApiSurface = { namedExports: new Set(['export1']), typeOnlyExports: new Set(), defaultExport: false, starExports: [], packageName: 'test-package', version: '1.0.0', typeDefinitions: new Map() }; // Mock both parseApiSurface and parseTypeScriptDefinitions since the analyzer tries TypeScript first mockParseApiSurface.mockReturnValue(mockApiSurface); mockParseTypeScriptDefinitions.mockReturnValue(mockApiSurface); const result = await analyzeNpmPackageVersions({ packageName: 'test-package', fromVersion: '1.0.0', toVersion: '2.0.0' }); expect(result.base).toEqual(mockApiSurface); expect(result.head).toEqual(mockApiSurface); expect(mockExecSync).toHaveBeenCalledTimes(2); }); it('should handle package download errors', async () => { mockExecSync.mockImplementation(() => { throw new Error('npm install failed'); }); await expect(analyzeNpmPackageVersions({ packageName: 'test-package', fromVersion: '1.0.0', toVersion: '2.0.0' })).rejects.toThrow('Failed to install test-package@1.0.0'); }); it('should clean up temporary files after analysis', async () => { const mockApiSurface = { namedExports: new Set(), typeOnlyExports: new Set(), defaultExport: false, starExports: [], packageName: 'test-package', version: '1.0.0', typeDefinitions: new Map() }; mockParseApiSurface.mockReturnValue(mockApiSurface); await analyzeNpmPackageVersions({ packageName: 'test-package', fromVersion: '1.0.0', toVersion: '2.0.0' }); expect(mockFs.rmSync).toHaveBeenCalledWith('/tmp/apisurf-test', { recursive: true, force: true }); }); it('should use custom registry when provided', async () => { const mockApiSurface = { namedExports: new Set(), typeOnlyExports: new Set(), defaultExport: false, starExports: [], packageName: 'test-package', version: '1.0.0', typeDefinitions: new Map() }; mockParseApiSurface.mockReturnValue(mockApiSurface); await analyzeNpmPackageVersions({ packageName: 'test-package', fromVersion: '1.0.0', toVersion: '2.0.0', registry: 'https://custom-registry.com' }); expect(mockExecSync).toHaveBeenCalledWith('npm install --registry=https://custom-registry.com', expect.any(Object)); }); }); describe('createNpmPackageAnalyzer', () => { beforeEach(() => { jest.clearAllMocks(); // Setup default mocks mockOs.tmpdir.mockReturnValue('/tmp'); mockFs.mkdtempSync.mockReturnValue('/tmp/apisurf-test'); mockPath.join.mockImplementation((...segments) => segments.join('/')); mockFs.mkdirSync.mockImplementation(() => { }); mockFs.writeFileSync.mockImplementation(() => { }); mockFs.existsSync.mockReturnValue(true); mockFs.readFileSync.mockReturnValue('export const test = "value";'); mockFs.rmSync.mockImplementation(() => { }); mockExecSync.mockImplementation(() => Buffer.from('')); }); it('should download package with correct structure', async () => { const analyzer = createNpmPackageAnalyzer(); const packageInfo = await analyzer.downloadPackage('test-package', '1.0.0'); expect(packageInfo).toEqual({ name: 'test-package', version: '1.0.0', tempDir: '/tmp/apisurf-test/test-package-1.0.0', packagePath: '/tmp/apisurf-test/test-package-1.0.0/node_modules/test-package' }); expect(mockFs.writeFileSync).toHaveBeenCalledWith('/tmp/apisurf-test/test-package-1.0.0/package.json', expect.stringContaining('temp-test-package-1.0.0')); }); it('should prefer TypeScript definitions when available', async () => { const analyzer = createNpmPackageAnalyzer(); // Mock package.json with types field mockFs.readFileSync.mockImplementation((filePath) => { if (filePath.includes('package.json')) { return JSON.stringify({ name: 'test-package', version: '1.0.0', types: 'index.d.ts' }); } return 'export interface Test { name: string; }'; }); const mockTypeApiSurface = { namedExports: new Set(['Test']), typeOnlyExports: new Set(['Test']), defaultExport: false, starExports: [], packageName: 'test-package', version: '1.0.0', typeDefinitions: new Map([['Test', 'interface Test { name: string; }']]) }; mockParseTypeScriptDefinitions.mockReturnValue(mockTypeApiSurface); const packageInfo = { name: 'test-package', version: '1.0.0', tempDir: '/tmp/test', packagePath: '/tmp/test/node_modules/test-package' }; const result = await analyzer.extractApiSurface(packageInfo); expect(mockParseTypeScriptDefinitions).toHaveBeenCalled(); expect(result).toEqual(mockTypeApiSurface); }); it('should fallback to JavaScript analysis when TypeScript definitions fail', async () => { const analyzer = createNpmPackageAnalyzer(); // Mock package.json with types field mockFs.readFileSync.mockImplementation((filePath) => { if (filePath.includes('package.json')) { return JSON.stringify({ name: 'test-package', version: '1.0.0', types: 'index.d.ts', main: 'index.js' }); } return 'export const test = "value";'; }); // Make TypeScript parsing fail mockParseTypeScriptDefinitions.mockImplementation(() => { throw new Error('Parse error'); }); const mockJsApiSurface = { namedExports: new Set(['test']), typeOnlyExports: new Set(), defaultExport: false, starExports: [], packageName: 'test-package', version: '1.0.0', typeDefinitions: new Map() }; mockParseApiSurface.mockReturnValue(mockJsApiSurface); const packageInfo = { name: 'test-package', version: '1.0.0', tempDir: '/tmp/test', packagePath: '/tmp/test/node_modules/test-package' }; const result = await analyzer.extractApiSurface(packageInfo); expect(mockParseApiSurface).toHaveBeenCalled(); expect(result).toEqual(mockJsApiSurface); }); it('should handle packages with no entry point found', async () => { const analyzer = createNpmPackageAnalyzer(); // Mock package.json without main or types mockFs.readFileSync.mockImplementation((filePath) => { if (filePath.includes('package.json')) { return JSON.stringify({ name: 'test-package', version: '1.0.0' }); } throw new Error('File not found'); }); mockFs.existsSync.mockImplementation((filePath) => { return filePath.includes('package.json'); }); const packageInfo = { name: 'test-package', version: '1.0.0', tempDir: '/tmp/test', packagePath: '/tmp/test/node_modules/test-package' }; const result = await analyzer.extractApiSurface(packageInfo); expect(result).toEqual({ namedExports: new Set(), typeOnlyExports: new Set(), defaultExport: false, starExports: [], packageName: 'test-package', version: '1.0.0', typeDefinitions: new Map() }); }); it('should copy .npmrc files for registry configuration', async () => { const analyzer = createNpmPackageAnalyzer(); // Mock .npmrc exists in current working directory mockFs.existsSync.mockImplementation((filePath) => { return filePath.includes('.npmrc') || filePath.includes('node_modules'); }); await analyzer.downloadPackage('test-package', '1.0.0'); expect(mockFs.copyFileSync).toHaveBeenCalledWith(expect.stringContaining('.npmrc'), expect.stringContaining('.npmrc')); }); it('should handle scoped package names correctly', async () => { const analyzer = createNpmPackageAnalyzer(); const packageInfo = await analyzer.downloadPackage('@scope/test-package', '1.0.0'); expect(packageInfo.tempDir).toContain('@scope-test-package-1.0.0'); expect(packageInfo.packagePath).toContain('node_modules/@scope/test-package'); }); it('should follow wrapper files to actual implementation', async () => { const analyzer = createNpmPackageAnalyzer(); mockFs.readFileSync.mockImplementation((filePath) => { if (filePath.includes('package.json')) { return JSON.stringify({ name: 'test-package', version: '1.0.0', main: 'index.js' }); } if (filePath.includes('index.js')) { return `if (process.env.NODE_ENV === 'production') { module.exports = require('./dist/index.production.js'); } else { module.exports = require('./dist/index.development.js'); }`; } return 'export const actualImplementation = true;'; }); const mockApiSurface = { namedExports: new Set(['actualImplementation']), typeOnlyExports: new Set(), defaultExport: false, starExports: [], packageName: 'test-package', version: '1.0.0', typeDefinitions: new Map() }; mockParseApiSurface.mockReturnValue(mockApiSurface); const packageInfo = { name: 'test-package', version: '1.0.0', tempDir: '/tmp/test', packagePath: '/tmp/test/node_modules/test-package' }; await analyzer.extractApiSurface(packageInfo); expect(mockParseApiSurface).toHaveBeenCalledWith('export const actualImplementation = true;', 'test-package', '1.0.0', expect.stringContaining('index.development.js')); }); });