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