UNPKG

apisurf

Version:

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

260 lines (259 loc) 10.3 kB
import { describe, it, expect, jest, beforeEach } from '@jest/globals'; // Create properly typed mocks const mockAnalyzeNpmPackageDiff = jest.fn(); const mockCompareApiSurfaces = jest.fn(); const mockExtractApiSurface = jest.fn(); const mockFindPackagesToAnalyze = jest.fn(); const mockGetCurrentBranch = jest.fn(); const mockCalculateSemverImpact = jest.fn(); const mockWriteApiSurfaceLog = jest.fn(); // Mock all dependencies jest.unstable_mockModule('./analyzeNpmPackageDiff.js', () => ({ analyzeNpmPackageDiff: mockAnalyzeNpmPackageDiff })); jest.unstable_mockModule('./compareApiSurfaces.js', () => ({ compareApiSurfaces: mockCompareApiSurfaces })); jest.unstable_mockModule('../utilities/extractApiSurface.js', () => ({ extractApiSurface: mockExtractApiSurface })); jest.unstable_mockModule('../utilities/findPackagesToAnalyze.js', () => ({ findPackagesToAnalyze: mockFindPackagesToAnalyze })); jest.unstable_mockModule('../utilities/getCurrentBranch.js', () => ({ getCurrentBranch: mockGetCurrentBranch })); jest.unstable_mockModule('../utilities/calculateSemverImpact.js', () => ({ calculateSemverImpact: mockCalculateSemverImpact })); jest.unstable_mockModule('../utilities/writeApiSurfaceLog.js', () => ({ writeApiSurfaceLog: mockWriteApiSurfaceLog })); // Import after mocking const { analyzeDiff } = await import('./analyzeDiff.js'); describe('analyzeDiff', () => { beforeEach(() => { jest.clearAllMocks(); }); it('should delegate to analyzeNpmPackageDiff when npmPackage option is provided', async () => { const options = { base: 'main', format: 'console', npmPackage: 'test-package', fromVersion: '1.0.0', toVersion: '2.0.0' }; const mockResult = { hasBreakingChanges: false, packages: [], summary: 'No changes', semverImpact: { minimumBump: 'patch', reason: 'No changes' } }; mockAnalyzeNpmPackageDiff.mockResolvedValue(mockResult); const result = await analyzeDiff(options); expect(mockAnalyzeNpmPackageDiff).toHaveBeenCalledWith(options); expect(result).toBe(mockResult); }); it('should analyze Git-based diff when npmPackage is not provided', async () => { const options = { base: 'main', format: 'console' }; mockGetCurrentBranch.mockReturnValue('feature-branch'); mockFindPackagesToAnalyze.mockReturnValue([ { name: 'test-package', path: './packages/test-package' } ]); const mockApiSurface = { namedExports: new Set(['export1']), typeOnlyExports: new Set(), defaultExport: false, starExports: [], packageName: 'test-package', version: '1.0.0', typeDefinitions: new Map() }; mockExtractApiSurface.mockResolvedValue(mockApiSurface); mockCompareApiSurfaces.mockReturnValue({ name: 'test-package', path: './packages/test-package', breakingChanges: [], nonBreakingChanges: [] }); mockCalculateSemverImpact.mockReturnValue({ minimumBump: 'patch', reason: 'No changes detected' }); const result = await analyzeDiff(options); expect(mockGetCurrentBranch).toHaveBeenCalled(); expect(mockFindPackagesToAnalyze).toHaveBeenCalledWith(undefined); expect(mockExtractApiSurface).toHaveBeenCalledTimes(2); expect(result.hasBreakingChanges).toBe(false); expect(result.summary).toBe('No breaking changes detected'); }); it('should use head option when provided', async () => { const options = { base: 'main', head: 'develop', format: 'console' }; mockFindPackagesToAnalyze.mockReturnValue([ { name: 'test-package', path: './packages/test-package' } ]); const mockApiSurface = { namedExports: new Set(), typeOnlyExports: new Set(), defaultExport: false, starExports: [], packageName: 'test-package', version: '1.0.0', typeDefinitions: new Map() }; mockExtractApiSurface.mockResolvedValue(mockApiSurface); mockCompareApiSurfaces.mockReturnValue({ name: 'test-package', path: './packages/test-package', breakingChanges: [], nonBreakingChanges: [] }); mockCalculateSemverImpact.mockReturnValue({ minimumBump: 'patch', reason: 'No changes detected' }); await analyzeDiff(options); expect(mockExtractApiSurface).toHaveBeenCalledWith(expect.objectContaining({ name: 'test-package', path: './packages/test-package' }), 'main'); expect(mockExtractApiSurface).toHaveBeenCalledWith(expect.objectContaining({ name: 'test-package', path: './packages/test-package' }), 'develop'); }); it('should detect breaking changes and return appropriate summary', async () => { const options = { base: 'main', format: 'console' }; mockGetCurrentBranch.mockReturnValue('feature-branch'); mockFindPackagesToAnalyze.mockReturnValue([ { name: 'test-package', path: './packages/test-package' } ]); const mockApiSurface = { namedExports: new Set(), typeOnlyExports: new Set(), defaultExport: false, starExports: [], packageName: 'test-package', version: '1.0.0', typeDefinitions: new Map() }; mockExtractApiSurface.mockResolvedValue(mockApiSurface); mockCompareApiSurfaces.mockReturnValue({ name: 'test-package', path: './packages/test-package', breakingChanges: [{ type: 'export-removed', description: 'Removed export', before: 'someFunction' }], nonBreakingChanges: [] }); mockCalculateSemverImpact.mockReturnValue({ minimumBump: 'major', reason: 'Breaking changes detected' }); const result = await analyzeDiff(options); expect(result.hasBreakingChanges).toBe(true); expect(result.summary).toBe('Breaking changes detected. Minimum semver bump: major'); expect(result.packages).toHaveLength(1); }); it('should write API surface log when logfile option is provided', async () => { const options = { base: 'main', format: 'console', logfile: './api-log.json' }; mockGetCurrentBranch.mockReturnValue('feature-branch'); mockFindPackagesToAnalyze.mockReturnValue([ { name: 'test-package', path: './packages/test-package' } ]); const mockApiSurface = { namedExports: new Set(), typeOnlyExports: new Set(), defaultExport: false, starExports: [], packageName: 'test-package', version: '1.0.0', typeDefinitions: new Map() }; mockExtractApiSurface.mockResolvedValue(mockApiSurface); mockCompareApiSurfaces.mockReturnValue({ name: 'test-package', path: './packages/test-package', breakingChanges: [], nonBreakingChanges: [] }); mockCalculateSemverImpact.mockReturnValue({ minimumBump: 'patch', reason: 'No changes detected' }); await analyzeDiff(options); expect(mockWriteApiSurfaceLog).toHaveBeenCalledWith('./api-log.json', mockApiSurface, mockApiSurface, 'test-package', 'main', 'feature-branch', 'console'); }); it('should pass packages filter to findPackagesToAnalyze', async () => { const options = { base: 'main', format: 'console', packages: 'specific-package' }; mockGetCurrentBranch.mockReturnValue('feature-branch'); mockFindPackagesToAnalyze.mockReturnValue([]); mockCalculateSemverImpact.mockReturnValue({ minimumBump: 'patch', reason: 'No changes detected' }); await analyzeDiff(options); expect(mockFindPackagesToAnalyze).toHaveBeenCalledWith('specific-package'); }); it('should filter out packages with no changes', async () => { const options = { base: 'main', format: 'console' }; mockGetCurrentBranch.mockReturnValue('feature-branch'); mockFindPackagesToAnalyze.mockReturnValue([ { name: 'package1', path: './packages/package1' }, { name: 'package2', path: './packages/package2' } ]); const mockApiSurface = { namedExports: new Set(), typeOnlyExports: new Set(), defaultExport: false, starExports: [], packageName: 'test-package', version: '1.0.0', typeDefinitions: new Map() }; mockExtractApiSurface.mockResolvedValue(mockApiSurface); // First package has no changes, second has changes mockCompareApiSurfaces .mockReturnValueOnce({ name: 'package1', path: './packages/package1', breakingChanges: [], nonBreakingChanges: [] }) .mockReturnValueOnce({ name: 'package2', path: './packages/package2', breakingChanges: [], nonBreakingChanges: [{ type: 'export-added', description: 'Added export', details: 'newFunction' }] }); mockCalculateSemverImpact.mockReturnValue({ minimumBump: 'minor', reason: 'New features added' }); const result = await analyzeDiff(options); // Only package2 should be included since package1 had no changes expect(result.packages).toHaveLength(1); expect(result.packages[0].name).toBe('package2'); }); });