diff --git a/AGENTS.md b/AGENTS.md index dce93eb..4ade7a3 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -194,6 +194,12 @@ subprocess error. as symlinks in the staged copy rather than followed, which would cause a self-copy error on repeated `update` runs. +**E2E testing contract:** `e2e-tests/plugin-dev.test.ts` validates `plugin dev` +lifecycle commands (`start`, `status`, `update`, `stop --clean`) against real +compose container engines (Podman or Docker). By default, tests use a lightweight +compose fixture. Setting `E2E_RHDH_LOCAL_DIR` targets an existing RHDH Local +checkout instead. + ### `rhdhVersion.ts` — version resolution engine `src/lib/rhdhVersion.ts` is the core abstraction that maps RHDH version diff --git a/e2e-tests/plugin-dev.test.ts b/e2e-tests/plugin-dev.test.ts new file mode 100644 index 0000000..05283ed --- /dev/null +++ b/e2e-tests/plugin-dev.test.ts @@ -0,0 +1,381 @@ +/** + * End-to-end integration tests for `rhdh-cli plugin dev`. + * + * Environment variables: + * - `E2E_RHDH_LOCAL_DIR`: Path to an existing RHDH Local checkout to test against. + * When set, the test uses that directory rather than the lightweight compose mock + * fixture, increases the Jest timeout to 10 minutes to accommodate full image + * pulls and startup, and avoids removing local volumes on cleanup. + * - `CONTAINER_TOOL`: Override container tool ('docker' or 'podman') for testing. + */ + +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'; +import { + cleanupCompose, + cleanupContainers, + createRhdhLocalFixture, + detectAvailableComposeToolsSync, +} from './support/rhdh-local-fixture'; + +const isRealRuntime = Boolean(process.env.E2E_RHDH_LOCAL_DIR); +const TEST_TIMEOUT = isRealRuntime ? 10 * 60 * 1000 : 5 * 60 * 1000; +const rhdhCli = path.resolve(__dirname, '../bin/rhdh-cli'); +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'); + + jest.setTimeout(TEST_TIMEOUT); + + beforeAll(async () => { + logSection('Setup test plugin and detect container tools'); + log(`rhdh-cli: ${rhdhCli}`); + log(`workspace: ${tmpDir}`); + log(`Detected compose tools: ${availableTools.join(', ') || 'none'}`); + + if (process.env.CI && availableTools.length === 0) { + throw new Error( + 'No compose-capable container engine (docker or podman) detected in CI environment. E2E container lifecycle tests must execute in CI.', + ); + } + + // Generate, install, typecheck, and build one test frontend plugin for dev command tests + log('Scaffolding test frontend plugin'); + await runCommand( + `"${rhdhCli}" plugin new test-frontend-plugin --type frontend --output "${pluginDir}" --rhdh-version 2.1.0`, + ); + + log('Installing dependencies in test plugin'); + await runCommand( + 'YARN_ENABLE_IMMUTABLE_INSTALLS=false YARN_ENABLE_SCRIPTS=false yarn install', + { cwd: pluginDir }, + ); + + log('Generating TypeScript declarations in test plugin'); + await runCommand('yarn tsc', { cwd: pluginDir }); + + log('Building test plugin'); + await runCommand('yarn build', { cwd: pluginDir }); + }); + + afterAll(async () => { + await fs.remove(tmpDir); + }); + + describe('pre-flight validation and errors', () => { + it('fails when --rhdh-local-dir is omitted and RHDH_LOCAL_DIR is not set', async () => { + const { stderr, message } = await runExpectingFailure( + `"${rhdhCli}" plugin dev start`, + { + cwd: pluginDir, + env: { ...process.env, RHDH_LOCAL_DIR: '', E2E_RHDH_LOCAL_DIR: '' }, + }, + ); + const combined = stderr + message; + expect(combined).toMatch(/Specify --rhdh-local-dir /); + }); + + it('fails when --rhdh-local-dir points to a non-existent directory', async () => { + const missingDir = path.join(tmpDir, 'non-existent-dir'); + const { stderr, message } = await runExpectingFailure( + `"${rhdhCli}" plugin dev start --rhdh-local-dir "${missingDir}"`, + { cwd: pluginDir }, + ); + const combined = stderr + message; + expect(combined).toMatch(/Missing required files/); + }); + + it('fails when --rhdh-local-dir points to an invalid directory missing required files', async () => { + const invalidDir = path.join(tmpDir, 'invalid-rhdh-dir'); + await fs.ensureDir(invalidDir); + const { stderr, message } = await runExpectingFailure( + `"${rhdhCli}" plugin dev start --rhdh-local-dir "${invalidDir}"`, + { cwd: pluginDir }, + ); + const combined = stderr + message; + expect(combined).toMatch(/Missing required files: compose\.yaml/); + }); + + it('fails when dynamic-plugins.override.yaml is missing', async () => { + const fixtureDir = path.join(tmpDir, 'fixture-no-override'); + const { runtimeDir } = await createRhdhLocalFixture(fixtureDir); + await fs.remove( + path.join( + runtimeDir, + 'configs/dynamic-plugins/dynamic-plugins.override.yaml', + ), + ); + + const { stderr, message } = await runExpectingFailure( + `"${rhdhCli}" plugin dev start --rhdh-local-dir "${runtimeDir}"`, + { cwd: pluginDir }, + ); + const combined = stderr + message; + expect(combined).toMatch(/RHDH Local override configuration is missing/); + }); + + it('fails when dynamic-plugins.override.yaml does not include generated config and --configure is omitted', async () => { + const fixtureDir = path.join(tmpDir, 'fixture-unconfigured'); + const { runtimeDir } = await createRhdhLocalFixture(fixtureDir); + + const { stderr, message } = await runExpectingFailure( + `"${rhdhCli}" plugin dev start --rhdh-local-dir "${runtimeDir}"`, + { cwd: pluginDir }, + ); + const combined = stderr + message; + expect(combined).toMatch( + /Add configs\/dynamic-plugins\/rhdh-cli\.generated\.local\.yaml to.*includes list/, + ); + expect(combined).toMatch(/--configure/); + }); + + it('fails when --container-tool receives an invalid tool', async () => { + const fixtureDir = path.join(tmpDir, 'fixture-invalid-tool'); + const { runtimeDir } = await createRhdhLocalFixture(fixtureDir); + + const { stderr, message } = await runExpectingFailure( + `"${rhdhCli}" plugin dev start --rhdh-local-dir "${runtimeDir}" --container-tool invalid-tool`, + { cwd: pluginDir }, + ); + const combined = stderr + message; + expect(combined).toMatch( + /Invalid value for --container-tool: invalid-tool/, + ); + }); + + it('fails fast on backend plugin when dist-types/ is missing', async () => { + const backendDir = path.join(tmpDir, 'test-backend-no-dist-types'); + await fs.ensureDir(backendDir); + await fs.writeJson(path.join(backendDir, 'package.json'), { + name: 'test-backend-plugin', + version: '0.1.0', + backstage: { role: 'backend-plugin' }, + }); + + const fixtureDir = path.join(tmpDir, 'fixture-backend-check'); + const { runtimeDir } = await createRhdhLocalFixture(fixtureDir); + + const { stderr, message } = await runExpectingFailure( + `"${rhdhCli}" plugin dev start --rhdh-local-dir "${runtimeDir}" --configure`, + { cwd: backendDir }, + ); + const combined = stderr + message; + expect(combined).toMatch( + /dist-types not found\. Run `yarn tsc` before using `plugin dev`/, + ); + }); + }); + + describeWithCompose('runtime not-running pre-flight checks', () => { + it('fails fast on plugin dev update when RHDH Local runtime is not running', async () => { + const fixtureDir = path.join(tmpDir, 'fixture-update-not-running'); + const { runtimeDir } = await createRhdhLocalFixture(fixtureDir); + + const toolToUse = availableTools[0]; + const { stderr, message } = await runExpectingFailure( + `"${rhdhCli}" plugin dev update --rhdh-local-dir "${runtimeDir}" --container-tool "${toolToUse}"`, + { cwd: pluginDir }, + ); + const combined = stderr + message; + expect(combined).toMatch( + /RHDH Local is not running\. Run `rhdh-cli plugin dev start` first\./, + ); + }); + + it('fails fast on plugin dev restart when RHDH Local runtime is not running', async () => { + const fixtureDir = path.join(tmpDir, 'fixture-restart-not-running'); + const { runtimeDir } = await createRhdhLocalFixture(fixtureDir); + + const toolToUse = availableTools[0]; + const { stderr, message } = await runExpectingFailure( + `"${rhdhCli}" plugin dev restart --rhdh-local-dir "${runtimeDir}" --container-tool "${toolToUse}"`, + { cwd: pluginDir }, + ); + const combined = stderr + message; + expect(combined).toMatch( + /RHDH Local is not running\. Run `rhdh-cli plugin dev start` first\./, + ); + }); + }); + + describeWithCompose('real container lifecycle', () => { + describe.each(availableTools)('with container tool %s', tool => { + let fixtureDir: string; + let runtimeDir: string; + let runtimePort: number; + + beforeAll(async () => { + const configuredRuntime = process.env.E2E_RHDH_LOCAL_DIR; + if (configuredRuntime) { + await cleanupContainers(tool); + runtimeDir = path.resolve(configuredRuntime); + runtimePort = 7007; + log( + `Using real RHDH Local directory for ${tool} test: ${runtimeDir}`, + ); + } else { + fixtureDir = path.join(tmpDir, `fixture-live-${tool}`); + const fixture = await createRhdhLocalFixture(fixtureDir); + runtimeDir = fixture.runtimeDir; + runtimePort = fixture.port; + log( + `Live container test runtime for ${tool} initialized at ${runtimeDir} on port ${runtimePort}`, + ); + } + }); + + afterAll(async () => { + if (runtimeDir) { + if (isRealRuntime) { + await cleanupContainers(tool); + } else { + await cleanupCompose(tool, runtimeDir); + } + } + }); + + it('executes plugin dev start --configure, status, update, and stop lifecycle cleanly', async () => { + logSection(`[${tool}] plugin dev start --configure`); + const startResult = await runCommand( + `"${rhdhCli}" plugin dev start --rhdh-local-dir "${runtimeDir}" --container-tool "${tool}" --configure`, + { cwd: pluginDir }, + ); + const startOutput = startResult.stdout + startResult.stderr; + expect(startOutput).toMatch( + /\[1\/4\] Building and exporting plugin\.\.\./, + ); + expect(startOutput).toMatch( + /\[2\/4\] Starting RHDH Local runtime\.\.\./, + ); + expect(startOutput).toMatch( + /\[3\/4\] Installing dynamic plugins\.\.\./, + ); + expect(startOutput).toMatch( + /\[4\/4\] Waiting for RHDH to be ready\.\.\./, + ); + expect(startOutput).toMatch(/RHDH is ready at http:\/\/localhost:/); + + // Verify configuration and staging files created during start + const overrideFile = path.join( + runtimeDir, + 'configs/dynamic-plugins/dynamic-plugins.override.yaml', + ); + const overrideContent = await fs.readFile(overrideFile, 'utf8'); + expect(overrideContent).toMatch( + /configs\/dynamic-plugins\/rhdh-cli\.generated\.local\.yaml/, + ); + + const pkg = await fs.readJson(path.join(pluginDir, 'package.json')); + const stagedPluginName = (pkg.name as string) + .replace(/^@/, '') + .replace(/\//, '-'); + + const generatedConfigFile = path.join( + runtimeDir, + 'configs/dynamic-plugins/rhdh-cli.generated.local.yaml', + ); + expect(await fs.pathExists(generatedConfigFile)).toBe(true); + const generatedContent = await fs.readFile(generatedConfigFile, 'utf8'); + expect(generatedContent).toMatch( + new RegExp(`package: "?\\./local-plugins/${stagedPluginName}"?`), + ); + expect(generatedContent).toMatch(/enabled: true/); + expect(generatedContent).toMatch(/pullPolicy: "?Always"?/); + + const stagedDir = path.join( + runtimeDir, + 'local-plugins', + stagedPluginName, + ); + expect(await fs.pathExists(stagedDir)).toBe(true); + expect(await fs.pathExists(path.join(stagedDir, 'package.json'))).toBe( + true, + ); + + logSection(`[${tool}] plugin dev status`); + const statusResult = await runCommand( + `"${rhdhCli}" plugin dev status --rhdh-local-dir "${runtimeDir}" --container-tool "${tool}"`, + { cwd: pluginDir }, + ); + const statusOutput = statusResult.stdout + statusResult.stderr; + expect(statusOutput).toMatch(/RHDH Local is running/); + + logSection(`[${tool}] plugin dev update`); + const updateResult = await runCommand( + `"${rhdhCli}" plugin dev update --rhdh-local-dir "${runtimeDir}" --container-tool "${tool}"`, + { cwd: pluginDir }, + ); + const updateOutput = updateResult.stdout + updateResult.stderr; + expect(updateOutput).toMatch( + /Refresh your browser at http:\/\/localhost:/, + ); + + logSection(`[${tool}] plugin dev stop --clean`); + const stopResult = await runCommand( + `"${rhdhCli}" plugin dev stop --rhdh-local-dir "${runtimeDir}" --container-tool "${tool}" --clean`, + { cwd: pluginDir }, + ); + const stopOutput = stopResult.stdout + stopResult.stderr; + expect(stopOutput).toMatch(/Stopped the RHDH Local runtime/); + expect(stopOutput).not.toMatch(/(?:^|\n)\s*Error:/i); + expect(stopOutput).not.toMatch(/Command '.*' exited with code/); + + // Verify status after stop reports not running or stopped + const postStopStatus = await runCommand( + `"${rhdhCli}" plugin dev status --rhdh-local-dir "${runtimeDir}" --container-tool "${tool}"`, + { cwd: pluginDir }, + ); + const postStopOutput = postStopStatus.stdout + postStopStatus.stderr; + expect(postStopOutput).toMatch( + /RHDH Local is not running|RHDH Local stopped/, + ); + }); + }); + }); +}); diff --git a/e2e-tests/plugin-new.test.ts b/e2e-tests/plugin-new.test.ts index af44322..3e9b36e 100644 --- a/e2e-tests/plugin-new.test.ts +++ b/e2e-tests/plugin-new.test.ts @@ -4,28 +4,80 @@ import path from 'node:path'; import { log, logSection, runCommand } from './support/plugin-export-build'; -const TEST_TIMEOUT = 5 * 60 * 1000; +// 8-minute Jest timeout gives headroom so MAX_ONRAMP_DURATION_MS SLO assertions can report failure before Jest aborts +const TEST_TIMEOUT = 8 * 60 * 1000; +const MAX_ONRAMP_DURATION_MS = 5 * 60 * 1000; const rhdhCli = path.resolve(__dirname, '../bin/rhdh-cli'); +interface BuildAndExportTimings { + installMs: number; + typecheckMs: number; + buildMs: number; + testMs: number; + exportMs: number; +} + /** Shared install + typecheck + build + test + export sequence for a generated project. */ -async function buildAndExport(pluginDir: string): Promise { +async function buildAndExport( + pluginDir: string, +): Promise { log(`Installing generated project in ${pluginDir}`); + const tInstall = Date.now(); await runCommand( 'YARN_ENABLE_IMMUTABLE_INSTALLS=false YARN_ENABLE_SCRIPTS=false yarn install', { cwd: pluginDir }, ); + const installMs = Date.now() - tInstall; log('Typechecking generated project'); + const tTypecheck = Date.now(); await runCommand('yarn tsc', { cwd: pluginDir }); + const typecheckMs = Date.now() - tTypecheck; log('Building generated project'); + const tBuild = Date.now(); await runCommand('yarn build', { cwd: pluginDir }); + const buildMs = Date.now() - tBuild; log('Running generated project tests'); + const tTest = Date.now(); await runCommand('yarn test --watchAll=false', { cwd: pluginDir }); + const testMs = Date.now() - tTest; log('Exporting as a dynamic plugin'); + const tExport = Date.now(); await runCommand(`"${rhdhCli}" plugin export`, { cwd: pluginDir }); + const exportMs = Date.now() - tExport; + + return { installMs, typecheckMs, buildMs, testMs, exportMs }; +} + +function logTimings( + label: string, + scaffoldMs: number, + timings: BuildAndExportTimings, +): number { + const totalMs = + scaffoldMs + + timings.installMs + + timings.typecheckMs + + timings.buildMs + + timings.testMs + + timings.exportMs; + + const scaffoldSec = (scaffoldMs / 1000).toFixed(1); + const installSec = (timings.installMs / 1000).toFixed(1); + const tscSec = (timings.typecheckMs / 1000).toFixed(1); + const buildSec = (timings.buildMs / 1000).toFixed(1); + const testSec = (timings.testMs / 1000).toFixed(1); + const exportSec = (timings.exportMs / 1000).toFixed(1); + const totalSec = (totalMs / 1000).toFixed(1); + + log( + `[timing] ${label}: scaffold=${scaffoldSec}s, install=${installSec}s, tsc=${tscSec}s, build=${buildSec}s, test=${testSec}s, export=${exportSec}s (total=${totalSec}s)`, + ); + + return totalMs; } describe('plugin new', () => { @@ -41,45 +93,56 @@ describe('plugin new', () => { const pluginDir = path.join(tmpDir, 'frontend-plugin'); logSection('Generate frontend plugin'); + const tScaffold = Date.now(); await runCommand( `"${rhdhCli}" plugin new example-plugin --type frontend --output "${pluginDir}" --rhdh-version 2.1.0`, ); + const scaffoldMs = Date.now() - tScaffold; - await buildAndExport(pluginDir); + const timings = await buildAndExport(pluginDir); + const totalMs = logTimings('frontend-plugin', scaffoldMs, timings); 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); + expect(totalMs).toBeLessThan(MAX_ONRAMP_DURATION_MS); }); it('creates a backend plugin that installs, typechecks, builds, and exports', async () => { const pluginDir = path.join(tmpDir, 'backend-plugin'); logSection('Generate backend plugin'); + const tScaffold = Date.now(); await runCommand( `"${rhdhCli}" plugin new example-plugin --type backend --output "${pluginDir}" --rhdh-version 2.1.0`, ); + const scaffoldMs = Date.now() - tScaffold; - await buildAndExport(pluginDir); + const timings = await buildAndExport(pluginDir); + const totalMs = logTimings('backend-plugin', scaffoldMs, timings); 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); + expect(totalMs).toBeLessThan(MAX_ONRAMP_DURATION_MS); }); it('creates a catalog-processor-module that installs, typechecks, builds, and exports', async () => { const pluginDir = path.join(tmpDir, 'catalog-processor-module'); logSection('Generate catalog processor module'); + const tScaffold = Date.now(); await runCommand( `"${rhdhCli}" plugin new example-plugin --type catalog-processor-module --output "${pluginDir}" --rhdh-version 2.1.0`, ); + const scaffoldMs = Date.now() - tScaffold; - await buildAndExport(pluginDir); + const timings = await buildAndExport(pluginDir); + const totalMs = logTimings('catalog-processor-module', scaffoldMs, timings); expect(fs.existsSync(path.join(pluginDir, 'dist'))).toBe(true); expect(fs.existsSync(path.join(pluginDir, 'dist-dynamic'))).toBe(true); @@ -93,5 +156,6 @@ describe('plugin new', () => { expect(exportedPkg.backstage.pluginPackage).toBe( '@backstage/plugin-catalog-backend', ); + expect(totalMs).toBeLessThan(MAX_ONRAMP_DURATION_MS); }); }); diff --git a/e2e-tests/support/plugin-export-build.ts b/e2e-tests/support/plugin-export-build.ts index e1de336..ede6198 100644 --- a/e2e-tests/support/plugin-export-build.ts +++ b/e2e-tests/support/plugin-export-build.ts @@ -39,9 +39,14 @@ export async function downloadFile(url: string, file: string): Promise { }); } +export interface RunCommandOptions { + cwd?: string; + env?: NodeJS.ProcessEnv; +} + export async function runCommand( command: string, - options: { cwd?: string } = {}, + options: RunCommandOptions = {}, ): Promise<{ stdout: string; stderr: string }> { const cwd = options.cwd || process.cwd(); diff --git a/e2e-tests/support/rhdh-local-fixture.ts b/e2e-tests/support/rhdh-local-fixture.ts new file mode 100644 index 0000000..2bfb89c --- /dev/null +++ b/e2e-tests/support/rhdh-local-fixture.ts @@ -0,0 +1,202 @@ +import fs from 'fs-extra'; +import { exec as execCallback, execSync } from 'node:child_process'; +import net from 'node:net'; +import path from 'node:path'; +import { promisify } from 'node:util'; + +import { log } from './plugin-export-build'; + +const exec = promisify(execCallback); + +export type SupportedContainerTool = 'podman' | 'docker'; + +/** + * Locates an executable in standard system directories to avoid relying on PATH resolution. + */ +export function findExecutable(name: string): string | undefined { + const candidates = [ + `/usr/bin/${name}`, + `/usr/local/bin/${name}`, + `/bin/${name}`, + `/opt/homebrew/bin/${name}`, + ]; + for (const candidate of candidates) { + if (fs.existsSync(candidate)) { + return candidate; + } + } + return undefined; +} + +/** + * Synchronously checks if a container tool has working compose support on the current host. + * Actually verifies that the compose engine can query runtime status. + */ +export function isComposeAvailableSync(tool: SupportedContainerTool): boolean { + const bin = findExecutable(tool); + if (!bin) { + return false; + } + try { + execSync(`echo "services: {}" | ${bin} compose -f - ps`, { + stdio: 'ignore', + }); + return true; + } catch { + return false; + } +} + +/** + * Synchronously detects container tools with working compose commands. + * Useful for Jest describe-level conditionals evaluated at module load. + */ +export function detectAvailableComposeToolsSync(): SupportedContainerTool[] { + const tools: SupportedContainerTool[] = []; + const envTool = process.env.CONTAINER_TOOL as + | SupportedContainerTool + | undefined; + + const candidates: SupportedContainerTool[] = + envTool === 'docker' || envTool === 'podman' + ? [envTool, envTool === 'docker' ? 'podman' : 'docker'] + : ['docker', 'podman']; + + for (const tool of candidates) { + if (isComposeAvailableSync(tool) && !tools.includes(tool)) { + tools.push(tool); + } + } + return tools; +} + +/** + * Finds an available TCP port starting from the requested port. + */ +export async function findAvailablePort(startPort = 7007): Promise { + return new Promise((resolve, reject) => { + const server = net.createServer(); + server.once('error', (err: NodeJS.ErrnoException) => { + if (err.code === 'EADDRINUSE') { + resolve(findAvailablePort(startPort + 1)); + } else { + reject(err); + } + }); + server.once('listening', () => { + const address = server.address(); + const port = + typeof address === 'object' && address ? address.port : startPort; + server.close(() => resolve(port)); + }); + server.listen(startPort); + }); +} + +export interface RhdhLocalFixtureOptions { + port?: number; +} + +/** + * Creates a lightweight RHDH-Local-shaped fixture directory. + * Uses nginx:alpine as a fast HTTP 200 responder on port 7007 and alpine:latest + * as a fast-exiting installer container to trigger real die/died events. + */ +export async function createRhdhLocalFixture( + targetDir: string, + options: RhdhLocalFixtureOptions = {}, +): Promise<{ runtimeDir: string; port: number }> { + const port = options.port ?? (await findAvailablePort(7007)); + const runtimeDir = path.resolve(targetDir); + + await fs.ensureDir(path.join(runtimeDir, 'configs/dynamic-plugins')); + await fs.ensureDir(path.join(runtimeDir, 'configs/app-config')); + await fs.ensureDir(path.join(runtimeDir, 'local-plugins')); + + const composeContent = [ + 'services:', + ' rhdh:', + ' container_name: rhdh', + ' image: nginx:alpine', + ' ports:', + ` - "${port}:80"`, + ' install-dynamic-plugins:', + ' container_name: rhdh-plugins-installer', + ' image: alpine:latest', + ' command: ["sh", "-c", "echo installer completed; exit 0"]', + ].join('\n'); + + await fs.writeFile(path.join(runtimeDir, 'compose.yaml'), composeContent); + + await fs.writeFile( + path.join(runtimeDir, 'compose-dynamic-plugins-root.yaml'), + 'services: {}\n', + ); + + const installerScript = path.join( + runtimeDir, + 'prepare-and-install-dynamic-plugins.sh', + ); + await fs.writeFile(installerScript, '#!/bin/sh\nexit 0\n'); + await fs.chmod(installerScript, 0o700); + + const startScript = path.join(runtimeDir, 'wait-for-plugins-and-start.sh'); + await fs.writeFile(startScript, '#!/bin/sh\nexit 0\n'); + await fs.chmod(startScript, 0o700); + + await fs.writeFile( + path.join(runtimeDir, 'default.env'), + `BASE_URL=http://localhost:${port}\n`, + ); + + await fs.writeFile( + path.join( + runtimeDir, + 'configs/dynamic-plugins/dynamic-plugins.override.yaml', + ), + 'includes: []\n', + ); + + await fs.writeFile( + path.join(runtimeDir, 'configs/app-config/app-config.yaml'), + `app:\n baseUrl: http://localhost:${port}\n`, + ); + + return { runtimeDir, port }; +} + +/** + * Forces removal of fixture container names if they were left running. + */ +export async function cleanupContainers( + containerTool: SupportedContainerTool, +): Promise { + const bin = findExecutable(containerTool) ?? containerTool; + try { + await exec(`${bin} rm -f rhdh rhdh-plugins-installer`, { + shell: true, + }); + } catch { + // containers may not exist; ignore + } +} + +/** + * Tears down any remaining compose containers and networks for a fixture runtime. + */ +export async function cleanupCompose( + containerTool: SupportedContainerTool, + runtimeDir: string, +): Promise { + const composeFiles = '-f compose.yaml -f compose-dynamic-plugins-root.yaml'; + const bin = findExecutable(containerTool) ?? containerTool; + try { + await exec(`${bin} compose ${composeFiles} down -v`, { + cwd: runtimeDir, + shell: true, + }); + } catch (err: unknown) { + const message = err instanceof Error ? err.message : JSON.stringify(err); + log(`Cleanup warning for ${containerTool} in ${runtimeDir}: ${message}`); + } +}