apisurf
Version:
Analyze API surface changes between npm package versions to catch breaking changes
260 lines (259 loc) • 10.3 kB
JavaScript
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');
});
});