import { fs, logger, scm } from '~test/util.ts'; import { GitTagsDatasource } from '../../datasource/git-tags/index.ts'; import { GithubTagsDatasource } from '../../datasource/github-tags/index.ts'; import { GitlabTagsDatasource } from '../../datasource/gitlab-tags/index.ts'; import * as datasource from '../../datasource/index.ts'; import { updateArtifacts } from './artifacts.ts'; vi.mock('../../../util/fs/index.ts'); vi.mock('../../datasource/index.ts', async () => { const actual = await vi.importActual( '../../datasource/index.ts', ); return { ...actual, getDigest: vi.fn(), }; }); const v2Fixture = JSON.stringify( { pins: [ { identity: 'by-commit', kind: 'remoteSourceControl', location: 'https://github.com/example/by-commit', state: { revision: 'decaf', }, }, { identity: 'alamofire', kind: 'remoteSourceControl', location: 'https://github.com/Alamofire/Alamofire', state: { revision: 'f455c2975872ccd2d9c81594c658af65716e9b9a', version: '5.9.1', }, }, { identity: 'swift-argument-parser', kind: 'remoteSourceControl', location: 'https://github.com/apple/swift-argument-parser.git', state: { revision: 'c8ed701b513cf5177118a175d85c4c06d8f9f97c', version: '1.4.0', }, }, ], version: 2, }, null, 2, ); const v3Fixture = JSON.stringify( { originHash: 'abc123def456', pins: [ { identity: 'alamofire', kind: 'remoteSourceControl', location: 'https://github.com/Alamofire/Alamofire', state: { revision: 'f455c2975872ccd2d9c81594c658af65716e9b9a', version: '5.9.1', }, }, ], version: 3, }, null, 2, ); describe('modules/manager/swift/artifacts', () => { it('returns null when no Package.resolved files exist', async () => { scm.getFileList.mockResolvedValue(['Package.swift', 'Sources/main.swift']); const result = await updateArtifacts({ packageFileName: 'Package.swift', updatedDeps: [ { depName: 'Alamofire/Alamofire', newVersion: 'v5.10.0', newValue: '5.10.0', }, ], newPackageFileContent: '', config: {}, }); expect(result).toBeNull(); }); it('returns null when updatedDeps is empty', async () => { scm.getFileList.mockResolvedValue(['Package.resolved']); const result = await updateArtifacts({ packageFileName: 'Package.swift', updatedDeps: [], newPackageFileContent: '', config: {}, }); expect(result).toBeNull(); }); it('returns null for lockFileMaintenance', async () => { scm.getFileList.mockResolvedValue(['Package.resolved']); const result = await updateArtifacts({ packageFileName: 'Package.swift', updatedDeps: [ { depName: 'Alamofire/Alamofire', newVersion: 'v5.10.0', newValue: '5.10.0', }, ], newPackageFileContent: '', config: { isLockFileMaintenance: true }, }); expect(result).toBeNull(); }); it('returns null for unparseable JSON', async () => { scm.getFileList.mockResolvedValue(['Package.resolved']); fs.readLocalFile.mockResolvedValue('not valid json{{{'); const result = await updateArtifacts({ packageFileName: 'Package.swift', updatedDeps: [ { depName: 'Alamofire/Alamofire', newVersion: 'v5.10.0', newValue: '5.10.0', }, ], newPackageFileContent: '', config: {}, }); expect(result).toBeNull(); }); it('returns null for unsupported v1 format', async () => { scm.getFileList.mockResolvedValue(['Package.resolved']); fs.readLocalFile.mockResolvedValue( JSON.stringify({ object: { pins: [] }, version: 1, }), ); const result = await updateArtifacts({ packageFileName: 'Package.swift', updatedDeps: [ { depName: 'Alamofire/Alamofire', newVersion: 'v5.10.0', newValue: '5.10.0', }, ], newPackageFileContent: '', config: {}, }); expect(result).toBeNull(); }); it('updates a single pin version and revision', async () => { scm.getFileList.mockResolvedValue(['Package.resolved']); fs.readLocalFile.mockResolvedValue(v2Fixture); vi.mocked(datasource.getDigest).mockResolvedValue('newrevisionsha123'); const result = await updateArtifacts({ packageFileName: 'Package.swift', updatedDeps: [ { depName: 'Alamofire/Alamofire', datasource: GithubTagsDatasource.id, newVersion: 'v5.10.0', newValue: '5.10.0', }, ], newPackageFileContent: '', config: {}, }); expect(result).toHaveLength(1); const { contents } = result![0].file as { contents: string }; expect(contents).toContain('"version": "5.10.0"'); expect(contents).toContain('"revision": "newrevisionsha123"'); // Other pin unchanged expect(contents).toContain('"version": "1.4.0"'); expect(contents).toContain( '"revision": "c8ed701b513cf5177118a175d85c4c06d8f9f97c"', ); }); it('parses lockfile with mixed source-control and registry pins', async () => { // Regression test: SwiftPM Package Registry (SE-0292) const v2MixedFixture = JSON.stringify( { pins: [ { identity: 'alamofire', kind: 'remoteSourceControl', location: 'https://github.com/Alamofire/Alamofire', state: { revision: 'f455c2975872ccd2d9c81594c658af65716e9b9a', version: '5.9.1', }, }, { identity: 'example.registry-package', kind: 'registry', location: '', state: { version: '1.2.3', }, }, ], version: 2, }, null, 2, ); scm.getFileList.mockResolvedValue(['Package.resolved']); fs.readLocalFile.mockResolvedValue(v2MixedFixture); vi.mocked(datasource.getDigest).mockResolvedValue('newrevisionsha123'); await expect( updateArtifacts({ packageFileName: 'Package.swift', updatedDeps: [ { depName: 'Alamofire/Alamofire', datasource: GithubTagsDatasource.id, newVersion: 'v5.10.0', newValue: '5.10.0', }, ], newPackageFileContent: '', config: {}, }), ).resolves.toEqual([ { file: { type: 'addition', path: 'Package.resolved', contents: JSON.stringify( { pins: [ { identity: 'alamofire', kind: 'remoteSourceControl', location: 'https://github.com/Alamofire/Alamofire', state: { revision: 'newrevisionsha123', version: '5.10.0', }, }, { identity: 'example.registry-package', kind: 'registry', location: '', state: { version: '1.2.3' }, }, ], version: 2, }, null, 2, ), }, }, ]); }); it('does not write `from:` range to Package.resolved', async () => { scm.getFileList.mockResolvedValue(['Package.resolved']); fs.readLocalFile.mockResolvedValue(v2Fixture); vi.mocked(datasource.getDigest).mockResolvedValue('newrevisionsha123'); const result = await updateArtifacts({ packageFileName: 'Package.swift', updatedDeps: [ { depName: 'Alamofire/Alamofire', datasource: GithubTagsDatasource.id, newVersion: '5.10.0', newValue: 'from: "5.10.0"', }, ], newPackageFileContent: '', config: {}, }); expect(result).toHaveLength(1); const { contents } = result![0].file as { contents: string }; expect(contents).toContain('"version": "5.10.0"'); expect(contents).not.toContain('from:'); }); it('updates multiple pins in one call', async () => { scm.getFileList.mockResolvedValue(['Package.resolved']); fs.readLocalFile.mockResolvedValue(v2Fixture); vi.mocked(datasource.getDigest) .mockResolvedValueOnce('alamofiresha') .mockResolvedValueOnce('argparsersha'); const result = await updateArtifacts({ packageFileName: 'Package.swift', updatedDeps: [ { depName: 'Alamofire/Alamofire', datasource: GithubTagsDatasource.id, newVersion: 'v5.10.0', newValue: '5.10.0', }, { depName: 'apple/swift-argument-parser', datasource: GithubTagsDatasource.id, newVersion: 'v1.5.0', newValue: '1.5.0', }, ], newPackageFileContent: '', config: {}, }); expect(result).toHaveLength(1); const { contents } = result![0].file as { contents: string }; expect(contents).toContain('"version": "5.10.0"'); expect(contents).toContain('"revision": "alamofiresha"'); expect(contents).toContain('"version": "1.5.0"'); expect(contents).toContain('"revision": "argparsersha"'); }); it('skips dep with no matching pin', async () => { scm.getFileList.mockResolvedValue(['Package.resolved']); fs.readLocalFile.mockResolvedValue(v2Fixture); const result = await updateArtifacts({ packageFileName: 'Package.swift', updatedDeps: [ { depName: 'nonexistent/package', datasource: GithubTagsDatasource.id, newVersion: 'v1.0.0', newValue: '1.0.0', }, ], newPackageFileContent: '', config: {}, }); expect(result).toBeNull(); }); it('handles getDigest failure — updates version, keeps old revision', async () => { scm.getFileList.mockResolvedValue(['Package.resolved']); fs.readLocalFile.mockResolvedValue(v2Fixture); vi.mocked(datasource.getDigest).mockResolvedValue(null); const result = await updateArtifacts({ packageFileName: 'Package.swift', updatedDeps: [ { depName: 'Alamofire/Alamofire', datasource: GithubTagsDatasource.id, newVersion: 'v5.10.0', newValue: '5.10.0', }, ], newPackageFileContent: '', config: {}, }); expect(result).toHaveLength(1); const { contents } = result![0].file as { contents: string }; expect(contents).toContain('"version": "5.10.0"'); // Revision unchanged since getDigest returned null expect(contents).toContain( '"revision": "f455c2975872ccd2d9c81594c658af65716e9b9a"', ); }); it('updates multiple Package.resolved files', async () => { scm.getFileList.mockResolvedValue([ 'Package.resolved', 'App.xcworkspace/xcshareddata/swiftpm/Package.resolved', ]); fs.readLocalFile.mockResolvedValue(v2Fixture); vi.mocked(datasource.getDigest).mockResolvedValue('newsha'); const result = await updateArtifacts({ packageFileName: 'Package.swift', updatedDeps: [ { depName: 'Alamofire/Alamofire', datasource: GithubTagsDatasource.id, newVersion: 'v5.10.0', newValue: '5.10.0', }, ], newPackageFileContent: '', config: {}, }); expect(result).toHaveLength(2); expect(result![0].file!.path).toBe('Package.resolved'); expect(result![1].file!.path).toBe( 'App.xcworkspace/xcshareddata/swiftpm/Package.resolved', ); }); it('matches URL with .git suffix normalization', async () => { scm.getFileList.mockResolvedValue(['Package.resolved']); fs.readLocalFile.mockResolvedValue(v2Fixture); vi.mocked(datasource.getDigest).mockResolvedValue('newsha'); // swift-argument-parser has .git in the fixture location const result = await updateArtifacts({ packageFileName: 'Package.swift', updatedDeps: [ { depName: 'apple/swift-argument-parser', datasource: GithubTagsDatasource.id, newVersion: 'v1.5.0', newValue: '1.5.0', }, ], newPackageFileContent: '', config: {}, }); expect(result).toHaveLength(1); const { contents } = result![0].file as { contents: string }; expect(contents).toContain('"version": "1.5.0"'); }); it('matches URL with trailing slash normalization', async () => { scm.getFileList.mockResolvedValue(['Package.resolved']); const fixtureWithSlash = v2Fixture.replace( '"https://github.com/Alamofire/Alamofire"', '"https://github.com/Alamofire/Alamofire/"', ); fs.readLocalFile.mockResolvedValue(fixtureWithSlash); vi.mocked(datasource.getDigest).mockResolvedValue('newsha'); const result = await updateArtifacts({ packageFileName: 'Package.swift', updatedDeps: [ { depName: 'Alamofire/Alamofire', datasource: GithubTagsDatasource.id, newVersion: 'v5.10.0', newValue: '5.10.0', }, ], newPackageFileContent: '', config: {}, }); expect(result).toHaveLength(1); }); it('matches URL case-insensitively', async () => { scm.getFileList.mockResolvedValue(['Package.resolved']); fs.readLocalFile.mockResolvedValue(v2Fixture); vi.mocked(datasource.getDigest).mockResolvedValue('newsha'); const result = await updateArtifacts({ packageFileName: 'Package.swift', updatedDeps: [ { depName: 'alamofire/alamofire', datasource: GithubTagsDatasource.id, newVersion: 'v5.10.0', newValue: '5.10.0', }, ], newPackageFileContent: '', config: {}, }); expect(result).toHaveLength(1); }); it('handles git-tags datasource (full URL as depName)', async () => { scm.getFileList.mockResolvedValue(['Package.resolved']); fs.readLocalFile.mockResolvedValue(v2Fixture); vi.mocked(datasource.getDigest).mockResolvedValue('newsha'); const result = await updateArtifacts({ packageFileName: 'Package.swift', updatedDeps: [ { depName: 'https://github.com/Alamofire/Alamofire.git', datasource: GitTagsDatasource.id, newVersion: 'v5.10.0', newValue: '5.10.0', }, ], newPackageFileContent: '', config: {}, }); expect(result).toHaveLength(1); const { contents } = result![0].file as { contents: string }; expect(contents).toContain('"version": "5.10.0"'); }); it('matches pin when Package.resolved uses SSH URL', async () => { scm.getFileList.mockResolvedValue(['Package.resolved']); const sshFixture = v2Fixture.replace( '"https://github.com/Alamofire/Alamofire"', '"git@github.com:Alamofire/Alamofire.git"', ); fs.readLocalFile.mockResolvedValue(sshFixture); vi.mocked(datasource.getDigest).mockResolvedValue('newsha'); const result = await updateArtifacts({ packageFileName: 'Package.swift', updatedDeps: [ { depName: 'Alamofire/Alamofire', datasource: GithubTagsDatasource.id, newVersion: 'v5.10.0', newValue: '5.10.0', }, ], newPackageFileContent: '', config: {}, }); expect(result).toHaveLength(1); const { contents } = result![0].file as { contents: string }; expect(contents).toContain('"version": "5.10.0"'); }); it('matches pin with git-tags datasource using SSH URL as depName', async () => { scm.getFileList.mockResolvedValue(['Package.resolved']); fs.readLocalFile.mockResolvedValue(v2Fixture); vi.mocked(datasource.getDigest).mockResolvedValue('newsha'); const result = await updateArtifacts({ packageFileName: 'Package.swift', updatedDeps: [ { depName: 'git@github.com:Alamofire/Alamofire.git', datasource: GitTagsDatasource.id, newVersion: 'v5.10.0', newValue: '5.10.0', }, ], newPackageFileContent: '', config: {}, }); expect(result).toHaveLength(1); const { contents } = result![0].file as { contents: string }; expect(contents).toContain('"version": "5.10.0"'); }); it('matches pin when Package.resolved uses ssh:// URL', async () => { scm.getFileList.mockResolvedValue(['Package.resolved']); const sshFixture = v2Fixture.replace( '"https://github.com/Alamofire/Alamofire"', '"ssh://git@github.com/Alamofire/Alamofire.git"', ); fs.readLocalFile.mockResolvedValue(sshFixture); vi.mocked(datasource.getDigest).mockResolvedValue('newsha'); const result = await updateArtifacts({ packageFileName: 'Package.swift', updatedDeps: [ { depName: 'Alamofire/Alamofire', datasource: GithubTagsDatasource.id, newVersion: 'v5.10.0', newValue: '5.10.0', }, ], newPackageFileContent: '', config: {}, }); expect(result).toHaveLength(1); const { contents } = result![0].file as { contents: string }; expect(contents).toContain('"version": "5.10.0"'); }); it('falls back to basic normalization for host-only URLs', async () => { scm.getFileList.mockResolvedValue(['Package.resolved']); const hostOnlyFixture = JSON.stringify( { pins: [ { identity: 'my-lib', kind: 'remoteSourceControl', location: 'https://custom-registry.example.com', state: { revision: 'oldsha', version: '1.0.0', }, }, ], version: 2, }, null, 2, ); fs.readLocalFile.mockResolvedValue(hostOnlyFixture); vi.mocked(datasource.getDigest).mockResolvedValue('newsha'); // No match expected since host-only URL won't match any dep const result = await updateArtifacts({ packageFileName: 'Package.swift', updatedDeps: [ { depName: 'org/my-lib', datasource: GithubTagsDatasource.id, newVersion: 'v2.0.0', newValue: '2.0.0', }, ], newPackageFileContent: '', config: {}, }); expect(result).toBeNull(); }); it('handles gitlab-tags with custom registryUrls', async () => { scm.getFileList.mockResolvedValue(['Package.resolved']); const gitlabFixture = JSON.stringify( { pins: [ { identity: 'my-lib', kind: 'remoteSourceControl', location: 'https://gitlab.example.com/org/my-lib', state: { revision: 'oldsha', version: '1.0.0', }, }, ], version: 2, }, null, 2, ); fs.readLocalFile.mockResolvedValue(gitlabFixture); vi.mocked(datasource.getDigest).mockResolvedValue('newsha'); const result = await updateArtifacts({ packageFileName: 'Package.swift', updatedDeps: [ { depName: 'org/my-lib', datasource: GitlabTagsDatasource.id, registryUrls: ['https://gitlab.example.com'], newVersion: 'v2.0.0', newValue: '2.0.0', }, ], newPackageFileContent: '', config: {}, }); expect(result).toHaveLength(1); const { contents } = result![0].file as { contents: string }; expect(contents).toContain('"version": "2.0.0"'); expect(contents).toContain('"revision": "newsha"'); }); it('uses dep.newDigest when already present', async () => { scm.getFileList.mockResolvedValue(['Package.resolved']); fs.readLocalFile.mockResolvedValue(v2Fixture); const result = await updateArtifacts({ packageFileName: 'Package.swift', updatedDeps: [ { depName: 'Alamofire/Alamofire', datasource: GithubTagsDatasource.id, newVersion: 'v5.10.0', newValue: '5.10.0', newDigest: 'precomputedsha456', }, ], newPackageFileContent: '', config: {}, }); expect(result).toHaveLength(1); const { contents } = result![0].file as { contents: string }; expect(contents).toContain('"revision": "precomputedsha456"'); // getDigest should not have been called expect(datasource.getDigest).not.toHaveBeenCalled(); }); it('preserves v3 originHash', async () => { scm.getFileList.mockResolvedValue(['Package.resolved']); fs.readLocalFile.mockResolvedValue(v3Fixture); vi.mocked(datasource.getDigest).mockResolvedValue('newsha'); const result = await updateArtifacts({ packageFileName: 'Package.swift', updatedDeps: [ { depName: 'Alamofire/Alamofire', datasource: GithubTagsDatasource.id, newVersion: 'v5.10.0', newValue: '5.10.0', }, ], newPackageFileContent: '', config: {}, }); expect(result).toHaveLength(1); const { contents } = result![0].file as { contents: string }; expect(contents).toContain('"originHash": "abc123def456"'); expect(contents).toContain('"version": "5.10.0"'); }); it('returns null when pin is already up-to-date', async () => { scm.getFileList.mockResolvedValue(['Package.resolved']); fs.readLocalFile.mockResolvedValue(v2Fixture); const result = await updateArtifacts({ packageFileName: 'Package.swift', updatedDeps: [ { depName: 'Alamofire/Alamofire', datasource: GithubTagsDatasource.id, newVersion: 'v5.9.1', // same as fixture newValue: '5.9.1', }, ], newPackageFileContent: '', config: {}, }); expect(result).toBeNull(); }); it('preserves formatting in targeted replacement', async () => { scm.getFileList.mockResolvedValue(['Package.resolved']); fs.readLocalFile.mockResolvedValue(v2Fixture); vi.mocked(datasource.getDigest).mockResolvedValue('newsha'); const result = await updateArtifacts({ packageFileName: 'Package.swift', updatedDeps: [ { depName: 'Alamofire/Alamofire', datasource: GithubTagsDatasource.id, newVersion: 'v5.10.0', newValue: '5.10.0', }, ], newPackageFileContent: '', config: {}, }); expect(result).toHaveLength(1); const { contents } = result![0].file as { contents: string }; // Verify overall structure is preserved expect(contents).toContain('"identity": "alamofire"'); expect(contents).toContain('"version": 2'); }); it('returns null when Package.resolved cannot be read', async () => { scm.getFileList.mockResolvedValue(['Package.resolved']); fs.readLocalFile.mockResolvedValue(null); const result = await updateArtifacts({ packageFileName: 'Package.swift', updatedDeps: [ { depName: 'Alamofire/Alamofire', newVersion: 'v5.10.0', newValue: '5.10.0', }, ], newPackageFileContent: '', config: {}, }); expect(result).toBeNull(); }); it('skips dep with no newValue', async () => { scm.getFileList.mockResolvedValue(['Package.resolved']); fs.readLocalFile.mockResolvedValue(v2Fixture); const result = await updateArtifacts({ packageFileName: 'Package.swift', updatedDeps: [ { depName: 'Alamofire/Alamofire', datasource: GithubTagsDatasource.id, // no newValue }, ], newPackageFileContent: '', config: {}, }); expect(result).toBeNull(); }); it('returns null when dep has no datasource or packageName', async () => { scm.getFileList.mockResolvedValue(['Package.resolved']); fs.readLocalFile.mockResolvedValue(v2Fixture); const result = await updateArtifacts({ packageFileName: 'Package.swift', updatedDeps: [ { depName: 'Alamofire/Alamofire', // no datasource — resolveCommitSha returns null newVersion: 'v5.10.0', newValue: '5.10.0', }, ], newPackageFileContent: '', config: {}, }); expect(result).toHaveLength(1); const { contents } = result![0].file as { contents: string }; expect(contents).toContain('"version": "5.10.0"'); // Revision unchanged — no datasource to resolve SHA expect(contents).toContain( '"revision": "f455c2975872ccd2d9c81594c658af65716e9b9a"', ); }); it('returns null when Package.resolved has no pins array', async () => { scm.getFileList.mockResolvedValue(['Package.resolved']); fs.readLocalFile.mockResolvedValue( JSON.stringify({ version: 2, somethingElse: true }), ); const result = await updateArtifacts({ packageFileName: 'Package.swift', updatedDeps: [ { depName: 'Alamofire/Alamofire', newVersion: 'v5.10.0', newValue: '5.10.0', }, ], newPackageFileContent: '', config: {}, }); expect(result).toBeNull(); }); it('handles getDigest throwing an error', async () => { scm.getFileList.mockResolvedValue(['Package.resolved']); fs.readLocalFile.mockResolvedValue(v2Fixture); vi.mocked(datasource.getDigest).mockRejectedValue( new Error('network error'), ); const result = await updateArtifacts({ packageFileName: 'Package.swift', updatedDeps: [ { depName: 'Alamofire/Alamofire', datasource: GithubTagsDatasource.id, newVersion: 'v5.10.0', newValue: '5.10.0', }, ], newPackageFileContent: '', config: {}, }); expect(result).toHaveLength(1); const { contents } = result![0].file as { contents: string }; expect(contents).toContain('"version": "5.10.0"'); // Revision unchanged — getDigest threw expect(contents).toContain( '"revision": "f455c2975872ccd2d9c81594c658af65716e9b9a"', ); }); it('if newValue is present, but newVersion is absent, no update is performed', async () => { scm.getFileList.mockResolvedValue(['Package.resolved']); fs.readLocalFile.mockResolvedValue(v2Fixture); vi.mocked(datasource.getDigest).mockResolvedValue('newrevisionsha123'); const result = await updateArtifacts({ packageFileName: 'Package.swift', updatedDeps: [ { depName: 'Alamofire/Alamofire', datasource: GithubTagsDatasource.id, newVersion: undefined, newValue: '5.10.0', }, ], newPackageFileContent: '', config: {}, }); expect(result).toBeNull(); expect(logger.logger.debug).toHaveBeenCalledWith( { depName: 'Alamofire/Alamofire', newVersion: undefined, newValue: '5.10.0', }, 'swift: found a newValue but not a newVersion', ); }); it('newValue is used to look up digest', async () => { scm.getFileList.mockResolvedValue(['Package.resolved']); fs.readLocalFile.mockResolvedValue(v2Fixture); vi.mocked(datasource.getDigest).mockResolvedValue('newrevisionsha123'); const result = await updateArtifacts({ packageFileName: 'Package.swift', updatedDeps: [ { depName: 'Alamofire/Alamofire', datasource: GithubTagsDatasource.id, newVersion: 'v5.10.0', newValue: '5.10.0', }, ], newPackageFileContent: '', config: {}, }); expect(result).toHaveLength(1); // getDigest should have been called with the v-prefixed newVersion tag expect(datasource.getDigest).toHaveBeenCalledWith( expect.objectContaining({ datasource: GithubTagsDatasource.id }), 'v5.10.0', ); }); });