Files
Stefan Ceriu 0ff55d2d34 feat: add XcodeGen manager for Swift package dependencies in project.yml files (#41889)
* feat: add XcodeGen manager for Swift package dependencies in project.yml files

* chore(xcodegen): inline project.yml fixture

* fix(xcodegen): use semver instead of swift versioning

* fix(xcodegen): use glob syntax for manager file pattern

* tests(xcodegen): expect logger is called on invalid packages

* tests(xcodegen): better assert extracted package contents

* chore(xcodegen): simplify regex usage

* chore(xcodegen): log projects without dependencies

* chore(xcodegen): simplify dependency resolving and remove regular expressions in favor of the `detectPlatform` helper function

* chore(xcodegen): use `coerceString` on package versions to simplify testing and improve coverage

* chore(xcodegen): address PR review comments.

---------

Co-authored-by: Michael Kriese <michael.kriese@visualon.de>
2026-04-15 15:13:04 +00:00

470 lines
12 KiB
TypeScript

import { codeBlock } from 'common-tags';
import { logger } from '~test/util.ts';
import { extractPackageFile } from './index.ts';
describe('modules/manager/xcodegen/extract', () => {
describe('extractPackageFile()', () => {
it('returns null for empty content', () => {
expect(extractPackageFile('', 'project.yml')).toBeNull();
});
it('returns null for invalid YAML', () => {
expect(extractPackageFile('nothing here: [', 'project.yml')).toBeNull();
expect(logger.logger.debug).toHaveBeenCalledWith(
expect.objectContaining({
packageFile: 'project.yml',
err: expect.any(Error),
}),
'Parsing XcodeGen project YAML failed',
);
});
it('returns null for YAML without packages', () => {
const content = codeBlock`
name: MyProject
targets:
App:
type: application
`;
expect(extractPackageFile(content, 'project.yml')).toBeNull();
expect(logger.logger.trace).toHaveBeenCalledWith(
expect.objectContaining({ packageFile: 'project.yml' }),
'XcodeGen project file has no packages',
);
});
it('returns null for empty packages', () => {
const content = codeBlock`
name: MyProject
packages: {}
`;
expect(extractPackageFile(content, 'project.yml')).toBeNull();
});
it('extracts packages from a realistic project.yml', () => {
const content = codeBlock`
name: MyProject
packages:
Yams:
url: https://github.com/jpsim/Yams
from: 2.0.0
RxClient:
path: ../RxClient
`;
expect(extractPackageFile(content, 'project.yml')).toEqual({
deps: [
{
depName: 'Yams',
packageName: 'jpsim/Yams',
datasource: 'github-tags',
currentValue: '2.0.0',
depType: 'from',
},
{
depName: 'RxClient',
skipReason: 'path-dependency',
},
],
});
});
it('extracts remote package with url and from', () => {
const content = codeBlock`
packages:
Yams:
url: https://github.com/jpsim/Yams
from: 2.0.0
`;
const result = extractPackageFile(content, 'project.yml');
expect(result).toEqual({
deps: [
{
depName: 'Yams',
packageName: 'jpsim/Yams',
datasource: 'github-tags',
currentValue: '2.0.0',
depType: 'from',
},
],
});
});
it('extracts remote package with github shorthand', () => {
const content = codeBlock`
packages:
Ink:
github: JohnSundell/Ink
from: 0.5.0
`;
const result = extractPackageFile(content, 'project.yml');
expect(result).toEqual({
deps: [
{
depName: 'Ink',
packageName: 'JohnSundell/Ink',
datasource: 'github-tags',
currentValue: '0.5.0',
depType: 'from',
},
],
});
});
it('extracts remote package with majorVersion', () => {
const content = codeBlock`
packages:
Alamofire:
url: https://github.com/Alamofire/Alamofire
majorVersion: 5.0.0
`;
const result = extractPackageFile(content, 'project.yml');
expect(result).toEqual({
deps: [
{
depName: 'Alamofire',
packageName: 'Alamofire/Alamofire',
datasource: 'github-tags',
currentValue: '5.0.0',
depType: 'majorVersion',
},
],
});
});
it('extracts remote package with minorVersion', () => {
const content = codeBlock`
packages:
SnapKit:
url: https://github.com/SnapKit/SnapKit
minorVersion: 5.6.0
`;
const result = extractPackageFile(content, 'project.yml');
expect(result).toEqual({
deps: [
{
depName: 'SnapKit',
packageName: 'SnapKit/SnapKit',
datasource: 'github-tags',
currentValue: '5.6.0',
depType: 'minorVersion',
},
],
});
});
it('extracts remote package with exactVersion', () => {
const content = codeBlock`
packages:
SwiftLint:
url: https://github.com/realm/SwiftLint
exactVersion: 0.50.3
`;
const result = extractPackageFile(content, 'project.yml');
expect(result).toEqual({
deps: [
{
depName: 'SwiftLint',
packageName: 'realm/SwiftLint',
datasource: 'github-tags',
currentValue: '0.50.3',
depType: 'exactVersion',
},
],
});
});
it('extracts remote package with version', () => {
const content = codeBlock`
packages:
Moya:
url: https://github.com/Moya/Moya
version: 15.0.0
`;
const result = extractPackageFile(content, 'project.yml');
expect(result).toEqual({
deps: [
{
depName: 'Moya',
packageName: 'Moya/Moya',
datasource: 'github-tags',
currentValue: '15.0.0',
depType: 'version',
},
],
});
});
it('skips local packages with path', () => {
const content = codeBlock`
packages:
RxClient:
path: ../RxClient
`;
const result = extractPackageFile(content, 'project.yml');
expect(result).toEqual({
deps: [
{
depName: 'RxClient',
skipReason: 'path-dependency',
},
],
});
});
it('skips packages with branch reference', () => {
const content = codeBlock`
packages:
SwiftPM:
url: https://github.com/apple/swift-package-manager
branch: swift-5.0-branch
`;
const result = extractPackageFile(content, 'project.yml');
expect(result).toEqual({
deps: [
{
depName: 'SwiftPM',
skipReason: 'unversioned-reference',
currentValue: 'swift-5.0-branch',
},
],
});
});
it('skips packages with revision reference', () => {
const content = codeBlock`
packages:
SomePkg:
url: https://github.com/example/some-pkg
revision: abc123
`;
const result = extractPackageFile(content, 'project.yml');
expect(result).toEqual({
deps: [
{
depName: 'SomePkg',
skipReason: 'unversioned-reference',
currentValue: 'abc123',
},
],
});
});
it('skips packages with minVersion/maxVersion range', () => {
const content = codeBlock`
packages:
SomePkg:
url: https://github.com/example/some-pkg
minVersion: 1.0.0
maxVersion: 2.0.0
`;
const result = extractPackageFile(content, 'project.yml');
expect(result).toEqual({
deps: [
{
depName: 'SomePkg',
skipReason: 'unsupported-version',
currentValue: '1.0.0 - 2.0.0',
},
],
});
});
it('uses gitlab-tags datasource for GitLab URLs', () => {
const content = codeBlock`
packages:
GitLabPkg:
url: https://gitlab.com/some-group/some-project
from: 1.0.0
`;
const result = extractPackageFile(content, 'project.yml');
expect(result).toEqual({
deps: [
{
depName: 'GitLabPkg',
packageName: 'some-group/some-project',
datasource: 'gitlab-tags',
currentValue: '1.0.0',
depType: 'from',
},
],
});
});
it('uses github-tags datasource with registryUrls for self-hosted GHES', () => {
const content = codeBlock`
packages:
Yams:
url: https://github.example.com/jpsim/Yams
from: 2.0.0
`;
expect(extractPackageFile(content, 'project.yml')).toEqual({
deps: [
{
depName: 'Yams',
packageName: 'jpsim/Yams',
datasource: 'github-tags',
registryUrls: ['https://github.example.com'],
currentValue: '2.0.0',
depType: 'from',
},
],
});
});
it('uses gitlab-tags datasource with registryUrls for self-hosted GitLab', () => {
const content = codeBlock`
packages:
GitLabPkg:
url: https://gitlab.example.com/some-group/some-project
from: 1.0.0
`;
expect(extractPackageFile(content, 'project.yml')).toEqual({
deps: [
{
depName: 'GitLabPkg',
packageName: 'some-group/some-project',
datasource: 'gitlab-tags',
registryUrls: ['https://gitlab.example.com'],
currentValue: '1.0.0',
depType: 'from',
},
],
});
});
it('uses git-tags datasource for non-GitHub/GitLab URLs', () => {
const content = codeBlock`
packages:
GenericPkg:
url: https://example.com/some/repo.git
from: 3.0.0
`;
const result = extractPackageFile(content, 'project.yml');
expect(result).toEqual({
deps: [
{
depName: 'GenericPkg',
packageName: 'https://example.com/some/repo.git',
datasource: 'git-tags',
currentValue: '3.0.0',
depType: 'from',
},
],
});
});
it('skips packages without url or github', () => {
const content = codeBlock`
packages:
BadPkg:
from: 1.0.0
`;
const result = extractPackageFile(content, 'project.yml');
expect(result).toEqual({
deps: [
{
depName: 'BadPkg',
skipReason: 'invalid-url',
},
],
});
});
it('skips packages without version specifier', () => {
const content = codeBlock`
packages:
NoPkg:
url: https://github.com/example/no-version
`;
const result = extractPackageFile(content, 'project.yml');
expect(result).toEqual({
deps: [
{
depName: 'NoPkg',
skipReason: 'unspecified-version',
},
],
});
});
it('extracts multiple packages correctly', () => {
const content = codeBlock`
packages:
Yams:
url: https://github.com/jpsim/Yams
from: 2.0.0
Ink:
github: JohnSundell/Ink
from: 0.5.0
RxClient:
path: ../RxClient
`;
const result = extractPackageFile(content, 'project.yml');
expect(result).toEqual({
deps: [
{
depName: 'Yams',
packageName: 'jpsim/Yams',
datasource: 'github-tags',
currentValue: '2.0.0',
depType: 'from',
},
{
depName: 'Ink',
packageName: 'JohnSundell/Ink',
datasource: 'github-tags',
currentValue: '0.5.0',
depType: 'from',
},
{
depName: 'RxClient',
skipReason: 'path-dependency',
},
],
});
});
it('handles github URL with .git suffix', () => {
const content = codeBlock`
packages:
Yams:
url: https://github.com/jpsim/Yams.git
from: 2.0.0
`;
const result = extractPackageFile(content, 'project.yml');
expect(result).toEqual({
deps: [
{
depName: 'Yams',
packageName: 'jpsim/Yams',
datasource: 'github-tags',
currentValue: '2.0.0',
depType: 'from',
},
],
});
});
it('handles numeric version values from YAML parsing', () => {
const content = codeBlock`
packages:
SomePkg:
url: https://github.com/example/some-pkg
from: 5
`;
const result = extractPackageFile(content, 'project.yml');
expect(result).toEqual({
deps: [
{
depName: 'SomePkg',
packageName: 'example/some-pkg',
datasource: 'github-tags',
currentValue: '5',
depType: 'from',
},
],
});
});
});
});