diff --git a/packages/yarn-plugin/package.json b/packages/yarn-plugin/package.json index 817497a770..5b4d8f3aea 100644 --- a/packages/yarn-plugin/package.json +++ b/packages/yarn-plugin/package.json @@ -36,6 +36,7 @@ "semver": "^7.6.0" }, "devDependencies": { + "@backstage/backend-test-utils": "workspace:^", "@backstage/cli": "workspace:^", "@yarnpkg/builder": "^4.0.0", "nodemon": "^3.0.1" diff --git a/packages/yarn-plugin/src/handlers/beforeWorkspacePacking.test.ts b/packages/yarn-plugin/src/handlers/beforeWorkspacePacking.test.ts new file mode 100644 index 0000000000..f5f15b861b --- /dev/null +++ b/packages/yarn-plugin/src/handlers/beforeWorkspacePacking.test.ts @@ -0,0 +1,120 @@ +/* + * Copyright 2024 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { Manifest, Workspace } from '@yarnpkg/core'; +import { npath, ppath } from '@yarnpkg/fslib'; +import { createMockDirectory } from '@backstage/backend-test-utils'; + +import { beforeWorkspacePacking } from './beforeWorkspacePacking'; + +jest.mock('@backstage/release-manifests', () => ({ + getManifestByVersion: jest.fn().mockResolvedValue({ + releaseVersion: '1.23.45', + packages: [ + { + name: '@backstage/core', + version: '3.2.1', + }, + ], + }), +})); + +const makeWorkspace = (manifest: object) => { + return { + manifest: Manifest.fromText(JSON.stringify(manifest)), + } as Workspace; +}; + +describe('beforeWorkspacePacking', () => { + const mockDir = createMockDirectory(); + + beforeEach(() => { + jest + .spyOn(ppath, 'cwd') + .mockReturnValue(npath.toPortablePath(mockDir.path)); + + mockDir.setContent({ + 'backstage.json': JSON.stringify({ + version: '1.23.45', + }), + 'package.json': JSON.stringify({ + workspaces: { + packages: ['packages/*'], + }, + }), + }); + }); + + afterEach(() => { + jest.restoreAllMocks(); + }); + + describe.each` + dependencyType + ${'dependencies'} + ${'devDependencies'} + ${'optionalDependencies'} + `('$dependencyType', ({ dependencyType }) => { + it(`ignores ${dependencyType} that don't use the backstage: protocol`, () => { + const result = { + name: 'test-package', + [dependencyType]: { + foo: '^1.1.1', + }, + }; + + beforeWorkspacePacking(makeWorkspace(result), result); + + expect(result).toEqual({ + name: 'test-package', + [dependencyType]: { + foo: '^1.1.1', + }, + }); + }); + + it(`throws an error for any backstage: versions with a selector other than ^`, async () => { + const result = { + name: 'test-package', + [dependencyType]: { + '@backstage/core': 'backstage:^1.1.1', + }, + }; + + await expect(() => + beforeWorkspacePacking(makeWorkspace(result), result), + ).rejects.toThrow(); + }); + + it('converts backstage:^ versions to the corresponding package version prefixed by ^', async () => { + const result = { + name: 'test-package', + [dependencyType]: { + '@backstage/core': 'backstage:^', + }, + }; + + await beforeWorkspacePacking(makeWorkspace(result), result); + + expect(result).toEqual({ + name: 'test-package', + [dependencyType]: { + '@backstage/core': '^3.2.1', + }, + }); + }); + }); +}); diff --git a/packages/yarn-plugin/src/resolver/BackstageResolver.test.ts b/packages/yarn-plugin/src/resolver/BackstageResolver.test.ts new file mode 100644 index 0000000000..9aad0cbc09 --- /dev/null +++ b/packages/yarn-plugin/src/resolver/BackstageResolver.test.ts @@ -0,0 +1,177 @@ +/* + * Copyright 2024 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { structUtils } from '@yarnpkg/core'; +import { npath, ppath } from '@yarnpkg/fslib'; +import { BackstageResolver } from './BackstageResolver'; +import { createMockDirectory } from '@backstage/backend-test-utils'; + +jest.mock('@backstage/release-manifests', () => ({ + getManifestByVersion: jest.fn().mockResolvedValue({ + releaseVersion: '1.23.45', + packages: [ + { + name: '@backstage/core', + version: '6.7.8', + }, + ], + }), +})); + +describe('BackstageResolver', () => { + const mockDir = createMockDirectory(); + let backstageResolver: BackstageResolver; + + beforeEach(() => { + jest + .spyOn(ppath, 'cwd') + .mockReturnValue(npath.toPortablePath(mockDir.path)); + + mockDir.setContent({ + 'backstage.json': JSON.stringify({ + version: '1.23.45', + }), + 'package.json': JSON.stringify({ + workspaces: { + packages: ['packages/*'], + }, + }), + packages: { + a: { + 'package.json': JSON.stringify({ + name: 'a', + dependencies: { + '@backstage/core': 'backstage:^', + }, + }), + }, + }, + }); + + backstageResolver = new BackstageResolver(); + }); + + afterEach(() => { + jest.restoreAllMocks(); + }); + + describe('supportsDescriptor', () => { + it.each([ + ['backstage:^'], + ['backstage:1.26.0-next.3'], + ['backstage:anything'], + ])('returns true for range "%s"', range => { + expect( + backstageResolver.supportsDescriptor( + structUtils.makeDescriptor( + structUtils.makeIdent('backstage', 'core'), + range, + ), + ), + ).toEqual(true); + }); + }); + + describe('bindDescriptor', () => { + describe('with range "backstage:^"', () => { + it('returns a descriptor basedwith a version range for the current Backstage version', () => { + expect( + backstageResolver.bindDescriptor( + structUtils.makeDescriptor( + structUtils.makeIdent('backstage', 'core'), + 'backstage:^', + ), + ), + ).toEqual( + structUtils.makeDescriptor( + structUtils.makeIdent('backstage', 'core'), + 'backstage:1.23.45', + ), + ); + }); + }); + + describe('with range "backstage:1.23.45"', () => { + it('throws an error', () => { + expect(() => + backstageResolver.bindDescriptor( + structUtils.makeDescriptor( + structUtils.makeIdent('backstage', 'core'), + 'backstage:1.23.45', + ), + ), + ).toThrow(/unsupported version range/i); + }); + }); + }); + + describe('getCandidates', () => { + it('returns an npm: descriptor based on the manifest for the appropriate backstage version', async () => { + const descriptor = structUtils.makeDescriptor( + structUtils.makeIdent('backstage', 'core'), + 'backstage:1.23.45', + ); + + await expect( + backstageResolver.getCandidates(descriptor), + ).resolves.toEqual([structUtils.makeLocator(descriptor, 'npm:6.7.8')]); + }); + + it('rejects descriptors not using the backstage: protocol', async () => { + await expect( + backstageResolver.getCandidates( + structUtils.makeDescriptor( + structUtils.makeIdent('backstage', 'core'), + 'npm:1.2.3', + ), + ), + ).rejects.toThrow(/unsupported version protocol/i); + }); + + it('rejects backstage: ranges with a ^ shorthand version', async () => { + await expect( + backstageResolver.getCandidates( + structUtils.makeDescriptor( + structUtils.makeIdent('backstage', 'core'), + 'backstage:^', + ), + ), + ).rejects.toThrow(/invalid backstage version/i); + }); + + it('rejects backstage: ranges with a * shorthand version', async () => { + await expect( + backstageResolver.getCandidates( + structUtils.makeDescriptor( + structUtils.makeIdent('backstage', 'core'), + 'backstage:*', + ), + ), + ).rejects.toThrow(/invalid backstage version/i); + }); + + it('rejects backstage: ranges with an invalid version specified', async () => { + await expect( + backstageResolver.getCandidates( + structUtils.makeDescriptor( + structUtils.makeIdent('backstage', 'core'), + 'backstage:latest', + ), + ), + ).rejects.toThrow(/invalid backstage version/i); + }); + }); +}); diff --git a/packages/yarn-plugin/src/resolver/BackstageResolver.ts b/packages/yarn-plugin/src/resolver/BackstageResolver.ts index fa51ceb880..3a56124fe3 100644 --- a/packages/yarn-plugin/src/resolver/BackstageResolver.ts +++ b/packages/yarn-plugin/src/resolver/BackstageResolver.ts @@ -21,6 +21,7 @@ import { Package, Resolver, } from '@yarnpkg/core'; +import semver from 'semver'; import { PROTOCOL } from '../constants'; import { getCurrentBackstageVersion, getPackageVersion } from '../util'; @@ -33,6 +34,16 @@ export class BackstageResolver implements Resolver { shouldPersistResolution = () => true; bindDescriptor(descriptor: Descriptor): Descriptor { + if (descriptor.range !== 'backstage:^') { + throw new Error( + `Unsupported version range "${ + descriptor.range + }" for package ${structUtils.stringifyIdent( + descriptor, + )}. The backstage protocol only supports the range "backstage:^".`, + ); + } + return structUtils.makeDescriptor( descriptor, `${PROTOCOL}${getCurrentBackstageVersion()}`, @@ -40,6 +51,23 @@ export class BackstageResolver implements Resolver { } async getCandidates(descriptor: Descriptor): Promise { + const range = structUtils.parseRange(descriptor.range); + if (range.protocol !== BackstageResolver.protocol) { + throw new Error( + `Unsupported version protocol in version range "${ + descriptor.range + }" for package ${structUtils.stringifyIdent(descriptor)}`, + ); + } + + if (!semver.valid(range.selector)) { + throw new Error( + `Invalid Backstage version string when resolving version for ${structUtils.stringifyIdent( + descriptor, + )}`, + ); + } + return [ structUtils.makeLocator( descriptor, diff --git a/yarn.lock b/yarn.lock index b8cc660d66..9ff777da8c 100644 --- a/yarn.lock +++ b/yarn.lock @@ -45131,6 +45131,7 @@ __metadata: version: 0.0.0-use.local resolution: "yarn-plugin-backstage@workspace:packages/yarn-plugin" dependencies: + "@backstage/backend-test-utils": "workspace:^" "@backstage/cli": "workspace:^" "@backstage/release-manifests": "workspace:^" "@yarnpkg/builder": ^4.0.0