From 9ad228564e58538864a5a97802c00b5d606bbdd9 Mon Sep 17 00:00:00 2001 From: Hope Hadfield Date: Thu, 24 Sep 2026 14:47:03 -0400 Subject: [PATCH] fix(plugin-export): stop shipping legacy backend config schema Assisted-by: OpenAI Codex Signed-off-by: Hope Hadfield --- CHANGELOG.md | 4 + .../export-dynamic-plugin/command.test.ts | 102 ++++++++++++++++++ src/commands/export-dynamic-plugin/command.ts | 23 ++-- 3 files changed, 115 insertions(+), 14 deletions(-) create mode 100644 src/commands/export-dynamic-plugin/command.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 0cd7baf..a237b63 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,10 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). ## [Unreleased] +### Removed + +- **`plugin export`:** Backend plugins and modules no longer ship the legacy `dist/configSchema.json` copy. Exports write only `dist/.config-schema.json` and remove a stale legacy copy when re-exporting without `--clean`, matching the RHDH schema loader ([RHIDP-17026](https://redhat.atlassian.net/browse/RHIDP-17026)). + ## 2.1.1 - 2026-09-21 ### Added diff --git a/src/commands/export-dynamic-plugin/command.test.ts b/src/commands/export-dynamic-plugin/command.test.ts new file mode 100644 index 0000000..324a8bb --- /dev/null +++ b/src/commands/export-dynamic-plugin/command.test.ts @@ -0,0 +1,102 @@ +/* + * Copyright (c) Red Hat, Inc. + * + * Licensed under the Apache License, Version 2.0. + */ + +jest.mock('../../lib/paths', () => ({ + paths: { + targetRoot: '/tmp/test-plugin', + resolveTarget: (part: string) => `/tmp/test-plugin/${part}`, + }, +})); +jest.mock('../../lib/schema/collect', () => ({ getConfigSchema: jest.fn() })); +jest.mock('../../lib/tasks', () => ({ Task: { log: jest.fn() } })); +jest.mock('./backend', () => ({ + backend: jest.fn().mockResolvedValue('/tmp/test-plugin/dist-dynamic'), +})); +jest.mock('./frontend', () => ({ + frontend: jest.fn().mockResolvedValue('/tmp/test-plugin/dist-dynamic'), +})); +jest.mock('./check-heavy-deps', () => ({ + checkHeavyDependencies: jest.fn().mockResolvedValue(undefined), +})); +jest.mock('./dev', () => ({ + applyDevOptions: jest.fn().mockResolvedValue(undefined), +})); +jest.mock('fs-extra', () => ({ + ...jest.requireActual('fs-extra'), + readJson: jest.fn(), + readJSON: jest.fn().mockResolvedValue({ backstage: {} }), + existsSync: jest.fn().mockReturnValue(false), + ensureDir: jest.fn().mockResolvedValue(undefined), + remove: jest.fn().mockResolvedValue(undefined), + writeJson: jest.fn().mockResolvedValue(undefined), +})); + +import fs from 'fs-extra'; + +import { getConfigSchema } from '../../lib/schema/collect'; +import { backend } from './backend'; +import { command } from './command'; +import { frontend } from './frontend'; + +const target = '/tmp/test-plugin/dist-dynamic/dist'; +const schema = { + $schema: 'http://json-schema.org/draft-07/schema#', + type: 'object', + properties: { example: { type: 'string' } }, +} as const; + +beforeEach(() => { + jest.clearAllMocks(); + jest.mocked(getConfigSchema).mockResolvedValue(schema); +}); + +test.each(['backend-plugin', 'backend-plugin-module'])( + 'exports only the modern schema for %s and removes a stale legacy copy', + async role => { + jest.mocked(fs.readJson).mockResolvedValue({ + name: '@internal/example', + backstage: { role }, + }); + + await command({}); + + expect(backend).toHaveBeenCalledTimes(1); + expect(frontend).not.toHaveBeenCalled(); + expect(fs.remove).toHaveBeenCalledTimes(1); + expect(fs.remove).toHaveBeenCalledWith(`${target}/configSchema.json`); + expect(fs.writeJson).toHaveBeenCalledTimes(1); + expect(fs.writeJson).toHaveBeenCalledWith( + `${target}/.config-schema.json`, + schema, + { encoding: 'utf8', spaces: 2 }, + ); + expect(jest.mocked(fs.remove).mock.invocationCallOrder[0]).toBeLessThan( + jest.mocked(fs.writeJson).mock.invocationCallOrder[0], + ); + }, +); + +test.each(['frontend-plugin', 'frontend-plugin-module'])( + 'exports only the modern schema for %s', + async role => { + jest.mocked(fs.readJson).mockResolvedValue({ + name: '@internal/example', + backstage: { role }, + }); + + await command({}); + + expect(frontend).toHaveBeenCalledTimes(1); + expect(backend).not.toHaveBeenCalled(); + expect(fs.remove).not.toHaveBeenCalled(); + expect(fs.writeJson).toHaveBeenCalledTimes(1); + expect(fs.writeJson).toHaveBeenCalledWith( + `${target}/.config-schema.json`, + schema, + { encoding: 'utf8', spaces: 2 }, + ); + }, +); diff --git a/src/commands/export-dynamic-plugin/command.ts b/src/commands/export-dynamic-plugin/command.ts index 5d64809..c8fd4d6 100644 --- a/src/commands/export-dynamic-plugin/command.ts +++ b/src/commands/export-dynamic-plugin/command.ts @@ -40,34 +40,29 @@ export async function command(opts: OptionValues): Promise { let targetPath: string; const roleInfo = PackageRoles.getRoleInfo(role); - let configSchemaPaths: string[]; if (role === 'backend-plugin' || role === 'backend-plugin-module') { targetPath = await backend(opts); - configSchemaPaths = [ - path.join(targetPath, 'dist/configSchema.json'), - path.join(targetPath, 'dist/.config-schema.json'), - ]; + // A re-export without --clean must not retain an earlier legacy schema. + await fs.remove(path.join(targetPath, 'dist/configSchema.json')); } else if (role === 'frontend-plugin' || role === 'frontend-plugin-module') { targetPath = await frontend(roleInfo, opts); - configSchemaPaths = [path.join(targetPath, 'dist/.config-schema.json')]; } else { throw new Error( 'Only packages with the "backend-plugin", "backend-plugin-module", "frontend-plugin" or "frontend-plugin-module" roles can be exported as dynamic plugins', ); } + const configSchemaPath = path.join(targetPath, 'dist/.config-schema.json'); Task.log( - `Saving self-contained config schema in ${chalk.cyan(configSchemaPaths.join(' and '))}`, + `Saving self-contained config schema in ${chalk.cyan(configSchemaPath)}`, ); const configSchema = await getConfigSchema(rawPkg.name); - for (const configSchemaPath of configSchemaPaths) { - await fs.ensureDir(path.dirname(paths.resolveTarget(configSchemaPath))); - await fs.writeJson(paths.resolveTarget(configSchemaPath), configSchema, { - encoding: 'utf8', - spaces: 2, - }); - } + await fs.ensureDir(path.dirname(configSchemaPath)); + await fs.writeJson(configSchemaPath, configSchema, { + encoding: 'utf8', + spaces: 2, + }); const heavyDepKind: HeavyDepKind = role === 'backend-plugin' || role === 'backend-plugin-module'