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