import { codeBlock } from 'common-tags'; import { fs } from '~test/util.ts'; import { extractAllPackageFiles, extractPackageFile } from './extract.ts'; vi.mock('../../../util/fs/index.ts'); describe('modules/manager/ant/extract', () => { beforeEach(() => { fs.isValidLocalPath.mockReturnValue(true); }); describe('extractPackageFile', () => { it('extracts inline version dependencies from build.xml', () => { expect( extractPackageFile( codeBlock` `, 'build.xml', ), ).toEqual({ deps: [ expect.objectContaining({ datasource: 'maven', depName: 'junit:junit', currentValue: '4.13.2', depType: 'test', }), ], }); }); it('extracts multiple dependencies', () => { expect( extractPackageFile( codeBlock` `, 'build.xml', ), ).toMatchObject({ deps: [ expect.objectContaining({ depName: 'junit:junit', currentValue: '4.13.2', depType: 'test', }), expect.objectContaining({ depName: 'org.slf4j:slf4j-api', currentValue: '1.7.36', depType: 'compile', }), expect.objectContaining({ depName: 'org.apache.commons:commons-lang3', currentValue: '3.12.0', depType: 'runtime', }), ], }); }); it('defaults depType to compile when no scope is set', () => { expect( extractPackageFile( codeBlock` `, 'build.xml', ), ).toEqual({ deps: [ expect.objectContaining({ depName: 'junit:junit', depType: 'compile', }), ], }); }); it('returns null for invalid XML', () => { expect(extractPackageFile('<<< not xml >>>', 'build.xml')).toBeNull(); }); it('returns null for build.xml with no dependencies', async () => { fs.readLocalFile.mockResolvedValue( '', ); await expect( extractAllPackageFiles({}, ['build.xml']), ).resolves.toBeNull(); }); it('ignores dependency nodes without version', () => { expect( extractPackageFile( codeBlock` `, 'build.xml', ), ).toBeNull(); }); it('extracts dependencies with single-quoted attributes', () => { expect( extractPackageFile( "", 'build.xml', ), ).toEqual({ deps: [ expect.objectContaining({ depName: 'junit:junit', currentValue: '4.13.2', }), ], }); }); it('returns null for unreadable build.xml', async () => { fs.readLocalFile.mockResolvedValue(null); await expect( extractAllPackageFiles({}, ['build.xml']), ).resolves.toBeNull(); }); it('does not revisit the same file', async () => { let readCount = 0; fs.readLocalFile.mockImplementation(() => { readCount++; return Promise.resolve(codeBlock` `); }); const result = await extractAllPackageFiles({}, [ 'build.xml', 'build.xml', ]); expect(result).toHaveLength(1); expect(readCount).toBe(1); }); }); describe('property resolution', () => { it('resolves inline property references', async () => { fs.readLocalFile.mockResolvedValue(codeBlock` `); const result = await extractAllPackageFiles({}, ['build.xml']); expect(result).toEqual([ { packageFile: 'build.xml', deps: [ expect.objectContaining({ depName: 'org.slf4j:slf4j-api', currentValue: '1.7.36', sharedVariableName: 'slf4j.version', }), ], }, ]); }); it('resolves properties from external .properties files', async () => { fs.readLocalFile.mockImplementation((file: string) => { if (file === 'build.xml') { return Promise.resolve(codeBlock` `); } if (file === 'versions.properties') { return Promise.resolve('slf4j.version=1.7.36\n'); } return Promise.resolve(null); }); const result = await extractAllPackageFiles({}, ['build.xml']); expect(result).toEqual([ { packageFile: 'versions.properties', deps: [ expect.objectContaining({ depName: 'org.slf4j:slf4j-api', currentValue: '1.7.36', sharedVariableName: 'slf4j.version', editFile: 'versions.properties', }), ], }, ]); }); it('implements first-definition-wins for inline properties', async () => { fs.readLocalFile.mockResolvedValue(codeBlock` `); const result = await extractAllPackageFiles({}, ['build.xml']); expect(result).toEqual([ { packageFile: 'build.xml', deps: [ expect.objectContaining({ currentValue: '4.13.2', sharedVariableName: 'junit.version', }), ], }, ]); }); it('inline properties take precedence over file properties', async () => { fs.readLocalFile.mockImplementation((file: string) => { if (file === 'build.xml') { return Promise.resolve(codeBlock` `); } if (file === 'versions.properties') { return Promise.resolve('junit.version=4.12\n'); } return Promise.resolve(null); }); const result = await extractAllPackageFiles({}, ['build.xml']); expect(result).toEqual([ { packageFile: 'build.xml', deps: [ expect.objectContaining({ currentValue: '4.13.2', sharedVariableName: 'junit.version', }), ], }, ]); }); it('skips dependencies with unresolvable property references', async () => { fs.readLocalFile.mockResolvedValue(codeBlock` `); const result = await extractAllPackageFiles({}, ['build.xml']); expect(result).toEqual([ { packageFile: 'build.xml', deps: [ expect.objectContaining({ depName: 'junit:junit', skipReason: 'version-placeholder', }), ], }, ]); }); it('detects circular property references', async () => { fs.readLocalFile.mockResolvedValue(codeBlock` `); const result = await extractAllPackageFiles({}, ['build.xml']); expect(result).toEqual([ { packageFile: 'build.xml', deps: [ expect.objectContaining({ depName: 'junit:junit', skipReason: 'recursive-placeholder', }), ], }, ]); }); it('resolves chained property references', async () => { fs.readLocalFile.mockResolvedValue(codeBlock` `); const result = await extractAllPackageFiles({}, ['build.xml']); // chained partial resolution: full.version = "1.7.36" (resolved chain) // but slf4j.version = "${full.version}" -> "1.7.36" (single prop ref, so sharedVariableName) expect(result).toEqual([ { packageFile: 'build.xml', deps: [ expect.objectContaining({ depName: 'org.slf4j:slf4j-api', currentValue: '1.7.36', sharedVariableName: 'slf4j.version', }), ], }, ]); }); it('groups multiple dependencies sharing the same property', async () => { fs.readLocalFile.mockResolvedValue(codeBlock` `); const result = await extractAllPackageFiles({}, ['build.xml']); expect(result).toEqual([ { packageFile: 'build.xml', deps: [ expect.objectContaining({ depName: 'com.fasterxml.jackson.core:jackson-core', currentValue: '2.15.2', sharedVariableName: 'jackson.version', }), expect.objectContaining({ depName: 'com.fasterxml.jackson.core:jackson-databind', currentValue: '2.15.2', sharedVariableName: 'jackson.version', }), ], }, ]); }); it('handles properties file in subdirectory', async () => { fs.readLocalFile.mockImplementation((file: string) => { if (file === 'subproject/build.xml') { return Promise.resolve(codeBlock` `); } if (file === 'subproject/config/deps.properties') { return Promise.resolve('junit.version=4.13.2\n'); } return Promise.resolve(null); }); const result = await extractAllPackageFiles({}, ['subproject/build.xml']); expect(result).toEqual([ { packageFile: 'subproject/config/deps.properties', deps: [ expect.objectContaining({ depName: 'junit:junit', currentValue: '4.13.2', sharedVariableName: 'junit.version', }), ], }, ]); }); it('handles unreadable properties file gracefully', async () => { fs.readLocalFile.mockImplementation((file: string) => { if (file === 'build.xml') { return Promise.resolve(codeBlock` `); } return Promise.resolve(null); }); const result = await extractAllPackageFiles({}, ['build.xml']); expect(result).toEqual([ { packageFile: 'build.xml', deps: [ expect.objectContaining({ depName: 'junit:junit', skipReason: 'version-placeholder', }), ], }, ]); }); it('returns deps with mixed inline and property versions', async () => { fs.readLocalFile.mockResolvedValue(codeBlock` `); const result = await extractAllPackageFiles({}, ['build.xml']); expect(result).toEqual([ { packageFile: 'build.xml', deps: [ expect.objectContaining({ depName: 'junit:junit', currentValue: '4.13.2', sharedVariableName: 'junit.version', }), expect.objectContaining({ depName: 'org.slf4j:slf4j-api', currentValue: '1.7.36', }), ], }, ]); }); it('ignores dependency without version during property resolution', async () => { fs.readLocalFile.mockResolvedValue(codeBlock` `); const result = await extractAllPackageFiles({}, ['build.xml']); expect(result).toEqual([ { packageFile: 'build.xml', deps: [ expect.objectContaining({ depName: 'junit:junit', currentValue: '4.13.2', sharedVariableName: 'junit.version', }), ], }, ]); }); it('skips partial placeholder in version string', async () => { fs.readLocalFile.mockResolvedValue(codeBlock` `); const result = await extractAllPackageFiles({}, ['build.xml']); expect(result).toEqual([ { packageFile: 'build.xml', deps: [ expect.objectContaining({ depName: 'org.slf4j:slf4j-api', skipReason: 'version-placeholder', }), ], }, ]); }); }); describe('edge cases', () => { it('handles unparseable XML returned by readLocalFile', async () => { fs.readLocalFile.mockResolvedValue('<<< not xml >>>'); const result = await extractAllPackageFiles({}, ['build.xml']); expect(result).toBeNull(); }); it('handles absolute path in property file reference', async () => { fs.readLocalFile.mockImplementation((file: string) => { if (file === 'build.xml') { return Promise.resolve(codeBlock` `); } if (file === '/absolute/versions.properties') { return Promise.resolve('junit.version=4.13.2\n'); } return Promise.resolve(null); }); const result = await extractAllPackageFiles({}, ['build.xml']); expect(result).toEqual([ { packageFile: '/absolute/versions.properties', deps: [ expect.objectContaining({ depName: 'junit:junit', currentValue: '4.13.2', sharedVariableName: 'junit.version', }), ], }, ]); }); it('skips duplicate property file references', async () => { let propsReadCount = 0; fs.readLocalFile.mockImplementation((file: string) => { if (file === 'build.xml') { return Promise.resolve(codeBlock` `); } if (file === 'versions.properties') { propsReadCount++; return Promise.resolve('junit.version=4.13.2\n'); } return Promise.resolve(null); }); const result = await extractAllPackageFiles({}, ['build.xml']); expect(propsReadCount).toBe(1); expect(result).toEqual([ { packageFile: 'versions.properties', deps: [ expect.objectContaining({ depName: 'junit:junit', currentValue: '4.13.2', }), ], }, ]); }); it('follows import file references', async () => { fs.readLocalFile.mockImplementation((fileName: string) => { const files: Record = { 'build.xml': codeBlock` `, 'deps.xml': codeBlock` `, }; return Promise.resolve(files[fileName] ?? null); }); const result = await extractAllPackageFiles({}, ['build.xml']); expect(result).toEqual([ { packageFile: 'deps.xml', deps: [ expect.objectContaining({ depName: 'junit:junit', currentValue: '4.13.2', }), ], }, ]); }); it('skips missing import files', async () => { fs.readLocalFile.mockImplementation((fileName: string) => { const files: Record = { 'build.xml': codeBlock` `, }; return Promise.resolve(files[fileName] ?? null); }); const result = await extractAllPackageFiles({}, ['build.xml']); expect(result).toEqual([ { packageFile: 'build.xml', deps: [ expect.objectContaining({ depName: 'junit:junit', currentValue: '4.13.2', }), ], }, ]); }); it('does not loop on self-importing files', async () => { fs.readLocalFile.mockImplementation((fileName: string) => { const files: Record = { 'build.xml': codeBlock` `, }; return Promise.resolve(files[fileName] ?? null); }); const result = await extractAllPackageFiles({}, ['build.xml']); expect(result).toEqual([ { packageFile: 'build.xml', deps: [ expect.objectContaining({ depName: 'junit:junit', currentValue: '4.13.2', }), ], }, ]); }); it('shares properties across imported files', async () => { fs.readLocalFile.mockImplementation((fileName: string) => { const files: Record = { 'build.xml': codeBlock` `, 'deps.xml': codeBlock` `, }; return Promise.resolve(files[fileName] ?? null); }); const result = await extractAllPackageFiles({}, ['build.xml']); expect(fs.readLocalFile).toHaveBeenCalledWith('deps.xml', 'utf8'); expect(result).toEqual([ { packageFile: 'build.xml', deps: [ expect.objectContaining({ depName: 'junit:junit', currentValue: '4.13.2', sharedVariableName: 'junit.version', editFile: 'build.xml', }), ], }, ]); }); it('extracts dependency from 3-part coords attribute', async () => { fs.readLocalFile.mockImplementation((fileName: string) => { const files: Record = { 'build.xml': codeBlock` `, }; return Promise.resolve(files[fileName] ?? null); }); const result = await extractAllPackageFiles({}, ['build.xml']); expect(result).toEqual([ { packageFile: 'build.xml', deps: [ expect.objectContaining({ datasource: 'maven', depName: 'junit:junit', currentValue: '4.13.2', depType: 'compile', }), ], }, ]); }); it('extracts scope from 4-part coords attribute', async () => { fs.readLocalFile.mockImplementation((fileName: string) => { const files: Record = { 'build.xml': codeBlock` `, }; return Promise.resolve(files[fileName] ?? null); }); const result = await extractAllPackageFiles({}, ['build.xml']); expect(result).toEqual([ { packageFile: 'build.xml', deps: [ expect.objectContaining({ depName: 'junit:junit', currentValue: '4.13.2', depType: 'test', }), ], }, ]); }); it('ignores coords with fewer than 3 parts', async () => { fs.readLocalFile.mockImplementation((fileName: string) => { const files: Record = { 'build.xml': codeBlock` `, }; return Promise.resolve(files[fileName] ?? null); }); await expect( extractAllPackageFiles({}, ['build.xml']), ).resolves.toBeNull(); }); it('ignores coords with empty groupId', async () => { fs.readLocalFile.mockImplementation((fileName: string) => { const files: Record = { 'build.xml': codeBlock` `, }; return Promise.resolve(files[fileName] ?? null); }); await expect( extractAllPackageFiles({}, ['build.xml']), ).resolves.toBeNull(); }); it('resolves property references in coords version', async () => { fs.readLocalFile.mockImplementation((fileName: string) => { const files: Record = { 'build.xml': codeBlock` `, }; return Promise.resolve(files[fileName] ?? null); }); const result = await extractAllPackageFiles({}, ['build.xml']); expect(result).toEqual([ { packageFile: 'build.xml', deps: [ expect.objectContaining({ depName: 'junit:junit', currentValue: '4.13.2', sharedVariableName: 'junit.version', }), ], }, ]); }); it('marks coords dependency with unresolvable property', async () => { fs.readLocalFile.mockImplementation((fileName: string) => { const files: Record = { 'build.xml': codeBlock` `, }; return Promise.resolve(files[fileName] ?? null); }); const result = await extractAllPackageFiles({}, ['build.xml']); expect(result).toEqual([ { packageFile: 'build.xml', deps: [ expect.objectContaining({ depName: 'junit:junit', skipReason: 'version-placeholder', }), ], }, ]); }); it('treats last part as version when it is not a known scope', async () => { fs.readLocalFile.mockImplementation((fileName: string) => { const files: Record = { 'build.xml': codeBlock` `, }; return Promise.resolve(files[fileName] ?? null); }); const result = await extractAllPackageFiles({}, ['build.xml']); expect(result).toEqual([ { packageFile: 'build.xml', deps: [ expect.objectContaining({ depName: 'org.example:lib', currentValue: '1.0.0', depType: 'compile', }), ], }, ]); }); it('collects registry URLs from remoteRepository elements', async () => { fs.readLocalFile.mockImplementation((fileName: string) => { const files: Record = { 'build.xml': codeBlock` `, }; return Promise.resolve(files[fileName] ?? null); }); const result = await extractAllPackageFiles({}, ['build.xml']); expect(result).toEqual([ { packageFile: 'build.xml', deps: [ expect.objectContaining({ depName: 'junit:junit', registryUrls: ['https://repo.example.com/maven2'], }), ], }, ]); }); it('passes registry URLs to coords-style dependencies', async () => { fs.readLocalFile.mockImplementation((fileName: string) => { const files: Record = { 'build.xml': codeBlock` `, }; return Promise.resolve(files[fileName] ?? null); }); const result = await extractAllPackageFiles({}, ['build.xml']); expect(result).toEqual([ { packageFile: 'build.xml', deps: [ expect.objectContaining({ depName: 'junit:junit', registryUrls: ['https://repo.example.com/maven2'], }), ], }, ]); }); it('collects registry URLs from settingsFile attribute', async () => { fs.readLocalFile.mockImplementation((fileName: string) => { const files: Record = { 'build.xml': codeBlock` `, 'build/settings.xml': codeBlock` https://artifactory.example.com/maven `, }; return Promise.resolve(files[fileName] ?? null); }); const result = await extractAllPackageFiles({}, ['build.xml']); expect(result).toEqual([ { packageFile: 'build.xml', deps: [ expect.objectContaining({ depName: 'junit:junit', registryUrls: ['https://artifactory.example.com/maven'], }), ], }, ]); }); it('merges registries from settingsFile and remoteRepository', async () => { fs.readLocalFile.mockImplementation((fileName: string) => { const files: Record = { 'build.xml': codeBlock` `, 'build/settings.xml': codeBlock` https://artifactory.example.com/maven `, }; return Promise.resolve(files[fileName] ?? null); }); const result = await extractAllPackageFiles({}, ['build.xml']); expect(result).toEqual([ { packageFile: 'build.xml', deps: [ expect.objectContaining({ depName: 'junit:junit', registryUrls: [ 'https://artifactory.example.com/maven', 'https://repo.example.com/maven2', ], }), ], }, ]); }); it('handles absolute settingsFile path', async () => { fs.readLocalFile.mockImplementation((fileName: string) => { const files: Record = { 'build.xml': codeBlock` `, '/etc/maven/settings.xml': codeBlock` https://internal.example.com/maven `, }; return Promise.resolve(files[fileName] ?? null); }); const result = await extractAllPackageFiles({}, ['build.xml']); expect(result).toEqual([ { packageFile: 'build.xml', deps: [ expect.objectContaining({ depName: 'junit:junit', registryUrls: ['https://internal.example.com/maven'], }), ], }, ]); }); it('logs debug when settingsFile cannot be read', async () => { fs.readLocalFile.mockImplementation((fileName: string) => { const files: Record = { 'build.xml': codeBlock` `, }; return Promise.resolve(files[fileName] ?? null); }); const result = await extractAllPackageFiles({}, ['build.xml']); expect(result).toEqual([ { packageFile: 'build.xml', deps: [ expect.objectContaining({ depName: 'junit:junit', }), ], }, ]); }); it('does not pass registries to dependencies outside the block', async () => { fs.readLocalFile.mockImplementation((fileName: string) => { const files: Record = { 'build.xml': codeBlock` `, }; return Promise.resolve(files[fileName] ?? null); }); const result = await extractAllPackageFiles({}, ['build.xml']); expect(result).toEqual([ { packageFile: 'build.xml', deps: [ expect.objectContaining({ depName: 'junit:junit', registryUrls: ['https://repo.example.com/maven2'], }), expect.objectContaining({ depName: 'org.slf4j:slf4j-api', }), ], }, ]); }); it('skips property file references with unresolved placeholders in path', async () => { fs.readLocalFile.mockImplementation((file: string) => { if (file === 'build.xml') { return Promise.resolve(codeBlock` `); } return Promise.resolve(null); }); const result = await extractAllPackageFiles({}, ['build.xml']); expect(fs.readLocalFile).toHaveBeenCalledTimes(1); expect(fs.readLocalFile).toHaveBeenCalledWith('build.xml', 'utf8'); expect(result).toEqual([ { packageFile: 'build.xml', deps: [ expect.objectContaining({ depName: 'junit:junit', currentValue: '4.13.2', }), ], }, ]); }); it('skips property file references that resolve outside the repository', async () => { fs.isValidLocalPath.mockImplementation( (file: string) => !file.includes('..'), ); fs.readLocalFile.mockImplementation((file: string) => { if (file === 'build.xml') { return Promise.resolve(codeBlock` `); } return Promise.resolve(null); }); const result = await extractAllPackageFiles({}, ['build.xml']); expect(fs.readLocalFile).toHaveBeenCalledTimes(1); expect(fs.readLocalFile).toHaveBeenCalledWith('build.xml', 'utf8'); expect(result).toEqual([ { packageFile: 'build.xml', deps: [ expect.objectContaining({ depName: 'junit:junit', currentValue: '4.13.2', }), ], }, ]); }); it('skips import file references that resolve outside the repository', async () => { fs.isValidLocalPath.mockImplementation( (file: string) => !file.includes('..'), ); fs.readLocalFile.mockImplementation((file: string) => { if (file === 'build.xml') { return Promise.resolve(codeBlock` `); } return Promise.resolve(null); }); const result = await extractAllPackageFiles({}, ['build.xml']); expect(fs.readLocalFile).toHaveBeenCalledTimes(1); expect(fs.readLocalFile).toHaveBeenCalledWith('build.xml', 'utf8'); expect(result).toEqual([ { packageFile: 'build.xml', deps: [ expect.objectContaining({ depName: 'junit:junit', currentValue: '4.13.2', }), ], }, ]); }); it('skips settingsFile references that resolve outside the repository', async () => { fs.isValidLocalPath.mockImplementation( (file: string) => !file.includes('..'), ); fs.readLocalFile.mockImplementation((file: string) => { if (file === 'build.xml') { return Promise.resolve(codeBlock` `); } return Promise.resolve(null); }); const result = await extractAllPackageFiles({}, ['build.xml']); expect(fs.readLocalFile).toHaveBeenCalledTimes(1); expect(fs.readLocalFile).toHaveBeenCalledWith('build.xml', 'utf8'); expect(result).toEqual([ { packageFile: 'build.xml', deps: [ expect.objectContaining({ depName: 'junit:junit', currentValue: '4.13.2', }), ], }, ]); }); it('skips import file references with unresolved placeholders in path', async () => { fs.readLocalFile.mockImplementation((file: string) => { if (file === 'build.xml') { return Promise.resolve(codeBlock` `); } return Promise.resolve(null); }); const result = await extractAllPackageFiles({}, ['build.xml']); expect(fs.readLocalFile).toHaveBeenCalledTimes(1); expect(fs.readLocalFile).toHaveBeenCalledWith('build.xml', 'utf8'); expect(result).toEqual([ { packageFile: 'build.xml', deps: [ expect.objectContaining({ depName: 'junit:junit', currentValue: '4.13.2', }), ], }, ]); }); it('handles chain referencing undefined property', async () => { fs.readLocalFile.mockResolvedValue(codeBlock` `); const result = await extractAllPackageFiles({}, ['build.xml']); expect(result).toEqual([ { packageFile: 'build.xml', deps: [ expect.objectContaining({ depName: 'junit:junit', skipReason: 'recursive-placeholder', }), ], }, ]); }); }); });