From c671f106611c9a310447cb023e53fed480a0f6db Mon Sep 17 00:00:00 2001 From: Stan Lewis Date: Fri, 2 Oct 2026 14:06:35 -0400 Subject: [PATCH 1/4] test(version-management): add e2e integration tests for plugin check-versions and upgrade - Add e2e-tests/plugin-version-management.test.ts verifying plugin check-versions and upgrade lifecycle - Test version mismatch detection, remediation output, and --json structured output - Test non-destructive --dry-run and --skip-install upgrade modes - Test full upgrade with package manager install and lockfile synchronization - Test multi-step version upgrade lifecycle across releases (1.8 -> 2.0 -> 2.1) - Test dynamic plugin export of an upgraded plugin fixture - Test offline and air-gapped auditing and upgrading via --manifest-file and RHDH_OFFLINE=true - Register versions:lint alias for check-versions in src/commands/index.ts - Enrich runCommand Error with stdout, stderr, code, and signal properties Assisted-By: opencode Signed-off-by: Stan Lewis rh-pre-commit.version: 2.4.0 rh-pre-commit.check-secrets: ENABLED --- e2e-tests/plugin-version-management.test.ts | 473 ++++++++++++++++++++ e2e-tests/support/plugin-export-build.ts | 9 +- src/commands/index.ts | 1 + 3 files changed, 482 insertions(+), 1 deletion(-) create mode 100644 e2e-tests/plugin-version-management.test.ts diff --git a/e2e-tests/plugin-version-management.test.ts b/e2e-tests/plugin-version-management.test.ts new file mode 100644 index 0000000..893de3c --- /dev/null +++ b/e2e-tests/plugin-version-management.test.ts @@ -0,0 +1,473 @@ +/** + * End-to-end integration tests for `rhdh-cli plugin check-versions` and `rhdh-cli plugin upgrade`. + * + * Verifies: + * - Version drift and mismatch detection via `check-versions` (and alias `versions:lint`). + * - Non-destructive `--dry-run` and `--skip-install` upgrade modes. + * - Full dependency upgrade with lockfile synchronization. + * - Multi-step release upgrade lifecycle (1.8 -> 2.0 -> 2.1). + * - Clean post-upgrade validation and dynamic plugin export (`plugin export`). + * - Air-gapped / offline auditing and upgrading via `--manifest-file` and `RHDH_OFFLINE=true`. + */ + +import fs from 'fs-extra'; +import os from 'node:os'; +import path from 'node:path'; + +import { + log, + logSection, + runCommand, + RunCommandOptions, +} from './support/plugin-export-build'; + +const TEST_TIMEOUT = 8 * 60 * 1000; +const rhdhCli = path.resolve(__dirname, '../bin/rhdh-cli'); + +async function runExpectingFailure( + command: string, + options: RunCommandOptions = {}, +): Promise<{ stdout: string; stderr: string; message: string }> { + let succeeded = false; + let stdout = ''; + let caughtError: unknown; + + try { + const res = await runCommand(command, options); + succeeded = true; + stdout = res.stdout; + } catch (err: unknown) { + caughtError = err; + } + + if (succeeded) { + throw new Error( + `Command expected to fail, but succeeded with output: ${stdout}`, + ); + } + + const e = (caughtError || {}) as { + stdout?: string; + stderr?: string; + message?: string; + }; + return { + stdout: e?.stdout || '', + stderr: e?.stderr || '', + message: e?.message || '', + }; +} + +describe('plugin version management e2e', () => { + const tmpDir = fs.mkdtempSync( + path.join(os.tmpdir(), 'rhdh-cli-e2e-ver-mgmt-'), + ); + const pluginDir = path.join(tmpDir, 'test-plugin'); + + jest.setTimeout(TEST_TIMEOUT); + + beforeAll(async () => { + logSection('Setup real plugin fixture for version management e2e'); + log(`rhdh-cli: ${rhdhCli}`); + log(`workspace: ${tmpDir}`); + + // Scaffold a standalone frontend plugin pinned to RHDH 2.1.0 + await runCommand( + `"${rhdhCli}" plugin new test-plugin --type frontend --output "${pluginDir}" --rhdh-version 2.1.0`, + ); + + log('Installing baseline dependencies in test plugin'); + await runCommand( + 'YARN_ENABLE_IMMUTABLE_INSTALLS=false YARN_ENABLE_SCRIPTS=false yarn install', + { cwd: pluginDir }, + ); + }); + + afterAll(async () => { + await fs.remove(tmpDir); + }); + + describe('dependency auditing (rhdh-cli plugin check-versions)', () => { + let originalPackageJson: Record; + let originalBackstageJson: Record; + + beforeEach(async () => { + originalPackageJson = await fs.readJson( + path.join(pluginDir, 'package.json'), + ); + originalBackstageJson = await fs.readJson( + path.join(pluginDir, 'backstage.json'), + ); + }); + + afterEach(async () => { + await fs.writeJson( + path.join(pluginDir, 'package.json'), + originalPackageJson, + { spaces: 2 }, + ); + await fs.writeJson( + path.join(pluginDir, 'backstage.json'), + originalBackstageJson, + { spaces: 2 }, + ); + }); + + it('fails with exit code 1 when dependencies mismatch target RHDH release', async () => { + // Intentionally drift @backstage/core-plugin-api to an earlier release version + const pkg = await fs.readJson(path.join(pluginDir, 'package.json')); + pkg.dependencies['@backstage/core-plugin-api'] = '^1.10.0'; + await fs.writeJson(path.join(pluginDir, 'package.json'), pkg, { + spaces: 2, + }); + + // Human output mode + const { stdout, stderr, message } = await runExpectingFailure( + `"${rhdhCli}" plugin check-versions --rhdh-version 2.1.0`, + { cwd: pluginDir }, + ); + const combined = stdout + stderr + message; + expect(combined).toContain('mismatch'); + expect(combined).toContain('@backstage/core-plugin-api'); + expect(combined).toContain('rhdh-cli plugin upgrade 2.1.0'); + + // JSON mode + const jsonRes = await runExpectingFailure( + `"${rhdhCli}" plugin check-versions --rhdh-version 2.1.0 --json`, + { cwd: pluginDir }, + ); + const parsed = JSON.parse(jsonRes.stdout); + expect(parsed.valid).toBe(false); + expect(parsed.counts.mismatched).toBeGreaterThanOrEqual(1); + + const mismatchedPkg = parsed.packages.find( + (p: { name: string }) => p.name === '@backstage/core-plugin-api', + ); + expect(mismatchedPkg).toBeDefined(); + expect(mismatchedPkg.status).toBe('mismatch'); + expect(mismatchedPkg.declared).toBe('^1.10.0'); + }); + + it('verifies alias plugin versions:lint behaves identically', async () => { + const pkg = await fs.readJson(path.join(pluginDir, 'package.json')); + pkg.dependencies['@backstage/core-plugin-api'] = '^1.10.0'; + await fs.writeJson(path.join(pluginDir, 'package.json'), pkg, { + spaces: 2, + }); + + const { stdout, stderr, message } = await runExpectingFailure( + `"${rhdhCli}" plugin versions:lint --rhdh-version 2.1.0`, + { cwd: pluginDir }, + ); + const combined = stdout + stderr + message; + expect(combined).toContain('mismatch'); + expect(combined).toContain('@backstage/core-plugin-api'); + }); + + it('passes with exit code 0 when dependencies align with targeted release', async () => { + const { stdout } = await runCommand( + `"${rhdhCli}" plugin check-versions --rhdh-version 2.1.0 --json`, + { cwd: pluginDir }, + ); + const parsed = JSON.parse(stdout); + expect(parsed.valid).toBe(true); + expect(parsed.counts.mismatched).toBe(0); + expect(parsed.counts.matching).toBeGreaterThan(0); + }); + }); + + describe('dependency upgrading (rhdh-cli plugin upgrade)', () => { + let skewedPackageJson: Record; + + beforeEach(async () => { + // Introduce skew: older Backstage versions for RHDH 2.0.0 + const pkg = await fs.readJson(path.join(pluginDir, 'package.json')); + pkg.dependencies['@backstage/core-plugin-api'] = '^1.12.7'; + pkg.dependencies['@backstage/core-components'] = '^0.18.11'; + await fs.writeJson(path.join(pluginDir, 'package.json'), pkg, { + spaces: 2, + }); + skewedPackageJson = pkg; + + await fs.writeJson( + path.join(pluginDir, 'backstage.json'), + { version: '1.52.0' }, + { spaces: 2 }, + ); + }); + + it('--dry-run displays planned updates without modifying files on disk', async () => { + const { stdout, stderr } = await runCommand( + `"${rhdhCli}" plugin upgrade 2.1.0 --dry-run`, + { cwd: pluginDir }, + ); + const combined = stdout + stderr; + expect(combined).toContain('@backstage/core-plugin-api'); + expect(combined).toContain('package.json'); + + // Verify files on disk remained untouched + const currentPkg = await fs.readJson( + path.join(pluginDir, 'package.json'), + ); + expect(currentPkg.dependencies['@backstage/core-plugin-api']).toBe( + '^1.12.7', + ); + expect(currentPkg.dependencies['@backstage/core-components']).toBe( + '^0.18.11', + ); + + const currentBackstageJson = await fs.readJson( + path.join(pluginDir, 'backstage.json'), + ); + expect(currentBackstageJson.version).toBe('1.52.0'); + }); + + it('--skip-install modifies package.json and backstage.json without running install', async () => { + const { stdout } = await runCommand( + `"${rhdhCli}" plugin upgrade 2.1.0 --skip-install --json`, + { cwd: pluginDir }, + ); + const parsed = JSON.parse(stdout); + expect(parsed.installed).toBe(false); + expect(parsed.updatedFiles).toContain('package.json'); + expect(parsed.updatedFiles).toContain('backstage.json'); + + const currentPkg = await fs.readJson( + path.join(pluginDir, 'package.json'), + ); + expect(currentPkg.dependencies['@backstage/core-plugin-api']).not.toBe( + '^1.12.7', + ); + + const currentBackstageJson = await fs.readJson( + path.join(pluginDir, 'backstage.json'), + ); + expect(currentBackstageJson.version).not.toBe('1.52.0'); + }); + + it('performs full upgrade with package manager install and alias plugin versions:bump', async () => { + // Re-skew + await fs.writeJson( + path.join(pluginDir, 'package.json'), + skewedPackageJson, + { spaces: 2 }, + ); + await fs.writeJson( + path.join(pluginDir, 'backstage.json'), + { version: '1.52.0' }, + { spaces: 2 }, + ); + + log('Running full upgrade via alias plugin versions:bump'); + const { stdout, stderr } = await runCommand( + `"${rhdhCli}" plugin versions:bump 2.1.0`, + { cwd: pluginDir }, + ); + const combined = stdout + stderr; + expect(combined).toContain('Successfully upgraded'); + + const currentBackstageJson = await fs.readJson( + path.join(pluginDir, 'backstage.json'), + ); + expect(currentBackstageJson.version).not.toBe('1.52.0'); + }); + }); + + describe('multi-step version upgrade lifecycle (1.8 -> 2.0 -> 2.1)', () => { + const multiStepDir = path.join(tmpDir, 'multi-step-fixture'); + + beforeAll(async () => { + await fs.mkdirp(multiStepDir); + // Simulate an older 1.8.0-era plugin package.json and backstage.json + await fs.writeJson( + path.join(multiStepDir, 'package.json'), + { + name: 'multi-step-fixture', + version: '0.1.0', + dependencies: { + '@backstage/core-plugin-api': '^1.9.3', + '@backstage/core-components': '^0.14.7', + react: '^18.0.0', + }, + }, + { spaces: 2 }, + ); + await fs.writeJson( + path.join(multiStepDir, 'backstage.json'), + { version: '1.42.5' }, + { spaces: 2 }, + ); + }); + + it('steps through 1.8 -> 2.0 -> 2.1 upgrades cleanly', async () => { + // 1. Audit against 2.0.0 detects mismatches + const check20Pre = await runExpectingFailure( + `"${rhdhCli}" plugin check-versions --rhdh-version 2.0.0 --json`, + { cwd: multiStepDir }, + ); + const check20PreJson = JSON.parse(check20Pre.stdout); + expect(check20PreJson.valid).toBe(false); + + // 2. Upgrade to 2.0.0 (Backstage 1.52.0) + const upgrade20 = await runCommand( + `"${rhdhCli}" plugin upgrade 2.0.0 --skip-install --json`, + { cwd: multiStepDir }, + ); + const upgrade20Json = JSON.parse(upgrade20.stdout); + expect(upgrade20Json.backstageVersion).toBe('1.52.0'); + + const pkgAfter20 = await fs.readJson( + path.join(multiStepDir, 'package.json'), + ); + expect(pkgAfter20.dependencies['@backstage/core-plugin-api']).toBe( + '^1.12.7', + ); + // Third-party dependency remains untouched + expect(pkgAfter20.dependencies.react).toBe('^18.0.0'); + + const backstageAfter20 = await fs.readJson( + path.join(multiStepDir, 'backstage.json'), + ); + expect(backstageAfter20.version).toBe('1.52.0'); + + // 3. Audit against 2.0.0 now passes cleanly + const check20Post = await runCommand( + `"${rhdhCli}" plugin check-versions --rhdh-version 2.0.0 --json`, + { cwd: multiStepDir }, + ); + expect(JSON.parse(check20Post.stdout).valid).toBe(true); + + // 4. Audit against 2.1.0 detects mismatches from 2.0.0 + const check21Pre = await runExpectingFailure( + `"${rhdhCli}" plugin check-versions --rhdh-version 2.1.0 --json`, + { cwd: multiStepDir }, + ); + expect(JSON.parse(check21Pre.stdout).valid).toBe(false); + + // 5. Upgrade to 2.1.0 (Backstage 1.54.6 / 1.54.9) + const upgrade21 = await runCommand( + `"${rhdhCli}" plugin upgrade 2.1.0 --skip-install --json`, + { cwd: multiStepDir }, + ); + const upgrade21Json = JSON.parse(upgrade21.stdout); + expect(upgrade21Json.updatedFiles).toContain('package.json'); + + const pkgAfter21 = await fs.readJson( + path.join(multiStepDir, 'package.json'), + ); + expect(pkgAfter21.dependencies['@backstage/core-plugin-api']).not.toBe( + '^1.12.7', + ); + + // 6. Audit against 2.1.0 now passes cleanly + const check21Post = await runCommand( + `"${rhdhCli}" plugin check-versions --rhdh-version 2.1.0 --json`, + { cwd: multiStepDir }, + ); + expect(JSON.parse(check21Post.stdout).valid).toBe(true); + }); + }); + + describe('post-upgrade build and dynamic export', () => { + it('builds and exports successfully as a dynamic plugin without version conflicts', async () => { + // Ensure pluginDir is aligned with 2.1.0 + await runCommand(`"${rhdhCli}" plugin upgrade 2.1.0 --skip-install`, { + cwd: pluginDir, + }); + + const checkRes = await runCommand( + `"${rhdhCli}" plugin check-versions --rhdh-version 2.1.0 --json`, + { cwd: pluginDir }, + ); + expect(JSON.parse(checkRes.stdout).valid).toBe(true); + + log( + 'Typechecking and generating declarations in upgraded plugin project', + ); + await runCommand('yarn tsc', { cwd: pluginDir }); + + log('Building upgraded plugin project'); + await runCommand('yarn build', { cwd: pluginDir }); + + log('Exporting upgraded dynamic plugin'); + await runCommand(`"${rhdhCli}" plugin export`, { cwd: pluginDir }); + + expect(fs.existsSync(path.join(pluginDir, 'dist'))).toBe(true); + expect(fs.existsSync(path.join(pluginDir, 'dist-dynamic'))).toBe(true); + expect( + fs.existsSync(path.join(pluginDir, 'dist-dynamic', 'package.json')), + ).toBe(true); + + const exportedPkg = await fs.readJson( + path.join(pluginDir, 'dist-dynamic', 'package.json'), + ); + expect(exportedPkg.backstage?.['supported-versions']).toBeDefined(); + }); + }); + + describe('air-gapped and offline execution', () => { + const offlineDir = path.join(tmpDir, 'offline-fixture'); + const localManifestPath = path.join(offlineDir, 'local-manifest.json'); + + beforeAll(async () => { + await fs.mkdirp(offlineDir); + + // Local offline Backstage release manifest + await fs.writeJson( + localManifestPath, + { + releaseVersion: '1.54.6', + packages: [ + { name: '@backstage/core-plugin-api', version: '1.12.9' }, + { name: '@backstage/core-components', version: '0.18.13' }, + ], + }, + { spaces: 2 }, + ); + + await fs.writeJson( + path.join(offlineDir, 'package.json'), + { + name: 'offline-fixture', + version: '0.1.0', + dependencies: { + '@backstage/core-plugin-api': '^1.10.0', + }, + }, + { spaces: 2 }, + ); + }); + + it('audits and upgrades cleanly using --manifest-file and RHDH_OFFLINE=true', async () => { + const offlineEnv = { ...process.env, RHDH_OFFLINE: 'true' }; + + // 1. Audit detects mismatch without remote network calls + const checkRes = await runExpectingFailure( + `"${rhdhCli}" plugin check-versions --rhdh-version 2.1.0 --manifest-file "${localManifestPath}" --json`, + { cwd: offlineDir, env: offlineEnv }, + ); + const parsedCheck = JSON.parse(checkRes.stdout); + expect(parsedCheck.source).toBe('matrix'); + expect(parsedCheck.valid).toBe(false); + + // 2. Upgrade updates dependencies without network calls + const upgradeRes = await runCommand( + `"${rhdhCli}" plugin upgrade 2.1.0 --manifest-file "${localManifestPath}" --skip-install --json`, + { cwd: offlineDir, env: offlineEnv }, + ); + const parsedUpgrade = JSON.parse(upgradeRes.stdout); + expect(parsedUpgrade.source).toBe('matrix'); + expect(parsedUpgrade.changes[0].name).toBe('@backstage/core-plugin-api'); + expect(parsedUpgrade.changes[0].target).toBe('^1.12.9'); + + // 3. Audit passes cleanly + const recheckRes = await runCommand( + `"${rhdhCli}" plugin check-versions --rhdh-version 2.1.0 --manifest-file "${localManifestPath}" --json`, + { cwd: offlineDir, env: offlineEnv }, + ); + const parsedRecheck = JSON.parse(recheckRes.stdout); + expect(parsedRecheck.valid).toBe(true); + expect(parsedRecheck.counts.mismatched).toBe(0); + }); + }); +}); diff --git a/e2e-tests/support/plugin-export-build.ts b/e2e-tests/support/plugin-export-build.ts index ede6198..aaf31ba 100644 --- a/e2e-tests/support/plugin-export-build.ts +++ b/e2e-tests/support/plugin-export-build.ts @@ -81,7 +81,14 @@ export async function runCommand( console.error(`${LOG_PREFIX} --- stdout ---\n${out}`); console.error(`${LOG_PREFIX} --- stderr ---\n${errOut}`); - throw new Error(enrichedMessage); + const enrichedError = Object.assign(new Error(enrichedMessage), { + code: e.code, + signal: e.signal, + stdout: e.stdout, + stderr: e.stderr, + }); + + throw enrichedError; } } diff --git a/src/commands/index.ts b/src/commands/index.ts index 1cb64d2..acdecc5 100644 --- a/src/commands/index.ts +++ b/src/commands/index.ts @@ -133,6 +133,7 @@ export function registerPluginCommand(program: Command) { command .command('check-versions') + .alias('versions:lint') .description( 'Check dynamic plugin dependencies in package.json against target RHDH release Backstage manifest', ) From 9ce0e861e72f31a94d086c0d77442a2844ee2ab6 Mon Sep 17 00:00:00 2001 From: Stan Lewis Date: Fri, 2 Oct 2026 14:12:04 -0400 Subject: [PATCH 2/4] test(e2e): deduplicate runExpectingFailure helper in support/plugin-export-build - Move runExpectingFailure into e2e-tests/support/plugin-export-build.ts - Share runExpectingFailure across plugin-dev.test.ts and plugin-version-management.test.ts - Resolve SonarCloud code duplication quality gate failure Assisted-By: opencode Signed-off-by: Stan Lewis rh-pre-commit.version: 2.4.0 rh-pre-commit.check-secrets: ENABLED --- e2e-tests/plugin-dev.test.ts | 35 +------------------- e2e-tests/plugin-version-management.test.ts | 36 +-------------------- e2e-tests/support/plugin-export-build.ts | 34 +++++++++++++++++++ 3 files changed, 36 insertions(+), 69 deletions(-) diff --git a/e2e-tests/plugin-dev.test.ts b/e2e-tests/plugin-dev.test.ts index 05283ed..66f6574 100644 --- a/e2e-tests/plugin-dev.test.ts +++ b/e2e-tests/plugin-dev.test.ts @@ -17,6 +17,7 @@ import { log, logSection, runCommand, + runExpectingFailure, RunCommandOptions, } from './support/plugin-export-build'; import { @@ -33,40 +34,6 @@ const availableTools = detectAvailableComposeToolsSync(); const describeWithCompose = availableTools.length > 0 ? describe : describe.skip; -async function runExpectingFailure( - command: string, - options: RunCommandOptions = {}, -): Promise<{ stdout: string; stderr: string; message: string }> { - let succeeded = false; - let stdout = ''; - let caughtError: unknown; - - try { - const res = await runCommand(command, options); - succeeded = true; - stdout = res.stdout; - } catch (err: unknown) { - caughtError = err; - } - - if (succeeded) { - throw new Error( - `Command expected to fail, but succeeded with output: ${stdout}`, - ); - } - - const e = (caughtError || {}) as { - stdout?: string; - stderr?: string; - message?: string; - }; - return { - stdout: e?.stdout || '', - stderr: e?.stderr || '', - message: e?.message || '', - }; -} - describe('plugin dev', () => { const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'rhdh-cli-plugin-dev-')); const pluginDir = path.join(tmpDir, 'test-frontend-plugin'); diff --git a/e2e-tests/plugin-version-management.test.ts b/e2e-tests/plugin-version-management.test.ts index 893de3c..9940fac 100644 --- a/e2e-tests/plugin-version-management.test.ts +++ b/e2e-tests/plugin-version-management.test.ts @@ -18,46 +18,12 @@ import { log, logSection, runCommand, - RunCommandOptions, + runExpectingFailure, } from './support/plugin-export-build'; const TEST_TIMEOUT = 8 * 60 * 1000; const rhdhCli = path.resolve(__dirname, '../bin/rhdh-cli'); -async function runExpectingFailure( - command: string, - options: RunCommandOptions = {}, -): Promise<{ stdout: string; stderr: string; message: string }> { - let succeeded = false; - let stdout = ''; - let caughtError: unknown; - - try { - const res = await runCommand(command, options); - succeeded = true; - stdout = res.stdout; - } catch (err: unknown) { - caughtError = err; - } - - if (succeeded) { - throw new Error( - `Command expected to fail, but succeeded with output: ${stdout}`, - ); - } - - const e = (caughtError || {}) as { - stdout?: string; - stderr?: string; - message?: string; - }; - return { - stdout: e?.stdout || '', - stderr: e?.stderr || '', - message: e?.message || '', - }; -} - describe('plugin version management e2e', () => { const tmpDir = fs.mkdtempSync( path.join(os.tmpdir(), 'rhdh-cli-e2e-ver-mgmt-'), diff --git a/e2e-tests/support/plugin-export-build.ts b/e2e-tests/support/plugin-export-build.ts index aaf31ba..b9977e6 100644 --- a/e2e-tests/support/plugin-export-build.ts +++ b/e2e-tests/support/plugin-export-build.ts @@ -92,6 +92,40 @@ export async function runCommand( } } +export async function runExpectingFailure( + command: string, + options: RunCommandOptions = {}, +): Promise<{ stdout: string; stderr: string; message: string }> { + let succeeded = false; + let stdout = ''; + let caughtError: unknown; + + try { + const res = await runCommand(command, options); + succeeded = true; + stdout = res.stdout; + } catch (err: unknown) { + caughtError = err; + } + + if (succeeded) { + throw new Error( + `Command expected to fail, but succeeded with output: ${stdout}`, + ); + } + + const e = (caughtError || {}) as { + stdout?: string; + stderr?: string; + message?: string; + }; + return { + stdout: e?.stdout || '', + stderr: e?.stderr || '', + message: e?.message || '', + }; +} + /** * Top-level directory names using the same steps as `plugin package`: * `npm pack --pack-destination …`, then `tar -xzf … --strip-components=1`. From c44248756a258a491bfd4a3c6c91d2b81bdb3e16 Mon Sep 17 00:00:00 2001 From: Stan Lewis Date: Fri, 2 Oct 2026 14:32:33 -0400 Subject: [PATCH 3/4] fix(upgrade): allow lockfile mutation during install in CI environments - Disable immutable installs (YARN_ENABLE_IMMUTABLE_INSTALLS=false) during upgrade's yarn install step - Allow Yarn Berry to update yarn.lock when running under CI=true or PR workflows - Pass YARN_ENABLE_IMMUTABLE_INSTALLS=false in e2e version management upgrade test Assisted-By: opencode Signed-off-by: Stan Lewis rh-pre-commit.version: 2.4.0 rh-pre-commit.check-secrets: ENABLED --- e2e-tests/plugin-version-management.test.ts | 5 ++++- src/commands/upgrade/command.ts | 14 ++++++++++++++ 2 files changed, 18 insertions(+), 1 deletion(-) diff --git a/e2e-tests/plugin-version-management.test.ts b/e2e-tests/plugin-version-management.test.ts index 9940fac..22fee8d 100644 --- a/e2e-tests/plugin-version-management.test.ts +++ b/e2e-tests/plugin-version-management.test.ts @@ -227,7 +227,10 @@ describe('plugin version management e2e', () => { log('Running full upgrade via alias plugin versions:bump'); const { stdout, stderr } = await runCommand( `"${rhdhCli}" plugin versions:bump 2.1.0`, - { cwd: pluginDir }, + { + cwd: pluginDir, + env: { ...process.env, YARN_ENABLE_IMMUTABLE_INSTALLS: 'false' }, + }, ); const combined = stdout + stderr; expect(combined).toContain('Successfully upgraded'); diff --git a/src/commands/upgrade/command.ts b/src/commands/upgrade/command.ts index 4abf506..e16b747 100644 --- a/src/commands/upgrade/command.ts +++ b/src/commands/upgrade/command.ts @@ -212,11 +212,25 @@ async function syncBackstageJson( */ async function runInstallDependencies(targetDir: string): Promise { const pm = await detectPackageManager(targetDir); + const prevImmutable = process.env.YARN_ENABLE_IMMUTABLE_INSTALLS; try { + // Upgrading dependencies inherently mutates the lockfile. Ensure Yarn Berry + // does not block lockfile updates when running in CI or automated PR workflows. + if (pm === 'yarn') { + process.env.YARN_ENABLE_IMMUTABLE_INSTALLS = 'false'; + } await Task.forCommand(`${pm} install`, { cwd: targetDir }); return true; } catch { return false; + } finally { + if (pm === 'yarn') { + if (prevImmutable === undefined) { + delete process.env.YARN_ENABLE_IMMUTABLE_INSTALLS; + } else { + process.env.YARN_ENABLE_IMMUTABLE_INSTALLS = prevImmutable; + } + } } } From 3a3652fb1a83a05d8e9bc4ff155af6ce2aefa821 Mon Sep 17 00:00:00 2001 From: Stan Lewis Date: Mon, 5 Oct 2026 06:19:31 -0400 Subject: [PATCH 4/4] test(version-management): address review feedback on e2e test suite - Remove unused RunCommandOptions import in plugin-dev.test.ts - Restore test isolation with afterEach hook in dependency upgrading suite - Pin multi-step upgrade lifecycle tests to RHDH_OFFLINE=true - Add backstage.json to offline test fixture and assert version synchronization - Revert global YARN_ENABLE_IMMUTABLE_INSTALLS mutation in upgrade command Assisted-By: opencode Signed-off-by: Stan Lewis rh-pre-commit.version: 2.4.0 rh-pre-commit.check-secrets: ENABLED --- e2e-tests/plugin-dev.test.ts | 1 - e2e-tests/plugin-version-management.test.ts | 65 ++++++++++++++------- src/commands/upgrade/command.ts | 14 ----- 3 files changed, 45 insertions(+), 35 deletions(-) diff --git a/e2e-tests/plugin-dev.test.ts b/e2e-tests/plugin-dev.test.ts index 66f6574..7de5ec8 100644 --- a/e2e-tests/plugin-dev.test.ts +++ b/e2e-tests/plugin-dev.test.ts @@ -18,7 +18,6 @@ import { logSection, runCommand, runExpectingFailure, - RunCommandOptions, } from './support/plugin-export-build'; import { cleanupCompose, diff --git a/e2e-tests/plugin-version-management.test.ts b/e2e-tests/plugin-version-management.test.ts index 22fee8d..0815e40 100644 --- a/e2e-tests/plugin-version-management.test.ts +++ b/e2e-tests/plugin-version-management.test.ts @@ -143,7 +143,17 @@ describe('plugin version management e2e', () => { }); describe('dependency upgrading (rhdh-cli plugin upgrade)', () => { - let skewedPackageJson: Record; + let baselinePackageJson: Record; + let baselineBackstageJson: Record; + + beforeAll(async () => { + baselinePackageJson = await fs.readJson( + path.join(pluginDir, 'package.json'), + ); + baselineBackstageJson = await fs.readJson( + path.join(pluginDir, 'backstage.json'), + ); + }); beforeEach(async () => { // Introduce skew: older Backstage versions for RHDH 2.0.0 @@ -153,7 +163,6 @@ describe('plugin version management e2e', () => { await fs.writeJson(path.join(pluginDir, 'package.json'), pkg, { spaces: 2, }); - skewedPackageJson = pkg; await fs.writeJson( path.join(pluginDir, 'backstage.json'), @@ -162,6 +171,19 @@ describe('plugin version management e2e', () => { ); }); + afterEach(async () => { + await fs.writeJson( + path.join(pluginDir, 'package.json'), + baselinePackageJson, + { spaces: 2 }, + ); + await fs.writeJson( + path.join(pluginDir, 'backstage.json'), + baselineBackstageJson, + { spaces: 2 }, + ); + }); + it('--dry-run displays planned updates without modifying files on disk', async () => { const { stdout, stderr } = await runCommand( `"${rhdhCli}" plugin upgrade 2.1.0 --dry-run`, @@ -212,18 +234,6 @@ describe('plugin version management e2e', () => { }); it('performs full upgrade with package manager install and alias plugin versions:bump', async () => { - // Re-skew - await fs.writeJson( - path.join(pluginDir, 'package.json'), - skewedPackageJson, - { spaces: 2 }, - ); - await fs.writeJson( - path.join(pluginDir, 'backstage.json'), - { version: '1.52.0' }, - { spaces: 2 }, - ); - log('Running full upgrade via alias plugin versions:bump'); const { stdout, stderr } = await runCommand( `"${rhdhCli}" plugin versions:bump 2.1.0`, @@ -269,10 +279,12 @@ describe('plugin version management e2e', () => { }); it('steps through 1.8 -> 2.0 -> 2.1 upgrades cleanly', async () => { + const offlineEnv = { ...process.env, RHDH_OFFLINE: 'true' }; + // 1. Audit against 2.0.0 detects mismatches const check20Pre = await runExpectingFailure( `"${rhdhCli}" plugin check-versions --rhdh-version 2.0.0 --json`, - { cwd: multiStepDir }, + { cwd: multiStepDir, env: offlineEnv }, ); const check20PreJson = JSON.parse(check20Pre.stdout); expect(check20PreJson.valid).toBe(false); @@ -280,7 +292,7 @@ describe('plugin version management e2e', () => { // 2. Upgrade to 2.0.0 (Backstage 1.52.0) const upgrade20 = await runCommand( `"${rhdhCli}" plugin upgrade 2.0.0 --skip-install --json`, - { cwd: multiStepDir }, + { cwd: multiStepDir, env: offlineEnv }, ); const upgrade20Json = JSON.parse(upgrade20.stdout); expect(upgrade20Json.backstageVersion).toBe('1.52.0'); @@ -302,21 +314,21 @@ describe('plugin version management e2e', () => { // 3. Audit against 2.0.0 now passes cleanly const check20Post = await runCommand( `"${rhdhCli}" plugin check-versions --rhdh-version 2.0.0 --json`, - { cwd: multiStepDir }, + { cwd: multiStepDir, env: offlineEnv }, ); expect(JSON.parse(check20Post.stdout).valid).toBe(true); // 4. Audit against 2.1.0 detects mismatches from 2.0.0 const check21Pre = await runExpectingFailure( `"${rhdhCli}" plugin check-versions --rhdh-version 2.1.0 --json`, - { cwd: multiStepDir }, + { cwd: multiStepDir, env: offlineEnv }, ); expect(JSON.parse(check21Pre.stdout).valid).toBe(false); // 5. Upgrade to 2.1.0 (Backstage 1.54.6 / 1.54.9) const upgrade21 = await runCommand( `"${rhdhCli}" plugin upgrade 2.1.0 --skip-install --json`, - { cwd: multiStepDir }, + { cwd: multiStepDir, env: offlineEnv }, ); const upgrade21Json = JSON.parse(upgrade21.stdout); expect(upgrade21Json.updatedFiles).toContain('package.json'); @@ -331,7 +343,7 @@ describe('plugin version management e2e', () => { // 6. Audit against 2.1.0 now passes cleanly const check21Post = await runCommand( `"${rhdhCli}" plugin check-versions --rhdh-version 2.1.0 --json`, - { cwd: multiStepDir }, + { cwd: multiStepDir, env: offlineEnv }, ); expect(JSON.parse(check21Post.stdout).valid).toBe(true); }); @@ -405,6 +417,12 @@ describe('plugin version management e2e', () => { }, { spaces: 2 }, ); + + await fs.writeJson( + path.join(offlineDir, 'backstage.json'), + { version: '1.50.0' }, + { spaces: 2 }, + ); }); it('audits and upgrades cleanly using --manifest-file and RHDH_OFFLINE=true', async () => { @@ -428,6 +446,13 @@ describe('plugin version management e2e', () => { expect(parsedUpgrade.source).toBe('matrix'); expect(parsedUpgrade.changes[0].name).toBe('@backstage/core-plugin-api'); expect(parsedUpgrade.changes[0].target).toBe('^1.12.9'); + expect(parsedUpgrade.updatedFiles).toContain('package.json'); + expect(parsedUpgrade.updatedFiles).toContain('backstage.json'); + + const updatedBackstageJson = await fs.readJson( + path.join(offlineDir, 'backstage.json'), + ); + expect(updatedBackstageJson.version).toBe('1.54.6'); // 3. Audit passes cleanly const recheckRes = await runCommand( diff --git a/src/commands/upgrade/command.ts b/src/commands/upgrade/command.ts index e16b747..4abf506 100644 --- a/src/commands/upgrade/command.ts +++ b/src/commands/upgrade/command.ts @@ -212,25 +212,11 @@ async function syncBackstageJson( */ async function runInstallDependencies(targetDir: string): Promise { const pm = await detectPackageManager(targetDir); - const prevImmutable = process.env.YARN_ENABLE_IMMUTABLE_INSTALLS; try { - // Upgrading dependencies inherently mutates the lockfile. Ensure Yarn Berry - // does not block lockfile updates when running in CI or automated PR workflows. - if (pm === 'yarn') { - process.env.YARN_ENABLE_IMMUTABLE_INSTALLS = 'false'; - } await Task.forCommand(`${pm} install`, { cwd: targetDir }); return true; } catch { return false; - } finally { - if (pm === 'yarn') { - if (prevImmutable === undefined) { - delete process.env.YARN_ENABLE_IMMUTABLE_INSTALLS; - } else { - process.env.YARN_ENABLE_IMMUTABLE_INSTALLS = prevImmutable; - } - } } }