Merge pull request #31122 from drodil/yarn_plugin_custom_url
feat(yarn-plugin): custom manifest location
This commit is contained in:
@@ -0,0 +1,20 @@
|
||||
---
|
||||
'@backstage/cli': patch
|
||||
---
|
||||
|
||||
Allow using custom manifest location in the yarn plugin and version bump.
|
||||
|
||||
The Backstage yarn plugin and version bump allows two new environment variables to configure custom manifest location:
|
||||
|
||||
- `BACKSTAGE_VERSIONS_BASE_URL`: The base URL for fetching the Backstage version
|
||||
manifest. Defaults to `https://versions.backstage.io/v1/releases/VERSION/manifest.json`.
|
||||
Useful for running the plugin in environment without direct access to the internet,
|
||||
for example by using a mirror of the versions API or a proxy.
|
||||
Note that the environment variable is just the host name, and the path is appended by
|
||||
the plugin. If you are using the yarn plugin, bump version command will also try
|
||||
to fetch the new version of the yarn plugin from the same base URL (defaults to
|
||||
`https://versions.backstage.io/v1/releases/RELEASE/yarn-plugin`)
|
||||
- `BACKSTAGE_MANIFEST_FILE`: Path to a local manifest file. If set, the plugin
|
||||
will not attempt to fetch the manifest from the network. Useful for running
|
||||
the plugin in environment without internet access and without mirror of the
|
||||
versions API.
|
||||
@@ -1,5 +1,5 @@
|
||||
/*
|
||||
* Copyright 2020 The Backstage Authors
|
||||
* Copyright 2025 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.
|
||||
@@ -13,7 +13,6 @@
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import fs from 'fs-extra';
|
||||
import { Command } from 'commander';
|
||||
import * as runObj from '../../../../lib/run';
|
||||
@@ -24,8 +23,8 @@ import { setupServer } from 'msw/node';
|
||||
import { rest } from 'msw';
|
||||
import { NotFoundError } from '@backstage/errors';
|
||||
import {
|
||||
MockDirectory,
|
||||
createMockDirectory,
|
||||
MockDirectory,
|
||||
} from '@backstage/backend-test-utils';
|
||||
|
||||
// Avoid mutating the global agents used in other tests
|
||||
@@ -1056,3 +1055,310 @@ describe('createVersionFinder', () => {
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('environment variables', () => {
|
||||
const worker = setupServer();
|
||||
registerMswTestHooks(worker);
|
||||
|
||||
beforeEach(() => {
|
||||
delete process.env.BACKSTAGE_MANIFEST_FILE;
|
||||
process.env.BACKSTAGE_VERSIONS_BASE_URL = 'https://custom.example.com';
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.resetAllMocks();
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
delete process.env.BACKSTAGE_MANIFEST_FILE;
|
||||
delete process.env.BACKSTAGE_VERSIONS_BASE_URL;
|
||||
});
|
||||
|
||||
it('should use custom base URL when BACKSTAGE_VERSIONS_BASE_URL is set', async () => {
|
||||
mockDir.setContent({
|
||||
'yarn.lock': lockfileMock,
|
||||
'package.json': JSON.stringify({
|
||||
workspaces: {
|
||||
packages: ['packages/*'],
|
||||
},
|
||||
}),
|
||||
packages: {
|
||||
a: {
|
||||
'package.json': JSON.stringify({
|
||||
name: 'a',
|
||||
dependencies: {
|
||||
'@backstage/core': '^1.0.5',
|
||||
},
|
||||
}),
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
jest.spyOn(runObj, 'run').mockResolvedValue(undefined);
|
||||
worker.use(
|
||||
rest.get(
|
||||
'https://custom.example.com/v1/tags/main/manifest.json',
|
||||
(_, res, ctx) =>
|
||||
res(
|
||||
ctx.status(200),
|
||||
ctx.json({
|
||||
releaseVersion: '1.5.0',
|
||||
packages: [
|
||||
{
|
||||
name: '@backstage/core',
|
||||
version: '1.5.0',
|
||||
},
|
||||
],
|
||||
}),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
const { log: logs } = await withLogCollector(['log', 'warn'], async () => {
|
||||
await bump({ pattern: null, release: 'main' } as unknown as Command);
|
||||
});
|
||||
|
||||
expectLogsToMatch(logs, [
|
||||
'Using default pattern glob @backstage/*',
|
||||
'Checking for updates of @backstage/core',
|
||||
'Some packages are outdated, updating',
|
||||
'bumping @backstage/core in a to ^1.5.0',
|
||||
'Your project is now at version 1.5.0, which has been written to backstage.json',
|
||||
'Running yarn install to install new versions',
|
||||
'Checking for moved packages to the @backstage-community namespace...',
|
||||
'Version bump complete!',
|
||||
]);
|
||||
|
||||
expect(runObj.run).toHaveBeenCalledTimes(1);
|
||||
expect(runObj.run).toHaveBeenCalledWith(
|
||||
'yarn',
|
||||
['install'],
|
||||
expect.any(Object),
|
||||
);
|
||||
|
||||
const packageA = await fs.readJson(
|
||||
mockDir.resolve('packages/a/package.json'),
|
||||
);
|
||||
expect(packageA).toEqual({
|
||||
name: 'a',
|
||||
dependencies: {
|
||||
'@backstage/core': '^1.5.0',
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('should use custom manifest file when BACKSTAGE_MANIFEST_FILE is set', async () => {
|
||||
const manifestPath = mockDir.resolve('custom-manifest.json');
|
||||
process.env.BACKSTAGE_MANIFEST_FILE = manifestPath;
|
||||
|
||||
const customManifest = {
|
||||
releaseVersion: '2.0.0',
|
||||
packages: [
|
||||
{
|
||||
name: '@backstage/core',
|
||||
version: '2.0.0',
|
||||
},
|
||||
{
|
||||
name: '@backstage/theme',
|
||||
version: '3.0.0',
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
mockDir.setContent({
|
||||
'custom-manifest.json': JSON.stringify(customManifest),
|
||||
'yarn.lock': lockfileMock,
|
||||
'package.json': JSON.stringify({
|
||||
workspaces: {
|
||||
packages: ['packages/*'],
|
||||
},
|
||||
}),
|
||||
packages: {
|
||||
a: {
|
||||
'package.json': JSON.stringify({
|
||||
name: 'a',
|
||||
dependencies: {
|
||||
'@backstage/core': '^1.0.5',
|
||||
'@backstage/theme': '^1.0.0',
|
||||
},
|
||||
}),
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
jest.spyOn(runObj, 'run').mockResolvedValue(undefined);
|
||||
|
||||
const { log: logs } = await withLogCollector(['log', 'warn'], async () => {
|
||||
await bump({ pattern: null, release: 'main' } as unknown as Command);
|
||||
});
|
||||
|
||||
expectLogsToMatch(logs, [
|
||||
'Using default pattern glob @backstage/*',
|
||||
'Checking for updates of @backstage/core',
|
||||
'Checking for updates of @backstage/theme',
|
||||
'Some packages are outdated, updating',
|
||||
'bumping @backstage/core in a to ^2.0.0',
|
||||
'bumping @backstage/theme in a to ^3.0.0',
|
||||
'Your project is now at version 2.0.0, which has been written to backstage.json',
|
||||
'Running yarn install to install new versions',
|
||||
'Checking for moved packages to the @backstage-community namespace...',
|
||||
'⚠️ The following packages may have breaking changes:',
|
||||
' @backstage/core : 1.0.6 ~> 2.0.0',
|
||||
' https://github.com/backstage/backstage/blob/master/packages/core/CHANGELOG.md',
|
||||
' @backstage/theme : 1.0.0 ~> 3.0.0',
|
||||
' https://github.com/backstage/backstage/blob/master/packages/theme/CHANGELOG.md',
|
||||
'Version bump complete!',
|
||||
]);
|
||||
|
||||
// Should not make any HTTP requests since using local manifest
|
||||
expect(mockFetchPackageInfo).not.toHaveBeenCalled();
|
||||
|
||||
expect(runObj.run).toHaveBeenCalledTimes(1);
|
||||
expect(runObj.run).toHaveBeenCalledWith(
|
||||
'yarn',
|
||||
['install'],
|
||||
expect.any(Object),
|
||||
);
|
||||
|
||||
const packageA = await fs.readJson(
|
||||
mockDir.resolve('packages/a/package.json'),
|
||||
);
|
||||
expect(packageA).toEqual({
|
||||
name: 'a',
|
||||
dependencies: {
|
||||
'@backstage/core': '^2.0.0',
|
||||
'@backstage/theme': '^3.0.0',
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('should use custom base URL for yarn plugin when BACKSTAGE_VERSIONS_BASE_URL is set with yarn plugin', async () => {
|
||||
mockDir.setContent({
|
||||
'.yarnrc.yml': yarnRcMock,
|
||||
'yarn.lock': lockfileMock,
|
||||
'package.json': JSON.stringify({
|
||||
workspaces: {
|
||||
packages: ['packages/*'],
|
||||
},
|
||||
}),
|
||||
packages: {
|
||||
a: {
|
||||
'package.json': JSON.stringify({
|
||||
name: 'a',
|
||||
dependencies: {
|
||||
'@backstage/core': '^1.0.5',
|
||||
},
|
||||
}),
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
jest.spyOn(runObj, 'run').mockResolvedValue(undefined);
|
||||
worker.use(
|
||||
rest.get(
|
||||
'https://custom.example.com/v1/tags/main/manifest.json',
|
||||
(_, res, ctx) =>
|
||||
res(
|
||||
ctx.status(200),
|
||||
ctx.json({
|
||||
releaseVersion: '1.5.0',
|
||||
packages: [
|
||||
{
|
||||
name: '@backstage/core',
|
||||
version: '1.5.0',
|
||||
},
|
||||
],
|
||||
}),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
const { log: logs } = await withLogCollector(['log', 'warn'], async () => {
|
||||
await bump({ pattern: null, release: 'main' } as unknown as Command);
|
||||
});
|
||||
|
||||
expectLogsToMatch(logs, [
|
||||
'Using default pattern glob @backstage/*',
|
||||
'Checking for updates of @backstage/core',
|
||||
'NOTE: this bump used backstage:^ versions in package.json files, since the Backstage yarn plugin was detected in the repository. To migrate back to explicit npm versions, remove the plugin by running "yarn plugin remove @yarnpkg/plugin-backstage", then repeat this command.',
|
||||
'Some packages are outdated, updating',
|
||||
'bumping @backstage/core in a to ^1.5.0',
|
||||
'Updating yarn plugin to v1.5.0...',
|
||||
'Your project is now at version 1.5.0, which has been written to backstage.json',
|
||||
'Running yarn install to install new versions',
|
||||
'Checking for moved packages to the @backstage-community namespace...',
|
||||
'Version bump complete!',
|
||||
]);
|
||||
|
||||
expect(runObj.run).toHaveBeenCalledTimes(2);
|
||||
expect(runObj.run).toHaveBeenCalledWith('yarn', [
|
||||
'plugin',
|
||||
'import',
|
||||
'https://custom.example.com/v1/releases/1.5.0/yarn-plugin',
|
||||
]);
|
||||
expect(runObj.run).toHaveBeenCalledWith(
|
||||
'yarn',
|
||||
['install'],
|
||||
expect.any(Object),
|
||||
);
|
||||
});
|
||||
|
||||
it('should handle missing manifest file when BACKSTAGE_MANIFEST_FILE is set', async () => {
|
||||
process.env.BACKSTAGE_MANIFEST_FILE = '/nonexistent/manifest.json';
|
||||
|
||||
mockDir.setContent({
|
||||
'yarn.lock': lockfileMock,
|
||||
'package.json': JSON.stringify({
|
||||
workspaces: {
|
||||
packages: ['packages/*'],
|
||||
},
|
||||
}),
|
||||
packages: {
|
||||
a: {
|
||||
'package.json': JSON.stringify({
|
||||
name: 'a',
|
||||
dependencies: {
|
||||
'@backstage/core': '^1.0.5',
|
||||
},
|
||||
}),
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
await expect(
|
||||
bump({ pattern: null, release: 'main' } as unknown as Command),
|
||||
).rejects.toThrow();
|
||||
});
|
||||
|
||||
it('should handle network errors when using custom base URL', async () => {
|
||||
mockDir.setContent({
|
||||
'yarn.lock': lockfileMock,
|
||||
'package.json': JSON.stringify({
|
||||
workspaces: {
|
||||
packages: ['packages/*'],
|
||||
},
|
||||
}),
|
||||
packages: {
|
||||
a: {
|
||||
'package.json': JSON.stringify({
|
||||
name: 'a',
|
||||
dependencies: {
|
||||
'@backstage/core': '^1.0.5',
|
||||
},
|
||||
}),
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
worker.use(
|
||||
rest.get(
|
||||
'https://custom.example.com/v1/tags/main/manifest.json',
|
||||
(_, res, ctx) => res(ctx.status(500), ctx.json({})),
|
||||
),
|
||||
);
|
||||
|
||||
await expect(
|
||||
bump({ pattern: null, release: 'main' } as unknown as Command),
|
||||
).rejects.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -16,6 +16,7 @@
|
||||
|
||||
maybeBootstrapProxy();
|
||||
|
||||
import { env } from 'process';
|
||||
import fs from 'fs-extra';
|
||||
import chalk from 'chalk';
|
||||
import { minimatch } from 'minimatch';
|
||||
@@ -26,9 +27,9 @@ import { resolve as resolvePath } from 'path';
|
||||
import { paths } from '../../../../lib/paths';
|
||||
import { getHasYarnPlugin } from '../../../../lib/yarnPlugin';
|
||||
import {
|
||||
mapDependencies,
|
||||
fetchPackageInfo,
|
||||
Lockfile,
|
||||
mapDependencies,
|
||||
YarnInfoInspectData,
|
||||
} from '../../../../lib/versioning';
|
||||
import { BACKSTAGE_JSON } from '@backstage/cli-common';
|
||||
@@ -104,8 +105,14 @@ export default async (opts: OptionValues) => {
|
||||
|
||||
let findTargetVersion: (name: string) => Promise<string>;
|
||||
let releaseManifest: ReleaseManifest;
|
||||
// Specific release specified. Be strict when resolving versions
|
||||
if (semver.valid(opts.release)) {
|
||||
if (env.BACKSTAGE_MANIFEST_FILE) {
|
||||
// Use specific manifest file if provided
|
||||
releaseManifest = await fs.readJson(env.BACKSTAGE_MANIFEST_FILE);
|
||||
findTargetVersion = createStrictVersionFinder({
|
||||
releaseManifest,
|
||||
});
|
||||
} else if (semver.valid(opts.release)) {
|
||||
// Specific release specified. Be strict when resolving versions
|
||||
releaseManifest = await getManifestByVersion({ version: opts.release });
|
||||
findTargetVersion = createStrictVersionFinder({
|
||||
releaseManifest,
|
||||
@@ -115,9 +122,11 @@ export default async (opts: OptionValues) => {
|
||||
if (opts.release === 'next') {
|
||||
const next = await getManifestByReleaseLine({
|
||||
releaseLine: 'next',
|
||||
versionsBaseUrl: env.BACKSTAGE_VERSIONS_BASE_URL,
|
||||
});
|
||||
const main = await getManifestByReleaseLine({
|
||||
releaseLine: 'main',
|
||||
versionsBaseUrl: env.BACKSTAGE_VERSIONS_BASE_URL,
|
||||
});
|
||||
// Prefer manifest with the latest release version
|
||||
releaseManifest = semver.gt(next.releaseVersion, main.releaseVersion)
|
||||
@@ -126,6 +135,7 @@ export default async (opts: OptionValues) => {
|
||||
} else {
|
||||
releaseManifest = await getManifestByReleaseLine({
|
||||
releaseLine: opts.release,
|
||||
versionsBaseUrl: env.BACKSTAGE_VERSIONS_BASE_URL,
|
||||
});
|
||||
}
|
||||
findTargetVersion = createVersionFinder({
|
||||
@@ -140,11 +150,12 @@ export default async (opts: OptionValues) => {
|
||||
`Updating yarn plugin to v${releaseManifest.releaseVersion}...`,
|
||||
);
|
||||
console.log();
|
||||
await run('yarn', [
|
||||
'plugin',
|
||||
'import',
|
||||
`https://versions.backstage.io/v1/releases/${releaseManifest.releaseVersion}/yarn-plugin`,
|
||||
]);
|
||||
|
||||
const yarnPluginUrl = env.BACKSTAGE_VERSIONS_BASE_URL
|
||||
? `${env.BACKSTAGE_VERSIONS_BASE_URL}/v1/releases/${releaseManifest.releaseVersion}/yarn-plugin`
|
||||
: `https://versions.backstage.io/v1/releases/${releaseManifest.releaseVersion}/yarn-plugin`;
|
||||
|
||||
await run('yarn', ['plugin', 'import', yarnPluginUrl]);
|
||||
console.log();
|
||||
}
|
||||
|
||||
|
||||
@@ -25,6 +25,21 @@ The yarn plugin recognizes the version string `"backstage:^"`, and replaces it
|
||||
with the appropriate version based on the overall Backstage version in
|
||||
backstage.json.
|
||||
|
||||
### Configuration
|
||||
|
||||
The plugin functionality can be configured with environment variables:
|
||||
|
||||
- `BACKSTAGE_VERSIONS_BASE_URL` - The base URL for fetching the Backstage version
|
||||
manifest. Defaults to `https://versions.backstage.io/v1/releases/VERSION/manifest.json`.
|
||||
Useful for running the plugin in environment without direct access to the internet,
|
||||
for example by using a mirror of the versions API or a proxy.
|
||||
Note that the environment variable is just the host name, and the path is appended by
|
||||
the plugin.
|
||||
- `BACKSTAGE_MANIFEST_FILE` - Path to a local manifest file. If set, the plugin
|
||||
will not attempt to fetch the manifest from the network. Useful for running
|
||||
the plugin in environment without internet access and without mirror of the
|
||||
versions API.
|
||||
|
||||
## Local Development
|
||||
|
||||
- Run unit tests: `yarn test`
|
||||
|
||||
@@ -0,0 +1,462 @@
|
||||
/*
|
||||
* 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 {
|
||||
Configuration,
|
||||
Descriptor,
|
||||
httpUtils,
|
||||
structUtils,
|
||||
} from '@yarnpkg/core';
|
||||
import { xfs } from '@yarnpkg/fslib';
|
||||
import { getManifestByVersion } from '@backstage/release-manifests';
|
||||
import { getPackageVersion } from './getPackageVersion';
|
||||
import { getCurrentBackstageVersion } from './getCurrentBackstageVersion';
|
||||
|
||||
jest.mock('@yarnpkg/core');
|
||||
jest.mock('@backstage/release-manifests');
|
||||
jest.mock('./getCurrentBackstageVersion');
|
||||
|
||||
const mockHttpUtils = httpUtils as jest.Mocked<typeof httpUtils>;
|
||||
const mockStructUtils = structUtils as jest.Mocked<typeof structUtils>;
|
||||
const mockGetManifestByVersion = getManifestByVersion as jest.MockedFunction<
|
||||
typeof getManifestByVersion
|
||||
>;
|
||||
const mockGetCurrentBackstageVersion =
|
||||
getCurrentBackstageVersion as jest.MockedFunction<
|
||||
typeof getCurrentBackstageVersion
|
||||
>;
|
||||
|
||||
describe('getPackageVersion', () => {
|
||||
const mockConfiguration = {} as Configuration;
|
||||
|
||||
beforeEach(() => {
|
||||
mockGetCurrentBackstageVersion.mockReturnValue('1.23.4');
|
||||
mockStructUtils.stringifyIdent.mockImplementation(
|
||||
(descriptor: any) =>
|
||||
`${descriptor.scope || ''}${descriptor.scope ? '/' : ''}${
|
||||
descriptor.name
|
||||
}`,
|
||||
);
|
||||
|
||||
mockStructUtils.parseRange.mockImplementation((range: string) => ({
|
||||
protocol: range.includes('backstage:') ? 'backstage:' : 'npm:',
|
||||
selector: range.includes('^') ? '^' : range.split(':')[1] || '',
|
||||
}));
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
describe('successful package resolution', () => {
|
||||
beforeEach(() => {
|
||||
mockHttpUtils.get.mockResolvedValue({
|
||||
packages: [
|
||||
{ name: '@backstage/core-plugin-api', version: '1.9.0' },
|
||||
{ name: '@backstage/core-app-api', version: '1.12.0' },
|
||||
],
|
||||
});
|
||||
|
||||
mockGetManifestByVersion.mockResolvedValue({
|
||||
releaseVersion: '1.23.4',
|
||||
packages: [
|
||||
{ name: '@backstage/core-plugin-api', version: '1.9.0' },
|
||||
{ name: '@backstage/core-app-api', version: '1.12.0' },
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
it('returns the correct version for a package in the manifest', async () => {
|
||||
const descriptor = {
|
||||
scope: '@backstage',
|
||||
name: 'core-plugin-api',
|
||||
range: 'backstage:^',
|
||||
} as Descriptor;
|
||||
|
||||
const result = await getPackageVersion(descriptor, mockConfiguration);
|
||||
|
||||
expect(result).toBe('1.9.0');
|
||||
expect(mockGetCurrentBackstageVersion).toHaveBeenCalledTimes(1);
|
||||
expect(mockGetManifestByVersion).toHaveBeenCalledWith({
|
||||
version: '1.23.4',
|
||||
versionsBaseUrl: undefined,
|
||||
fetch: expect.any(Function),
|
||||
});
|
||||
});
|
||||
|
||||
it('uses custom versionsBaseUrl from environment when provided', async () => {
|
||||
const originalEnv = process.env.BACKSTAGE_VERSIONS_BASE_URL;
|
||||
process.env.BACKSTAGE_VERSIONS_BASE_URL = 'https://custom.example.com';
|
||||
|
||||
const descriptor = {
|
||||
scope: '@backstage',
|
||||
name: 'core-plugin-api',
|
||||
range: 'backstage:^',
|
||||
} as Descriptor;
|
||||
|
||||
await getPackageVersion(descriptor, mockConfiguration);
|
||||
|
||||
expect(mockGetManifestByVersion).toHaveBeenCalledWith({
|
||||
version: '1.23.4',
|
||||
versionsBaseUrl: 'https://custom.example.com',
|
||||
fetch: expect.any(Function),
|
||||
});
|
||||
|
||||
process.env.BACKSTAGE_VERSIONS_BASE_URL = originalEnv;
|
||||
});
|
||||
|
||||
it('uses yarn httpUtils for fetching with proper response format', async () => {
|
||||
const descriptor = {
|
||||
scope: '@backstage',
|
||||
name: 'core-plugin-api',
|
||||
range: 'backstage:^',
|
||||
} as Descriptor;
|
||||
|
||||
const mockResponse = {
|
||||
packages: [{ name: '@backstage/core-plugin-api', version: '1.9.0' }],
|
||||
};
|
||||
|
||||
mockHttpUtils.get.mockResolvedValue(mockResponse);
|
||||
|
||||
await getPackageVersion(descriptor, mockConfiguration);
|
||||
|
||||
expect(mockGetManifestByVersion).toHaveBeenCalledTimes(1);
|
||||
|
||||
// Get the fetch function that was passed to getManifestByVersion
|
||||
const fetchFunction = mockGetManifestByVersion.mock.calls[0][0].fetch;
|
||||
|
||||
// Test the custom fetch function
|
||||
const testUrl = 'https://example.com/manifest.json';
|
||||
const fetchResult = await fetchFunction!(testUrl);
|
||||
|
||||
expect(mockHttpUtils.get).toHaveBeenCalledWith(testUrl, {
|
||||
configuration: mockConfiguration,
|
||||
jsonResponse: true,
|
||||
});
|
||||
|
||||
expect(fetchResult).toEqual({
|
||||
status: 200,
|
||||
url: testUrl,
|
||||
json: expect.any(Function),
|
||||
});
|
||||
|
||||
expect(await fetchResult.json()).toEqual(mockResponse);
|
||||
});
|
||||
});
|
||||
|
||||
describe('error cases', () => {
|
||||
it('throws error for unsupported protocol', async () => {
|
||||
const descriptor = {
|
||||
scope: '@backstage',
|
||||
name: 'core-plugin-api',
|
||||
range: 'npm:^1.0.0',
|
||||
} as Descriptor;
|
||||
|
||||
mockStructUtils.parseRange.mockReturnValue({
|
||||
protocol: 'npm:',
|
||||
selector: '^1.0.0',
|
||||
});
|
||||
|
||||
await expect(
|
||||
getPackageVersion(descriptor, mockConfiguration),
|
||||
).rejects.toThrow(
|
||||
'Unsupported version protocol in version range "npm:^1.0.0" for package @backstage/core-plugin-api',
|
||||
);
|
||||
});
|
||||
|
||||
it('throws error for unexpected version selector', async () => {
|
||||
const descriptor = {
|
||||
scope: '@backstage',
|
||||
name: 'core-plugin-api',
|
||||
range: 'backstage:~1.0.0',
|
||||
} as Descriptor;
|
||||
|
||||
mockStructUtils.parseRange.mockReturnValue({
|
||||
protocol: 'backstage:',
|
||||
selector: '~1.0.0',
|
||||
});
|
||||
|
||||
await expect(
|
||||
getPackageVersion(descriptor, mockConfiguration),
|
||||
).rejects.toThrow(
|
||||
'Unexpected version selector "~1.0.0" for package @backstage/core-plugin-api',
|
||||
);
|
||||
});
|
||||
|
||||
it('throws error when package is not found in manifest', async () => {
|
||||
const descriptor = {
|
||||
scope: '@backstage',
|
||||
name: 'non-existent-package',
|
||||
range: 'backstage:^',
|
||||
} as Descriptor;
|
||||
|
||||
mockGetManifestByVersion.mockResolvedValue({
|
||||
releaseVersion: '1.23.4',
|
||||
packages: [{ name: '@backstage/core-plugin-api', version: '1.9.0' }],
|
||||
});
|
||||
|
||||
await expect(
|
||||
getPackageVersion(descriptor, mockConfiguration),
|
||||
).rejects.toThrow(
|
||||
'Package @backstage/non-existent-package not found in manifest for Backstage v1.23.4. ' +
|
||||
'This means the specified package is not included in this Backstage ' +
|
||||
'release. This may imply the package has been replaced with an alternative - ' +
|
||||
'please review the documentation for the package. If you need to continue ' +
|
||||
'using this package, it will be necessary to switch to manually managing its ' +
|
||||
'version.',
|
||||
);
|
||||
});
|
||||
|
||||
it('propagates errors from getManifestByVersion', async () => {
|
||||
const descriptor = {
|
||||
scope: '@backstage',
|
||||
name: 'core-plugin-api',
|
||||
range: 'backstage:^',
|
||||
} as Descriptor;
|
||||
|
||||
const manifestError = new Error('Failed to fetch manifest');
|
||||
mockGetManifestByVersion.mockRejectedValue(manifestError);
|
||||
|
||||
await expect(
|
||||
getPackageVersion(descriptor, mockConfiguration),
|
||||
).rejects.toThrow('Failed to fetch manifest');
|
||||
});
|
||||
|
||||
it('propagates errors from getCurrentBackstageVersion', async () => {
|
||||
const descriptor = {
|
||||
scope: '@backstage',
|
||||
name: 'core-plugin-api',
|
||||
range: 'backstage:^',
|
||||
} as Descriptor;
|
||||
|
||||
const versionError = new Error('Failed to get current version');
|
||||
mockGetCurrentBackstageVersion.mockImplementation(() => {
|
||||
throw versionError;
|
||||
});
|
||||
|
||||
await expect(
|
||||
getPackageVersion(descriptor, mockConfiguration),
|
||||
).rejects.toThrow('Failed to get current version');
|
||||
});
|
||||
});
|
||||
|
||||
describe('different package name formats', () => {
|
||||
beforeEach(() => {
|
||||
mockGetManifestByVersion.mockResolvedValue({
|
||||
releaseVersion: '1.23.4',
|
||||
packages: [
|
||||
{ name: '@backstage/core-plugin-api', version: '1.9.0' },
|
||||
{ name: 'backstage-plugin-simple', version: '0.5.0' },
|
||||
{ name: '@internal/custom-package', version: '2.1.0' },
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
it('handles scoped packages correctly', async () => {
|
||||
const descriptor = {
|
||||
scope: '@backstage',
|
||||
name: 'core-plugin-api',
|
||||
range: 'backstage:^',
|
||||
} as Descriptor;
|
||||
|
||||
const result = await getPackageVersion(descriptor, mockConfiguration);
|
||||
expect(result).toBe('1.9.0');
|
||||
});
|
||||
|
||||
it('handles non-scoped packages correctly', async () => {
|
||||
const descriptor = {
|
||||
name: 'backstage-plugin-simple',
|
||||
range: 'backstage:^',
|
||||
} as Descriptor;
|
||||
|
||||
mockStructUtils.stringifyIdent.mockReturnValue('backstage-plugin-simple');
|
||||
|
||||
const result = await getPackageVersion(descriptor, mockConfiguration);
|
||||
expect(result).toBe('0.5.0');
|
||||
});
|
||||
|
||||
it('handles packages with different scopes', async () => {
|
||||
const descriptor = {
|
||||
scope: '@internal',
|
||||
name: 'custom-package',
|
||||
range: 'backstage:^',
|
||||
} as Descriptor;
|
||||
|
||||
mockStructUtils.stringifyIdent.mockReturnValue(
|
||||
'@internal/custom-package',
|
||||
);
|
||||
|
||||
const result = await getPackageVersion(descriptor, mockConfiguration);
|
||||
expect(result).toBe('2.1.0');
|
||||
});
|
||||
});
|
||||
|
||||
describe('BACKSTAGE_MANIFEST_FILE environment variable', () => {
|
||||
const manifestFilePath = '/path/to/manifest.json';
|
||||
|
||||
beforeEach(() => {
|
||||
// Clean up environment variables
|
||||
delete process.env.BACKSTAGE_MANIFEST_FILE;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
// Clean up environment variables
|
||||
delete process.env.BACKSTAGE_MANIFEST_FILE;
|
||||
});
|
||||
|
||||
it('reads manifest from file when BACKSTAGE_MANIFEST_FILE is set', async () => {
|
||||
process.env.BACKSTAGE_MANIFEST_FILE = manifestFilePath;
|
||||
|
||||
const mockManifest = {
|
||||
packages: [
|
||||
{ name: '@backstage/core-plugin-api', version: '1.9.0' },
|
||||
{ name: '@backstage/core-app-api', version: '1.12.0' },
|
||||
],
|
||||
};
|
||||
|
||||
const readJsonSyncSpy = jest.spyOn(xfs, 'readJsonSync');
|
||||
readJsonSyncSpy.mockReturnValue(mockManifest);
|
||||
|
||||
const descriptor = {
|
||||
scope: '@backstage',
|
||||
name: 'core-plugin-api',
|
||||
range: 'backstage:^',
|
||||
} as Descriptor;
|
||||
|
||||
const result = await getPackageVersion(descriptor, mockConfiguration);
|
||||
|
||||
expect(result).toBe('1.9.0');
|
||||
expect(readJsonSyncSpy).toHaveBeenCalledWith(manifestFilePath);
|
||||
expect(mockGetManifestByVersion).not.toHaveBeenCalled();
|
||||
expect(mockHttpUtils.get).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('finds package in file-based manifest', async () => {
|
||||
process.env.BACKSTAGE_MANIFEST_FILE = manifestFilePath;
|
||||
|
||||
const mockManifest = {
|
||||
packages: [
|
||||
{ name: '@backstage/core-plugin-api', version: '2.1.0' },
|
||||
{ name: '@backstage/backend-common', version: '0.19.5' },
|
||||
{ name: 'simple-package', version: '1.0.0' },
|
||||
],
|
||||
};
|
||||
|
||||
const readJsonSyncSpy = jest.spyOn(xfs, 'readJsonSync');
|
||||
readJsonSyncSpy.mockReturnValue(mockManifest);
|
||||
|
||||
const descriptor = {
|
||||
scope: '@backstage',
|
||||
name: 'backend-common',
|
||||
range: 'backstage:^',
|
||||
} as Descriptor;
|
||||
|
||||
mockStructUtils.stringifyIdent.mockReturnValue(
|
||||
'@backstage/backend-common',
|
||||
);
|
||||
|
||||
const result = await getPackageVersion(descriptor, mockConfiguration);
|
||||
|
||||
expect(result).toBe('0.19.5');
|
||||
expect(readJsonSyncSpy).toHaveBeenCalledWith(manifestFilePath);
|
||||
});
|
||||
|
||||
it('throws error when package not found in file-based manifest', async () => {
|
||||
process.env.BACKSTAGE_MANIFEST_FILE = manifestFilePath;
|
||||
|
||||
const mockManifest = {
|
||||
packages: [{ name: '@backstage/core-plugin-api', version: '1.9.0' }],
|
||||
};
|
||||
|
||||
const readJsonSyncSpy = jest.spyOn(xfs, 'readJsonSync');
|
||||
readJsonSyncSpy.mockReturnValue(mockManifest);
|
||||
|
||||
const descriptor = {
|
||||
scope: '@backstage',
|
||||
name: 'missing-package',
|
||||
range: 'backstage:^',
|
||||
} as Descriptor;
|
||||
|
||||
mockStructUtils.stringifyIdent.mockReturnValue(
|
||||
'@backstage/missing-package',
|
||||
);
|
||||
|
||||
await expect(
|
||||
getPackageVersion(descriptor, mockConfiguration),
|
||||
).rejects.toThrow(
|
||||
'Package @backstage/missing-package not found in manifest for Backstage v1.23.4. ' +
|
||||
'This means the specified package is not included in this Backstage ' +
|
||||
'release. This may imply the package has been replaced with an alternative - ' +
|
||||
'please review the documentation for the package. If you need to continue ' +
|
||||
'using this package, it will be necessary to switch to manually managing its ' +
|
||||
'version.',
|
||||
);
|
||||
|
||||
expect(readJsonSyncSpy).toHaveBeenCalledWith(manifestFilePath);
|
||||
expect(mockGetManifestByVersion).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('propagates errors from xfs.readJsonSync when file cannot be read', async () => {
|
||||
process.env.BACKSTAGE_MANIFEST_FILE = manifestFilePath;
|
||||
|
||||
const fileError = new Error('ENOENT: no such file or directory');
|
||||
const readJsonSyncSpy = jest.spyOn(xfs, 'readJsonSync');
|
||||
readJsonSyncSpy.mockImplementation(() => {
|
||||
throw fileError;
|
||||
});
|
||||
|
||||
const descriptor = {
|
||||
scope: '@backstage',
|
||||
name: 'core-plugin-api',
|
||||
range: 'backstage:^',
|
||||
} as Descriptor;
|
||||
|
||||
await expect(
|
||||
getPackageVersion(descriptor, mockConfiguration),
|
||||
).rejects.toThrow('ENOENT: no such file or directory');
|
||||
|
||||
expect(readJsonSyncSpy).toHaveBeenCalledWith(manifestFilePath);
|
||||
expect(mockGetManifestByVersion).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('handles non-scoped packages in file-based manifest', async () => {
|
||||
process.env.BACKSTAGE_MANIFEST_FILE = manifestFilePath;
|
||||
|
||||
const mockManifest = {
|
||||
packages: [
|
||||
{ name: 'backstage-plugin-simple', version: '0.5.0' },
|
||||
{ name: '@backstage/core-plugin-api', version: '1.9.0' },
|
||||
],
|
||||
};
|
||||
|
||||
const readJsonSyncSpy = jest.spyOn(xfs, 'readJsonSync');
|
||||
readJsonSyncSpy.mockReturnValue(mockManifest);
|
||||
|
||||
const descriptor = {
|
||||
name: 'backstage-plugin-simple',
|
||||
range: 'backstage:^',
|
||||
} as Descriptor;
|
||||
|
||||
mockStructUtils.stringifyIdent.mockReturnValue('backstage-plugin-simple');
|
||||
|
||||
const result = await getPackageVersion(descriptor, mockConfiguration);
|
||||
|
||||
expect(result).toBe('0.5.0');
|
||||
expect(readJsonSyncSpy).toHaveBeenCalledWith(manifestFilePath);
|
||||
expect(mockGetManifestByVersion).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -20,10 +20,12 @@ import {
|
||||
httpUtils,
|
||||
structUtils,
|
||||
} from '@yarnpkg/core';
|
||||
import { PortablePath, xfs } from '@yarnpkg/fslib';
|
||||
import { getManifestByVersion } from '@backstage/release-manifests';
|
||||
|
||||
import { PROTOCOL } from '../constants';
|
||||
import { getCurrentBackstageVersion } from './getCurrentBackstageVersion';
|
||||
import { env } from 'process';
|
||||
|
||||
export const getPackageVersion = async (
|
||||
descriptor: Descriptor,
|
||||
@@ -45,43 +47,46 @@ export const getPackageVersion = async (
|
||||
}
|
||||
|
||||
const backstageVersion = getCurrentBackstageVersion();
|
||||
const manifestFile = env.BACKSTAGE_MANIFEST_FILE;
|
||||
const manifest = manifestFile
|
||||
? await xfs.readJsonSync(manifestFile as PortablePath)
|
||||
: await getManifestByVersion({
|
||||
version: backstageVersion,
|
||||
versionsBaseUrl: env.BACKSTAGE_VERSIONS_BASE_URL,
|
||||
// We override the fetch function used inside getManifestByVersion with a
|
||||
// custom implementation that calls yarn's built-in `httpUtils` method
|
||||
// instead. This has a couple of benefits:
|
||||
//
|
||||
// 1. This means that the fetch should leverage yarn's built-in cache, so we
|
||||
// don't need to explicitly memoize the fetch.
|
||||
// 2. The request should automatically take account of any proxy settings
|
||||
// configured in yarn.
|
||||
fetch: async (url: string) => {
|
||||
const response = await httpUtils.get(url, {
|
||||
configuration,
|
||||
jsonResponse: true,
|
||||
});
|
||||
|
||||
const manifest = await getManifestByVersion({
|
||||
version: backstageVersion,
|
||||
// We override the fetch function used inside getManifestByVersion with a
|
||||
// custom implementation that calls yarn's built-in `httpUtils` method
|
||||
// instead. This has a couple of benefits:
|
||||
//
|
||||
// 1. This means that the fetch should leverage yarn's built-in cache, so we
|
||||
// don't need to explicitly memoize the fetch.
|
||||
// 2. The request should automatically take account of any proxy settings
|
||||
// configured in yarn.
|
||||
fetch: async (url: string) => {
|
||||
const response = await httpUtils.get(url, {
|
||||
configuration,
|
||||
jsonResponse: true,
|
||||
// The release-manifests package expects fetchFn to resolve with a subset
|
||||
// of the native HTTP Response object, but yarn's httpUtils implementation
|
||||
// keeps most of the details hidden. This means we need to construct an
|
||||
// object which quacks like a Response in the appropriate ways.
|
||||
return {
|
||||
// The function has some custom handling for non-200 errors. Yarn
|
||||
// doesn't provide the status code, but if we've got to this point
|
||||
// without throwing, we can assume the request has been successful.
|
||||
status: 200,
|
||||
// The requested URL, used to correctly report errors
|
||||
url,
|
||||
// Yarn automatically parses the response as JSON, so our implementation
|
||||
// can simply return it.
|
||||
json: () => response,
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
// The release-manifests package expects fetchFn to resolve with a subset
|
||||
// of the native HTTP Response object, but yarn's httpUtils implementation
|
||||
// keeps most of the details hidden. This means we need to construct an
|
||||
// object which quacks like a Response in the appropriate ways.
|
||||
return {
|
||||
// The function has some custom handling for non-200 errors. Yarn
|
||||
// doesn't provide the status code, but if we've got to this point
|
||||
// without throwing, we can assume the request has been successful.
|
||||
status: 200,
|
||||
// The requested URL, used to correctly report errors
|
||||
url,
|
||||
// Yarn automatically parses the response as JSON, so our implementation
|
||||
// can simply return it.
|
||||
json: () => response,
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
const manifestEntry = manifest.packages.find(
|
||||
candidate => candidate.name === ident,
|
||||
(candidate: { name: string; version: string }) => candidate.name === ident,
|
||||
);
|
||||
|
||||
if (!manifestEntry) {
|
||||
|
||||
Reference in New Issue
Block a user