diff --git a/packages/backend-dynamic-feature-service/src/manager/plugin-manager.test.ts b/packages/backend-dynamic-feature-service/src/manager/plugin-manager.test.ts index b778e4df6e..a9340d5962 100644 --- a/packages/backend-dynamic-feature-service/src/manager/plugin-manager.test.ts +++ b/packages/backend-dynamic-feature-service/src/manager/plugin-manager.test.ts @@ -39,6 +39,7 @@ import { ConfigSources } from '@backstage/config-loader'; import { Logs, MockedLogger, LogContent } from '../__testUtils__/testUtils'; import { PluginScanner } from '../scanner/plugin-scanner'; import { targetPaths } from '@backstage/cli-common'; +import { overrideTargetPaths } from '@backstage/cli-common/testUtils'; import { createMockDirectory } from '@backstage/backend-test-utils'; import { rootLifecycleServiceFactory } from '@backstage/backend-defaults/rootLifecycle'; import { BackstagePackageJson, PackageRole } from '@backstage/cli-node'; @@ -47,15 +48,7 @@ describe('backend-dynamic-feature-service', () => { const mockDir = createMockDirectory(); beforeAll(() => { - jest - .spyOn(targetPaths, 'resolveRoot') - .mockImplementation((...paths: string[]) => - require('path').resolve(__dirname, '../../../..', ...paths), - ); - }); - - afterAll(() => { - jest.restoreAllMocks(); + overrideTargetPaths(require('path').resolve(__dirname, '../../../..')); }); describe('loadPlugins', () => { diff --git a/packages/backend/package.json b/packages/backend/package.json index fee2734e6e..b6b97f214e 100644 --- a/packages/backend/package.json +++ b/packages/backend/package.json @@ -61,6 +61,7 @@ "@backstage/plugin-search-backend": "workspace:^", "@backstage/plugin-search-backend-module-catalog": "workspace:^", "@backstage/plugin-search-backend-module-elasticsearch": "workspace:^", + "@backstage/plugin-search-backend-module-explore": "workspace:^", "@backstage/plugin-search-backend-module-techdocs": "workspace:^", "@backstage/plugin-search-backend-node": "workspace:^", "@backstage/plugin-signals-backend": "workspace:^", @@ -68,7 +69,6 @@ "@opentelemetry/auto-instrumentations-node": "^0.67.0", "@opentelemetry/exporter-prometheus": "^0.211.0", "@opentelemetry/sdk-node": "^0.211.0", - "@backstage/plugin-search-backend-module-explore": "workspace:^", "example-app": "link:../app" }, "devDependencies": { diff --git a/packages/cli-common/package.json b/packages/cli-common/package.json index 9134eeeaee..4eae1d96a4 100644 --- a/packages/cli-common/package.json +++ b/packages/cli-common/package.json @@ -6,9 +6,7 @@ "role": "node-library" }, "publishConfig": { - "access": "public", - "main": "dist/index.cjs.js", - "types": "dist/index.d.ts" + "access": "public" }, "keywords": [ "backstage" @@ -20,8 +18,23 @@ "directory": "packages/cli-common" }, "license": "Apache-2.0", + "exports": { + ".": "./src/index.ts", + "./testUtils": "./src/testUtils.ts", + "./package.json": "./package.json" + }, "main": "src/index.ts", "types": "src/index.ts", + "typesVersions": { + "*": { + "testUtils": [ + "src/testUtils.ts" + ], + "package.json": [ + "package.json" + ] + } + }, "files": [ "dist" ], diff --git a/packages/cli-common/report-testUtils.api.md b/packages/cli-common/report-testUtils.api.md new file mode 100644 index 0000000000..012803d1fb --- /dev/null +++ b/packages/cli-common/report-testUtils.api.md @@ -0,0 +1,23 @@ +## API Report File for "@backstage/cli-common" + +> Do not edit this file. It is a report generated by [API Extractor](https://api-extractor.com/). + +```ts +// @public +export function overrideTargetPaths( + dirOrOptions: string | OverrideTargetPathsOptions, +): TargetPathsOverride; + +// @public +export interface OverrideTargetPathsOptions { + dir: string; + rootDir?: string; +} + +// @public +export interface TargetPathsOverride { + restore(): void; +} + +// (No @packageDocumentation comment for this package) +``` diff --git a/packages/cli-common/report.api.md b/packages/cli-common/report.api.md index 18f2ccb63c..ba649cacc9 100644 --- a/packages/cli-common/report.api.md +++ b/packages/cli-common/report.api.md @@ -16,7 +16,6 @@ export function bootstrapEnvProxyAgents(): void; // @public export class ExitCodeError extends CustomErrorBase { constructor(code: number, command?: string); - // (undocumented) readonly code: number; } diff --git a/packages/cli-common/src/errors.ts b/packages/cli-common/src/errors.ts index 07664d2ffb..c684ac68d5 100644 --- a/packages/cli-common/src/errors.ts +++ b/packages/cli-common/src/errors.ts @@ -21,6 +21,7 @@ import { CustomErrorBase } from '@backstage/errors'; * @public */ export class ExitCodeError extends CustomErrorBase { + /** The exit code of the child process. */ readonly code: number; constructor(code: number, command?: string) { diff --git a/packages/cli-common/src/paths.ts b/packages/cli-common/src/paths.ts index 63173fb70e..1cd84d4f5b 100644 --- a/packages/cli-common/src/paths.ts +++ b/packages/cli-common/src/paths.ts @@ -204,12 +204,23 @@ export function findOwnDir(searchDir: string) { return OwnPathsImpl.findDir(searchDir); } +// Used by the test utility in testUtils.ts to override targetPaths +export let targetPathsOverride: TargetPaths | undefined; + +/** @internal */ +export function setTargetPathsOverride(override: TargetPaths | undefined) { + targetPathsOverride = override; +} + class TargetPathsImpl implements TargetPaths { #cwd: string | undefined; #dir: string | undefined; #rootDir: string | undefined; get dir(): string { + if (targetPathsOverride) { + return targetPathsOverride.dir; + } const cwd = process.cwd(); if (this.#dir !== undefined && this.#cwd === cwd) { return this.#dir; @@ -224,6 +235,9 @@ class TargetPathsImpl implements TargetPaths { } get rootDir(): string { + if (targetPathsOverride) { + return targetPathsOverride.rootDir; + } // Access dir first to ensure cwd is fresh, which also invalidates rootDir on cwd change const dir = this.dir; if (this.#rootDir !== undefined) { @@ -246,10 +260,16 @@ class TargetPathsImpl implements TargetPaths { } resolve = (...paths: string[]): string => { + if (targetPathsOverride) { + return targetPathsOverride.resolve(...paths); + } return resolvePath(this.dir, ...paths); }; resolveRoot = (...paths: string[]): string => { + if (targetPathsOverride) { + return targetPathsOverride.resolveRoot(...paths); + } return resolvePath(this.rootDir, ...paths); }; } diff --git a/packages/cli-common/src/testUtils.ts b/packages/cli-common/src/testUtils.ts new file mode 100644 index 0000000000..87c42c469e --- /dev/null +++ b/packages/cli-common/src/testUtils.ts @@ -0,0 +1,78 @@ +/* + * 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 { resolve as resolvePath } from 'node:path'; +import { setTargetPathsOverride } from './paths'; + +/** + * Options for {@link overrideTargetPaths}. + * + * @public + */ +export interface OverrideTargetPathsOptions { + /** The target package directory. */ + dir: string; + /** The target monorepo root directory. Defaults to `dir` if not provided. */ + rootDir?: string; +} + +/** + * Return value of {@link overrideTargetPaths}. + * + * @public + */ +export interface TargetPathsOverride { + /** Restores `targetPaths` to its normal behavior. */ + restore(): void; +} + +/** + * Overrides the `targetPaths` singleton to resolve from the given directory + * instead of `process.cwd()`. + * + * When called with a string, that value is used as both `dir` and `rootDir`. + * Pass an options object to set them independently. + * + * Calling `restore()` on the return value reverts to normal behavior. + * Restoration is only needed if you want to change the override within a + * test file; each Jest worker starts with a clean module state. + * + * @public + */ +export function overrideTargetPaths( + dirOrOptions: string | OverrideTargetPathsOptions, +): TargetPathsOverride { + const { dir, rootDir } = + typeof dirOrOptions === 'string' + ? { dir: dirOrOptions, rootDir: dirOrOptions } + : { + dir: dirOrOptions.dir, + rootDir: dirOrOptions.rootDir ?? dirOrOptions.dir, + }; + + setTargetPathsOverride({ + dir, + rootDir, + resolve: (...paths: string[]) => resolvePath(dir, ...paths), + resolveRoot: (...paths: string[]) => resolvePath(rootDir, ...paths), + }); + + return { + restore() { + setTargetPathsOverride(undefined); + }, + }; +} diff --git a/packages/cli/src/lib/yarnPlugin.test.ts b/packages/cli/src/lib/yarnPlugin.test.ts index a51fa4864a..a07c2a2ac6 100644 --- a/packages/cli/src/lib/yarnPlugin.test.ts +++ b/packages/cli/src/lib/yarnPlugin.test.ts @@ -15,21 +15,15 @@ */ import { createMockDirectory } from '@backstage/backend-test-utils'; -import { targetPaths } from '@backstage/cli-common'; +import { overrideTargetPaths } from '@backstage/cli-common/testUtils'; import { getHasYarnPlugin } from './yarnPlugin'; const mockDir = createMockDirectory(); +overrideTargetPaths(mockDir.path); describe('getHasYarnPlugin', () => { beforeEach(() => { mockDir.clear(); - jest - .spyOn(targetPaths, 'resolveRoot') - .mockImplementation((...args: string[]) => mockDir.resolve(...args)); - }); - - afterEach(() => { - jest.restoreAllMocks(); }); it('should return false when .yarnrc.yml does not exist', async () => { diff --git a/packages/cli/src/modules/build/commands/repo/start.test.ts b/packages/cli/src/modules/build/commands/repo/start.test.ts index 54414b7e90..a9eb01b21b 100644 --- a/packages/cli/src/modules/build/commands/repo/start.test.ts +++ b/packages/cli/src/modules/build/commands/repo/start.test.ts @@ -16,8 +16,9 @@ import { PackageGraph } from '@backstage/cli-node'; import { findTargetPackages } from './start'; -import { posix } from 'node:path'; -import { targetPaths } from '@backstage/cli-common'; +import { overrideTargetPaths } from '@backstage/cli-common/testUtils'; + +overrideTargetPaths({ dir: '/root', rootDir: '/root' }); const mocks = { app: { @@ -97,11 +98,6 @@ const mocks = { describe('findTargetPackages', () => { beforeEach(() => { jest.clearAllMocks(); - jest - .spyOn(targetPaths, 'resolveRoot') - .mockImplementation((...parts: string[]) => { - return posix.resolve('/root', ...parts); - }); }); it('should select default packages', async () => { diff --git a/packages/cli/src/modules/build/lib/role.test.ts b/packages/cli/src/modules/build/lib/role.test.ts index 76b202ae98..dbed4310fa 100644 --- a/packages/cli/src/modules/build/lib/role.test.ts +++ b/packages/cli/src/modules/build/lib/role.test.ts @@ -15,24 +15,12 @@ */ import { createMockDirectory } from '@backstage/backend-test-utils'; +import { overrideTargetPaths } from '@backstage/cli-common/testUtils'; import { Command } from 'commander'; import { findRoleFromCommand } from './role'; const mockDir = createMockDirectory(); - -jest.mock('@backstage/cli-common', () => ({ - ...jest.requireActual('@backstage/cli-common'), - targetPaths: { - get dir() { - return mockDir.path; - }, - get rootDir() { - return mockDir.path; - }, - resolve: (...args: string[]) => mockDir.resolve(...args), - resolveRoot: (...args: string[]) => mockDir.resolve(...args), - }, -})); +overrideTargetPaths(mockDir.path); describe('findRoleFromCommand', () => { function mkCommand(args?: string) { diff --git a/packages/cli/src/modules/migrate/commands/versions/bump.test.ts b/packages/cli/src/modules/migrate/commands/versions/bump.test.ts index eafe141e57..b44de6fc6b 100644 --- a/packages/cli/src/modules/migrate/commands/versions/bump.test.ts +++ b/packages/cli/src/modules/migrate/commands/versions/bump.test.ts @@ -16,6 +16,7 @@ import fs from 'fs-extra'; import { Command } from 'commander'; import * as runObj from '@backstage/cli-common'; +import { overrideTargetPaths } from '@backstage/cli-common/testUtils'; import bump, { bumpBackstageJsonVersion, createVersionFinder } from './bump'; import { registerMswTestHooks, withLogCollector } from '@backstage/test-utils'; import { YarnInfoInspectData } from '../../../../lib/versioning/packages'; @@ -64,16 +65,6 @@ jest.mock('@backstage/cli-common', () => { const actual = jest.requireActual('@backstage/cli-common'); return { ...actual, - targetPaths: { - get dir() { - return mockDir.path; - }, - get rootDir() { - return mockDir.path; - }, - resolve: (...args: string[]) => mockDir.resolve(...args), - resolveRoot: (...args: string[]) => mockDir.resolve(...args), - }, findPaths: () => ({ resolveTargetRoot: (...args: string[]) => mockDir.resolve(...args), get targetDir() { @@ -147,6 +138,7 @@ const expectLogsToMatch = ( describe('bump', () => { mockDir = createMockDirectory(); + beforeAll(() => overrideTargetPaths(mockDir.path)); beforeEach(() => { mockFetchPackageInfo.mockImplementation(async name => ({ @@ -953,6 +945,7 @@ describe('bump', () => { describe('bumpBackstageJsonVersion', () => { mockDir = createMockDirectory(); + beforeAll(() => overrideTargetPaths(mockDir.path)); afterEach(() => { jest.resetAllMocks(); @@ -1089,6 +1082,8 @@ describe('environment variables', () => { const worker = setupServer(); registerMswTestHooks(worker); + beforeAll(() => overrideTargetPaths(mockDir.path)); + beforeEach(() => { delete process.env.BACKSTAGE_MANIFEST_FILE; process.env.BACKSTAGE_VERSIONS_BASE_URL = 'https://custom.example.com'; diff --git a/packages/cli/src/modules/migrate/commands/versions/migrate.test.ts b/packages/cli/src/modules/migrate/commands/versions/migrate.test.ts index 74ba9ae975..16684d36f3 100644 --- a/packages/cli/src/modules/migrate/commands/versions/migrate.test.ts +++ b/packages/cli/src/modules/migrate/commands/versions/migrate.test.ts @@ -18,6 +18,7 @@ import { createMockDirectory, } from '@backstage/backend-test-utils'; import * as runObj from '@backstage/cli-common'; +import { overrideTargetPaths } from '@backstage/cli-common/testUtils'; import migrate from './migrate'; import { withLogCollector } from '@backstage/test-utils'; import fs from 'fs-extra'; @@ -37,16 +38,6 @@ jest.mock('@backstage/cli-common', () => { const actual = jest.requireActual('@backstage/cli-common'); return { ...actual, - targetPaths: { - get dir() { - return mockDir.path; - }, - get rootDir() { - return mockDir.path; - }, - resolve: (...args: string[]) => mockDir.resolve(...args), - resolveRoot: (...args: string[]) => mockDir.resolve(...args), - }, findPaths: () => ({ resolveTargetRoot: (...args: string[]) => mockDir.resolve(...args), get targetDir() { @@ -66,6 +57,7 @@ function expectLogsToMatch(receivedLogs: String[], expected: String[]): void { describe('versions:migrate', () => { mockDir = createMockDirectory(); + beforeAll(() => overrideTargetPaths(mockDir.path)); beforeEach(() => { (runObj.run as jest.Mock).mockReturnValue({ diff --git a/packages/cli/src/modules/new/lib/execution/writeTemplateContents.test.ts b/packages/cli/src/modules/new/lib/execution/writeTemplateContents.test.ts index 768f46e20e..8132246be6 100644 --- a/packages/cli/src/modules/new/lib/execution/writeTemplateContents.test.ts +++ b/packages/cli/src/modules/new/lib/execution/writeTemplateContents.test.ts @@ -17,7 +17,10 @@ import { relative as relativePath } from 'node:path'; import { writeTemplateContents } from './writeTemplateContents'; import { createMockDirectory } from '@backstage/backend-test-utils'; -import { targetPaths } from '@backstage/cli-common'; +import { overrideTargetPaths } from '@backstage/cli-common/testUtils'; + +const mockDir = createMockDirectory(); +overrideTargetPaths(mockDir.path); const baseConfig = { version: '0.1.0', @@ -26,14 +29,14 @@ const baseConfig = { }; describe('writeTemplateContents', () => { - const mockDir = createMockDirectory(); - beforeEach(() => { mockDir.clear(); + mockDir.setContent({ + 'package.json': JSON.stringify({ + workspaces: { packages: ['packages/*', 'plugins/*'] }, + }), + }); jest.resetAllMocks(); - jest - .spyOn(targetPaths, 'resolveRoot') - .mockImplementation((...args: string[]) => mockDir.resolve(...args)); }); it('should write an empty template', async () => { @@ -53,7 +56,11 @@ describe('writeTemplateContents', () => { ); expect(relativePath(mockDir.path, targetDir)).toBe('plugins/plugin-test'); - expect(mockDir.content()).toEqual({}); + expect(mockDir.content()).toEqual({ + 'package.json': JSON.stringify({ + workspaces: { packages: ['packages/*', 'plugins/*'] }, + }), + }); }); it('should write template with various files', async () => { @@ -87,6 +94,9 @@ describe('writeTemplateContents', () => { ); expect(mockDir.content()).toEqual({ + 'package.json': JSON.stringify({ + workspaces: { packages: ['packages/*', 'plugins/*'] }, + }), out: { 'test.txt': 'test', 'plugin.txt': 'id=test', diff --git a/packages/cli/src/modules/translations/commands/export.ts b/packages/cli/src/modules/translations/commands/export.ts index 96b3ceb0c6..0329b1241a 100644 --- a/packages/cli/src/modules/translations/commands/export.ts +++ b/packages/cli/src/modules/translations/commands/export.ts @@ -14,7 +14,7 @@ * limitations under the License. */ -import { paths } from '../../../lib/paths'; +import { targetPaths } from '@backstage/cli-common'; import fs from 'fs-extra'; import { dirname, resolve as resolvePath } from 'node:path'; import { @@ -41,14 +41,14 @@ export default async (options: ExportOptions) => { validatePattern(options.pattern); const targetPackageJson = await readTargetPackage( - paths.targetDir, - paths.targetRoot, + targetPaths.dir, + targetPaths.rootDir, ); - const outputDir = resolvePath(paths.targetDir, options.output); + const outputDir = resolvePath(targetPaths.dir, options.output); const manifestPath = resolvePath(outputDir, 'manifest.json'); - const tsconfigPath = paths.resolveTargetRoot('tsconfig.json'); + const tsconfigPath = targetPaths.resolveRoot('tsconfig.json'); if (!(await fs.pathExists(tsconfigPath))) { throw new Error( `No tsconfig.json found at ${tsconfigPath}. ` + @@ -61,7 +61,7 @@ export default async (options: ExportOptions) => { ); const packages = await discoverFrontendPackages( targetPackageJson, - paths.targetDir, + targetPaths.dir, ); console.log(`Found ${packages.length} frontend packages to scan`); diff --git a/packages/cli/src/modules/translations/commands/import.ts b/packages/cli/src/modules/translations/commands/import.ts index 7275da4c24..c104275f46 100644 --- a/packages/cli/src/modules/translations/commands/import.ts +++ b/packages/cli/src/modules/translations/commands/import.ts @@ -14,7 +14,7 @@ * limitations under the License. */ -import { paths } from '../../../lib/paths'; +import { targetPaths } from '@backstage/cli-common'; import fs from 'fs-extra'; import { resolve as resolvePath, @@ -45,11 +45,11 @@ interface Manifest { } export default async (options: ImportOptions) => { - await readTargetPackage(paths.targetDir, paths.targetRoot); + await readTargetPackage(targetPaths.dir, targetPaths.rootDir); - const inputDir = resolvePath(paths.targetDir, options.input); + const inputDir = resolvePath(targetPaths.dir, options.input); const manifestPath = resolvePath(inputDir, 'manifest.json'); - const outputPath = resolvePath(paths.targetDir, options.output); + const outputPath = resolvePath(targetPaths.dir, options.output); if (!(await fs.pathExists(manifestPath))) { throw new Error( diff --git a/packages/create-app/src/createApp.test.ts b/packages/create-app/src/createApp.test.ts index 56dded39ee..1f78c084c5 100644 --- a/packages/create-app/src/createApp.test.ts +++ b/packages/create-app/src/createApp.test.ts @@ -22,14 +22,18 @@ import createApp from './createApp'; import { findOwnPaths, targetPaths } from '@backstage/cli-common'; import { tmpdir } from 'node:os'; import { createMockDirectory } from '@backstage/backend-test-utils'; +import { overrideTargetPaths } from '@backstage/cli-common/testUtils'; jest.mock('./lib/tasks'); +const MOCK_TARGET_DIR = '/mock/target-dir'; +const MOCK_TARGET_ROOT = '/mock/target-root'; +overrideTargetPaths({ dir: MOCK_TARGET_DIR, rootDir: MOCK_TARGET_ROOT }); + jest.mock('@backstage/cli-common', () => { const pathModule = require('node:path'); const actual = jest.requireActual('@backstage/cli-common'); const MOCK_CREATE_APP_ROOT = '/mock/create-app-root'; - const MOCK_TARGET_DIR = '/mock/target-dir'; const mockOwnPaths = { resolve: (...paths: string[]) => pathModule.join(MOCK_CREATE_APP_ROOT, ...paths), @@ -40,18 +44,6 @@ jest.mock('@backstage/cli-common', () => { ...actual, findPaths: jest.fn(), findOwnPaths: () => mockOwnPaths, - targetPaths: { - get dir() { - return MOCK_TARGET_DIR; - }, - get rootDir() { - return '/mock/target-root'; - }, - resolve: (...paths: string[]) => - pathModule.resolve(MOCK_TARGET_DIR, ...paths), - resolveRoot: (...paths: string[]) => - pathModule.resolve('/mock/target-root', ...paths), - }, }; }); diff --git a/packages/repo-tools/src/commands/api-reports/buildApiReports.test.ts b/packages/repo-tools/src/commands/api-reports/buildApiReports.test.ts index 000ef11cac..8346296f44 100644 --- a/packages/repo-tools/src/commands/api-reports/buildApiReports.test.ts +++ b/packages/repo-tools/src/commands/api-reports/buildApiReports.test.ts @@ -16,7 +16,7 @@ import { createMockDirectory } from '@backstage/backend-test-utils'; import { normalize } from 'node:path'; -import { targetPaths } from '@backstage/cli-common'; +import { overrideTargetPaths } from '@backstage/cli-common/testUtils'; import { categorizePackageDirs } from './categorizePackageDirs'; @@ -52,11 +52,8 @@ jest.mock('./categorizePackageDirs', () => ({ })); const mockDir = createMockDirectory(); +overrideTargetPaths(mockDir.path); -jest.spyOn(targetPaths, 'rootDir', 'get').mockReturnValue(mockDir.path); -jest - .spyOn(targetPaths, 'resolveRoot') - .mockImplementation((...path: string[]) => mockDir.resolve(...path)); jest.spyOn(PackageGraph, 'listTargetPackages').mockResolvedValue([ { dir: normalize(mockDir.resolve('packages/package-a')), @@ -83,30 +80,28 @@ jest.spyOn(PackageGraph, 'listTargetPackages').mockResolvedValue([ describe('buildApiReports', () => { beforeEach(() => { mockDir.setContent({ - [targetPaths.rootDir]: { - 'package.json': JSON.stringify({ - workspaces: { packages: ['packages/*', 'plugins/*'] }, - }), - packages: { - 'package-a': { - 'package.json': '{}', - }, - 'package-b': { - 'package.json': '{}', - }, - 'package-c': {}, - 'README.md': 'Hello World', + 'package.json': JSON.stringify({ + workspaces: { packages: ['packages/*', 'plugins/*'] }, + }), + packages: { + 'package-a': { + 'package.json': '{}', }, - plugins: { - 'plugin-a': { - 'package.json': '{}', - }, - 'plugin-b': { - 'package.json': '{}', - }, - 'plugin-c': { - 'package.json': '{}', - }, + 'package-b': { + 'package.json': '{}', + }, + 'package-c': {}, + 'README.md': 'Hello World', + }, + plugins: { + 'plugin-a': { + 'package.json': '{}', + }, + 'plugin-b': { + 'package.json': '{}', + }, + 'plugin-c': { + 'package.json': '{}', }, }, });