UNPKG

apisurf

Version:

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

335 lines (334 loc) 12.5 kB
import { describe, it, expect, jest, beforeEach } from '@jest/globals'; // Create properly typed mocks const mockAnalyzeNpmPackageVersions = jest.fn(); const mockCompareApiSurfaces = jest.fn(); const mockCalculateSemverImpact = jest.fn(); const mockWriteApiSurfaceLog = jest.fn(); // Mock modules before importing jest.unstable_mockModule('./analyzeNpmPackageVersions.js', () => ({ analyzeNpmPackageVersions: mockAnalyzeNpmPackageVersions })); jest.unstable_mockModule('./compareApiSurfaces.js', () => ({ compareApiSurfaces: mockCompareApiSurfaces })); jest.unstable_mockModule('../utilities/calculateSemverImpact.js', () => ({ calculateSemverImpact: mockCalculateSemverImpact })); jest.unstable_mockModule('../utilities/writeApiSurfaceLog.js', () => ({ writeApiSurfaceLog: mockWriteApiSurfaceLog })); // Import after mocking const { analyzeNpmPackageDiff } = await import('./analyzeNpmPackageDiff.js'); describe('analyzeNpmPackageDiff', () => { beforeEach(() => { jest.clearAllMocks(); }); it('should throw error when required NPM options are missing', async () => { const optionsWithoutNpmPackage = { base: 'main', format: 'console' }; await expect(analyzeNpmPackageDiff(optionsWithoutNpmPackage)).rejects.toThrow('NPM mode requires --npm-package, --from-version, and --to-version options'); const optionsWithoutFromVersion = { base: 'main', format: 'console', npmPackage: 'test-package' }; await expect(analyzeNpmPackageDiff(optionsWithoutFromVersion)).rejects.toThrow('NPM mode requires --npm-package, --from-version, and --to-version options'); const optionsWithoutToVersion = { base: 'main', format: 'console', npmPackage: 'test-package', fromVersion: '1.0.0' }; await expect(analyzeNpmPackageDiff(optionsWithoutToVersion)).rejects.toThrow('NPM mode requires --npm-package, --from-version, and --to-version options'); }); it('should analyze NPM package diff with no breaking changes', async () => { const options = { base: 'main', format: 'console', npmPackage: 'test-package', fromVersion: '1.0.0', toVersion: '1.1.0' }; const mockBaseApiSurface = { namedExports: new Set(['export1']), typeOnlyExports: new Set(), defaultExport: false, starExports: [], packageName: 'test-package', version: '1.0.0', typeDefinitions: new Map() }; const mockHeadApiSurface = { namedExports: new Set(['export1', 'export2']), typeOnlyExports: new Set(), defaultExport: false, starExports: [], packageName: 'test-package', version: '1.1.0', typeDefinitions: new Map() }; mockAnalyzeNpmPackageVersions.mockResolvedValue({ base: mockBaseApiSurface, head: mockHeadApiSurface }); mockCompareApiSurfaces.mockReturnValue({ name: 'test-package', path: 'npm:test-package', breakingChanges: [], nonBreakingChanges: [{ type: 'export-added', description: 'Added export export2', details: 'export2' }] }); mockCalculateSemverImpact.mockReturnValue({ minimumBump: 'minor', reason: 'New features added' }); const result = await analyzeNpmPackageDiff(options); expect(mockAnalyzeNpmPackageVersions).toHaveBeenCalledWith({ packageName: 'test-package', fromVersion: '1.0.0', toVersion: '1.1.0', registry: undefined, verbose: undefined, format: 'console' }); expect(result).toEqual({ hasBreakingChanges: false, packages: [{ name: 'test-package', path: 'npm:test-package', breakingChanges: [], nonBreakingChanges: [{ type: 'export-added', description: 'Added export export2', details: 'export2' }] }], summary: 'No breaking changes detected between 1.0.0 and 1.1.0', semverImpact: { minimumBump: 'minor', reason: 'New features added' }, npmPackage: 'test-package', fromVersion: '1.0.0', toVersion: '1.1.0' }); }); it('should analyze NPM package diff with breaking changes', async () => { const options = { base: 'main', format: 'console', npmPackage: 'test-package', fromVersion: '1.0.0', toVersion: '2.0.0' }; const mockBaseApiSurface = { namedExports: new Set(['export1', 'export2']), typeOnlyExports: new Set(), defaultExport: false, starExports: [], packageName: 'test-package', version: '1.0.0', typeDefinitions: new Map() }; const mockHeadApiSurface = { namedExports: new Set(['export1']), typeOnlyExports: new Set(), defaultExport: false, starExports: [], packageName: 'test-package', version: '2.0.0', typeDefinitions: new Map() }; mockAnalyzeNpmPackageVersions.mockResolvedValue({ base: mockBaseApiSurface, head: mockHeadApiSurface }); mockCompareApiSurfaces.mockReturnValue({ name: 'test-package', path: 'npm:test-package', breakingChanges: [{ type: 'export-removed', description: 'Removed export export2', before: 'export2' }], nonBreakingChanges: [] }); mockCalculateSemverImpact.mockReturnValue({ minimumBump: 'major', reason: 'Breaking changes detected' }); const result = await analyzeNpmPackageDiff(options); expect(result.hasBreakingChanges).toBe(true); expect(result.summary).toBe('Breaking changes detected between 1.0.0 and 2.0.0. Minimum semver bump: major'); expect(result.semverImpact.minimumBump).toBe('major'); }); it('should write API surface log when logfile option is provided', async () => { const options = { base: 'main', format: 'console', npmPackage: 'test-package', fromVersion: '1.0.0', toVersion: '1.1.0', logfile: './api-changes.json' }; const mockBaseApiSurface = { namedExports: new Set(['export1']), typeOnlyExports: new Set(), defaultExport: false, starExports: [], packageName: 'test-package', version: '1.0.0', typeDefinitions: new Map() }; const mockHeadApiSurface = { namedExports: new Set(['export1']), typeOnlyExports: new Set(), defaultExport: false, starExports: [], packageName: 'test-package', version: '1.1.0', typeDefinitions: new Map() }; mockAnalyzeNpmPackageVersions.mockResolvedValue({ base: mockBaseApiSurface, head: mockHeadApiSurface }); mockCompareApiSurfaces.mockReturnValue({ name: 'test-package', path: 'npm:test-package', breakingChanges: [], nonBreakingChanges: [] }); mockCalculateSemverImpact.mockReturnValue({ minimumBump: 'patch', reason: 'No changes detected' }); await analyzeNpmPackageDiff(options); expect(mockWriteApiSurfaceLog).toHaveBeenCalledWith('./api-changes.json', mockBaseApiSurface, mockHeadApiSurface, 'test-package', '1.0.0', '1.1.0', 'console'); }); it('should pass custom registry to analyzeNpmPackageVersions', async () => { const options = { base: 'main', format: 'console', npmPackage: 'test-package', fromVersion: '1.0.0', toVersion: '1.1.0', registry: 'https://custom-registry.com' }; const mockApiSurface = { namedExports: new Set(), typeOnlyExports: new Set(), defaultExport: false, starExports: [], packageName: 'test-package', version: '1.0.0', typeDefinitions: new Map() }; mockAnalyzeNpmPackageVersions.mockResolvedValue({ base: mockApiSurface, head: mockApiSurface }); mockCompareApiSurfaces.mockReturnValue({ name: 'test-package', path: 'npm:test-package', breakingChanges: [], nonBreakingChanges: [] }); mockCalculateSemverImpact.mockReturnValue({ minimumBump: 'patch', reason: 'No changes detected' }); await analyzeNpmPackageDiff(options); expect(mockAnalyzeNpmPackageVersions).toHaveBeenCalledWith({ packageName: 'test-package', fromVersion: '1.0.0', toVersion: '1.1.0', registry: 'https://custom-registry.com', verbose: undefined, format: 'console' }); }); it('should pass verbose flag to analyzeNpmPackageVersions', async () => { const options = { base: 'main', format: 'console', npmPackage: 'test-package', fromVersion: '1.0.0', toVersion: '1.1.0', verbose: true }; const mockApiSurface = { namedExports: new Set(), typeOnlyExports: new Set(), defaultExport: false, starExports: [], packageName: 'test-package', version: '1.0.0', typeDefinitions: new Map() }; mockAnalyzeNpmPackageVersions.mockResolvedValue({ base: mockApiSurface, head: mockApiSurface }); mockCompareApiSurfaces.mockReturnValue({ name: 'test-package', path: 'npm:test-package', breakingChanges: [], nonBreakingChanges: [] }); mockCalculateSemverImpact.mockReturnValue({ minimumBump: 'patch', reason: 'No changes detected' }); await analyzeNpmPackageDiff(options); expect(mockAnalyzeNpmPackageVersions).toHaveBeenCalledWith({ packageName: 'test-package', fromVersion: '1.0.0', toVersion: '1.1.0', registry: undefined, verbose: true, format: 'console' }); }); it('should handle scoped package names correctly', async () => { const options = { base: 'main', format: 'console', npmPackage: '@scope/test-package', fromVersion: '1.0.0', toVersion: '1.1.0' }; const mockApiSurface = { namedExports: new Set(), typeOnlyExports: new Set(), defaultExport: false, starExports: [], packageName: '@scope/test-package', version: '1.0.0', typeDefinitions: new Map() }; mockAnalyzeNpmPackageVersions.mockResolvedValue({ base: mockApiSurface, head: mockApiSurface }); mockCompareApiSurfaces.mockReturnValue({ name: '@scope/test-package', path: 'npm:@scope/test-package', breakingChanges: [], nonBreakingChanges: [] }); mockCalculateSemverImpact.mockReturnValue({ minimumBump: 'patch', reason: 'No changes detected' }); const result = await analyzeNpmPackageDiff(options); expect(result.npmPackage).toBe('@scope/test-package'); expect(result.packages[0].name).toBe('@scope/test-package'); expect(result.packages[0].path).toBe('npm:@scope/test-package'); }); });