diff --git a/packages/cli/src/modules/start/commands/repo/start.test.ts b/packages/cli/src/modules/start/commands/repo/start.test.ts new file mode 100644 index 0000000000..2950552209 --- /dev/null +++ b/packages/cli/src/modules/start/commands/repo/start.test.ts @@ -0,0 +1,221 @@ +/* + * 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. + * 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 { PackageGraph } from '@backstage/cli-node'; +import { findTargetPackages } from './start'; +import { posix } from 'path'; +import { paths } from '../../../../lib/paths'; + +const mocks = { + app: { + packageJson: { + name: 'app', + version: '0', + backstage: { role: 'frontend' }, + }, + dir: '/root/packages/app', + }, + backend: { + packageJson: { + name: 'backend', + version: '0', + backstage: { role: 'backend' }, + }, + dir: '/root/packages/backend', + }, + appNext: { + packageJson: { + name: 'app-next', + version: '0', + backstage: { role: 'frontend' }, + }, + dir: '/root/packages/app-next', + }, + backendNext: { + packageJson: { + name: 'backend-next', + version: '0', + backstage: { role: 'backend' }, + }, + dir: '/root/packages/backend-next', + }, + otherApp: { + packageJson: { + name: 'other-app', + version: '0', + backstage: { role: 'frontend' }, + }, + dir: '/root/packages/other-app', + }, + pluginX: { + packageJson: { + name: 'plugin-x', + version: '0', + backstage: { role: 'frontend-plugin', pluginId: 'x' }, + }, + dir: '/root/plugins/plugin-x', + }, + pluginXBackend: { + packageJson: { + name: 'plugin-x-backend', + version: '0', + backstage: { role: 'backend-plugin', pluginId: 'x' }, + }, + dir: '/root/plugins/plugin-x-backend', + }, + pluginY: { + packageJson: { + name: 'plugin-y', + version: '0', + backstage: { role: 'frontend-plugin', pluginId: 'y' }, + }, + dir: '/root/plugins/plugin-y', + }, + pluginYBackend: { + packageJson: { + name: 'plugin-y-backend', + version: '0', + backstage: { role: 'backend-plugin', pluginId: 'y' }, + }, + dir: '/root/plugins/plugin-y-backend', + }, +} as const; + +describe('findTargetPackages', () => { + beforeEach(() => { + jest.clearAllMocks(); + jest + .spyOn(paths, 'resolveTargetRoot') + .mockImplementation((...parts: string[]) => { + return posix.resolve('/root', ...parts); + }); + }); + + it('should select default packages', async () => { + jest + .spyOn(PackageGraph, 'listTargetPackages') + .mockResolvedValue(Object.values(mocks)); + const result = await findTargetPackages([], []); + expect(result).toEqual([mocks.app, mocks.backend]); + }); + + it('should select packages by plugin ID', async () => { + jest + .spyOn(PackageGraph, 'listTargetPackages') + .mockResolvedValue(Object.values(mocks)); + const result = await findTargetPackages([], ['x']); + expect(result).toEqual([mocks.pluginX, mocks.pluginXBackend]); + }); + + it('should throw an error if no packages match the plugin ID', async () => { + jest + .spyOn(PackageGraph, 'listTargetPackages') + .mockResolvedValue(Object.values(mocks)); + await expect( + findTargetPackages([], ['nonexistent-plugin']), + ).rejects.toThrow( + "Unable to find any plugin packages with plugin ID 'nonexistent-plugin'. Make sure backstage.pluginId is set in your package.json files by running 'yarn fix --publish'.", + ); + }); + + it('should select packages by explicit names', async () => { + jest + .spyOn(PackageGraph, 'listTargetPackages') + .mockResolvedValue(Object.values(mocks)); + const result = await findTargetPackages(['other-app'], []); + expect(result).toEqual([mocks.otherApp]); + }); + + it('should throw an error if no package matches the explicit name', async () => { + jest + .spyOn(PackageGraph, 'listTargetPackages') + .mockResolvedValue(Object.values(mocks)); + await expect( + findTargetPackages(['nonexistent-package'], []), + ).rejects.toThrow("Unable to find package by name 'nonexistent-package'"); + }); + + it('should select packages by relative path', async () => { + jest + .spyOn(PackageGraph, 'listTargetPackages') + .mockResolvedValue(Object.values(mocks)); + const result = await findTargetPackages( + ['packages/app', 'packages/backend-next'], + [], + ); + expect(result).toEqual([mocks.app, mocks.backendNext]); + }); + + it('should throw an error if no package matches the relative path', async () => { + jest + .spyOn(PackageGraph, 'listTargetPackages') + .mockResolvedValue(Object.values(mocks)); + await expect(findTargetPackages(['nonexistent/path'], [])).rejects.toThrow( + "Unable to find package by name 'nonexistent/path'", + ); + }); + + it('should select a single frontend or backend package if no arguments are provided', async () => { + jest + .spyOn(PackageGraph, 'listTargetPackages') + .mockResolvedValue([mocks.app]); + const result = await findTargetPackages([], []); + expect(result).toEqual([mocks.app]); + }); + + it('should throw an error if multiple frontend packages other than packages/app are found without explicit selection', async () => { + jest + .spyOn(PackageGraph, 'listTargetPackages') + .mockResolvedValue([mocks.otherApp, mocks.appNext]); + await expect(findTargetPackages([], [])).rejects.toThrow( + "Found multiple packages with role 'frontend' but none of the use the default path '/root/packages/app',choose which packages you want to run by passing the package names explicitly as arguments, for example 'yarn backstage-cli repo start my-app my-backend'.", + ); + }); + + it('should select a single plugin package if no app or backend packages are found', async () => { + jest + .spyOn(PackageGraph, 'listTargetPackages') + .mockResolvedValue([mocks.pluginX]); + const result = await findTargetPackages([], []); + expect(result).toEqual([mocks.pluginX]); + }); + + it('should select a pair of plugin packages if no app or backend packages are found', async () => { + jest + .spyOn(PackageGraph, 'listTargetPackages') + .mockResolvedValue([mocks.pluginX, mocks.pluginXBackend]); + const result = await findTargetPackages([], []); + expect(result).toEqual([mocks.pluginX, mocks.pluginXBackend]); + }); + + // Right now we're not validating this because it requires backstage.pluginId to be set, and it's a strange case anyway + it('should select a pair of plugin packages even if they are from different plugins', async () => { + jest + .spyOn(PackageGraph, 'listTargetPackages') + .mockResolvedValue([mocks.pluginX, mocks.pluginYBackend]); + const result = await findTargetPackages([], []); + expect(result).toEqual([mocks.pluginX, mocks.pluginYBackend]); + }); + + it('should throw an error if multiple plugin packages are found without explicit selection', async () => { + jest + .spyOn(PackageGraph, 'listTargetPackages') + .mockResolvedValue([mocks.pluginX, mocks.pluginY]); + await expect(findTargetPackages([], [])).rejects.toThrow( + "Found multiple packages with role 'frontend-plugin', please choose which packages you want to run by passing the package names explicitly as arguments, for example 'yarn backstage-cli repo start my-plugin my-plugin-backend'.", + ); + }); +}); diff --git a/packages/cli/src/modules/start/commands/repo/start.ts b/packages/cli/src/modules/start/commands/repo/start.ts index b25054bbfc..b0bb8f5c01 100644 --- a/packages/cli/src/modules/start/commands/repo/start.ts +++ b/packages/cli/src/modules/start/commands/repo/start.ts @@ -61,7 +61,10 @@ export async function command(namesOrPaths: string[], options: CommandOptions) { await Promise.all(packageOptions.map(entry => startPackage(entry.options))); } -async function findTargetPackages(namesOrPaths: string[], pluginIds: string[]) { +export async function findTargetPackages( + namesOrPaths: string[], + pluginIds: string[], +) { const targetPackages = new Array(); const packages = await PackageGraph.listTargetPackages(); @@ -75,7 +78,7 @@ async function findTargetPackages(namesOrPaths: string[], pluginIds: string[]) { ACCEPTED_PACKAGE_ROLES.includes(pkg.packageJson.backstage.role) ); }); - if (!matchingPackages) { + if (matchingPackages.length === 0) { throw new Error( `Unable to find any plugin packages with plugin ID '${pluginId}'. Make sure backstage.pluginId is set in your package.json files by running 'yarn fix --publish'.`, ); @@ -145,7 +148,7 @@ async function findTargetPackages(namesOrPaths: string[], pluginIds: string[]) { ); if (matchingPackages.length > 1) { throw new Error( - `Found multiple packages with role '${role}', please choose which packages you want` + + `Found multiple packages with role '${role}', please choose which packages you want ` + `to run by passing the package names explicitly as arguments, for example ` + `'yarn backstage-cli repo start my-plugin my-plugin-backend'.`, );