You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
ghost/e2e/helpers/environment/EnvironmentManager.ts

150 lines
5.8 KiB

import * as fs from 'fs';
import Docker from 'dockerode';
import logging from '@tryghost/logging';
import baseDebug from '@tryghost/debug';
import path from 'path';
import {randomUUID} from 'crypto';
import {DockerCompose} from './DockerCompose';
import {MySQLManager} from './MySQLManager';
import {TinybirdManager} from './TinybirdManager';
import {GhostManager} from './GhostManager';
import {COMPOSE_FILE_PATH, COMPOSE_PROJECT, STATE_DIR} from './constants';
const debug = baseDebug('e2e:EnvironmentManager');
export interface GhostInstance {
containerId: string; // docker container ID
instanceId: string; // unique instance name (e.g. ghost_<siteUuid>)
database: string;
port: number;
baseUrl: string;
siteUuid: string;
}
// Tinybird state type is managed by TinybirdManager
/**
* Manages the lifecycle of Docker containers and shared services for end-to-end tests
*
* @usage
* ```
* const environmentManager = new EnvironmentManager();
* await environmentManager.globalSetup(); // Call once before all tests to start MySQL, Tinybird, etc.
* const ghostInstance = await environmentManager.setupGhostInstance(workerId); // Call before each test to create an isolated Ghost instance
* await environmentManager.teardownGhostInstance(ghostInstance); // Call after each test to clean up the Ghost instance
* await environmentManager.globalTeardown(); // Call once after all tests to stop shared services
* ````
*/
export class EnvironmentManager {
private docker: Docker;
private dockerCompose: DockerCompose;
private mysql: MySQLManager;
private tinybird: TinybirdManager;
private ghost: GhostManager;
constructor() {
this.docker = new Docker();
this.dockerCompose = new DockerCompose({
composeFilePath: COMPOSE_FILE_PATH,
projectName: COMPOSE_PROJECT,
docker: this.docker
});
this.mysql = new MySQLManager(this.dockerCompose);
this.tinybird = new TinybirdManager(this.dockerCompose);
this.ghost = new GhostManager(this.docker, this.dockerCompose, this.tinybird);
}
/**
* Setup shared global environment for tests (i.e. mysql, tinybird)
* This should be called once before all tests run.
*
* 1. Start docker-compose services (including running Ghost migrations on the default database)
* 2. Create a MySQL snapshot of the database after migrations, so we can quickly clone from it for each test without re-running migrations
* 3. Fetch Tinybird tokens from the tinybird-local service and store in /data/state/tinybird.json
*/
public async globalSetup(): Promise<void> {
logging.info('Starting global environment setup...');
this.dockerCompose.upAndWaitFor(['ghost-migrations', 'tb-cli']);
await this.mysql.createSnapshot();
this.tinybird.fetchTokens();
logging.info('Global environment setup complete');
}
/**
* Teardown global environment
* This should be called once after all tests have finished.
* 1. Stop and remove all docker-compose services
* 2. Clean up any state files created during the tests
3. If PRESERVE_ENV=true is set, skip the teardown to allow manual inspection
*/
public async globalTeardown(): Promise<void> {
if (this.shouldPreserveEnvironment()) {
logging.info('PRESERVE_ENV is set to true - skipping global environment teardown');
return;
}
logging.info('Starting global environment teardown...');
this.dockerCompose.down();
this.cleanupStateFiles();
logging.info('Global environment teardown complete');
}
/**
* Setup an isolated Ghost instance for a test
*/
public async setupGhostInstance(): Promise<GhostInstance> {
try {
const siteUuid = randomUUID();
const instanceId = `ghost_${siteUuid}`;
await this.mysql.setupTestDatabase(instanceId, siteUuid);
return await this.ghost.startInstance(instanceId, siteUuid);
} catch (error) {
logging.error('Failed to setup Ghost instance:', error);
throw new Error(`Failed to setup Ghost instance: ${error}`);
}
}
/**
* Teardown a Ghost instance
*/
public async teardownGhostInstance(ghostInstance: GhostInstance): Promise<void> {
if (this.shouldPreserveEnvironment()) {
return;
}
try {
debug('Tearing down Ghost instance:', ghostInstance.containerId);
await this.ghost.remove(ghostInstance.containerId);
await this.mysql.cleanupTestDatabase(ghostInstance.database);
debug('Ghost instance teardown completed');
} catch (error) {
logging.error('Failed to teardown Ghost instance:', error);
// Don't throw - we want tests to continue even if cleanup fails
}
}
private cleanupStateFiles(): void {
try {
if (fs.existsSync(STATE_DIR)) {
// Delete all files in the directory, but keep the directory itself
const files = fs.readdirSync(STATE_DIR);
for (const file of files) {
const filePath = path.join(STATE_DIR, file);
const stat = fs.statSync(filePath);
if (stat.isDirectory()) {
fs.rmSync(filePath, {recursive: true, force: true});
} else {
fs.unlinkSync(filePath);
}
}
debug('State files cleaned up');
}
} catch (error) {
logging.error('Failed to cleanup state files:', error);
throw new Error(`Failed to cleanup state files: ${error}`);
}
}
private shouldPreserveEnvironment(): boolean {
return process.env.PRESERVE_ENV === 'true';
}
}