From 75c0eb98509d30baaa4de216bceec942a29e4f82 Mon Sep 17 00:00:00 2001 From: Jonatan Svennberg Date: Mon, 23 Feb 2026 10:50:47 +0100 Subject: [PATCH 01/22] Refactored E2E environment manager ref https://linear.app/ghost/issue/BER-3363/unified-environment-manager Unified the E2E environment manager for dev and build modes. --- docker/dev-gateway/Caddyfile.build | 36 ++ e2e/helpers/environment/constants.ts | 142 ++--- .../environment/dev-environment-manager.ts | 126 ----- e2e/helpers/environment/docker-compose.ts | 304 ----------- .../environment/environment-factory.ts | 22 +- .../environment/environment-manager.ts | 222 ++++---- e2e/helpers/environment/index.ts | 1 - .../environment/service-availability.ts | 12 +- .../service-managers/dev-ghost-manager.ts | 318 ----------- .../service-managers/ghost-manager.ts | 509 ++++++++++++------ .../environment/service-managers/index.ts | 2 - .../service-managers/mysql-manager.ts | 28 +- .../service-managers/tinybird-manager.ts | 114 ---- 13 files changed, 577 insertions(+), 1259 deletions(-) create mode 100644 docker/dev-gateway/Caddyfile.build delete mode 100644 e2e/helpers/environment/dev-environment-manager.ts delete mode 100644 e2e/helpers/environment/docker-compose.ts delete mode 100644 e2e/helpers/environment/service-managers/dev-ghost-manager.ts delete mode 100644 e2e/helpers/environment/service-managers/tinybird-manager.ts diff --git a/docker/dev-gateway/Caddyfile.build b/docker/dev-gateway/Caddyfile.build new file mode 100644 index 00000000000..9ffbad2c254 --- /dev/null +++ b/docker/dev-gateway/Caddyfile.build @@ -0,0 +1,36 @@ +# Build mode Caddyfile +# Used for testing pre-built images (local or registry) + +{ + admin off +} + +:80 { + log { + output stdout + format transform "{common_log}" + } + + # Analytics API - proxy to analytics service + # Handles paths like /.ghost/analytics/* or /blog/.ghost/analytics/* + @analytics_paths path_regexp analytics_match ^(.*)/\.ghost/analytics(.*)$ + handle @analytics_paths { + rewrite * {re.analytics_match.2} + reverse_proxy {env.ANALYTICS_PROXY_TARGET} { + header_up Host {host} + header_up X-Forwarded-Host {host} + header_up X-Real-IP {remote_host} + header_up X-Forwarded-For {remote_host} + } + } + + # Everything else to Ghost + handle { + reverse_proxy {env.GHOST_BACKEND} { + header_up Host {host} + header_up X-Real-IP {remote_host} + header_up X-Forwarded-For {remote_host} + header_up X-Forwarded-Proto https + } + } +} diff --git a/e2e/helpers/environment/constants.ts b/e2e/helpers/environment/constants.ts index 24f7ca5c1d4..0e0e8c82662 100644 --- a/e2e/helpers/environment/constants.ts +++ b/e2e/helpers/environment/constants.ts @@ -6,36 +6,42 @@ const __dirname = path.dirname(__filename); export const CONFIG_DIR = path.resolve(__dirname, '../../data/state'); -export const DOCKER_COMPOSE_CONFIG = { - FILE_PATH: path.resolve(__dirname, '../../compose.yml'), - PROJECT: 'ghost-e2e' -}; - -export const GHOST_DEFAULTS = { - PORT: 2368 -}; +// Repository root path (for compose files and source mounting) +export const REPO_ROOT = path.resolve(__dirname, '../../..'); -export interface GhostImageProfile { - image: string; - workdir: string; - command: string[]; -} +/** + * Compose file paths for infrastructure services. + * Used by EnvironmentManager to start required services. + */ +export const COMPOSE_FILES = { + infra: path.resolve(REPO_ROOT, 'compose.infra.yaml'), + dev: path.resolve(REPO_ROOT, 'compose.dev.yaml'), + analytics: path.resolve(REPO_ROOT, 'compose.analytics.yaml') +} as const; -export function getImageProfile(): GhostImageProfile { - const image = process.env.GHOST_E2E_IMAGE || 'ghost-e2e:local'; - return { - image, - workdir: '/home/ghost', - command: ['node', 'index.js'] - }; -} +/** + * Caddyfile paths for different modes. + * - dev: Proxies to host dev servers for HMR + * - build: Minimal passthrough (assets served by Ghost from /content/files/) + */ +export const CADDYFILE_PATHS = { + dev: path.resolve(REPO_ROOT, 'docker/dev-gateway/Caddyfile'), + build: path.resolve(REPO_ROOT, 'docker/dev-gateway/Caddyfile.build') +} as const; -export const MYSQL = { - HOST: 'mysql', - PORT: 3306, - USER: 'root', - PASSWORD: 'root' -}; +/** + * Build mode image configuration. + * Used for build mode - can be locally built or pulled from registry. + * + * Override with environment variable: + * - GHOST_E2E_IMAGE: Image name (default: ghost-e2e:local) + * + * Examples: + * - Local: ghost-e2e:local (built from e2e/Dockerfile.e2e) + * - Registry: ghcr.io/tryghost/ghost:latest (as E2E base image) + * - Community: ghost + */ +export const BUILD_IMAGE = process.env.GHOST_E2E_IMAGE || 'ghost-e2e:local'; export const TINYBIRD = { LOCAL_HOST: 'tinybird-local', @@ -44,19 +50,6 @@ export const TINYBIRD = { CONFIG_DIR: CONFIG_DIR }; -export const PUBLIC_APPS = { - PORTAL_URL: '/ghost/assets/portal/portal.min.js', - COMMENTS_URL: '/ghost/assets/comments-ui/comments-ui.min.js', - SODO_SEARCH_URL: '/ghost/assets/sodo-search/sodo-search.min.js', - SODO_SEARCH_STYLES: '/ghost/assets/sodo-search/main.css', - SIGNUP_FORM_URL: '/ghost/assets/signup-form/signup-form.min.js', - ANNOUNCEMENT_BAR_URL: '/ghost/assets/announcement-bar/announcement-bar.min.js' -}; - -export const MAILPIT = { - PORT: 1025 -}; - /** * Configuration for dev environment mode. * Used when yarn dev infrastructure is detected. @@ -66,6 +59,45 @@ export const DEV_ENVIRONMENT = { networkName: 'ghost_dev' } as const; +/** + * Base environment variables shared by all modes. + */ +export const BASE_GHOST_ENV = [ + // Environment configuration + 'NODE_ENV=development', + 'server__host=0.0.0.0', + 'server__port=2368', + + // Database configuration (database name is set per container) + 'database__client=mysql2', + 'database__connection__host=ghost-dev-mysql', + 'database__connection__port=3306', + 'database__connection__user=root', + 'database__connection__password=root', + + // Redis configuration + 'adapters__cache__Redis__host=ghost-dev-redis', + 'adapters__cache__Redis__port=6379', + + // Email configuration + 'mail__transport=SMTP', + 'mail__options__host=ghost-dev-mailpit', + 'mail__options__port=1025' +] as const; + +/** + * Public app asset URLs for dev mode (served via gateway proxying to host dev servers). + * Build mode assets are baked into the E2E image via ENV vars in e2e/Dockerfile.e2e. + */ +export const LOCAL_ASSET_URLS = [ + 'portal__url=/ghost/assets/portal/portal.min.js', + 'comments__url=/ghost/assets/comments-ui/comments-ui.min.js', + 'sodoSearch__url=/ghost/assets/sodo-search/sodo-search.min.js', + 'sodoSearch__styles=/ghost/assets/sodo-search/main.css', + 'signupForm__url=/ghost/assets/signup-form/signup-form.min.js', + 'announcementBar__url=/ghost/assets/announcement-bar/announcement-bar.min.js' +] as const; + export const TEST_ENVIRONMENT = { projectNamespace: 'ghost-dev-e2e', gateway: { @@ -76,35 +108,9 @@ export const TEST_ENVIRONMENT = { workdir: '/home/ghost/ghost/core', port: 2368, env: [ - // Environment configuration - 'NODE_ENV=development', - 'server__host=0.0.0.0', - `server__port=2368`, - - // Database configuration (database name is set per container) - 'database__client=mysql2', - `database__connection__host=ghost-dev-mysql`, - `database__connection__port=3306`, - `database__connection__user=root`, - `database__connection__password=root`, - - // Redis configuration - 'adapters__cache__Redis__host=ghost-dev-redis', - 'adapters__cache__Redis__port=6379', - - // Email configuration - 'mail__transport=SMTP', - 'mail__options__host=ghost-dev-mailpit', - 'mail__options__port=1025', - + ...BASE_GHOST_ENV, // Public assets via gateway (same as compose.dev.yaml) - `portal__url=${PUBLIC_APPS.PORTAL_URL}`, - `comments__url=${PUBLIC_APPS.COMMENTS_URL}`, - `sodoSearch__url=${PUBLIC_APPS.SODO_SEARCH_URL}`, - `sodoSearch__styles=${PUBLIC_APPS.SODO_SEARCH_STYLES}`, - `signupForm__url=${PUBLIC_APPS.SIGNUP_FORM_URL}`, - `announcementBar__url=${PUBLIC_APPS.ANNOUNCEMENT_BAR_URL}` + ...LOCAL_ASSET_URLS ] } } as const; - diff --git a/e2e/helpers/environment/dev-environment-manager.ts b/e2e/helpers/environment/dev-environment-manager.ts deleted file mode 100644 index 89d8a4afea4..00000000000 --- a/e2e/helpers/environment/dev-environment-manager.ts +++ /dev/null @@ -1,126 +0,0 @@ -import Docker from 'dockerode'; -import baseDebug from '@tryghost/debug'; -import logging from '@tryghost/logging'; -import {DevGhostManager} from './service-managers/dev-ghost-manager'; -import {DockerCompose} from './docker-compose'; -import {GhostInstance, MySQLManager} from './service-managers'; -import {randomUUID} from 'crypto'; - -const debug = baseDebug('e2e:DevEnvironmentManager'); - -/** - * Orchestrates e2e test environment when dev infrastructure is available. - * - * Uses: - * - MySQLManager with DockerCompose pointing to ghost-dev project - * - DevGhostManager for Ghost/Gateway container lifecycle - * - * All e2e containers use the 'ghost-dev-e2e' project namespace for easy cleanup. - */ -export class DevEnvironmentManager { - private readonly workerIndex: number; - private readonly dockerCompose: DockerCompose; - private readonly mysql: MySQLManager; - private readonly ghost: DevGhostManager; - private initialized = false; - - constructor() { - this.workerIndex = parseInt(process.env.TEST_PARALLEL_INDEX || '0', 10); - - // Use DockerCompose pointing to ghost-dev project to find MySQL container - this.dockerCompose = new DockerCompose({ - composeFilePath: '', // Not needed for container lookup - projectName: 'ghost-dev', - docker: new Docker() - }); - this.mysql = new MySQLManager(this.dockerCompose); - this.ghost = new DevGhostManager({ - workerIndex: this.workerIndex - }); - } - - /** - * Global setup - creates database snapshot for test isolation. - * Mirrors the standalone environment: run migrations, then snapshot. - */ - async globalSetup(): Promise { - logging.info('Starting dev environment global setup...'); - - await this.cleanupResources(); - - // Create base database, run migrations, then snapshot - // This mirrors what docker-compose does with ghost-migrations service - await this.mysql.recreateBaseDatabase('ghost_e2e_base'); - await this.ghost.runMigrations('ghost_e2e_base'); - await this.mysql.createSnapshot('ghost_e2e_base'); - - logging.info('Dev environment global setup complete'); - } - - /** - * Global teardown - cleanup resources. - */ - async globalTeardown(): Promise { - if (this.shouldPreserveEnvironment()) { - logging.info('PRESERVE_ENV is set - skipping teardown'); - return; - } - - logging.info('Starting dev environment global teardown...'); - await this.cleanupResources(); - logging.info('Dev environment global teardown complete'); - } - - /** - * Per-test setup - creates containers on first call, then clones database and restarts Ghost. - */ - async perTestSetup(options: {config?: unknown} = {}): Promise { - // Lazy initialization of Ghost containers (once per worker) - if (!this.initialized) { - debug('Initializing Ghost containers for worker', this.workerIndex); - await this.ghost.setup(); - this.initialized = true; - } - - const siteUuid = randomUUID(); - const instanceId = `ghost_e2e_${siteUuid.replace(/-/g, '_')}`; - - // Setup database - await this.mysql.setupTestDatabase(instanceId, siteUuid); - - // Restart Ghost with new database - const extraConfig = options.config as Record | undefined; - await this.ghost.restartWithDatabase(instanceId, extraConfig); - await this.ghost.waitForReady(); - - const port = this.ghost.getGatewayPort(); - - return { - containerId: this.ghost.ghostContainerId!, - instanceId, - database: instanceId, - port, - baseUrl: `http://localhost:${port}`, - siteUuid - }; - } - - /** - * Per-test teardown - drops test database. - */ - async perTestTeardown(instance: GhostInstance): Promise { - await this.mysql.cleanupTestDatabase(instance.database); - } - - private async cleanupResources(): Promise { - logging.info('Cleaning up e2e resources...'); - await this.ghost.cleanupAllContainers(); - await this.mysql.dropAllTestDatabases(); - await this.mysql.deleteSnapshot(); - logging.info('E2E resources cleaned up'); - } - - private shouldPreserveEnvironment(): boolean { - return process.env.PRESERVE_ENV === 'true'; - } -} diff --git a/e2e/helpers/environment/docker-compose.ts b/e2e/helpers/environment/docker-compose.ts deleted file mode 100644 index 5df31b69120..00000000000 --- a/e2e/helpers/environment/docker-compose.ts +++ /dev/null @@ -1,304 +0,0 @@ -import Docker from 'dockerode'; -import baseDebug from '@tryghost/debug'; -import logging from '@tryghost/logging'; -import {execSync} from 'child_process'; -import type {Container} from 'dockerode'; - -const debug = baseDebug('e2e:DockerCompose'); - -type ContainerStatusItem = { - Name: string; - Command: string; - CreatedAt: string; - ExitCode: number; - Health: string; - State: string; - Service: string; -} - -export class DockerCompose { - private readonly composeFilePath: string; - private readonly projectName: string; - private readonly docker: Docker; - - constructor(options: { composeFilePath: string; projectName: string; docker: Docker }) { - this.composeFilePath = options.composeFilePath; - this.projectName = options.projectName; - this.docker = options.docker; - } - - async up(): Promise { - const command = this.composeCommand('up -d'); - - try { - logging.info('Starting docker compose services...'); - execSync(command, {encoding: 'utf-8', maxBuffer: 1024 * 1024 * 10}); - logging.info('Docker compose services are up'); - } catch (error) { - this.logCommandFailure(command, error); - logging.error('Failed to start docker compose services:', error); - this.ps(); - this.logs(); - throw error; - } - - await this.waitForAll(); - } - - // Stop and remove all services for the project including volumes - down(): void { - const command = this.composeCommand('down -v'); - - try { - execSync(command, {encoding: 'utf-8', maxBuffer: 1024 * 1024 * 10}); - } catch (error) { - this.logCommandFailure(command, error); - logging.error('Failed to stop docker compose services:', error); - throw error; - } - } - - execShellInService(service: string, shellCommand: string): string { - const command = this.composeCommand(`run --rm -T --entrypoint sh ${service} -c "${shellCommand}"`); - debug('readFileFromService running:', command); - - return execSync(command, {encoding: 'utf-8'}); - } - - execInService(service: string, command: string[]): string { - const cmdArgs = command.map(arg => `"${arg}"`).join(' '); - const cmd = this.composeCommand(`run --rm -T ${service} ${cmdArgs}`); - - debug('execInService running:', cmd); - return execSync(cmd, {encoding: 'utf-8'}); - } - - async getContainerForService(serviceLabel: string): Promise { - debug('getContainerForService called for service:', serviceLabel); - - const containers = await this.docker.listContainers({ - all: true, - filters: { - label: [ - `com.docker.compose.project=${this.projectName}`, - `com.docker.compose.service=${serviceLabel}` - ] - } - }); - - if (containers.length === 0) { - throw new Error(`No container found for service: ${serviceLabel}`); - } - - if (containers.length > 1) { - throw new Error(`Multiple containers found for service: ${serviceLabel}`); - } - - const container = this.docker.getContainer(containers[0].Id); - - debug('getContainerForService returning container:', container.id); - return container; - } - - /** - * Get the host port for a service's container port. - * This is useful when services use dynamic port mapping. - * - * @param serviceLabel The compose service name - * @param containerPort The container port (e.g., '4175') - * @returns The host port as a string - */ - async getHostPortForService(serviceLabel: string, containerPort: number): Promise { - const container = await this.getContainerForService(serviceLabel); - const containerInfo = await container.inspect(); - const portKey = `${containerPort}/tcp`; - const portMapping = containerInfo.NetworkSettings.Ports[portKey]; - - if (!portMapping || portMapping.length === 0) { - throw new Error(`Service ${serviceLabel} does not have port ${containerPort} exposed`); - } - const hostPort = portMapping[0].HostPort; - - debug(`Service ${serviceLabel} port ${containerPort} is mapped to host port ${hostPort}`); - return hostPort; - } - - async getNetwork(): Promise { - const networkId = await this.getNetworkId(); - debug('getNetwork returning network ID:', networkId); - - const network = this.docker.getNetwork(networkId); - - debug('getNetwork returning network:', network.id); - return network; - } - - private async getNetworkId() { - debug('getNetwork called'); - - const networks = await this.docker.listNetworks({ - filters: {label: [`com.docker.compose.project=${this.projectName}`]} - }); - - debug('getNetwork found networks:', networks.map(n => n.Id)); - - if (networks.length === 0) { - throw new Error('No Docker network found for the Compose project'); - } - if (networks.length > 1) { - throw new Error('Multiple Docker networks found for the Compose project'); - } - - return networks[0].Id; - } - - // Output all container logs for debugging - private logs(): void { - try { - logging.error('\n=== Docker compose logs ==='); - - const logs = execSync( - this.composeCommand('logs'), - {encoding: 'utf-8', maxBuffer: 1024 * 1024 * 10} // 10MB buffer for logs - ); - - logging.error(logs); - logging.error('=== End docker compose logs ===\n'); - } catch (logError) { - debug('Could not get docker compose logs:', logError); - } - } - - private ps(): void { - try { - logging.error('\n=== Docker compose ps -a ==='); - - const ps = execSync(this.composeCommand('ps -a'), { - encoding: 'utf-8', - maxBuffer: 1024 * 1024 * 10 - }); - - logging.error(ps); - logging.error('=== End docker compose ps -a ===\n'); - } catch (psError) { - debug('Could not get docker compose ps -a:', psError); - } - } - - private composeCommand(args: string): string { - return `docker compose -f ${this.composeFilePath} -p ${this.projectName} ${args}`; - } - - private logCommandFailure(command: string, error: unknown): void { - if (!(error instanceof Error)) { - return; - } - - const commandError = error as Error & { - stdout?: Buffer | string; - stderr?: Buffer | string; - }; - - const stdout = commandError.stdout?.toString().trim(); - const stderr = commandError.stderr?.toString().trim(); - - logging.error(`Command failed: ${command}`); - - if (stdout) { - logging.error('\n=== docker compose command stdout ==='); - logging.error(stdout); - logging.error('=== End docker compose command stdout ===\n'); - } - - if (stderr) { - logging.error('\n=== docker compose command stderr ==='); - logging.error(stderr); - logging.error('=== End docker compose command stderr ===\n'); - } - } - - private async getContainers(): Promise { - const command = this.composeCommand('ps -a --format json'); - const output = execSync(command, {encoding: 'utf-8'}).trim(); - - if (!output) { - return null; - } - - const containers = output - .split('\n') - .filter(line => line.trim()) - .map(line => JSON.parse(line)); - - if (containers.length === 0) { - return null; - } - - return containers; - } - - /** - * Wait until all services from the compose file are ready. - * NOTE: `docker compose up -d --wait` does not work here because it will exit with code 1 if any container exited. - * - * @param timeoutMs Maximum time to wait for all services to be ready (default: 60000ms) - * @param intervalMs Interval between status checks (default: 500ms) - * - */ - private async waitForAll(timeoutMs = 60000, intervalMs = 500): Promise { - const sleep = (ms: number) => new Promise((resolve) => { - setTimeout(resolve, ms); - }); - - const deadline = Date.now() + timeoutMs; - while (Date.now() < deadline) { - const containers = await this.getContainers(); - const allContainersReady = this.areAllContainersReady(containers); - - if (allContainersReady) { - return; - } - - await sleep(intervalMs); - } - - throw new Error('Timeout waiting for services to be ready'); - } - - private areAllContainersReady(containers: ContainerStatusItem[] | null): boolean { - if (!containers || containers.length === 0) { - return false; - } - - return containers.every(container => this.isContainerReady(container)); - } - - /** - * Check if a container is ready based on its status. - * - * A container is considered ready if: - * - It has a healthcheck and is healthy - * - It has exited with code 0 (no healthcheck) - * - * @param container Container status item - * @returns true if the container is ready, false otherwise - * @throws Error if the container has exited with a non-zero code - */ - private isContainerReady(container: ContainerStatusItem): boolean { - const {Health, State, ExitCode, Name, Service} = container; - - if (Health) { - return Health === 'healthy'; - } - - if (State !== 'exited') { - return false; - } - - if (ExitCode === 0) { - return true; - } - - throw new Error(`${Name || Service} exited with code ${ExitCode}`); - } -} diff --git a/e2e/helpers/environment/environment-factory.ts b/e2e/helpers/environment/environment-factory.ts index 808cc82908a..5a5b33ecea1 100644 --- a/e2e/helpers/environment/environment-factory.ts +++ b/e2e/helpers/environment/environment-factory.ts @@ -1,31 +1,15 @@ -import {DevEnvironmentManager} from './dev-environment-manager'; import {EnvironmentManager} from './environment-manager'; -import {getImageProfile} from './constants'; -import {isDevEnvironmentAvailable} from './service-availability'; // Cached manager instance (one per worker process) -let cachedManager: EnvironmentManager | DevEnvironmentManager | null = null; +let cachedManager: EnvironmentManager | null = null; /** * Get the environment manager for this worker. * Creates and caches a manager on first call, returns cached instance thereafter. - * - * Priority: GHOST_E2E_IMAGE > dev environment detection > default container mode */ -export async function getEnvironmentManager(): Promise { +export async function getEnvironmentManager(): Promise { if (!cachedManager) { - // Check for dev environment first (unless an explicit image was provided) - if (!process.env.GHOST_E2E_IMAGE) { - const useDevEnv = await isDevEnvironmentAvailable(); - if (useDevEnv) { - cachedManager = new DevEnvironmentManager(); - return cachedManager; - } - } - - // Container mode: use profile from env vars - const profile = getImageProfile(); - cachedManager = new EnvironmentManager(profile); + cachedManager = new EnvironmentManager(); } return cachedManager; } diff --git a/e2e/helpers/environment/environment-manager.ts b/e2e/helpers/environment/environment-manager.ts index ae35364b840..8c3230b02c6 100644 --- a/e2e/helpers/environment/environment-manager.ts +++ b/e2e/helpers/environment/environment-manager.ts @@ -1,169 +1,147 @@ -import Docker from 'dockerode'; import baseDebug from '@tryghost/debug'; import logging from '@tryghost/logging'; -import {DOCKER_COMPOSE_CONFIG, TINYBIRD} from './constants'; -import {DockerCompose} from './docker-compose'; -import {GhostInstance, GhostManager, MySQLManager, TinybirdManager} from './service-managers'; +import {GhostInstance, MySQLManager} from './service-managers'; +import {GhostManager} from './service-managers/ghost-manager'; import {randomUUID} from 'crypto'; -import type {GhostImageProfile} from './constants'; +import type {GhostConfig} from '@/helpers/playwright/fixture'; const debug = baseDebug('e2e:EnvironmentManager'); /** - * 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.perTestSetup(); // Call before each test to create an isolated Ghost instance - * await environmentManager.perTestTeardown(ghostInstance); // Call after each test to clean up the Ghost instance - * await environmentManager.globalTeardown(); // Call once after all tests to stop shared services - * ```` + * Environment modes for E2E testing. + * + * - dev: Uses dev infrastructure with hot-reloading dev servers (default) + * - build: Uses pre-built image (local or registry, controlled by GHOST_E2E_IMAGE) + */ +export type EnvironmentMode = 'dev' | 'build'; +type GhostEnvOverrides = GhostConfig | Record; + +/** + * Orchestrates e2e test environment. + * + * Supports two modes controlled by GHOST_E2E_MODE environment variable: + * - dev (default): Uses dev infrastructure with hot-reloading + * - build: Uses pre-built image (set GHOST_E2E_IMAGE for registry images) + * + * All modes use the same infrastructure (MySQL, Redis, Mailpit, Tinybird) + * started via docker compose. Ghost and gateway containers are created + * dynamically per-worker for test isolation. */ export class EnvironmentManager { - private readonly dockerCompose: DockerCompose; + private readonly mode: EnvironmentMode; + private readonly workerIndex: number; private readonly mysql: MySQLManager; - private readonly tinybird: TinybirdManager; private readonly ghost: GhostManager; - - constructor( - profile: GhostImageProfile, - composeFilePath: string = DOCKER_COMPOSE_CONFIG.FILE_PATH, - composeProjectName: string = DOCKER_COMPOSE_CONFIG.PROJECT - ) { - const docker = new Docker(); - this.dockerCompose = new DockerCompose({ - composeFilePath: composeFilePath, - projectName: composeProjectName, - docker: docker + private initialized = false; + + constructor() { + this.mode = this.detectMode(); + this.workerIndex = parseInt(process.env.TEST_PARALLEL_INDEX || '0', 10); + + this.mysql = new MySQLManager(); + this.ghost = new GhostManager({ + workerIndex: this.workerIndex, + mode: this.mode }); + } - this.mysql = new MySQLManager(this.dockerCompose); - this.tinybird = new TinybirdManager(this.dockerCompose, TINYBIRD.CONFIG_DIR, TINYBIRD.CLI_ENV_PATH); - this.ghost = new GhostManager(docker, this.dockerCompose, this.tinybird, profile); + /** + * Detect environment mode from GHOST_E2E_MODE environment variable. + */ + private detectMode(): EnvironmentMode { + const envMode = process.env.GHOST_E2E_MODE; + return (envMode === 'build') ? 'build' : 'dev'; // Default to dev mode } /** - * Setup shared global environment for tests (i.e. mysql, tinybird) - * This should be called once before all tests run. - * - * 1. Clean up any leftover resources from previous test runs - * 2. Start docker-compose services (including running Ghost migrations on the default database) - * 3. Wait for all services to be ready (healthy or exited with code 0) - * 4. Create a MySQL snapshot of the database after migrations, so we can quickly clone from it for each test without re-running migrations - * 5. Fetch Tinybird tokens from the tinybird-local service and store in /data/state/tinybird.json - * - * NOTE: Playwright workers run in their own processes, so each worker gets its own instance of EnvironmentManager. - * This is why we need to use a shared state file for Tinybird tokens - this.tinybird instance is not shared between workers. + * Global setup - creates database snapshot for test isolation. + * + * Creates the worker 0 containers (Ghost + Gateway) and waits for Ghost to + * become healthy. Ghost automatically runs migrations on startup. Once healthy, + * we snapshot the database for test isolation. */ - public async globalSetup(): Promise { - logging.info('Starting global environment setup...'); + async globalSetup(): Promise { + logging.info(`Starting ${this.mode} environment global setup...`); await this.cleanupResources(); - await this.dockerCompose.up(); - await this.mysql.createSnapshot(); - this.tinybird.fetchAndSaveConfig(); - logging.info('Global environment setup complete'); - } + // Create base database + await this.mysql.recreateBaseDatabase('ghost_e2e_base'); - /** - * Setup that executes on each test start - */ - public async perTestSetup(options: {config?: unknown} = {}): Promise { - try { - const {siteUuid, instanceId} = this.uniqueTestDetails(); - await this.mysql.setupTestDatabase(instanceId, siteUuid); - - return await this.ghost.createAndStartInstance(instanceId, siteUuid, options.config); - } catch (error) { - logging.error('Failed to setup Ghost instance:', error); - throw new Error(`Failed to setup Ghost instance: ${error}`); - } + // Create containers and wait for Ghost to be healthy (runs migrations) + await this.ghost.setup('ghost_e2e_base'); + await this.ghost.waitForReady(); + this.initialized = true; + + // Snapshot the migrated database for test isolation + await this.mysql.createSnapshot('ghost_e2e_base'); + + logging.info(`${this.mode} environment global setup complete`); } /** - * This should be called once after all tests have finished. - * - * 1. Remove all Ghost containers - * 2. Clean up test databases - * 3. Recreate the ghost_testing database for the next run - * 4. Truncate Tinybird analytics_events datasource - * 5. If PRESERVE_ENV=true is set, skip the teardown to allow manual inspection + * Global teardown - cleanup resources. */ - public async globalTeardown(): Promise { + async globalTeardown(): Promise { if (this.shouldPreserveEnvironment()) { - logging.info('PRESERVE_ENV is set to true - skipping global environment teardown'); + logging.info('PRESERVE_ENV is set - skipping teardown'); return; } - logging.info('Starting global environment teardown...'); - + logging.info(`Starting ${this.mode} environment global teardown...`); await this.cleanupResources(); - - logging.info('Global environment teardown complete (docker compose services left running)'); + logging.info(`${this.mode} environment global teardown complete`); } /** - * Setup that executes on each test stop + * Per-test setup - creates containers on first call, then clones database and restarts Ghost. */ - public async perTestTeardown(ghostInstance: GhostInstance): Promise { - try { - debug('Tearing down Ghost instance:', ghostInstance.containerId); + async perTestSetup(options: {config?: GhostEnvOverrides} = {}): Promise { + // Lazy initialization of Ghost containers (once per worker) + if (!this.initialized) { + debug('Initializing Ghost containers for worker', this.workerIndex, 'in mode', this.mode); + await this.ghost.setup(); + this.initialized = true; + } - await this.ghost.stopAndRemoveInstance(ghostInstance.containerId); - await this.mysql.cleanupTestDatabase(ghostInstance.database); + const siteUuid = randomUUID(); + const instanceId = `ghost_e2e_${siteUuid.replace(/-/g, '_')}`; - debug('Ghost instance teardown completed'); - } catch (error) { - // Don't throw - we want tests to continue even if cleanup fails - logging.error('Failed to teardown Ghost instance:', error); - } + // Setup database + await this.mysql.setupTestDatabase(instanceId, siteUuid); + + // Restart Ghost with new database + await this.ghost.restartWithDatabase(instanceId, options.config); + await this.ghost.waitForReady(); + + const port = this.ghost.getGatewayPort(); + + return { + containerId: this.ghost.ghostContainerId!, + instanceId, + database: instanceId, + port, + baseUrl: `http://localhost:${port}`, + siteUuid + }; } /** - * Clean up leftover resources from previous test runs - * This should be called at the start of globalSetup to ensure a clean slate, - * especially after interrupted test runs (e.g. via ctrl+c) - * - * 1. Remove all leftover Ghost containers - * 2. Clean up leftover test databases (if MySQL is running) - * 3. Delete the MySQL snapshot (if MySQL is running) - * 4. Recreate the ghost_testing database (if MySQL is running) - * 5. Truncate Tinybird analytics_events datasource (if Tinybird is running) - * - * Note: Docker compose services are left running for reuse across test runs + * Per-test teardown - drops test database. */ + async perTestTeardown(instance: GhostInstance): Promise { + await this.mysql.cleanupTestDatabase(instance.database); + } + private async cleanupResources(): Promise { - try { - logging.info('Cleaning up leftover resources from previous test runs...'); - - await this.ghost.removeAll(); - await this.mysql.dropAllTestDatabases(); - await this.mysql.deleteSnapshot(); - await this.mysql.recreateBaseDatabase(); - this.tinybird.truncateAnalyticsEvents(); - - logging.info('Leftover resources cleaned up successfully'); - } catch (error) { - // Don't throw - we want to continue with setup even if cleanup fails - logging.warn('Failed to clean up some leftover resources:', error); - } + logging.info('Cleaning up e2e resources...'); + await this.ghost.cleanupAllContainers(); + await this.mysql.dropAllTestDatabases(); + await this.mysql.deleteSnapshot(); + logging.info('E2E resources cleaned up'); } private shouldPreserveEnvironment(): boolean { return process.env.PRESERVE_ENV === 'true'; } - - // each test is going to have unique Ghost container, and site uuid for analytic events - private uniqueTestDetails() { - const siteUuid = randomUUID(); - const instanceId = `ghost_${siteUuid}`; - - return { - siteUuid, - instanceId - }; - } } diff --git a/e2e/helpers/environment/index.ts b/e2e/helpers/environment/index.ts index 5c31d03f5e2..a1e7b503ec1 100644 --- a/e2e/helpers/environment/index.ts +++ b/e2e/helpers/environment/index.ts @@ -1,6 +1,5 @@ export * from './service-managers'; export * from './environment-manager'; -export * from './dev-environment-manager'; export * from './environment-factory'; export * from './service-availability'; diff --git a/e2e/helpers/environment/service-availability.ts b/e2e/helpers/environment/service-availability.ts index b9d8182623e..d2933c9c112 100644 --- a/e2e/helpers/environment/service-availability.ts +++ b/e2e/helpers/environment/service-availability.ts @@ -67,19 +67,13 @@ export async function isDevEnvironmentAvailable(): Promise { return true; } - -// Cache availability checks per process -const tinybirdAvailable: boolean | null = null; - /** * Check if Tinybird is running. * Checks for tinybird-local service in ghost-dev compose project. */ export async function isTinybirdAvailable(): Promise { - if (tinybirdAvailable !== null) { - return tinybirdAvailable; - } - const docker = new Docker(); - return isServiceAvailable(docker, TINYBIRD.LOCAL_HOST); + const tinybirdAvailable = await isServiceAvailable(docker, TINYBIRD.LOCAL_HOST); + debug(`Tinybird availability for compose project ${DEV_ENVIRONMENT.projectNamespace}:`, tinybirdAvailable); + return tinybirdAvailable; } diff --git a/e2e/helpers/environment/service-managers/dev-ghost-manager.ts b/e2e/helpers/environment/service-managers/dev-ghost-manager.ts deleted file mode 100644 index 9be629ade26..00000000000 --- a/e2e/helpers/environment/service-managers/dev-ghost-manager.ts +++ /dev/null @@ -1,318 +0,0 @@ -import Docker from 'dockerode'; -import baseDebug from '@tryghost/debug'; -import path from 'path'; -import {DEV_ENVIRONMENT, TEST_ENVIRONMENT, TINYBIRD} from '@/helpers/environment/constants'; -import {fileURLToPath} from 'url'; -import {isTinybirdAvailable} from '@/helpers/environment/service-availability'; -import type {Container, ContainerCreateOptions} from 'dockerode'; - -const __filename = fileURLToPath(import.meta.url); -const __dirname = path.dirname(__filename); - -const debug = baseDebug('e2e:DevGhostManager'); - -export interface DevGhostManagerConfig { - workerIndex: number; -} - -/** - * Manages Ghost and Gateway containers for dev environment mode. - * Creates worker-scoped containers that persist across tests. - */ -export class DevGhostManager { - private readonly docker: Docker; - private readonly config: DevGhostManagerConfig; - private ghostContainer: Container | null = null; - private gatewayContainer: Container | null = null; - - constructor(config: DevGhostManagerConfig) { - this.docker = new Docker(); - this.config = config; - } - - get ghostContainerId(): string | null { - return this.ghostContainer?.id ?? null; - } - - get gatewayContainerId(): string | null { - return this.gatewayContainer?.id ?? null; - } - - getGatewayPort(): number { - return 30000 + this.config.workerIndex; - } - - async setup(): Promise { - debug(`Setting up containers for worker ${this.config.workerIndex}...`); - - const ghostName = `ghost-e2e-worker-${this.config.workerIndex}`; - const gatewayName = `ghost-e2e-gateway-${this.config.workerIndex}`; - - // Try to reuse existing containers (handles process restarts after test failures) - this.ghostContainer = await this.getOrCreateContainer(ghostName, () => this.createGhostContainer(ghostName)); - this.gatewayContainer = await this.getOrCreateContainer(gatewayName, () => this.createGatewayContainer(gatewayName, ghostName)); - - debug(`Worker ${this.config.workerIndex} containers ready`); - } - - /** - * Get existing container if running, otherwise create new one. - * This handles Playwright respawning processes after test failures. - */ - private async getOrCreateContainer(name: string, create: () => Promise): Promise { - try { - const existing = this.docker.getContainer(name); - const info = await existing.inspect(); - - if (info.State.Running) { - debug(`Reusing running container: ${name}`); - return existing; - } - - // Exists but stopped - start it - debug(`Starting stopped container: ${name}`); - await existing.start(); - return existing; - } catch { - // Doesn't exist - create new - debug(`Creating new container: ${name}`); - const container = await create(); - await container.start(); - return container; - } - } - - async teardown(): Promise { - debug(`Tearing down worker ${this.config.workerIndex} containers...`); - - if (this.gatewayContainer) { - await this.removeContainer(this.gatewayContainer); - this.gatewayContainer = null; - } - if (this.ghostContainer) { - await this.removeContainer(this.ghostContainer); - this.ghostContainer = null; - } - - debug(`Worker ${this.config.workerIndex} containers removed`); - } - - async restartWithDatabase(databaseName: string, extraConfig?: Record): Promise { - if (!this.ghostContainer) { - throw new Error('Ghost container not initialized'); - } - - debug('Restarting Ghost with database:', databaseName); - - const info = await this.ghostContainer.inspect(); - const containerName = info.Name.replace(/^\//, ''); - - // Remove old and create new with updated database - await this.removeContainer(this.ghostContainer); - this.ghostContainer = await this.createGhostContainer(containerName, databaseName, extraConfig); - await this.ghostContainer.start(); - - debug('Ghost restarted with database:', databaseName); - } - - async waitForReady(timeoutMs: number = 60000): Promise { - const port = this.getGatewayPort(); - const healthUrl = `http://localhost:${port}/ghost/api/admin/site/`; - const startTime = Date.now(); - - while (Date.now() - startTime < timeoutMs) { - try { - const response = await fetch(healthUrl, { - method: 'GET', - signal: AbortSignal.timeout(5000) - }); - if (response.status < 500) { - debug('Ghost is ready'); - return; - } - } catch { - // Keep trying - } - await new Promise((r) => { - setTimeout(r, 500); - }); - } - - throw new Error(`Timeout waiting for Ghost on port ${port}`); - } - - private async buildEnv(database: string = 'ghost_testing', extraConfig?: Record): Promise { - const env = [ - ...TEST_ENVIRONMENT.ghost.env, - `database__connection__database=${database}`, - `url=http://localhost:${this.getGatewayPort()}` - ]; - - // Add Tinybird config if available - // Static endpoints are set here; workspaceId and adminToken are sourced from - // /mnt/shared-config/.env.tinybird by development.entrypoint.sh - if (await isTinybirdAvailable()) { - env.push( - `TB_HOST=http://${TINYBIRD.LOCAL_HOST}:${TINYBIRD.PORT}`, - `TB_LOCAL_HOST=${TINYBIRD.LOCAL_HOST}`, - `tinybird__stats__endpoint=http://${TINYBIRD.LOCAL_HOST}:${TINYBIRD.PORT}`, - `tinybird__stats__endpointBrowser=http://localhost:${TINYBIRD.PORT}`, - `tinybird__tracker__endpoint=http://localhost:${this.getGatewayPort()}/.ghost/analytics/api/v1/page_hit`, - 'tinybird__tracker__datasource=analytics_events' - ); - } - - if (extraConfig) { - for (const [key, value] of Object.entries(extraConfig)) { - env.push(`${key}=${value}`); - } - } - - return env; - } - - private async createGhostContainer( - name: string, - database: string = 'ghost_testing', - extraConfig?: Record - ): Promise { - const repoRoot = path.resolve(__dirname, '../../../..'); - - // Mount only the ghost subdirectory, matching compose.dev.yaml - // The image has node_modules and package.json at /home/ghost/ (installed at build time) - // We mount source code at /home/ghost/ghost/ for hot-reload - // Also mount shared-config volume to access Tinybird tokens (created by tb-cli) - const config: ContainerCreateOptions = { - name, - Image: TEST_ENVIRONMENT.ghost.image, - Env: await this.buildEnv(database, extraConfig), - ExposedPorts: {[`${TEST_ENVIRONMENT.ghost.port}/tcp`]: {}}, - HostConfig: { - Binds: [ - `${repoRoot}/ghost:/home/ghost/ghost`, - // Mount shared-config volume from the ghost-dev project (not ghost-dev-e2e) - // This gives e2e tests access to Tinybird credentials created by yarn dev - 'ghost-dev_shared-config:/mnt/shared-config:ro' - ], - ExtraHosts: ['host.docker.internal:host-gateway'] - }, - NetworkingConfig: { - EndpointsConfig: { - [DEV_ENVIRONMENT.networkName]: {Aliases: [name]} - } - }, - Labels: { - 'com.docker.compose.project': TEST_ENVIRONMENT.projectNamespace, - 'tryghost/e2e': 'ghost-dev' - } - }; - - return this.docker.createContainer(config); - } - - private async createGatewayContainer(name: string, ghostBackend: string): Promise { - // Gateway just needs to know where Ghost is - everything else uses defaults from the image - const config: ContainerCreateOptions = { - name, - Image: TEST_ENVIRONMENT.gateway.image, - Env: [`GHOST_BACKEND=${ghostBackend}:${TEST_ENVIRONMENT.ghost.port}`], - ExposedPorts: {'80/tcp': {}}, - HostConfig: { - PortBindings: {'80/tcp': [{HostPort: String(this.getGatewayPort())}]}, - ExtraHosts: ['host.docker.internal:host-gateway'] - }, - NetworkingConfig: { - EndpointsConfig: { - [DEV_ENVIRONMENT.networkName]: {Aliases: [name]} - } - }, - Labels: { - 'com.docker.compose.project': TEST_ENVIRONMENT.projectNamespace, - 'tryghost/e2e': 'gateway-dev' - } - }; - - return this.docker.createContainer(config); - } - - private async removeContainer(container: Container): Promise { - try { - await container.remove({force: true}); - } catch { - debug('Failed to remove container:', container.id); - } - } - - /** - * Remove all e2e containers by project label. - */ - async cleanupAllContainers(): Promise { - try { - const containers = await this.docker.listContainers({ - all: true, - filters: { - label: [`com.docker.compose.project=${TEST_ENVIRONMENT.projectNamespace}`] - } - }); - - await Promise.all( - containers.map(c => this.docker.getContainer(c.Id).remove({force: true})) - ); - } catch { - // Ignore - no containers to remove or removal failed - } - } - - /** - * Run knex-migrator init on a database. - * Creates a temporary container to run migrations, matching how compose.yml does it. - */ - async runMigrations(database: string): Promise { - debug('Running migrations for database:', database); - - const repoRoot = path.resolve(__dirname, '../../../..'); - const containerName = `ghost-e2e-migrations-${Date.now()}`; - const container = await this.docker.createContainer({ - name: containerName, - Image: TEST_ENVIRONMENT.ghost.image, - Cmd: ['yarn', 'knex-migrator', 'init'], - WorkingDir: '/home/ghost', - Env: [ - ...TEST_ENVIRONMENT.ghost.env, - `database__connection__database=${database}` - ], - HostConfig: { - Binds: [`${repoRoot}/ghost:/home/ghost/ghost`], - AutoRemove: false - }, - NetworkingConfig: { - EndpointsConfig: { - [DEV_ENVIRONMENT.networkName]: {} - } - }, - Labels: { - 'com.docker.compose.project': TEST_ENVIRONMENT.projectNamespace, - 'tryghost/e2e': 'migrations' - } - }); - - await container.start(); - - // Wait for container to finish - const result = await container.wait(); - - if (result.StatusCode !== 0) { - try { - const logs = await container.logs({stdout: true, stderr: true}); - debug('Migration logs:', logs.toString()); - } catch { - debug('Could not retrieve migration logs'); - } - await this.removeContainer(container); - throw new Error(`Migrations failed with exit code ${result.StatusCode}`); - } - - await this.removeContainer(container); - debug('Migrations completed successfully'); - } -} diff --git a/e2e/helpers/environment/service-managers/ghost-manager.ts b/e2e/helpers/environment/service-managers/ghost-manager.ts index 3e1435cd31e..0c7949d083b 100644 --- a/e2e/helpers/environment/service-managers/ghost-manager.ts +++ b/e2e/helpers/environment/service-managers/ghost-manager.ts @@ -1,211 +1,400 @@ import Docker from 'dockerode'; import baseDebug from '@tryghost/debug'; import logging from '@tryghost/logging'; -import {DOCKER_COMPOSE_CONFIG, GHOST_DEFAULTS, MAILPIT, MYSQL, PUBLIC_APPS, TINYBIRD} from '@/helpers/environment/constants'; -import {DockerCompose} from '@/helpers/environment/docker-compose'; -import {TinybirdManager} from './tinybird-manager'; +import { + BASE_GHOST_ENV, + BUILD_IMAGE, + CADDYFILE_PATHS, + DEV_ENVIRONMENT, + LOCAL_ASSET_URLS, + REPO_ROOT, + TEST_ENVIRONMENT, + TINYBIRD +} from '@/helpers/environment/constants'; +import {isTinybirdAvailable} from '@/helpers/environment/service-availability'; +import {readFile} from 'fs/promises'; import type {Container, ContainerCreateOptions} from 'dockerode'; -import type {GhostImageProfile} from '@/helpers/environment/constants'; +import type {EnvironmentMode} from '@/helpers/environment/environment-manager'; +import type {GhostConfig} from '@/helpers/playwright/fixture'; const debug = baseDebug('e2e:GhostManager'); +type GhostEnvOverrides = GhostConfig | Record; +/** + * Represents a running Ghost instance for E2E tests. + */ export interface GhostInstance { - containerId: string; // docker container ID - instanceId: string; // unique instance name (e.g. ghost_) + containerId: string; + instanceId: string; database: string; port: number; baseUrl: string; siteUuid: string; } -export interface GhostStartConfig { - instanceId: string; - siteUuid: string; - config?: unknown; +export interface GhostManagerConfig { + workerIndex: number; + mode: EnvironmentMode; } +/** + * Manages Ghost and Gateway containers for dev environment mode. + * Creates worker-scoped containers that persist across tests. + */ export class GhostManager { - private docker: Docker; - private dockerCompose: DockerCompose; - private tinybird: TinybirdManager; - private profile: GhostImageProfile; + private readonly docker: Docker; + private readonly config: GhostManagerConfig; + private ghostContainer: Container | null = null; + private gatewayContainer: Container | null = null; + + constructor(config: GhostManagerConfig) { + this.docker = new Docker(); + this.config = config; + } + + get ghostContainerId(): string | null { + return this.ghostContainer?.id ?? null; + } + + getGatewayPort(): number { + return 30000 + this.config.workerIndex; + } + + /** + * Set up Ghost and Gateway containers for this worker. + * + * @param database Optional database name to use. If not provided, uses 'ghost_testing'. + */ + async setup(database?: string): Promise { + debug(`Setting up containers for worker ${this.config.workerIndex}...`); + + // For build mode, verify the image exists before proceeding + if (this.config.mode === 'build') { + await this.verifyBuildImageExists(); + } - constructor(docker: Docker, dockerCompose: DockerCompose, tinybird: TinybirdManager, profile: GhostImageProfile) { - this.docker = docker; - this.dockerCompose = dockerCompose; - this.tinybird = tinybird; - this.profile = profile; + const ghostName = `ghost-e2e-worker-${this.config.workerIndex}`; + const gatewayName = `ghost-e2e-gateway-${this.config.workerIndex}`; + + // Try to reuse existing containers (handles process restarts after test failures) + this.ghostContainer = await this.getOrCreateContainer(ghostName, () => this.createGhostContainer(ghostName, database)); + this.gatewayContainer = await this.getOrCreateContainer(gatewayName, () => this.createGatewayContainer(gatewayName, ghostName)); + + debug(`Worker ${this.config.workerIndex} containers ready`); } - private async createAndStart(config: GhostStartConfig): Promise { + /** + * Verify the build image exists locally. + * Fails early with a helpful error message if the image is not available. + */ + async verifyBuildImageExists(): Promise { try { - const network = await this.dockerCompose.getNetwork(); - const tinyBirdConfig = this.tinybird.loadConfig(); - - // Use deterministic port based on worker index (or 0 if not in parallel) - const hostPort = 30000 + parseInt(process.env.TEST_PARALLEL_INDEX || '0', 10); - - const environment = { - server__host: '0.0.0.0', - server__port: String(GHOST_DEFAULTS.PORT), - url: `http://localhost:${hostPort}`, - NODE_ENV: 'development', - // Db configuration - database__client: 'mysql2', - database__connection__host: MYSQL.HOST, - database__connection__port: String(MYSQL.PORT), - database__connection__user: MYSQL.USER, - database__connection__password: MYSQL.PASSWORD, - database__connection__database: config.instanceId, - // Tinybird configuration - TB_HOST: `http://${TINYBIRD.LOCAL_HOST}:${TINYBIRD.PORT}`, - TB_LOCAL_HOST: TINYBIRD.LOCAL_HOST, - tinybird__stats__endpoint: `http://${TINYBIRD.LOCAL_HOST}:${TINYBIRD.PORT}`, - tinybird__stats__endpointBrowser: 'http://localhost:7181', - tinybird__tracker__endpoint: 'http://localhost:8080/.ghost/analytics/api/v1/page_hit', - tinybird__tracker__datasource: 'analytics_events', - tinybird__workspaceId: tinyBirdConfig.workspaceId, - tinybird__adminToken: tinyBirdConfig.adminToken, - // Email configuration - mail__transport: 'SMTP', - mail__options__host: 'mailpit', - mail__options__port: `${MAILPIT.PORT}`, - mail__options__secure: 'false', - // Public apps — served from Ghost's admin assets path via E2E image layer - portal__url: PUBLIC_APPS.PORTAL_URL, - comments__url: PUBLIC_APPS.COMMENTS_URL, - sodoSearch__url: PUBLIC_APPS.SODO_SEARCH_URL, - sodoSearch__styles: PUBLIC_APPS.SODO_SEARCH_STYLES, - signupForm__url: PUBLIC_APPS.SIGNUP_FORM_URL, - announcementBar__url: PUBLIC_APPS.ANNOUNCEMENT_BAR_URL, - ...(config.config ? config.config : {}) - } as Record; - - const containerConfig: ContainerCreateOptions = { - Image: this.profile.image, - Env: Object.entries(environment).map(([key, value]) => `${key}=${value}`), - NetworkingConfig: { - EndpointsConfig: { - [network.id]: { - Aliases: [config.instanceId] - } - } - }, - ExposedPorts: { - [`${GHOST_DEFAULTS.PORT}/tcp`]: {} - }, - HostConfig: { - PortBindings: { - [`${GHOST_DEFAULTS.PORT}/tcp`]: [{HostPort: String(hostPort)}] - } - }, - Labels: { - 'com.docker.compose.project': DOCKER_COMPOSE_CONFIG.PROJECT, - 'com.docker.compose.service': `ghost-${config.siteUuid}`, - 'tryghost/e2e': 'ghost' - }, - WorkingDir: this.profile.workdir, - Cmd: this.profile.command, - AttachStdout: true, - AttachStderr: true - }; - - debug('Ghost environment variables:', JSON.stringify(environment, null, 2)); - debug('Full Docker container config:', JSON.stringify(containerConfig, null, 2)); - debug('Starting Ghost container...'); - - const container = await this.docker.createContainer(containerConfig); - await container.start(); + const image = this.docker.getImage(BUILD_IMAGE); + await image.inspect(); + debug(`Build image verified: ${BUILD_IMAGE}`); + } catch { + throw new Error( + `Build image not found: ${BUILD_IMAGE}\n\n` + + `To fix this, either:\n` + + ` 1. Build locally: yarn build:e2e-image\n` + + ` 2. Pull from registry: docker pull ${BUILD_IMAGE}\n` + + ` 3. Use a different image: GHOST_E2E_IMAGE= yarn test:build` + ); + } + } - debug('Ghost container started:', container.id); - return container; + /** + * Get existing container if running, otherwise create new one. + * This handles Playwright respawning processes after test failures. + */ + private async getOrCreateContainer(name: string, create: () => Promise): Promise { + try { + const existing = this.docker.getContainer(name); + const info = await existing.inspect(); + + if (info.State.Running) { + debug(`Reusing running container: ${name}`); + return existing; + } + + // Exists but stopped - start it + debug(`Starting stopped container: ${name}`); + await existing.start(); + return existing; } catch (error) { - logging.error('Failed to create Ghost container:', error); - throw new Error(`Failed to create Ghost container: ${error}`); + const statusCode = (error as {statusCode?: number})?.statusCode; + const message = error instanceof Error ? error.message : String(error); + const isNotFound = statusCode === 404 || /No such container/i.test(message); + + if (!isNotFound) { + debug(`Unexpected error inspecting container ${name}:`, error); + throw error; + } + + debug(`Creating new container: ${name}`); + const container = await create(); + await container.start(); + return container; } } - async createAndStartInstance(instanceId: string, siteUuid: string, config?: unknown): Promise { - const container = await this.createAndStart({instanceId, siteUuid, config}); - const containerInfo = await container.inspect(); - const hostPort = parseInt(containerInfo.NetworkSettings.Ports[`${GHOST_DEFAULTS.PORT}/tcp`][0].HostPort, 10); - await this.waitReady(hostPort, 30000); + async teardown(): Promise { + debug(`Tearing down worker ${this.config.workerIndex} containers...`); - return { - containerId: container.id, - instanceId, - database: instanceId, - port: hostPort, - baseUrl: `http://localhost:${hostPort}`, - siteUuid - }; + if (this.gatewayContainer) { + await this.removeContainer(this.gatewayContainer); + this.gatewayContainer = null; + } + if (this.ghostContainer) { + await this.removeContainer(this.ghostContainer); + this.ghostContainer = null; + } + + debug(`Worker ${this.config.workerIndex} containers removed`); } - async removeAll(): Promise { - try { - debug('Finding all Ghost containers...'); - const containers = await this.docker.listContainers({ - all: true, - filters: { - label: ['tryghost/e2e=ghost'] - } - }); + async restartWithDatabase(databaseName: string, extraConfig?: GhostEnvOverrides): Promise { + if (!this.ghostContainer) { + throw new Error('Ghost container not initialized'); + } - if (containers.length === 0) { - debug('No Ghost containers found'); - return; + debug('Restarting Ghost with database:', databaseName); + + const info = await this.ghostContainer.inspect(); + const containerName = info.Name.replace(/^\//, ''); + + // Remove old and create new with updated database + await this.removeContainer(this.ghostContainer); + this.ghostContainer = await this.createGhostContainer(containerName, databaseName, extraConfig); + await this.ghostContainer.start(); + + debug('Ghost restarted with database:', databaseName); + } + + /** + * Wait for Ghost container to become healthy. + * Uses Docker's built-in health check mechanism. + */ + async waitForReady(timeoutMs: number = 120000): Promise { + if (!this.ghostContainer) { + throw new Error('Ghost container not initialized'); + } + await this.waitForHealthy(this.ghostContainer, timeoutMs); + } + + private async buildEnv(database: string = 'ghost_testing', extraConfig?: GhostEnvOverrides): Promise { + const env = [ + ...BASE_GHOST_ENV, + `database__connection__database=${database}`, + `url=http://localhost:${this.getGatewayPort()}` + ]; + + // For dev mode, add local asset URLs (served via gateway proxying to host dev servers) + // Registry mode uses production CDN URLs (no override needed) + // Local mode has asset URLs baked into the E2E image via ENV vars + if (this.config.mode === 'dev') { + env.push(...LOCAL_ASSET_URLS); + } + + // Add Tinybird config if available + // Static endpoints are set here; workspaceId and adminToken are sourced from + // /mnt/shared-config/.env.tinybird by development.entrypoint.sh + if (await isTinybirdAvailable()) { + env.push( + `TB_HOST=http://${TINYBIRD.LOCAL_HOST}:${TINYBIRD.PORT}`, + `TB_LOCAL_HOST=${TINYBIRD.LOCAL_HOST}`, + `tinybird__stats__endpoint=http://${TINYBIRD.LOCAL_HOST}:${TINYBIRD.PORT}`, + `tinybird__stats__endpointBrowser=http://localhost:${TINYBIRD.PORT}`, + `tinybird__tracker__endpoint=http://localhost:${this.getGatewayPort()}/.ghost/analytics/api/v1/page_hit`, + 'tinybird__tracker__datasource=analytics_events' + ); + } + + if (extraConfig) { + for (const [key, value] of Object.entries(extraConfig)) { + if (typeof value !== 'string') { + continue; + } + env.push(`${key}=${value}`); } + } + + return env; + } + + private async createGhostContainer( + name: string, + database: string = 'ghost_testing', + extraConfig?: GhostEnvOverrides + ): Promise { + const mode = this.config.mode; + debug(`Creating Ghost container for mode: ${mode}`); + + // Determine image based on mode + // - build: Build image (local or registry, controlled by GHOST_E2E_IMAGE) + // - dev: Dev image from compose.dev.yaml + const image = mode === 'build' ? BUILD_IMAGE : TEST_ENVIRONMENT.ghost.image; - debug(`Found ${containers.length} Ghost container(s) to remove`); - for (const containerInfo of containers) { - await this.stopAndRemoveInstance(containerInfo.Id); + // Build volume mounts based on mode + const binds = this.getGhostBinds(); + + const config: ContainerCreateOptions = { + name, + Image: image, + Env: await this.buildEnv(database, extraConfig), + ExposedPorts: {[`${TEST_ENVIRONMENT.ghost.port}/tcp`]: {}}, + Healthcheck: { + // Same health check as compose.dev.yaml - Ghost is ready when it responds + Test: ['CMD', 'node', '-e', `fetch('http://localhost:${TEST_ENVIRONMENT.ghost.port}',{redirect:'manual'}).then(r=>process.exit(r.status<500?0:1)).catch(()=>process.exit(1))`], + Interval: 1000000000, // 1s in nanoseconds + Timeout: 5000000000, // 5s in nanoseconds + Retries: 60, + StartPeriod: 5000000000 // 5s in nanoseconds + }, + HostConfig: { + Binds: binds, + ExtraHosts: ['host.docker.internal:host-gateway'] + }, + NetworkingConfig: { + EndpointsConfig: { + [DEV_ENVIRONMENT.networkName]: {Aliases: [name]} + } + }, + Labels: { + 'com.docker.compose.project': TEST_ENVIRONMENT.projectNamespace, + 'tryghost/e2e': `ghost-${mode}` } - debug('All Ghost containers removed'); - } catch (error) { - // Don't throw - we want to continue with setup even if cleanup fails - logging.error('Failed to remove all Ghost containers:', error); + }; + + return this.docker.createContainer(config); + } + + /** + * Get volume binds for Ghost container based on mode. + * - dev: Mount ghost directory for source code (hot reload) + * - build: No source mounts, fully self-contained image + */ + private getGhostBinds(): string[] { + const binds: string[] = [ + // Shared config volume for Tinybird credentials (all modes) + 'ghost-dev_shared-config:/mnt/shared-config:ro' + ]; + + if (this.config.mode === 'dev') { + binds.push(`${REPO_ROOT}/ghost:/home/ghost/ghost`); } + + return binds; } - async stopAndRemoveInstance(containerId: string): Promise { - try { - const container = this.docker.getContainer(containerId); - try { - await container.stop({t: 10}); - } catch (error) { - debug('Error stopping container:', error); - debug('Container already stopped or stop failed, forcing removal:', containerId); + private async createGatewayContainer(name: string, ghostBackend: string): Promise { + const mode = this.config.mode; + debug(`Creating Gateway container for mode: ${mode}`); + + // Use caddy image and mount appropriate Caddyfile based on mode + // - dev: Proxies to host dev servers for HMR + // - build: Minimal passthrough (assets served by Ghost or default CDN) + const caddyfilePath = mode === 'dev' ? CADDYFILE_PATHS.dev : CADDYFILE_PATHS.build; + + const binds: string[] = [ + `${caddyfilePath}:/etc/caddy/Caddyfile:ro` + ]; + + // Environment variables for Caddy + const env = [ + `GHOST_BACKEND=${ghostBackend}:${TEST_ENVIRONMENT.ghost.port}`, + 'ANALYTICS_PROXY_TARGET=ghost-dev-analytics:3000' + ]; + + const config: ContainerCreateOptions = { + name, + Image: TEST_ENVIRONMENT.gateway.image, + Env: env, + ExposedPorts: {'80/tcp': {}}, + HostConfig: { + Binds: binds, + PortBindings: {'80/tcp': [{HostPort: String(this.getGatewayPort())}]}, + ExtraHosts: ['host.docker.internal:host-gateway'] + }, + NetworkingConfig: { + EndpointsConfig: { + [DEV_ENVIRONMENT.networkName]: {Aliases: [name]} + } + }, + Labels: { + 'com.docker.compose.project': TEST_ENVIRONMENT.projectNamespace, + 'tryghost/e2e': `gateway-${mode}` } + }; + + return this.docker.createContainer(config); + } + + private async removeContainer(container: Container): Promise { + try { await container.remove({force: true}); - debug('Container removed:', containerId); + } catch { + debug('Failed to remove container:', container.id); + } + } + + /** + * Remove all e2e containers by project label. + */ + async cleanupAllContainers(): Promise { + try { + const containers = await this.docker.listContainers({ + all: true, + filters: { + label: [`com.docker.compose.project=${TEST_ENVIRONMENT.projectNamespace}`] + } + }); + + await Promise.all( + containers.map(c => this.docker.getContainer(c.Id).remove({force: true})) + ); } catch (error) { - debug('Failed to remove container:', error); + debug('cleanupAllContainers: failed to list/remove containers', error); } } - private async waitReady(port: number, timeoutMs: number = 60000): Promise { + /** + * Wait for a container to become healthy according to Docker's health check. + */ + private async waitForHealthy(container: Container, timeoutMs: number): Promise { const startTime = Date.now(); - const healthUrl = `http://localhost:${port}/ghost/api/admin/site/`; while (Date.now() - startTime < timeoutMs) { - try { - const response = await fetch(healthUrl, { - method: 'GET', - signal: AbortSignal.timeout(5000) - }); - if (response.status < 500) { - debug('Ghost is ready, responded with status:', response.status); - return; - } - debug('Ghost not ready yet, status:', response.status); - } catch (error) { - debug('Ghost health check failed, retrying...', error instanceof Error ? error.message : String(error)); + const info = await container.inspect(); + const health = info.State.Health; + const status = health?.Status; + + if (status === 'healthy') { + debug('Container is healthy'); + return; } - await new Promise((resolve) => { - setTimeout(resolve, 200); + + if (status === 'unhealthy') { + const logs = await container.logs({stdout: true, stderr: true, tail: 100}); + logging.error(`Container became unhealthy:\n${logs.toString()}`); + throw new Error('Ghost container became unhealthy during initialization'); + } + + if (!info.State.Running) { + const logs = await container.logs({stdout: true, stderr: true, tail: 100}); + logging.error(`Container stopped unexpectedly:\n${logs.toString()}`); + throw new Error('Ghost container stopped during initialization'); + } + + // Still starting - wait and check again + await new Promise((r) => { + setTimeout(r, 1000); }); } - throw new Error(`Timeout waiting for Ghost to start on port ${port}`); + // Timeout + const logs = await container.logs({stdout: true, stderr: true, tail: 100}); + logging.error(`Timeout waiting for container. Last logs:\n${logs.toString()}`); + throw new Error('Timeout waiting for Ghost to become healthy'); } } diff --git a/e2e/helpers/environment/service-managers/index.ts b/e2e/helpers/environment/service-managers/index.ts index 97b63a9d295..548979a23df 100644 --- a/e2e/helpers/environment/service-managers/index.ts +++ b/e2e/helpers/environment/service-managers/index.ts @@ -1,4 +1,2 @@ -export * from './dev-ghost-manager'; export * from './ghost-manager'; export * from './mysql-manager'; -export * from './tinybird-manager'; diff --git a/e2e/helpers/environment/service-managers/mysql-manager.ts b/e2e/helpers/environment/service-managers/mysql-manager.ts index 325ea087795..18bd4190ef1 100644 --- a/e2e/helpers/environment/service-managers/mysql-manager.ts +++ b/e2e/helpers/environment/service-managers/mysql-manager.ts @@ -1,6 +1,6 @@ +import Docker from 'dockerode'; import baseDebug from '@tryghost/debug'; import logging from '@tryghost/logging'; -import {DockerCompose} from '@/helpers/environment/docker-compose'; import {PassThrough} from 'stream'; import type {Container} from 'dockerode'; @@ -13,20 +13,21 @@ interface ContainerWithModem extends Container { } /** - * Encapsulates MySQL operations within the docker-compose environment. + * Manages MySQL operations for E2E tests. * Handles creating snapshots, creating/restoring/dropping databases, and * updating database settings needed by tests. */ export class MySQLManager { - private readonly dockerCompose: DockerCompose; + private readonly docker: Docker; private readonly containerName: string; - constructor(dockerCompose: DockerCompose, containerName: string = 'mysql') { - this.dockerCompose = dockerCompose; + constructor(containerName: string = 'ghost-dev-mysql') { + this.docker = new Docker(); this.containerName = containerName; } async setupTestDatabase(databaseName: string, siteUuid: string): Promise { + debug('Setting up test database:', databaseName); try { await this.createDatabase(databaseName); await this.restoreDatabaseFromSnapshot(databaseName); @@ -35,7 +36,7 @@ export class MySQLManager { debug('Test database setup completed:', databaseName, 'with site_uuid:', siteUuid); } catch (error) { logging.error('Failed to setup test database:', error); - throw new Error(`Failed to setup test database: ${error}`); + throw error instanceof Error ? error : new Error(`Failed to setup test database: ${String(error)}`); } } @@ -127,17 +128,12 @@ export class MySQLManager { } async recreateBaseDatabase(database: string = 'ghost_testing'): Promise { - try { - debug('Recreating base database:', database); + debug('Recreating base database:', database); - await this.dropDatabase(database); - await this.createDatabase(database); + await this.dropDatabase(database); + await this.createDatabase(database); - debug('Base database recreated:', database); - } catch (error) { - debug('Failed to recreate base database (MySQL may not be running):', error); - // Don't throw - we want to continue with setup even if database recreation fails - } + debug('Base database recreated:', database); } private parseDatabaseNames(text: string) { @@ -171,7 +167,7 @@ export class MySQLManager { } private async exec(command: string) { - const container = await this.dockerCompose.getContainerForService(this.containerName); + const container = this.docker.getContainer(this.containerName); return await this.execInContainer(container, command); } diff --git a/e2e/helpers/environment/service-managers/tinybird-manager.ts b/e2e/helpers/environment/service-managers/tinybird-manager.ts deleted file mode 100644 index 32ad1e2d32e..00000000000 --- a/e2e/helpers/environment/service-managers/tinybird-manager.ts +++ /dev/null @@ -1,114 +0,0 @@ -import * as fs from 'fs'; -import baseDebug from '@tryghost/debug'; -import logging from '@tryghost/logging'; -import path from 'path'; -import {DockerCompose} from '@/helpers/environment/docker-compose'; -import {ensureDir} from '@/helpers/utils'; - -const debug = baseDebug('e2e:TinybirdManager'); - -export interface TinybirdConfig { - workspaceId: string; - adminToken: string; - trackerToken: string; -} - -/** - * Manages TinyBird and Tinybird CLI operations within these docker containers. - * Encapsulates TinyBird and Tinybird CLI operations within the docker-compose environment. - * Handles Tinybird token fetching and local config persistence. - */ -export class TinybirdManager { - private readonly configFile; - private readonly cliEnvPath: string; - private readonly dockerCompose: DockerCompose; - - constructor(dockerCompose: DockerCompose, private readonly configDir: string, cliEnvPath: string) { - this.dockerCompose = dockerCompose; - this.configFile = path.join(this.configDir, 'tinybird.json'); - this.cliEnvPath = cliEnvPath; - } - - truncateAnalyticsEvents(): void { - try { - debug('Truncating analytics_events datasource...'); - this.dockerCompose.execInService( - 'tb-cli', - [ - 'tb', - 'datasource', - 'truncate', - 'analytics_events', - '--yes', - '--cascade' - ] - ); - - debug('analytics_events datasource truncated'); - } catch (error) { - // Don't throw - we want to continue with setup even if truncate fails - debug('Failed to truncate analytics_events (Tinybird may not be running):', error); - } - } - - loadConfig(): TinybirdConfig { - try { - if (!fs.existsSync(this.configFile)) { - throw new Error('Tinybird config file does not exist'); - } - const data = fs.readFileSync(this.configFile, 'utf8'); - const config = JSON.parse(data) as TinybirdConfig; - - debug('Tinybird config loaded:', config); - return config; - } catch (error) { - logging.error('Failed to load Tinybird config:', error); - throw new Error(`Failed to load Tinybird config: ${error}`); - } - } - - /** - * Fetch Tinybird tokens and other details from the tinybird-local service and store them in a local file like - * data/state/tinybird.json - */ - fetchAndSaveConfig(): void { - const config = this.fetchConfigFromCLI(); - this.saveConfig(config); - } - - private saveConfig(config: TinybirdConfig): void { - try { - ensureDir(this.configDir); - fs.writeFileSync(this.configFile, JSON.stringify(config, null, 2)); - - debug('Tinybird config saved to file:', config); - } catch (error) { - logging.error('Failed to save Tinybird config to file:', error); - throw new Error(`Failed to save Tinybird config to file: ${error}`); - } - } - - private fetchConfigFromCLI() { - logging.info('Fetching Tinybird tokens...'); - - const rawTinybirdEnv = this.dockerCompose.execShellInService('tb-cli', `cat ${this.cliEnvPath}`); - const envLines = rawTinybirdEnv.split('\n'); - const envVars: Record = {}; - - for (const line of envLines) { - const [key, value] = line.split('='); - if (key && value) { - envVars[key.trim()] = value.trim(); - } - } - - const config: TinybirdConfig = { - workspaceId: envVars.TINYBIRD_WORKSPACE_ID, - adminToken: envVars.TINYBIRD_ADMIN_TOKEN, - trackerToken: envVars.TINYBIRD_TRACKER_TOKEN - }; - - logging.info('Tinybird tokens fetched'); - return config; - } -} From efe3d6b73071adfd45ce68d281034f4bf96b2c50 Mon Sep 17 00:00:00 2001 From: Jonatan Svennberg Date: Mon, 23 Feb 2026 10:51:41 +0100 Subject: [PATCH 02/22] Added Tinybird config and build gateway support ref https://linear.app/ghost/issue/BER-3363/unified-environment-manager Added Tinybird file config support and build gateway image handling. --- e2e/helpers/environment/constants.ts | 9 ++- .../service-managers/ghost-manager.ts | 60 +++++++++++++++++-- 2 files changed, 63 insertions(+), 6 deletions(-) diff --git a/e2e/helpers/environment/constants.ts b/e2e/helpers/environment/constants.ts index 0e0e8c82662..f73230f122f 100644 --- a/e2e/helpers/environment/constants.ts +++ b/e2e/helpers/environment/constants.ts @@ -43,11 +43,18 @@ export const CADDYFILE_PATHS = { */ export const BUILD_IMAGE = process.env.GHOST_E2E_IMAGE || 'ghost-e2e:local'; +/** + * Build mode gateway image. + * Uses stock Caddy by default so CI does not need a custom gateway build. + */ +export const BUILD_GATEWAY_IMAGE = process.env.GHOST_E2E_GATEWAY_IMAGE || 'caddy:2-alpine'; + export const TINYBIRD = { LOCAL_HOST: 'tinybird-local', PORT: 7181, CLI_ENV_PATH: '/mnt/shared-config/.env.tinybird', - CONFIG_DIR: CONFIG_DIR + CONFIG_DIR: CONFIG_DIR, + JSON_PATH: path.resolve(CONFIG_DIR, 'tinybird.json') }; /** diff --git a/e2e/helpers/environment/service-managers/ghost-manager.ts b/e2e/helpers/environment/service-managers/ghost-manager.ts index 0c7949d083b..6dbbbc0718e 100644 --- a/e2e/helpers/environment/service-managers/ghost-manager.ts +++ b/e2e/helpers/environment/service-managers/ghost-manager.ts @@ -3,6 +3,7 @@ import baseDebug from '@tryghost/debug'; import logging from '@tryghost/logging'; import { BASE_GHOST_ENV, + BUILD_GATEWAY_IMAGE, BUILD_IMAGE, CADDYFILE_PATHS, DEV_ENVIRONMENT, @@ -20,6 +21,11 @@ import type {GhostConfig} from '@/helpers/playwright/fixture'; const debug = baseDebug('e2e:GhostManager'); type GhostEnvOverrides = GhostConfig | Record; +interface TinybirdConfigFile { + workspaceId?: string; + adminToken?: string; + trackerToken?: string; +} /** * Represents a running Ghost instance for E2E tests. */ @@ -38,7 +44,7 @@ export interface GhostManagerConfig { } /** - * Manages Ghost and Gateway containers for dev environment mode. + * Manages Ghost and Gateway containers for E2E tests across dev/build modes. * Creates worker-scoped containers that persist across tests. */ export class GhostManager { @@ -96,11 +102,24 @@ export class GhostManager { throw new Error( `Build image not found: ${BUILD_IMAGE}\n\n` + `To fix this, either:\n` + - ` 1. Build locally: yarn build:e2e-image\n` + + ` 1. Build locally: yarn workspace @tryghost/e2e build:docker (with GHOST_E2E_BASE_IMAGE set)\n` + ` 2. Pull from registry: docker pull ${BUILD_IMAGE}\n` + ` 3. Use a different image: GHOST_E2E_IMAGE= yarn test:build` ); } + + try { + const gatewayImage = this.docker.getImage(BUILD_GATEWAY_IMAGE); + await gatewayImage.inspect(); + debug(`Build gateway image verified: ${BUILD_GATEWAY_IMAGE}`); + } catch { + throw new Error( + `Build gateway image not found: ${BUILD_GATEWAY_IMAGE}\n\n` + + `To fix this, either:\n` + + ` 1. Pull gateway image: docker pull ${BUILD_GATEWAY_IMAGE}\n` + + ` 2. Use a different gateway image: GHOST_E2E_GATEWAY_IMAGE= yarn test:build` + ); + } } /** @@ -197,8 +216,8 @@ export class GhostManager { } // Add Tinybird config if available - // Static endpoints are set here; workspaceId and adminToken are sourced from - // /mnt/shared-config/.env.tinybird by development.entrypoint.sh + // Static endpoints are set here; tokens are loaded from a host-generated + // e2e/data/state/tinybird.json file when present. if (await isTinybirdAvailable()) { env.push( `TB_HOST=http://${TINYBIRD.LOCAL_HOST}:${TINYBIRD.PORT}`, @@ -208,6 +227,17 @@ export class GhostManager { `tinybird__tracker__endpoint=http://localhost:${this.getGatewayPort()}/.ghost/analytics/api/v1/page_hit`, 'tinybird__tracker__datasource=analytics_events' ); + + const tinybirdConfig = await this.loadTinybirdConfig(); + if (tinybirdConfig?.workspaceId) { + env.push(`tinybird__workspaceId=${tinybirdConfig.workspaceId}`); + } + if (tinybirdConfig?.adminToken) { + env.push(`tinybird__adminToken=${tinybirdConfig.adminToken}`); + } + if (tinybirdConfig?.trackerToken) { + env.push(`TINYBIRD_TRACKER_TOKEN=${tinybirdConfig.trackerToken}`); + } } if (extraConfig) { @@ -222,6 +252,23 @@ export class GhostManager { return env; } + private async loadTinybirdConfig(): Promise { + try { + const raw = await readFile(TINYBIRD.JSON_PATH, 'utf8'); + const parsed = JSON.parse(raw) as TinybirdConfigFile; + + if (!parsed.workspaceId || !parsed.adminToken) { + debug(`Tinybird config file is missing required fields: ${TINYBIRD.JSON_PATH}`); + return null; + } + + return parsed; + } catch (error) { + debug(`Tinybird config not available at ${TINYBIRD.JSON_PATH}:`, error); + return null; + } + } + private async createGhostContainer( name: string, database: string = 'ghost_testing', @@ -306,9 +353,12 @@ export class GhostManager { 'ANALYTICS_PROXY_TARGET=ghost-dev-analytics:3000' ]; + // Build mode can use stock Caddy (no custom plugin/image build required) + const image = mode === 'build' ? BUILD_GATEWAY_IMAGE : TEST_ENVIRONMENT.gateway.image; + const config: ContainerCreateOptions = { name, - Image: TEST_ENVIRONMENT.gateway.image, + Image: image, Env: env, ExposedPorts: {'80/tcp': {}}, HostConfig: { From f954d2f7066104c6b1cb821d6c2736ad31642b22 Mon Sep 17 00:00:00 2001 From: Jonatan Svennberg Date: Mon, 23 Feb 2026 10:54:27 +0100 Subject: [PATCH 03/22] Updated E2E scripts and build-mode workflow ref https://linear.app/ghost/issue/BER-3363/unified-environment-manager Updated E2E scripts and docs for the infra-first build mode workflow. --- e2e/Dockerfile.e2e | 26 ++++---- e2e/README.md | 85 +++++++++++++-------------- e2e/package.json | 6 +- e2e/scripts/fetch-tinybird-config.mjs | 84 ++++++++++++++++++++++++++ 4 files changed, 145 insertions(+), 56 deletions(-) create mode 100644 e2e/scripts/fetch-tinybird-config.mjs diff --git a/e2e/Dockerfile.e2e b/e2e/Dockerfile.e2e index b291e6d07f5..277a3bdd78b 100644 --- a/e2e/Dockerfile.e2e +++ b/e2e/Dockerfile.e2e @@ -1,20 +1,26 @@ -# E2E test layer: copies locally-built public apps into any Ghost image -# so Ghost serves them at /ghost/assets/{app}/{app}.min.js (same origin, no CORS). +# E2E test layer: copies locally-built public apps into Ghost's content folder +# so Ghost serves them from /content/files/* (same origin, no CORS). # # Usage: # docker build -f e2e/Dockerfile.e2e \ # --build-arg GHOST_IMAGE=ghost-monorepo:latest \ # -t ghost-e2e:local . # -# Works with any base image (monorepo or production). +# Intended for the production Ghost image built in CI. ARG GHOST_IMAGE=ghost-monorepo:latest FROM $GHOST_IMAGE -# Public app UMD bundles — Ghost serves these from /ghost/assets/ -COPY apps/portal/umd/portal.min.js core/built/admin/assets/portal/portal.min.js -COPY apps/comments-ui/umd/comments-ui.min.js core/built/admin/assets/comments-ui/comments-ui.min.js -COPY apps/sodo-search/umd/sodo-search.min.js core/built/admin/assets/sodo-search/sodo-search.min.js -COPY apps/sodo-search/umd/main.css core/built/admin/assets/sodo-search/main.css -COPY apps/signup-form/umd/signup-form.min.js core/built/admin/assets/signup-form/signup-form.min.js -COPY apps/announcement-bar/umd/announcement-bar.min.js core/built/admin/assets/announcement-bar/announcement-bar.min.js +# Public app UMD bundles — Ghost serves these from /content/files/ +COPY apps/portal/umd /home/ghost/content/files/portal +COPY apps/comments-ui/umd /home/ghost/content/files/comments-ui +COPY apps/sodo-search/umd /home/ghost/content/files/sodo-search +COPY apps/signup-form/umd /home/ghost/content/files/signup-form +COPY apps/announcement-bar/umd /home/ghost/content/files/announcement-bar + +ENV portal__url=/content/files/portal/portal.min.js +ENV comments__url=/content/files/comments-ui/comments-ui.min.js +ENV sodoSearch__url=/content/files/sodo-search/sodo-search.min.js +ENV sodoSearch__styles=/content/files/sodo-search/main.css +ENV signupForm__url=/content/files/signup-form/signup-form.min.js +ENV announcementBar__url=/content/files/announcement-bar/announcement-bar.min.js diff --git a/e2e/README.md b/e2e/README.md index 3b125c2b4e2..6d4fc0b5752 100644 --- a/e2e/README.md +++ b/e2e/README.md @@ -31,6 +31,24 @@ yarn dev yarn test ``` +If infra is already running, `yarn workspace @tryghost/e2e infra:up` is safe to run again. + +### Build Mode (Prebuilt Image) + +Use build mode when you don’t want to run dev servers. It uses a prebuilt Ghost image and serves public assets from `/content/files`. + +```bash +# From repository root +yarn build +yarn workspace @tryghost/e2e build:apps +GHOST_E2E_BASE_IMAGE= yarn workspace @tryghost/e2e build:docker +yarn workspace @tryghost/e2e infra:up +yarn workspace @tryghost/e2e tinybird:fetch-config # needed for analytics tests + +# Run tests +GHOST_E2E_MODE=build GHOST_E2E_IMAGE=ghost-e2e:local yarn workspace @tryghost/e2e test +``` + ### Running Specific Tests @@ -149,43 +167,22 @@ For example, a `ghostInstance` fixture creates a new Ghost instance with its own Test isolation is extremely important to avoid flaky tests that are hard to debug. For the most part, you shouldn't have to worry about this when writing tests, because each test gets a fresh Ghost instance with its own database. -#### Standalone Mode (Default) - -When dev environment is not running, tests use full container isolation: - -- Global setup (`tests/global.setup.ts`): - - Starts shared services (MySQL, Tinybird, etc.) - - Runs Ghost migrations to create a template database - - Saves a snapshot of the template database using `mysqldump` -- Before each test (`helpers/playwright/fixture.ts`): - - Creates a new database by restoring from the template snapshot - - Starts a new Ghost container connected to the new database -- After each test (`helpers/playwright/fixture.ts`): - - Stops and removes the Ghost container - - Drops the test database -- Global teardown (`tests/global.teardown.ts`): - - Stops and removes shared services - -#### Dev Environment Mode (When `yarn dev` is running) - -When dev environment is detected, tests use a more efficient approach: - -- Global setup: - - Creates a database snapshot in the existing `ghost-dev-mysql` -- Worker setup (once per Playwright worker): - - Creates a Ghost container for the worker - - Creates a Caddy gateway container for routing -- Before each test: - - Clones database from snapshot - - Restarts Ghost container with new database -- After each test: - - Drops the test database -- Worker teardown: - - Removes worker's Ghost and gateway containers -- Global teardown: - - Cleans up all e2e containers (namespace: `ghost-dev-e2e`) - -All e2e containers use the `ghost-dev-e2e` project namespace for easy identification and cleanup. +Infrastructure (MySQL, Redis, Mailpit, Tinybird) must already be running before tests start. Use `yarn dev` or `yarn workspace @tryghost/e2e infra:up`. + +Global setup (`tests/global.setup.ts`) does: +- Cleans up e2e containers and test databases +- Creates a base database, starts Ghost, waits for health, snapshots the DB + +Per-test (`helpers/playwright/fixture.ts`) does: +- Clones a new database from the snapshot +- Restarts Ghost with the new database and waits for readiness + +Global teardown (`tests/global.teardown.ts`) does: +- Cleans up e2e containers and test databases (infra services stay running) + +Modes: +- Dev mode: Ghost mounts source code and proxies assets to host dev servers +- Build mode: Ghost uses a prebuilt image and serves assets from `/content/files` ### Best Practices @@ -202,13 +199,13 @@ Tests run automatically in GitHub Actions on every PR and commit to `main`. ### CI Process -1. **Setup**: Ubuntu runner with Node.js and MySQL -2. **Docker Build & Push**: Build Ghost image and push to GitHub Container Registry -3. **Pull Images**: Pull Ghost, MySQL, Tinybird, etc. images -4. **Test Execution**: - - Wait for Ghost to be ready - - Run Playwright tests - - Upload test artifacts +1. **Setup**: Ubuntu runner with Node.js and Docker +2. **Build Assets**: Build server/admin assets and public app UMD bundles +3. **Build E2E Image**: `yarn workspace @tryghost/e2e build:docker` (layers public apps into `/content/files`) +4. **Start Infra**: `yarn workspace @tryghost/e2e infra:up` (starts MySQL/Redis/Mailpit/Tinybird services only) +5. **Fetch Tinybird Config**: `yarn workspace @tryghost/e2e tinybird:fetch-config` +6. **Test Execution**: Run Playwright E2E tests (CI later runs them inside the official Playwright container) +7. **Artifacts**: Upload Playwright traces and reports on failure ## Available Scripts diff --git a/e2e/package.json b/e2e/package.json index cca4008e0db..9c7bd11d00c 100644 --- a/e2e/package.json +++ b/e2e/package.json @@ -11,9 +11,11 @@ "build:ts": "tsc --noEmit", "build:apps": "nx run-many --target=build --projects=@tryghost/portal,@tryghost/comments-ui,@tryghost/sodo-search,@tryghost/signup-form,@tryghost/announcement-bar", "build:docker": "docker build -f Dockerfile.e2e --build-arg GHOST_IMAGE=${GHOST_E2E_BASE_IMAGE:?Set GHOST_E2E_BASE_IMAGE} -t ${GHOST_E2E_IMAGE:-ghost-e2e:local} ..", - "docker:update": "docker compose pull && docker compose up -d --force-recreate", "prepare": "tsc --noEmit", - "pretest": "(test -n \"$GHOST_E2E_SKIP_BUILD\" || test -n \"$CI\") && echo 'Skipping Docker build' || echo 'Tip: run yarn dev first, or set GHOST_E2E_IMAGE for container mode'", + "pretest": "(test -n \"$GHOST_E2E_SKIP_BUILD\" || test -n \"$CI\") && echo 'Skipping Docker build' || echo 'Tip: run yarn dev or yarn workspace @tryghost/e2e infra:up before running tests'", + "infra:up": "docker compose -f ../compose.dev.yaml -f ../compose.dev.analytics.yaml up -d --wait mysql redis mailpit tinybird-local tb-cli analytics", + "infra:down": "docker compose -f ../compose.dev.yaml -f ../compose.dev.analytics.yaml stop analytics tb-cli tinybird-local mailpit redis mysql", + "tinybird:fetch-config": "node ./scripts/fetch-tinybird-config.mjs", "test": "playwright test --project=main", "test:analytics": "playwright test --project=analytics", "test:all": "playwright test --project=main --project=analytics", diff --git a/e2e/scripts/fetch-tinybird-config.mjs b/e2e/scripts/fetch-tinybird-config.mjs new file mode 100644 index 00000000000..5cc595f3970 --- /dev/null +++ b/e2e/scripts/fetch-tinybird-config.mjs @@ -0,0 +1,84 @@ +import fs from 'node:fs'; +import path from 'node:path'; +import {execFileSync} from 'node:child_process'; +import {fileURLToPath} from 'node:url'; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = path.dirname(__filename); +const repoRoot = path.resolve(__dirname, '../..'); +const stateDir = path.resolve(repoRoot, 'e2e/data/state'); +const configPath = path.resolve(stateDir, 'tinybird.json'); + +function log(message) { + process.stdout.write(`${message}\n`); +} + +function parseEnv(raw) { + const vars = {}; + + for (const line of raw.split('\n')) { + const trimmed = line.trim(); + if (!trimmed || trimmed.startsWith('#')) { + continue; + } + + const separatorIndex = trimmed.indexOf('='); + if (separatorIndex === -1) { + continue; + } + + vars[trimmed.slice(0, separatorIndex).trim()] = trimmed.slice(separatorIndex + 1).trim(); + } + + return vars; +} + +function clearConfigIfPresent() { + if (fs.existsSync(configPath)) { + fs.rmSync(configPath, {force: true}); + log(`Removed stale Tinybird config at ${configPath}`); + } +} + +try { + const rawEnv = execFileSync( + 'docker', + [ + 'compose', + '-f', path.resolve(repoRoot, 'compose.dev.yaml'), + '-f', path.resolve(repoRoot, 'compose.dev.analytics.yaml'), + 'run', + '--rm', + '-T', + 'tb-cli', + 'cat', + '/mnt/shared-config/.env.tinybird' + ], + { + cwd: repoRoot, + encoding: 'utf8', + stdio: ['ignore', 'pipe', 'pipe'] + } + ); + + const env = parseEnv(rawEnv); + + if (!env.TINYBIRD_WORKSPACE_ID || !env.TINYBIRD_ADMIN_TOKEN) { + clearConfigIfPresent(); + log('Tinybird config is not available yet; continuing without e2e/data/state/tinybird.json'); + process.exit(0); + } + + fs.mkdirSync(stateDir, {recursive: true}); + fs.writeFileSync(configPath, JSON.stringify({ + workspaceId: env.TINYBIRD_WORKSPACE_ID, + adminToken: env.TINYBIRD_ADMIN_TOKEN, + trackerToken: env.TINYBIRD_TRACKER_TOKEN + }, null, 2)); + + log(`Wrote Tinybird config to ${configPath}`); +} catch (error) { + clearConfigIfPresent(); + const message = error instanceof Error ? error.message : String(error); + log(`Tinybird config fetch skipped: ${message}`); +} From 4086350924efe36d1c510f9bf6722be949804aa5 Mon Sep 17 00:00:00 2001 From: Jonatan Svennberg Date: Mon, 23 Feb 2026 10:54:57 +0100 Subject: [PATCH 04/22] Updated CI E2E job for unified manager (host) ref https://linear.app/ghost/issue/BER-3363/unified-environment-manager Switched the CI E2E job to the unified manager on the host runner. --- .github/workflows/ci.yml | 17 +++++++++++++---- 1 file changed, 13 insertions(+), 4 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 30a73bafb40..d8d3fbce82d 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1269,20 +1269,29 @@ jobs: GHOST_E2E_BASE_IMAGE: ${{ steps.load.outputs.image-tag }} run: yarn workspace @tryghost/e2e build:docker - - name: Pull images - env: - GHOST_E2E_IMAGE: ghost-e2e:local - run: docker compose -f e2e/compose.yml pull + - name: Pull build mode gateway image + run: docker pull caddy:2-alpine + + - name: Start E2E infra + run: yarn workspace @tryghost/e2e infra:up + + - name: Fetch Tinybird config + run: yarn workspace @tryghost/e2e tinybird:fetch-config - name: Setup Playwright uses: ./.github/actions/setup-playwright - name: Run e2e tests env: + GHOST_E2E_MODE: build GHOST_E2E_IMAGE: ghost-e2e:local TEST_WORKERS_COUNT: 1 run: yarn test:e2e:all --shard=${{ matrix.shardIndex }}/${{ matrix.shardTotal }} --retries=2 + - name: Stop E2E infra + if: always() + run: yarn workspace @tryghost/e2e infra:down + - name: Upload blob report to GitHub Actions Artifacts if: failure() uses: actions/upload-artifact@v4 From 013b80982d4505b7dde0228c6723c1a313373643 Mon Sep 17 00:00:00 2001 From: Jonatan Svennberg Date: Thu, 19 Feb 2026 11:00:58 +0100 Subject: [PATCH 05/22] Fixed flaky member signup attribution tests ref https://linear.app/ghost/issue/BER-3363/unified-environment-manager Stabilized member signup attribution tests before the CI runner migration. --- e2e/helpers/pages/public/public-page.ts | 23 ++++++++++++++++++++++- 1 file changed, 22 insertions(+), 1 deletion(-) diff --git a/e2e/helpers/pages/public/public-page.ts b/e2e/helpers/pages/public/public-page.ts index f348e1fa8d7..b9df4a93926 100644 --- a/e2e/helpers/pages/public/public-page.ts +++ b/e2e/helpers/pages/public/public-page.ts @@ -81,11 +81,11 @@ export class PublicPage extends BasePage { const testInfo = test.info(); let pageHitPromise = null; if (testInfo.project.name === 'analytics') { - await this.enableAnalyticsRequests(); pageHitPromise = this.pageHitRequestPromise(); } await this.enableAnalyticsRequests(); const result = await super.goto(url, options); + await this.waitForMemberAttributionReady(); if (pageHitPromise) { await pageHitPromise; } @@ -100,11 +100,32 @@ export class PublicPage extends BasePage { }); } + protected async waitForMemberAttributionReady(): Promise { + // Test-only anti-pattern: we synchronize on async client bootstrap state + // to keep attribution-dependent assertions deterministic in CI. + await this.page.waitForFunction(() => { + try { + const raw = window.sessionStorage.getItem('ghost-history'); + + if (!raw) { + return false; + } + + const history = JSON.parse(raw); + return Array.isArray(history) && history.length > 0; + } catch { + return false; + } + }); + } + async openPortalViaSubscribeButton(): Promise { + await this.waitForMemberAttributionReady(); await this.portal.clickLinkAndWaitForPopup(this.subscribeLink); } async openPortalViaSignInLink(): Promise { + await this.waitForMemberAttributionReady(); await this.portal.clickLinkAndWaitForPopup(this.signInLink); } } From c8c8c287474e1df777cf5817742d5d84b23498b7 Mon Sep 17 00:00:00 2001 From: Jonatan Svennberg Date: Mon, 23 Feb 2026 10:56:10 +0100 Subject: [PATCH 06/22] Updated CI E2E job for Playwright container ref https://linear.app/ghost/issue/BER-3363/unified-environment-manager Moved CI E2E execution into the official Playwright container. Co-authored-by: Troy Ciesco --- .github/workflows/ci.yml | 28 ++++++++++++++++++++-------- 1 file changed, 20 insertions(+), 8 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index d8d3fbce82d..e776301c6be 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1278,15 +1278,27 @@ jobs: - name: Fetch Tinybird config run: yarn workspace @tryghost/e2e tinybird:fetch-config - - name: Setup Playwright - uses: ./.github/actions/setup-playwright + - name: Get Playwright version + id: playwright + run: | + PLAYWRIGHT_VERSION=$(node -p 'require("./e2e/package.json").devDependencies["@playwright/test"]') + echo "version=$PLAYWRIGHT_VERSION" >> "$GITHUB_OUTPUT" - - name: Run e2e tests - env: - GHOST_E2E_MODE: build - GHOST_E2E_IMAGE: ghost-e2e:local - TEST_WORKERS_COUNT: 1 - run: yarn test:e2e:all --shard=${{ matrix.shardIndex }}/${{ matrix.shardTotal }} --retries=2 + - name: Pull Playwright image + run: docker pull mcr.microsoft.com/playwright:v${{ steps.playwright.outputs.version }}-noble + + - name: Run e2e tests in Playwright container + run: | + docker run --rm --network host --ipc host \ + -v /var/run/docker.sock:/var/run/docker.sock \ + -v "${{ github.workspace }}:${{ github.workspace }}" \ + -w "${{ github.workspace }}/e2e" \ + -e CI=true \ + -e TEST_WORKERS_COUNT=1 \ + -e GHOST_E2E_MODE=build \ + -e GHOST_E2E_IMAGE=ghost-e2e:local \ + mcr.microsoft.com/playwright:v${{ steps.playwright.outputs.version }}-noble \ + yarn test:all --shard=${{ matrix.shardIndex }}/${{ matrix.shardTotal }} --retries=2 - name: Stop E2E infra if: always() From d831d14bfc8ac98fe1bdf97fe55e81b3f6976253 Mon Sep 17 00:00:00 2001 From: Jonatan Svennberg Date: Mon, 23 Feb 2026 17:28:38 +0100 Subject: [PATCH 07/22] Fixed E2E CI gateway config and failure logs ref https://linear.app/ghost/issue/BER-3363/unified-environment-manager Stock caddy:2-alpine cannot parse the transform log encoder in Caddyfile.build, and shard failures now print worker/gateway logs for debugging. --- .github/workflows/ci.yml | 32 ++++++++++++++++++++++++++++++ docker/dev-gateway/Caddyfile.build | 2 +- 2 files changed, 33 insertions(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index e776301c6be..bd73855131c 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1300,6 +1300,38 @@ jobs: mcr.microsoft.com/playwright:v${{ steps.playwright.outputs.version }}-noble \ yarn test:all --shard=${{ matrix.shardIndex }}/${{ matrix.shardTotal }} --retries=2 + - name: Dump E2E docker logs + if: failure() + run: | + echo "::group::docker ps -a" + docker ps -a --format 'table {{.Names}}\t{{.Image}}\t{{.Status}}' + echo "::endgroup::" + + dump_container_logs() { + local pattern="$1" + local label="$2" + local found=0 + + while IFS= read -r container_name; do + if [ -z "$container_name" ]; then + continue + fi + + found=1 + echo "::group::${label}: ${container_name}" + docker inspect "$container_name" --format 'State={{json .State}}' || true + docker logs --tail=500 "$container_name" || true + echo "::endgroup::" + done < <(docker ps -a --format '{{.Names}}' | grep -E "$pattern" || true) + + if [ "$found" -eq 0 ]; then + echo "No containers matched ${label} pattern: ${pattern}" + fi + } + + dump_container_logs '^ghost-e2e-worker-' 'Ghost worker' + dump_container_logs '^ghost-e2e-gateway-' 'E2E gateway' + - name: Stop E2E infra if: always() run: yarn workspace @tryghost/e2e infra:down diff --git a/docker/dev-gateway/Caddyfile.build b/docker/dev-gateway/Caddyfile.build index 9ffbad2c254..7e736b7d3d1 100644 --- a/docker/dev-gateway/Caddyfile.build +++ b/docker/dev-gateway/Caddyfile.build @@ -8,7 +8,7 @@ :80 { log { output stdout - format transform "{common_log}" + format console } # Analytics API - proxy to analytics service From 154a51efc9439812d0eddb116edfe29d8ce944c8 Mon Sep 17 00:00:00 2001 From: Jonatan Svennberg Date: Tue, 24 Feb 2026 07:26:18 +0100 Subject: [PATCH 08/22] Cleaned up E2E env docs and compose defaults ref https://linear.app/ghost/issue/BER-3363/unified-environment-manager Reduced stale dev-mode wording and derived only the E2E pieces that actually follow COMPOSE_PROJECT_NAME. --- e2e/README.md | 5 +-- e2e/helpers/environment/constants.ts | 34 ++++++------------- .../environment/service-availability.ts | 3 -- .../service-managers/ghost-manager.ts | 3 +- .../service-managers/mysql-manager.ts | 3 +- e2e/helpers/playwright/fixture.ts | 6 ++-- 6 files changed, 21 insertions(+), 33 deletions(-) diff --git a/e2e/README.md b/e2e/README.md index 6d4fc0b5752..3e4228e8014 100644 --- a/e2e/README.md +++ b/e2e/README.md @@ -21,7 +21,7 @@ yarn test ### Dev Environment Mode (Recommended for Development) -When `yarn dev` is running from the repository root, e2e tests automatically detect it and use a more efficient execution mode: +Dev mode is the default (`GHOST_E2E_MODE=dev`). Start infra with `yarn dev` (or `infra:up`) before running tests: ```bash # Terminal 1: Start dev environment (from repository root) @@ -32,6 +32,7 @@ yarn test ``` If infra is already running, `yarn workspace @tryghost/e2e infra:up` is safe to run again. +If you use a custom compose project name locally, set `COMPOSE_PROJECT_NAME` for both infra and tests so E2E-managed image/volume names stay aligned. ### Build Mode (Prebuilt Image) @@ -204,7 +205,7 @@ Tests run automatically in GitHub Actions on every PR and commit to `main`. 3. **Build E2E Image**: `yarn workspace @tryghost/e2e build:docker` (layers public apps into `/content/files`) 4. **Start Infra**: `yarn workspace @tryghost/e2e infra:up` (starts MySQL/Redis/Mailpit/Tinybird services only) 5. **Fetch Tinybird Config**: `yarn workspace @tryghost/e2e tinybird:fetch-config` -6. **Test Execution**: Run Playwright E2E tests (CI later runs them inside the official Playwright container) +6. **Test Execution**: Run Playwright E2E tests inside the official Playwright container 7. **Artifacts**: Upload Playwright traces and reports on failure ## Available Scripts diff --git a/e2e/helpers/environment/constants.ts b/e2e/helpers/environment/constants.ts index f73230f122f..a7ccdcd7945 100644 --- a/e2e/helpers/environment/constants.ts +++ b/e2e/helpers/environment/constants.ts @@ -6,18 +6,14 @@ const __dirname = path.dirname(__filename); export const CONFIG_DIR = path.resolve(__dirname, '../../data/state'); -// Repository root path (for compose files and source mounting) +// Repository root path (for source mounting and config files) export const REPO_ROOT = path.resolve(__dirname, '../../..'); -/** - * Compose file paths for infrastructure services. - * Used by EnvironmentManager to start required services. - */ -export const COMPOSE_FILES = { - infra: path.resolve(REPO_ROOT, 'compose.infra.yaml'), - dev: path.resolve(REPO_ROOT, 'compose.dev.yaml'), - analytics: path.resolve(REPO_ROOT, 'compose.analytics.yaml') -} as const; +export const DEV_COMPOSE_PROJECT = process.env.COMPOSE_PROJECT_NAME || 'ghost-dev'; +// compose.dev.yaml pins the network name explicitly, so this does not follow COMPOSE_PROJECT_NAME. +export const DEV_NETWORK_NAME = 'ghost_dev'; +export const DEV_SHARED_CONFIG_VOLUME = `${DEV_COMPOSE_PROJECT}_shared-config`; +export const DEV_PRIMARY_DATABASE = process.env.MYSQL_DATABASE || 'ghost_dev'; /** * Caddyfile paths for different modes. @@ -52,8 +48,6 @@ export const BUILD_GATEWAY_IMAGE = process.env.GHOST_E2E_GATEWAY_IMAGE || 'caddy export const TINYBIRD = { LOCAL_HOST: 'tinybird-local', PORT: 7181, - CLI_ENV_PATH: '/mnt/shared-config/.env.tinybird', - CONFIG_DIR: CONFIG_DIR, JSON_PATH: path.resolve(CONFIG_DIR, 'tinybird.json') }; @@ -62,8 +56,8 @@ export const TINYBIRD = { * Used when yarn dev infrastructure is detected. */ export const DEV_ENVIRONMENT = { - projectNamespace: 'ghost-dev', - networkName: 'ghost_dev' + projectNamespace: DEV_COMPOSE_PROJECT, + networkName: DEV_NETWORK_NAME } as const; /** @@ -108,16 +102,10 @@ export const LOCAL_ASSET_URLS = [ export const TEST_ENVIRONMENT = { projectNamespace: 'ghost-dev-e2e', gateway: { - image: 'ghost-dev-ghost-dev-gateway' + image: `${DEV_COMPOSE_PROJECT}-ghost-dev-gateway` }, ghost: { - image: 'ghost-dev-ghost-dev', - workdir: '/home/ghost/ghost/core', - port: 2368, - env: [ - ...BASE_GHOST_ENV, - // Public assets via gateway (same as compose.dev.yaml) - ...LOCAL_ASSET_URLS - ] + image: `${DEV_COMPOSE_PROJECT}-ghost-dev`, + port: 2368 } } as const; diff --git a/e2e/helpers/environment/service-availability.ts b/e2e/helpers/environment/service-availability.ts index d2933c9c112..185baa2cc5a 100644 --- a/e2e/helpers/environment/service-availability.ts +++ b/e2e/helpers/environment/service-availability.ts @@ -4,9 +4,6 @@ import {DEV_ENVIRONMENT, TINYBIRD} from './constants'; const debug = baseDebug('e2e:ServiceAvailability'); -/** - * Find running Tinybird containers for a specific Docker Compose project. - */ async function isServiceAvailable(docker: Docker, serviceName: string) { const containers = await docker.listContainers({ filters: { diff --git a/e2e/helpers/environment/service-managers/ghost-manager.ts b/e2e/helpers/environment/service-managers/ghost-manager.ts index 6dbbbc0718e..e2b2f1eaf84 100644 --- a/e2e/helpers/environment/service-managers/ghost-manager.ts +++ b/e2e/helpers/environment/service-managers/ghost-manager.ts @@ -7,6 +7,7 @@ import { BUILD_IMAGE, CADDYFILE_PATHS, DEV_ENVIRONMENT, + DEV_SHARED_CONFIG_VOLUME, LOCAL_ASSET_URLS, REPO_ROOT, TEST_ENVIRONMENT, @@ -324,7 +325,7 @@ export class GhostManager { private getGhostBinds(): string[] { const binds: string[] = [ // Shared config volume for Tinybird credentials (all modes) - 'ghost-dev_shared-config:/mnt/shared-config:ro' + `${DEV_SHARED_CONFIG_VOLUME}:/mnt/shared-config:ro` ]; if (this.config.mode === 'dev') { diff --git a/e2e/helpers/environment/service-managers/mysql-manager.ts b/e2e/helpers/environment/service-managers/mysql-manager.ts index 18bd4190ef1..8104aeeef6b 100644 --- a/e2e/helpers/environment/service-managers/mysql-manager.ts +++ b/e2e/helpers/environment/service-managers/mysql-manager.ts @@ -1,6 +1,7 @@ import Docker from 'dockerode'; import baseDebug from '@tryghost/debug'; import logging from '@tryghost/logging'; +import {DEV_PRIMARY_DATABASE} from '@/helpers/environment/constants'; import {PassThrough} from 'stream'; import type {Container} from 'dockerode'; @@ -83,7 +84,7 @@ export class MySQLManager { try { debug('Finding all test databases to clean up...'); - const query = 'SELECT schema_name FROM information_schema.schemata WHERE schema_name LIKE \'ghost_%\' AND schema_name NOT IN (\'ghost_testing\', \'ghost_e2e_base\', \'ghost_dev\')'; + const query = `SELECT schema_name FROM information_schema.schemata WHERE schema_name LIKE 'ghost_%' AND schema_name NOT IN ('ghost_testing', 'ghost_e2e_base', '${DEV_PRIMARY_DATABASE}')`; const output = await this.exec(`mysql -uroot -proot -N -e "${query}"`); const databaseNames = this.parseDatabaseNames(output); diff --git a/e2e/helpers/playwright/fixture.ts b/e2e/helpers/playwright/fixture.ts index d690ffe0388..7df6af1f0ef 100644 --- a/e2e/helpers/playwright/fixture.ts +++ b/e2e/helpers/playwright/fixture.ts @@ -55,9 +55,9 @@ async function setupNewAuthenticatedPage(browser: Browser, baseURL: string, ghos * Playwright fixture that provides a unique Ghost instance for each test * Each instance gets its own database, runs on a unique port, and includes authentication * - * Automatically detects if dev environment (yarn dev) is running: - * - Dev mode: Uses worker-scoped containers with per-test database cloning (faster) - * - Standalone mode: Uses per-test containers (traditional behavior) + * Uses the unified E2E environment manager: + * - Dev mode (default): Worker-scoped containers with per-test database cloning + * - Build mode: Same isolation model, but Ghost runs from a prebuilt image * * Optionally allows setting labs flags via test.use({labs: {featureName: true}}) * and Stripe connection via test.use({stripeConnected: true}) From 28ce1be2472bdb150cba3b5ec752eeb8277f424a Mon Sep 17 00:00:00 2001 From: Jonatan Svennberg Date: Tue, 24 Feb 2026 07:31:02 +0100 Subject: [PATCH 09/22] Updated E2E CI scripts and Tinybird sync flow ref https://linear.app/ghost/issue/BER-3363/unified-environment-manager Moved CI shell logic into versioned scripts, parallelized build-mode runtime prep, and made infra startup sync/reset Tinybird state. Co-authored-by: Troy Ciesco --- .github/workflows/ci.yml | 66 ++------- e2e/README.md | 19 ++- e2e/package.json | 8 +- e2e/scripts/dump-e2e-docker-logs.sh | 32 +++++ e2e/scripts/fetch-tinybird-config.mjs | 84 ----------- e2e/scripts/infra-down.sh | 10 ++ e2e/scripts/infra-up.sh | 12 ++ e2e/scripts/load-playwright-container-env.sh | 21 +++ e2e/scripts/prepare-ci-e2e-build-mode.sh | 42 ++++++ e2e/scripts/run-playwright-ci.sh | 31 +++++ e2e/scripts/sync-tinybird-state.mjs | 139 +++++++++++++++++++ 11 files changed, 319 insertions(+), 145 deletions(-) create mode 100755 e2e/scripts/dump-e2e-docker-logs.sh delete mode 100644 e2e/scripts/fetch-tinybird-config.mjs create mode 100755 e2e/scripts/infra-down.sh create mode 100755 e2e/scripts/infra-up.sh create mode 100644 e2e/scripts/load-playwright-container-env.sh create mode 100755 e2e/scripts/prepare-ci-e2e-build-mode.sh create mode 100755 e2e/scripts/run-playwright-ci.sh create mode 100644 e2e/scripts/sync-tinybird-state.mjs diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index bd73855131c..6cb4d74d8b0 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1269,68 +1269,22 @@ jobs: GHOST_E2E_BASE_IMAGE: ${{ steps.load.outputs.image-tag }} run: yarn workspace @tryghost/e2e build:docker - - name: Pull build mode gateway image - run: docker pull caddy:2-alpine - - - name: Start E2E infra - run: yarn workspace @tryghost/e2e infra:up - - - name: Fetch Tinybird config - run: yarn workspace @tryghost/e2e tinybird:fetch-config - - - name: Get Playwright version - id: playwright - run: | - PLAYWRIGHT_VERSION=$(node -p 'require("./e2e/package.json").devDependencies["@playwright/test"]') - echo "version=$PLAYWRIGHT_VERSION" >> "$GITHUB_OUTPUT" - - - name: Pull Playwright image - run: docker pull mcr.microsoft.com/playwright:v${{ steps.playwright.outputs.version }}-noble + - name: Prepare E2E build-mode runtime + run: bash ./e2e/scripts/prepare-ci-e2e-build-mode.sh - name: Run e2e tests in Playwright container run: | - docker run --rm --network host --ipc host \ - -v /var/run/docker.sock:/var/run/docker.sock \ - -v "${{ github.workspace }}:${{ github.workspace }}" \ - -w "${{ github.workspace }}/e2e" \ - -e CI=true \ - -e TEST_WORKERS_COUNT=1 \ - -e GHOST_E2E_MODE=build \ - -e GHOST_E2E_IMAGE=ghost-e2e:local \ - mcr.microsoft.com/playwright:v${{ steps.playwright.outputs.version }}-noble \ - yarn test:all --shard=${{ matrix.shardIndex }}/${{ matrix.shardTotal }} --retries=2 + TEST_WORKERS_COUNT=1 \ + GHOST_E2E_MODE=build \ + GHOST_E2E_IMAGE=ghost-e2e:local \ + bash ./e2e/scripts/run-playwright-ci.sh \ + ${{ matrix.shardIndex }} \ + ${{ matrix.shardTotal }} \ + 2 - name: Dump E2E docker logs if: failure() - run: | - echo "::group::docker ps -a" - docker ps -a --format 'table {{.Names}}\t{{.Image}}\t{{.Status}}' - echo "::endgroup::" - - dump_container_logs() { - local pattern="$1" - local label="$2" - local found=0 - - while IFS= read -r container_name; do - if [ -z "$container_name" ]; then - continue - fi - - found=1 - echo "::group::${label}: ${container_name}" - docker inspect "$container_name" --format 'State={{json .State}}' || true - docker logs --tail=500 "$container_name" || true - echo "::endgroup::" - done < <(docker ps -a --format '{{.Names}}' | grep -E "$pattern" || true) - - if [ "$found" -eq 0 ]; then - echo "No containers matched ${label} pattern: ${pattern}" - fi - } - - dump_container_logs '^ghost-e2e-worker-' 'Ghost worker' - dump_container_logs '^ghost-e2e-gateway-' 'E2E gateway' + run: bash ./e2e/scripts/dump-e2e-docker-logs.sh - name: Stop E2E infra if: always() diff --git a/e2e/README.md b/e2e/README.md index 3e4228e8014..173712ddbfb 100644 --- a/e2e/README.md +++ b/e2e/README.md @@ -44,12 +44,19 @@ yarn build yarn workspace @tryghost/e2e build:apps GHOST_E2E_BASE_IMAGE= yarn workspace @tryghost/e2e build:docker yarn workspace @tryghost/e2e infra:up -yarn workspace @tryghost/e2e tinybird:fetch-config # needed for analytics tests # Run tests GHOST_E2E_MODE=build GHOST_E2E_IMAGE=ghost-e2e:local yarn workspace @tryghost/e2e test ``` +`infra:up` also syncs `e2e/data/state/tinybird.json` and truncates the Tinybird `analytics_events` datasource when Tinybird is available. + +For a CI-like local preflight (pulls Playwright + gateway images and starts infra), run: + +```bash +yarn workspace @tryghost/e2e preflight:build +``` + ### Running Specific Tests @@ -204,7 +211,7 @@ Tests run automatically in GitHub Actions on every PR and commit to `main`. 2. **Build Assets**: Build server/admin assets and public app UMD bundles 3. **Build E2E Image**: `yarn workspace @tryghost/e2e build:docker` (layers public apps into `/content/files`) 4. **Start Infra**: `yarn workspace @tryghost/e2e infra:up` (starts MySQL/Redis/Mailpit/Tinybird services only) -5. **Fetch Tinybird Config**: `yarn workspace @tryghost/e2e tinybird:fetch-config` +5. **Prepare E2E Runtime**: Pull Playwright/gateway images in parallel and start infra (`yarn workspace @tryghost/e2e preflight:build`) 6. **Test Execution**: Run Playwright E2E tests inside the official Playwright container 7. **Artifacts**: Upload Playwright traces and reports on failure @@ -216,6 +223,14 @@ Within the e2e directory: # Run all tests yarn test +# Start/stop test infra (MySQL/Redis/Mailpit/Tinybird) and sync Tinybird state +yarn infra:up +yarn infra:down +yarn tinybird:sync + +# CI-like preflight for build mode (pulls images + starts infra) +yarn preflight:build + # Debug failed tests (keeps containers) PRESERVE_ENV=true yarn test diff --git a/e2e/package.json b/e2e/package.json index 9c7bd11d00c..e16831016f5 100644 --- a/e2e/package.json +++ b/e2e/package.json @@ -13,9 +13,11 @@ "build:docker": "docker build -f Dockerfile.e2e --build-arg GHOST_IMAGE=${GHOST_E2E_BASE_IMAGE:?Set GHOST_E2E_BASE_IMAGE} -t ${GHOST_E2E_IMAGE:-ghost-e2e:local} ..", "prepare": "tsc --noEmit", "pretest": "(test -n \"$GHOST_E2E_SKIP_BUILD\" || test -n \"$CI\") && echo 'Skipping Docker build' || echo 'Tip: run yarn dev or yarn workspace @tryghost/e2e infra:up before running tests'", - "infra:up": "docker compose -f ../compose.dev.yaml -f ../compose.dev.analytics.yaml up -d --wait mysql redis mailpit tinybird-local tb-cli analytics", - "infra:down": "docker compose -f ../compose.dev.yaml -f ../compose.dev.analytics.yaml stop analytics tb-cli tinybird-local mailpit redis mysql", - "tinybird:fetch-config": "node ./scripts/fetch-tinybird-config.mjs", + "infra:up": "bash ./scripts/infra-up.sh", + "infra:down": "bash ./scripts/infra-down.sh", + "tinybird:sync": "node ./scripts/sync-tinybird-state.mjs", + "tinybird:fetch-config": "yarn tinybird:sync", + "preflight:build": "bash ./scripts/prepare-ci-e2e-build-mode.sh", "test": "playwright test --project=main", "test:analytics": "playwright test --project=analytics", "test:all": "playwright test --project=main --project=analytics", diff --git a/e2e/scripts/dump-e2e-docker-logs.sh b/e2e/scripts/dump-e2e-docker-logs.sh new file mode 100755 index 00000000000..37e113c2c19 --- /dev/null +++ b/e2e/scripts/dump-e2e-docker-logs.sh @@ -0,0 +1,32 @@ +#!/usr/bin/env bash +set -euo pipefail + +echo "::group::docker ps -a" +docker ps -a --format 'table {{.Names}}\t{{.Image}}\t{{.Status}}' +echo "::endgroup::" + +dump_container_logs() { + local pattern="$1" + local label="$2" + local found=0 + + while IFS= read -r container_name; do + if [[ -z "$container_name" ]]; then + continue + fi + + found=1 + echo "::group::${label}: ${container_name}" + docker inspect "$container_name" --format 'State={{json .State}}' || true + docker logs --tail=500 "$container_name" || true + echo "::endgroup::" + done < <(docker ps -a --format '{{.Names}}' | grep -E "$pattern" || true) + + if [[ "$found" -eq 0 ]]; then + echo "No containers matched ${label} pattern: ${pattern}" + fi +} + +dump_container_logs '^ghost-e2e-worker-' 'Ghost worker' +dump_container_logs '^ghost-e2e-gateway-' 'E2E gateway' +dump_container_logs '^ghost-dev-(mysql|redis|mailpit|analytics|analytics-db|tinybird-local|tb-cli)$' 'E2E infra' diff --git a/e2e/scripts/fetch-tinybird-config.mjs b/e2e/scripts/fetch-tinybird-config.mjs deleted file mode 100644 index 5cc595f3970..00000000000 --- a/e2e/scripts/fetch-tinybird-config.mjs +++ /dev/null @@ -1,84 +0,0 @@ -import fs from 'node:fs'; -import path from 'node:path'; -import {execFileSync} from 'node:child_process'; -import {fileURLToPath} from 'node:url'; - -const __filename = fileURLToPath(import.meta.url); -const __dirname = path.dirname(__filename); -const repoRoot = path.resolve(__dirname, '../..'); -const stateDir = path.resolve(repoRoot, 'e2e/data/state'); -const configPath = path.resolve(stateDir, 'tinybird.json'); - -function log(message) { - process.stdout.write(`${message}\n`); -} - -function parseEnv(raw) { - const vars = {}; - - for (const line of raw.split('\n')) { - const trimmed = line.trim(); - if (!trimmed || trimmed.startsWith('#')) { - continue; - } - - const separatorIndex = trimmed.indexOf('='); - if (separatorIndex === -1) { - continue; - } - - vars[trimmed.slice(0, separatorIndex).trim()] = trimmed.slice(separatorIndex + 1).trim(); - } - - return vars; -} - -function clearConfigIfPresent() { - if (fs.existsSync(configPath)) { - fs.rmSync(configPath, {force: true}); - log(`Removed stale Tinybird config at ${configPath}`); - } -} - -try { - const rawEnv = execFileSync( - 'docker', - [ - 'compose', - '-f', path.resolve(repoRoot, 'compose.dev.yaml'), - '-f', path.resolve(repoRoot, 'compose.dev.analytics.yaml'), - 'run', - '--rm', - '-T', - 'tb-cli', - 'cat', - '/mnt/shared-config/.env.tinybird' - ], - { - cwd: repoRoot, - encoding: 'utf8', - stdio: ['ignore', 'pipe', 'pipe'] - } - ); - - const env = parseEnv(rawEnv); - - if (!env.TINYBIRD_WORKSPACE_ID || !env.TINYBIRD_ADMIN_TOKEN) { - clearConfigIfPresent(); - log('Tinybird config is not available yet; continuing without e2e/data/state/tinybird.json'); - process.exit(0); - } - - fs.mkdirSync(stateDir, {recursive: true}); - fs.writeFileSync(configPath, JSON.stringify({ - workspaceId: env.TINYBIRD_WORKSPACE_ID, - adminToken: env.TINYBIRD_ADMIN_TOKEN, - trackerToken: env.TINYBIRD_TRACKER_TOKEN - }, null, 2)); - - log(`Wrote Tinybird config to ${configPath}`); -} catch (error) { - clearConfigIfPresent(); - const message = error instanceof Error ? error.message : String(error); - log(`Tinybird config fetch skipped: ${message}`); -} diff --git a/e2e/scripts/infra-down.sh b/e2e/scripts/infra-down.sh new file mode 100755 index 00000000000..9c2433cecaf --- /dev/null +++ b/e2e/scripts/infra-down.sh @@ -0,0 +1,10 @@ +#!/usr/bin/env bash +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +REPO_ROOT="$(cd "$SCRIPT_DIR/../.." && pwd)" + +cd "$REPO_ROOT" + +docker compose -f compose.dev.yaml -f compose.dev.analytics.yaml stop \ + analytics tb-cli tinybird-local mailpit redis mysql diff --git a/e2e/scripts/infra-up.sh b/e2e/scripts/infra-up.sh new file mode 100755 index 00000000000..b51118e2342 --- /dev/null +++ b/e2e/scripts/infra-up.sh @@ -0,0 +1,12 @@ +#!/usr/bin/env bash +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +REPO_ROOT="$(cd "$SCRIPT_DIR/../.." && pwd)" + +cd "$REPO_ROOT" + +docker compose -f compose.dev.yaml -f compose.dev.analytics.yaml up -d --wait \ + mysql redis mailpit tinybird-local tb-cli analytics + +node "$REPO_ROOT/e2e/scripts/sync-tinybird-state.mjs" diff --git a/e2e/scripts/load-playwright-container-env.sh b/e2e/scripts/load-playwright-container-env.sh new file mode 100644 index 00000000000..a8af603e4a7 --- /dev/null +++ b/e2e/scripts/load-playwright-container-env.sh @@ -0,0 +1,21 @@ +#!/usr/bin/env bash + +if [[ "${BASH_SOURCE[0]}" == "$0" ]]; then + echo "This script must be sourced, not executed" >&2 + exit 1 +fi + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +REPO_ROOT="$(cd "$SCRIPT_DIR/../.." && pwd)" + +cd "$REPO_ROOT" + +PLAYWRIGHT_VERSION="$(node -p 'require("./e2e/package.json").devDependencies["@playwright/test"]')" +PLAYWRIGHT_IMAGE="mcr.microsoft.com/playwright:v${PLAYWRIGHT_VERSION}-noble" +WORKSPACE_PATH="${GITHUB_WORKSPACE:-$REPO_ROOT}" + +export SCRIPT_DIR +export REPO_ROOT +export PLAYWRIGHT_VERSION +export PLAYWRIGHT_IMAGE +export WORKSPACE_PATH diff --git a/e2e/scripts/prepare-ci-e2e-build-mode.sh b/e2e/scripts/prepare-ci-e2e-build-mode.sh new file mode 100755 index 00000000000..863ae387524 --- /dev/null +++ b/e2e/scripts/prepare-ci-e2e-build-mode.sh @@ -0,0 +1,42 @@ +#!/usr/bin/env bash +set -euo pipefail + +source "$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/load-playwright-container-env.sh" +GATEWAY_IMAGE="${GHOST_E2E_GATEWAY_IMAGE:-caddy:2-alpine}" + +if [[ -n "${GITHUB_OUTPUT:-}" ]]; then + { + echo "playwright_version=${PLAYWRIGHT_VERSION}" + echo "playwright_image=${PLAYWRIGHT_IMAGE}" + } >> "$GITHUB_OUTPUT" +fi + +echo "Preparing E2E build-mode runtime" +echo "Playwright image: ${PLAYWRIGHT_IMAGE}" +echo "Gateway image: ${GATEWAY_IMAGE}" + +pids=() +labels=() + +run_bg() { + local label="$1" + shift + labels+=("$label") + ( + echo "[${label}] starting" + "$@" + echo "[${label}] done" + ) & + pids+=("$!") +} + +run_bg "pull-gateway-image" docker pull "$GATEWAY_IMAGE" +run_bg "pull-playwright-image" docker pull "$PLAYWRIGHT_IMAGE" +run_bg "start-infra" bash "$REPO_ROOT/e2e/scripts/infra-up.sh" + +for i in "${!pids[@]}"; do + if ! wait "${pids[$i]}"; then + echo "[${labels[$i]}] failed" >&2 + exit 1 + fi +done diff --git a/e2e/scripts/run-playwright-ci.sh b/e2e/scripts/run-playwright-ci.sh new file mode 100755 index 00000000000..5466c644990 --- /dev/null +++ b/e2e/scripts/run-playwright-ci.sh @@ -0,0 +1,31 @@ +#!/usr/bin/env bash +set -euo pipefail + +if [[ $# -lt 2 ]]; then + echo "Usage: $0 [retries]" >&2 + exit 1 +fi + +SHARD_INDEX="$1" +SHARD_TOTAL="$2" +RETRIES="${3:-2}" + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +REPO_ROOT="$(cd "$SCRIPT_DIR/../.." && pwd)" + +cd "$REPO_ROOT" + +PLAYWRIGHT_VERSION="$(node -p 'require("./e2e/package.json").devDependencies["@playwright/test"]')" +PLAYWRIGHT_IMAGE="mcr.microsoft.com/playwright:v${PLAYWRIGHT_VERSION}-noble" +WORKSPACE_PATH="${GITHUB_WORKSPACE:-$REPO_ROOT}" + +docker run --rm --network host --ipc host \ + -v /var/run/docker.sock:/var/run/docker.sock \ + -v "${WORKSPACE_PATH}:${WORKSPACE_PATH}" \ + -w "${WORKSPACE_PATH}/e2e" \ + -e CI=true \ + -e TEST_WORKERS_COUNT="${TEST_WORKERS_COUNT:-1}" \ + -e GHOST_E2E_MODE="${GHOST_E2E_MODE:-build}" \ + -e GHOST_E2E_IMAGE="${GHOST_E2E_IMAGE:-ghost-e2e:local}" \ + "$PLAYWRIGHT_IMAGE" \ + yarn test:all --shard="${SHARD_INDEX}/${SHARD_TOTAL}" --retries="${RETRIES}" diff --git a/e2e/scripts/sync-tinybird-state.mjs b/e2e/scripts/sync-tinybird-state.mjs new file mode 100644 index 00000000000..0ec0c4f9a25 --- /dev/null +++ b/e2e/scripts/sync-tinybird-state.mjs @@ -0,0 +1,139 @@ +import fs from 'node:fs'; +import path from 'node:path'; +import {execFileSync} from 'node:child_process'; +import {fileURLToPath} from 'node:url'; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = path.dirname(__filename); +const repoRoot = path.resolve(__dirname, '../..'); +const stateDir = path.resolve(repoRoot, 'e2e/data/state'); +const configPath = path.resolve(stateDir, 'tinybird.json'); + +const composeArgs = [ + 'compose', + '-f', path.resolve(repoRoot, 'compose.dev.yaml'), + '-f', path.resolve(repoRoot, 'compose.dev.analytics.yaml') +]; +const composeProject = process.env.COMPOSE_PROJECT_NAME || 'ghost-dev'; + +function log(message) { + process.stdout.write(`${message}\n`); +} + +function parseEnv(raw) { + const vars = {}; + + for (const line of raw.split('\n')) { + const trimmed = line.trim(); + if (!trimmed || trimmed.startsWith('#')) { + continue; + } + + const separatorIndex = trimmed.indexOf('='); + if (separatorIndex === -1) { + continue; + } + + vars[trimmed.slice(0, separatorIndex).trim()] = trimmed.slice(separatorIndex + 1).trim(); + } + + return vars; +} + +function clearConfigIfPresent() { + if (fs.existsSync(configPath)) { + fs.rmSync(configPath, {force: true}); + log(`Removed stale Tinybird config at ${configPath}`); + } +} + +function runCompose(args) { + return execFileSync('docker', [...composeArgs, ...args], { + cwd: repoRoot, + encoding: 'utf8', + stdio: ['ignore', 'pipe', 'pipe'] + }); +} + +function isTinybirdRunning() { + const output = execFileSync('docker', [ + 'ps', + '--filter', `label=com.docker.compose.project=${composeProject}`, + '--filter', 'label=com.docker.compose.service=tinybird-local', + '--filter', 'status=running', + '--format', '{{.Names}}' + ], { + cwd: repoRoot, + encoding: 'utf8', + stdio: ['ignore', 'pipe', 'pipe'] + }); + + return Boolean(output.trim()); +} + +function fetchConfigFromTbCli() { + return runCompose([ + 'run', + '--rm', + '-T', + 'tb-cli', + 'cat', + '/mnt/shared-config/.env.tinybird' + ]); +} + +function truncateAnalyticsEvents() { + runCompose([ + 'run', + '--rm', + '-T', + 'tb-cli', + 'tb', + 'datasource', + 'truncate', + 'analytics_events', + '--yes', + '--cascade' + ]); +} + +function writeConfig(env) { + fs.mkdirSync(stateDir, {recursive: true}); + fs.writeFileSync(configPath, JSON.stringify({ + workspaceId: env.TINYBIRD_WORKSPACE_ID, + adminToken: env.TINYBIRD_ADMIN_TOKEN, + trackerToken: env.TINYBIRD_TRACKER_TOKEN + }, null, 2)); +} + +try { + if (!isTinybirdRunning()) { + clearConfigIfPresent(); + log(`Tinybird is not running for compose project ${composeProject}; skipping Tinybird state sync (non-analytics runs are allowed)`); + process.exit(0); + } + + const rawEnv = fetchConfigFromTbCli(); + const env = parseEnv(rawEnv); + + if (!env.TINYBIRD_WORKSPACE_ID || !env.TINYBIRD_ADMIN_TOKEN) { + clearConfigIfPresent(); + throw new Error('Tinybird is running but required config values are missing in /mnt/shared-config/.env.tinybird'); + } + + writeConfig(env); + log(`Wrote Tinybird config to ${configPath}`); + + try { + truncateAnalyticsEvents(); + log('Truncated Tinybird analytics_events datasource'); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + throw new Error(`Failed to truncate Tinybird analytics_events datasource: ${message}`); + } +} catch (error) { + clearConfigIfPresent(); + const message = error instanceof Error ? error.message : String(error); + log(`Tinybird state sync failed: ${message}`); + process.exit(1); +} From 2280b86aa3c3a007d3b16564bd5e6e1b16177a33 Mon Sep 17 00:00:00 2001 From: Jonatan Svennberg Date: Tue, 24 Feb 2026 08:21:35 +0100 Subject: [PATCH 10/22] Updated E2E Tinybird prep to run at test start ref https://linear.app/ghost/issue/BER-3363/unified-environment-manager Made Tinybird prep automatic for local tests, startup-only infra commands, and fail-fast sync/reset when Tinybird is running. --- e2e/README.md | 26 ++++++++++++++++-------- e2e/package.json | 10 ++++----- e2e/scripts/infra-up.sh | 2 -- e2e/scripts/prepare-ci-e2e-build-mode.sh | 2 ++ e2e/scripts/run-local-playwright.sh | 12 +++++++++++ 5 files changed, 37 insertions(+), 15 deletions(-) create mode 100755 e2e/scripts/run-local-playwright.sh diff --git a/e2e/README.md b/e2e/README.md index 173712ddbfb..14a5cbb597c 100644 --- a/e2e/README.md +++ b/e2e/README.md @@ -34,6 +34,20 @@ yarn test If infra is already running, `yarn workspace @tryghost/e2e infra:up` is safe to run again. If you use a custom compose project name locally, set `COMPOSE_PROJECT_NAME` for both infra and tests so E2E-managed image/volume names stay aligned. +### Analytics Development Flow (No Extra Tinybird Steps) + +When working on analytics locally, use: + +```bash +# Terminal 1 (repo root) +yarn dev:analytics + +# Terminal 2 +yarn workspace @tryghost/e2e test:analytics +``` + +E2E test scripts automatically sync Tinybird tokens and reset analytics test state when Tinybird is running. + ### Build Mode (Prebuilt Image) Use build mode when you don’t want to run dev servers. It uses a prebuilt Ghost image and serves public assets from `/content/files`. @@ -49,8 +63,6 @@ yarn workspace @tryghost/e2e infra:up GHOST_E2E_MODE=build GHOST_E2E_IMAGE=ghost-e2e:local yarn workspace @tryghost/e2e test ``` -`infra:up` also syncs `e2e/data/state/tinybird.json` and truncates the Tinybird `analytics_events` datasource when Tinybird is available. - For a CI-like local preflight (pulls Playwright + gateway images and starts infra), run: ```bash @@ -210,10 +222,9 @@ Tests run automatically in GitHub Actions on every PR and commit to `main`. 1. **Setup**: Ubuntu runner with Node.js and Docker 2. **Build Assets**: Build server/admin assets and public app UMD bundles 3. **Build E2E Image**: `yarn workspace @tryghost/e2e build:docker` (layers public apps into `/content/files`) -4. **Start Infra**: `yarn workspace @tryghost/e2e infra:up` (starts MySQL/Redis/Mailpit/Tinybird services only) -5. **Prepare E2E Runtime**: Pull Playwright/gateway images in parallel and start infra (`yarn workspace @tryghost/e2e preflight:build`) -6. **Test Execution**: Run Playwright E2E tests inside the official Playwright container -7. **Artifacts**: Upload Playwright traces and reports on failure +4. **Prepare E2E Runtime**: Pull Playwright/gateway images in parallel, start infra, and sync Tinybird state (`yarn workspace @tryghost/e2e preflight:build`) +5. **Test Execution**: Run Playwright E2E tests inside the official Playwright container +6. **Artifacts**: Upload Playwright traces and reports on failure ## Available Scripts @@ -223,10 +234,9 @@ Within the e2e directory: # Run all tests yarn test -# Start/stop test infra (MySQL/Redis/Mailpit/Tinybird) and sync Tinybird state +# Start/stop test infra (MySQL/Redis/Mailpit/Tinybird) yarn infra:up yarn infra:down -yarn tinybird:sync # CI-like preflight for build mode (pulls images + starts infra) yarn preflight:build diff --git a/e2e/package.json b/e2e/package.json index e16831016f5..2f829601e9a 100644 --- a/e2e/package.json +++ b/e2e/package.json @@ -18,11 +18,11 @@ "tinybird:sync": "node ./scripts/sync-tinybird-state.mjs", "tinybird:fetch-config": "yarn tinybird:sync", "preflight:build": "bash ./scripts/prepare-ci-e2e-build-mode.sh", - "test": "playwright test --project=main", - "test:analytics": "playwright test --project=analytics", - "test:all": "playwright test --project=main --project=analytics", - "test:single": "playwright test --project=main -g", - "test:debug": "playwright test --project=main --headed --timeout=60000 -g", + "test": "bash ./scripts/run-local-playwright.sh playwright test --project=main", + "test:analytics": "bash ./scripts/run-local-playwright.sh playwright test --project=analytics", + "test:all": "bash ./scripts/run-local-playwright.sh playwright test --project=main --project=analytics", + "test:single": "bash ./scripts/run-local-playwright.sh playwright test --project=main -g", + "test:debug": "bash ./scripts/run-local-playwright.sh playwright test --project=main --headed --timeout=60000 -g", "test:types": "tsc --noEmit", "lint": "eslint . --cache" }, diff --git a/e2e/scripts/infra-up.sh b/e2e/scripts/infra-up.sh index b51118e2342..14547102040 100755 --- a/e2e/scripts/infra-up.sh +++ b/e2e/scripts/infra-up.sh @@ -8,5 +8,3 @@ cd "$REPO_ROOT" docker compose -f compose.dev.yaml -f compose.dev.analytics.yaml up -d --wait \ mysql redis mailpit tinybird-local tb-cli analytics - -node "$REPO_ROOT/e2e/scripts/sync-tinybird-state.mjs" diff --git a/e2e/scripts/prepare-ci-e2e-build-mode.sh b/e2e/scripts/prepare-ci-e2e-build-mode.sh index 863ae387524..1f5e253868b 100755 --- a/e2e/scripts/prepare-ci-e2e-build-mode.sh +++ b/e2e/scripts/prepare-ci-e2e-build-mode.sh @@ -40,3 +40,5 @@ for i in "${!pids[@]}"; do exit 1 fi done + +node "$REPO_ROOT/e2e/scripts/sync-tinybird-state.mjs" diff --git a/e2e/scripts/run-local-playwright.sh b/e2e/scripts/run-local-playwright.sh new file mode 100755 index 00000000000..f7116a939c8 --- /dev/null +++ b/e2e/scripts/run-local-playwright.sh @@ -0,0 +1,12 @@ +#!/usr/bin/env bash +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +REPO_ROOT="$(cd "$SCRIPT_DIR/../.." && pwd)" + +if [[ "${CI:-}" != "true" ]]; then + node "$REPO_ROOT/e2e/scripts/sync-tinybird-state.mjs" +fi + +cd "$REPO_ROOT/e2e" +exec "$@" From 3c001da4e6ce11a11381ab2b4411d9282dc2b5af Mon Sep 17 00:00:00 2001 From: Jonatan Svennberg Date: Tue, 24 Feb 2026 08:31:21 +0100 Subject: [PATCH 11/22] Cleaned up E2E CI job orchestration ref https://linear.app/ghost/issue/BER-3363/unified-environment-manager Collapsed CI prep into a single script, overlapped runtime preflight with builds, and switched the Playwright runner script to env-driven shard configuration. --- .github/workflows/ci.yml | 26 ++++------- e2e/package.json | 11 ++--- e2e/scripts/prepare-ci-e2e-build-mode.sh | 7 --- e2e/scripts/prepare-ci-e2e-job.sh | 46 +++++++++++++++++++ ...ight-ci.sh => run-playwright-container.sh} | 21 +++------ ...l-playwright.sh => run-playwright-host.sh} | 0 6 files changed, 68 insertions(+), 43 deletions(-) create mode 100644 e2e/scripts/prepare-ci-e2e-job.sh rename e2e/scripts/{run-playwright-ci.sh => run-playwright-container.sh} (50%) rename e2e/scripts/{run-local-playwright.sh => run-playwright-host.sh} (100%) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 6cb4d74d8b0..7ad4f04233a 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1261,26 +1261,20 @@ jobs: env: DEPENDENCY_CACHE_KEY: ${{ needs.job_setup.outputs.dependency_cache_key }} - - name: Build public app UMD bundles - run: yarn workspace @tryghost/e2e build:apps - - - name: Build E2E image layer + - name: Prepare E2E CI job env: GHOST_E2E_BASE_IMAGE: ${{ steps.load.outputs.image-tag }} - run: yarn workspace @tryghost/e2e build:docker - - - name: Prepare E2E build-mode runtime - run: bash ./e2e/scripts/prepare-ci-e2e-build-mode.sh + run: bash ./e2e/scripts/prepare-ci-e2e-job.sh - name: Run e2e tests in Playwright container - run: | - TEST_WORKERS_COUNT=1 \ - GHOST_E2E_MODE=build \ - GHOST_E2E_IMAGE=ghost-e2e:local \ - bash ./e2e/scripts/run-playwright-ci.sh \ - ${{ matrix.shardIndex }} \ - ${{ matrix.shardTotal }} \ - 2 + env: + TEST_WORKERS_COUNT: 1 + GHOST_E2E_MODE: build + GHOST_E2E_IMAGE: ghost-e2e:local + E2E_SHARD_INDEX: ${{ matrix.shardIndex }} + E2E_SHARD_TOTAL: ${{ matrix.shardTotal }} + E2E_RETRIES: 2 + run: bash ./e2e/scripts/run-playwright-container.sh - name: Dump E2E docker logs if: failure() diff --git a/e2e/package.json b/e2e/package.json index 2f829601e9a..0feacf3b728 100644 --- a/e2e/package.json +++ b/e2e/package.json @@ -16,13 +16,12 @@ "infra:up": "bash ./scripts/infra-up.sh", "infra:down": "bash ./scripts/infra-down.sh", "tinybird:sync": "node ./scripts/sync-tinybird-state.mjs", - "tinybird:fetch-config": "yarn tinybird:sync", "preflight:build": "bash ./scripts/prepare-ci-e2e-build-mode.sh", - "test": "bash ./scripts/run-local-playwright.sh playwright test --project=main", - "test:analytics": "bash ./scripts/run-local-playwright.sh playwright test --project=analytics", - "test:all": "bash ./scripts/run-local-playwright.sh playwright test --project=main --project=analytics", - "test:single": "bash ./scripts/run-local-playwright.sh playwright test --project=main -g", - "test:debug": "bash ./scripts/run-local-playwright.sh playwright test --project=main --headed --timeout=60000 -g", + "test": "bash ./scripts/run-playwright-host.sh playwright test --project=main", + "test:analytics": "bash ./scripts/run-playwright-host.sh playwright test --project=analytics", + "test:all": "bash ./scripts/run-playwright-host.sh playwright test --project=main --project=analytics", + "test:single": "bash ./scripts/run-playwright-host.sh playwright test --project=main -g", + "test:debug": "bash ./scripts/run-playwright-host.sh playwright test --project=main --headed --timeout=60000 -g", "test:types": "tsc --noEmit", "lint": "eslint . --cache" }, diff --git a/e2e/scripts/prepare-ci-e2e-build-mode.sh b/e2e/scripts/prepare-ci-e2e-build-mode.sh index 1f5e253868b..dec7064cf75 100755 --- a/e2e/scripts/prepare-ci-e2e-build-mode.sh +++ b/e2e/scripts/prepare-ci-e2e-build-mode.sh @@ -4,13 +4,6 @@ set -euo pipefail source "$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/load-playwright-container-env.sh" GATEWAY_IMAGE="${GHOST_E2E_GATEWAY_IMAGE:-caddy:2-alpine}" -if [[ -n "${GITHUB_OUTPUT:-}" ]]; then - { - echo "playwright_version=${PLAYWRIGHT_VERSION}" - echo "playwright_image=${PLAYWRIGHT_IMAGE}" - } >> "$GITHUB_OUTPUT" -fi - echo "Preparing E2E build-mode runtime" echo "Playwright image: ${PLAYWRIGHT_IMAGE}" echo "Gateway image: ${GATEWAY_IMAGE}" diff --git a/e2e/scripts/prepare-ci-e2e-job.sh b/e2e/scripts/prepare-ci-e2e-job.sh new file mode 100644 index 00000000000..309359414a1 --- /dev/null +++ b/e2e/scripts/prepare-ci-e2e-job.sh @@ -0,0 +1,46 @@ +#!/usr/bin/env bash +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +REPO_ROOT="$(cd "$SCRIPT_DIR/../.." && pwd)" + +if [[ -z "${GHOST_E2E_BASE_IMAGE:-}" ]]; then + echo "GHOST_E2E_BASE_IMAGE is required" >&2 + exit 1 +fi + +cd "$REPO_ROOT" + +echo "Preparing CI E2E job" +echo "Base image: ${GHOST_E2E_BASE_IMAGE}" +echo "E2E image: ${GHOST_E2E_IMAGE:-ghost-e2e:local}" + +pids=() +labels=() + +run_bg() { + local label="$1" + shift + labels+=("$label") + ( + echo "[${label}] starting" + "$@" + echo "[${label}] done" + ) & + pids+=("$!") +} + +# Mostly IO-bound runtime prep (image pulls + infra startup + Tinybird sync) can +# overlap with the app/docker builds. +run_bg "runtime-preflight" bash "$REPO_ROOT/e2e/scripts/prepare-ci-e2e-build-mode.sh" + +# Build the assets + E2E image layer while IO-heavy prep is running. +yarn workspace @tryghost/e2e build:apps +yarn workspace @tryghost/e2e build:docker + +for i in "${!pids[@]}"; do + if ! wait "${pids[$i]}"; then + echo "[${labels[$i]}] failed" >&2 + exit 1 + fi +done diff --git a/e2e/scripts/run-playwright-ci.sh b/e2e/scripts/run-playwright-container.sh similarity index 50% rename from e2e/scripts/run-playwright-ci.sh rename to e2e/scripts/run-playwright-container.sh index 5466c644990..ae57c6e0507 100755 --- a/e2e/scripts/run-playwright-ci.sh +++ b/e2e/scripts/run-playwright-container.sh @@ -1,23 +1,16 @@ #!/usr/bin/env bash set -euo pipefail -if [[ $# -lt 2 ]]; then - echo "Usage: $0 [retries]" >&2 +SHARD_INDEX="${E2E_SHARD_INDEX:-}" +SHARD_TOTAL="${E2E_SHARD_TOTAL:-}" +RETRIES="${E2E_RETRIES:-2}" + +if [[ -z "$SHARD_INDEX" || -z "$SHARD_TOTAL" ]]; then + echo "Missing E2E_SHARD_INDEX or E2E_SHARD_TOTAL environment variables" >&2 exit 1 fi -SHARD_INDEX="$1" -SHARD_TOTAL="$2" -RETRIES="${3:-2}" - -SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" -REPO_ROOT="$(cd "$SCRIPT_DIR/../.." && pwd)" - -cd "$REPO_ROOT" - -PLAYWRIGHT_VERSION="$(node -p 'require("./e2e/package.json").devDependencies["@playwright/test"]')" -PLAYWRIGHT_IMAGE="mcr.microsoft.com/playwright:v${PLAYWRIGHT_VERSION}-noble" -WORKSPACE_PATH="${GITHUB_WORKSPACE:-$REPO_ROOT}" +source "$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/load-playwright-container-env.sh" docker run --rm --network host --ipc host \ -v /var/run/docker.sock:/var/run/docker.sock \ diff --git a/e2e/scripts/run-local-playwright.sh b/e2e/scripts/run-playwright-host.sh similarity index 100% rename from e2e/scripts/run-local-playwright.sh rename to e2e/scripts/run-playwright-host.sh From 1fd3d0a08587fdcc344f34823a2f82849f21a399 Mon Sep 17 00:00:00 2001 From: Jonatan Svennberg Date: Tue, 24 Feb 2026 09:27:55 +0100 Subject: [PATCH 12/22] Fixed E2E gateway image passthrough and attribution wait scope --- e2e/helpers/pages/public/public-page.ts | 1 - e2e/scripts/run-playwright-container.sh | 1 + 2 files changed, 1 insertion(+), 1 deletion(-) diff --git a/e2e/helpers/pages/public/public-page.ts b/e2e/helpers/pages/public/public-page.ts index b9df4a93926..1e6fb74f7d2 100644 --- a/e2e/helpers/pages/public/public-page.ts +++ b/e2e/helpers/pages/public/public-page.ts @@ -85,7 +85,6 @@ export class PublicPage extends BasePage { } await this.enableAnalyticsRequests(); const result = await super.goto(url, options); - await this.waitForMemberAttributionReady(); if (pageHitPromise) { await pageHitPromise; } diff --git a/e2e/scripts/run-playwright-container.sh b/e2e/scripts/run-playwright-container.sh index ae57c6e0507..2f5ea9d06bc 100755 --- a/e2e/scripts/run-playwright-container.sh +++ b/e2e/scripts/run-playwright-container.sh @@ -20,5 +20,6 @@ docker run --rm --network host --ipc host \ -e TEST_WORKERS_COUNT="${TEST_WORKERS_COUNT:-1}" \ -e GHOST_E2E_MODE="${GHOST_E2E_MODE:-build}" \ -e GHOST_E2E_IMAGE="${GHOST_E2E_IMAGE:-ghost-e2e:local}" \ + -e GHOST_E2E_GATEWAY_IMAGE="${GHOST_E2E_GATEWAY_IMAGE:-caddy:2-alpine}" \ "$PLAYWRIGHT_IMAGE" \ yarn test:all --shard="${SHARD_INDEX}/${SHARD_TOTAL}" --retries="${RETRIES}" From 4286ae8555f92aec5f6d20db50d4ee62935e33ba Mon Sep 17 00:00:00 2001 From: Jonatan Svennberg Date: Tue, 24 Feb 2026 09:34:52 +0100 Subject: [PATCH 13/22] Improved E2E cleanup container removal resilience --- e2e/helpers/environment/service-managers/ghost-manager.ts | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/e2e/helpers/environment/service-managers/ghost-manager.ts b/e2e/helpers/environment/service-managers/ghost-manager.ts index e2b2f1eaf84..1aae0885cc0 100644 --- a/e2e/helpers/environment/service-managers/ghost-manager.ts +++ b/e2e/helpers/environment/service-managers/ghost-manager.ts @@ -401,9 +401,15 @@ export class GhostManager { } }); - await Promise.all( + const results = await Promise.allSettled( containers.map(c => this.docker.getContainer(c.Id).remove({force: true})) ); + + for (const [index, result] of results.entries()) { + if (result.status === 'rejected') { + debug('cleanupAllContainers: failed to remove container', containers[index]?.Id, result.reason); + } + } } catch (error) { debug('cleanupAllContainers: failed to list/remove containers', error); } From f46c00aa914e00ebada74d4dbed0f0457389be88 Mon Sep 17 00:00:00 2001 From: Jonatan Svennberg Date: Tue, 24 Feb 2026 15:18:39 +0100 Subject: [PATCH 14/22] Polished E2E setup guidance and dev-mode prerequisites --- e2e/README.md | 3 ++- e2e/helpers/environment/constants.ts | 8 +++++--- .../service-managers/ghost-manager.ts | 18 +++++++++--------- e2e/scripts/infra-up.sh | 12 ++++++++++++ e2e/scripts/prepare-ci-e2e-build-mode.sh | 2 +- 5 files changed, 29 insertions(+), 14 deletions(-) diff --git a/e2e/README.md b/e2e/README.md index 14a5cbb597c..51ad4a6ff6d 100644 --- a/e2e/README.md +++ b/e2e/README.md @@ -32,6 +32,7 @@ yarn test ``` If infra is already running, `yarn workspace @tryghost/e2e infra:up` is safe to run again. +For dev-mode test runs, `infra:up` also ensures required local Ghost/gateway dev images exist. If you use a custom compose project name locally, set `COMPOSE_PROJECT_NAME` for both infra and tests so E2E-managed image/volume names stay aligned. ### Analytics Development Flow (No Extra Tinybird Steps) @@ -57,7 +58,7 @@ Use build mode when you don’t want to run dev servers. It uses a prebuilt Ghos yarn build yarn workspace @tryghost/e2e build:apps GHOST_E2E_BASE_IMAGE= yarn workspace @tryghost/e2e build:docker -yarn workspace @tryghost/e2e infra:up +GHOST_E2E_MODE=build yarn workspace @tryghost/e2e infra:up # Run tests GHOST_E2E_MODE=build GHOST_E2E_IMAGE=ghost-e2e:local yarn workspace @tryghost/e2e test diff --git a/e2e/helpers/environment/constants.ts b/e2e/helpers/environment/constants.ts index a7ccdcd7945..c39d033fe1b 100644 --- a/e2e/helpers/environment/constants.ts +++ b/e2e/helpers/environment/constants.ts @@ -87,10 +87,12 @@ export const BASE_GHOST_ENV = [ ] as const; /** - * Public app asset URLs for dev mode (served via gateway proxying to host dev servers). - * Build mode assets are baked into the E2E image via ENV vars in e2e/Dockerfile.e2e. + * Public app asset URLs for dev mode only (served via gateway proxying to host dev servers). + * + * These are not needed in build mode because build-mode assets are baked into + * the E2E image via ENV vars in e2e/Dockerfile.e2e. */ -export const LOCAL_ASSET_URLS = [ +export const DEV_ASSET_URLS = [ 'portal__url=/ghost/assets/portal/portal.min.js', 'comments__url=/ghost/assets/comments-ui/comments-ui.min.js', 'sodoSearch__url=/ghost/assets/sodo-search/sodo-search.min.js', diff --git a/e2e/helpers/environment/service-managers/ghost-manager.ts b/e2e/helpers/environment/service-managers/ghost-manager.ts index 1aae0885cc0..cb7d46c547c 100644 --- a/e2e/helpers/environment/service-managers/ghost-manager.ts +++ b/e2e/helpers/environment/service-managers/ghost-manager.ts @@ -6,9 +6,9 @@ import { BUILD_GATEWAY_IMAGE, BUILD_IMAGE, CADDYFILE_PATHS, + DEV_ASSET_URLS, DEV_ENVIRONMENT, DEV_SHARED_CONFIG_VOLUME, - LOCAL_ASSET_URLS, REPO_ROOT, TEST_ENVIRONMENT, TINYBIRD @@ -105,7 +105,7 @@ export class GhostManager { `To fix this, either:\n` + ` 1. Build locally: yarn workspace @tryghost/e2e build:docker (with GHOST_E2E_BASE_IMAGE set)\n` + ` 2. Pull from registry: docker pull ${BUILD_IMAGE}\n` + - ` 3. Use a different image: GHOST_E2E_IMAGE= yarn test:build` + ` 3. Use a different image: GHOST_E2E_MODE=build GHOST_E2E_IMAGE= yarn workspace @tryghost/e2e test` ); } @@ -118,7 +118,7 @@ export class GhostManager { `Build gateway image not found: ${BUILD_GATEWAY_IMAGE}\n\n` + `To fix this, either:\n` + ` 1. Pull gateway image: docker pull ${BUILD_GATEWAY_IMAGE}\n` + - ` 2. Use a different gateway image: GHOST_E2E_GATEWAY_IMAGE= yarn test:build` + ` 2. Use a different gateway image: GHOST_E2E_MODE=build GHOST_E2E_GATEWAY_IMAGE= yarn workspace @tryghost/e2e test` ); } } @@ -210,10 +210,9 @@ export class GhostManager { ]; // For dev mode, add local asset URLs (served via gateway proxying to host dev servers) - // Registry mode uses production CDN URLs (no override needed) - // Local mode has asset URLs baked into the E2E image via ENV vars + // Build mode has asset URLs baked into the E2E image via ENV vars if (this.config.mode === 'dev') { - env.push(...LOCAL_ASSET_URLS); + env.push(...DEV_ASSET_URLS); } // Add Tinybird config if available @@ -243,10 +242,11 @@ export class GhostManager { if (extraConfig) { for (const [key, value] of Object.entries(extraConfig)) { - if (typeof value !== 'string') { - continue; + if (typeof value === 'string' || typeof value === 'number' || typeof value === 'boolean') { + env.push(`${key}=${String(value)}`); + } else { + debug(`buildEnv: skipping non-scalar extraConfig key '${key}' (type: ${typeof value})`); } - env.push(`${key}=${value}`); } } diff --git a/e2e/scripts/infra-up.sh b/e2e/scripts/infra-up.sh index 14547102040..5b635ed5bd6 100755 --- a/e2e/scripts/infra-up.sh +++ b/e2e/scripts/infra-up.sh @@ -6,5 +6,17 @@ REPO_ROOT="$(cd "$SCRIPT_DIR/../.." && pwd)" cd "$REPO_ROOT" +MODE="${GHOST_E2E_MODE:-dev}" +if [[ "$MODE" != "build" ]]; then + DEV_COMPOSE_PROJECT="${COMPOSE_PROJECT_NAME:-ghost-dev}" + GHOST_DEV_IMAGE="${DEV_COMPOSE_PROJECT}-ghost-dev" + GATEWAY_IMAGE="${DEV_COMPOSE_PROJECT}-ghost-dev-gateway" + + if ! docker image inspect "$GHOST_DEV_IMAGE" >/dev/null 2>&1 || ! docker image inspect "$GATEWAY_IMAGE" >/dev/null 2>&1; then + echo "Building missing dev images for E2E (${GHOST_DEV_IMAGE}, ${GATEWAY_IMAGE})..." + docker compose -f compose.dev.yaml -f compose.dev.analytics.yaml build ghost-dev ghost-dev-gateway + fi +fi + docker compose -f compose.dev.yaml -f compose.dev.analytics.yaml up -d --wait \ mysql redis mailpit tinybird-local tb-cli analytics diff --git a/e2e/scripts/prepare-ci-e2e-build-mode.sh b/e2e/scripts/prepare-ci-e2e-build-mode.sh index dec7064cf75..383045fed17 100755 --- a/e2e/scripts/prepare-ci-e2e-build-mode.sh +++ b/e2e/scripts/prepare-ci-e2e-build-mode.sh @@ -25,7 +25,7 @@ run_bg() { run_bg "pull-gateway-image" docker pull "$GATEWAY_IMAGE" run_bg "pull-playwright-image" docker pull "$PLAYWRIGHT_IMAGE" -run_bg "start-infra" bash "$REPO_ROOT/e2e/scripts/infra-up.sh" +run_bg "start-infra" env GHOST_E2E_MODE=build bash "$REPO_ROOT/e2e/scripts/infra-up.sh" for i in "${!pids[@]}"; do if ! wait "${pids[$i]}"; then From 305facdcf36e98cdee2b533f45cd934eda2e2147 Mon Sep 17 00:00:00 2001 From: Jonatan Svennberg Date: Tue, 24 Feb 2026 15:45:32 +0100 Subject: [PATCH 15/22] Baked dev asset URLs into image and removed E2E duplication --- compose.dev.yaml | 8 -------- docker/ghost-dev/Dockerfile | 10 +++++++++- e2e/helpers/environment/constants.ts | 15 --------------- .../environment/service-managers/ghost-manager.ts | 7 ------- 4 files changed, 9 insertions(+), 31 deletions(-) diff --git a/compose.dev.yaml b/compose.dev.yaml index 14ad1419ffe..63539a7743b 100644 --- a/compose.dev.yaml +++ b/compose.dev.yaml @@ -85,14 +85,6 @@ services: # Redis cache (optional) adapters__cache__Redis__host: redis adapters__cache__Redis__port: 6379 - # Public app assets - proxied through Caddy gateway to host dev servers - # Using /ghost/assets/* paths - portal__url: /ghost/assets/portal/portal.min.js - comments__url: /ghost/assets/comments-ui/comments-ui.min.js - sodoSearch__url: /ghost/assets/sodo-search/sodo-search.min.js - sodoSearch__styles: /ghost/assets/sodo-search/main.css - signupForm__url: /ghost/assets/signup-form/signup-form.min.js - announcementBar__url: /ghost/assets/announcement-bar/announcement-bar.min.js depends_on: mysql: condition: service_healthy diff --git a/docker/ghost-dev/Dockerfile b/docker/ghost-dev/Dockerfile index eea3209a8fe..fc3a193d71f 100644 --- a/docker/ghost-dev/Dockerfile +++ b/docker/ghost-dev/Dockerfile @@ -33,10 +33,18 @@ RUN --mount=type=cache,target=/usr/local/share/.cache/yarn,id=yarn-cache \ COPY docker/ghost-dev/entrypoint.sh entrypoint.sh RUN chmod +x entrypoint.sh +# Public app assets are served via /ghost/assets/* in dev mode. +# Caddy forwards these paths to host frontend dev servers. +ENV portal__url=/ghost/assets/portal/portal.min.js \ + comments__url=/ghost/assets/comments-ui/comments-ui.min.js \ + sodoSearch__url=/ghost/assets/sodo-search/sodo-search.min.js \ + sodoSearch__styles=/ghost/assets/sodo-search/main.css \ + signupForm__url=/ghost/assets/signup-form/signup-form.min.js \ + announcementBar__url=/ghost/assets/announcement-bar/announcement-bar.min.js + # Source code will be mounted from host at /home/ghost/ghost/core # This allows node --watch to pick up file changes for hot-reload WORKDIR /home/ghost/ghost/core ENTRYPOINT ["/home/ghost/entrypoint.sh"] CMD ["node", "--watch", "--import=tsx", "index.js"] - diff --git a/e2e/helpers/environment/constants.ts b/e2e/helpers/environment/constants.ts index c39d033fe1b..7c909c2bcb6 100644 --- a/e2e/helpers/environment/constants.ts +++ b/e2e/helpers/environment/constants.ts @@ -86,21 +86,6 @@ export const BASE_GHOST_ENV = [ 'mail__options__port=1025' ] as const; -/** - * Public app asset URLs for dev mode only (served via gateway proxying to host dev servers). - * - * These are not needed in build mode because build-mode assets are baked into - * the E2E image via ENV vars in e2e/Dockerfile.e2e. - */ -export const DEV_ASSET_URLS = [ - 'portal__url=/ghost/assets/portal/portal.min.js', - 'comments__url=/ghost/assets/comments-ui/comments-ui.min.js', - 'sodoSearch__url=/ghost/assets/sodo-search/sodo-search.min.js', - 'sodoSearch__styles=/ghost/assets/sodo-search/main.css', - 'signupForm__url=/ghost/assets/signup-form/signup-form.min.js', - 'announcementBar__url=/ghost/assets/announcement-bar/announcement-bar.min.js' -] as const; - export const TEST_ENVIRONMENT = { projectNamespace: 'ghost-dev-e2e', gateway: { diff --git a/e2e/helpers/environment/service-managers/ghost-manager.ts b/e2e/helpers/environment/service-managers/ghost-manager.ts index cb7d46c547c..d429b8b0d0f 100644 --- a/e2e/helpers/environment/service-managers/ghost-manager.ts +++ b/e2e/helpers/environment/service-managers/ghost-manager.ts @@ -6,7 +6,6 @@ import { BUILD_GATEWAY_IMAGE, BUILD_IMAGE, CADDYFILE_PATHS, - DEV_ASSET_URLS, DEV_ENVIRONMENT, DEV_SHARED_CONFIG_VOLUME, REPO_ROOT, @@ -209,12 +208,6 @@ export class GhostManager { `url=http://localhost:${this.getGatewayPort()}` ]; - // For dev mode, add local asset URLs (served via gateway proxying to host dev servers) - // Build mode has asset URLs baked into the E2E image via ENV vars - if (this.config.mode === 'dev') { - env.push(...DEV_ASSET_URLS); - } - // Add Tinybird config if available // Static endpoints are set here; tokens are loaded from a host-generated // e2e/data/state/tinybird.json file when present. From d5980a77585a445a7accbe7e72a0aad4d5b05461 Mon Sep 17 00:00:00 2001 From: Jonatan Svennberg Date: Tue, 24 Feb 2026 15:50:00 +0100 Subject: [PATCH 16/22] Cleaned up unused E2E environment helpers ref https://linear.app/ghost/issue/BER-3363 Removed dead service-availability exports and the obsolete pretest skip branch after unifying the environment manager. --- e2e/helpers/environment/index.ts | 2 - .../environment/service-availability.ts | 48 ------------------- e2e/package.json | 2 +- 3 files changed, 1 insertion(+), 51 deletions(-) diff --git a/e2e/helpers/environment/index.ts b/e2e/helpers/environment/index.ts index a1e7b503ec1..70ef4e42746 100644 --- a/e2e/helpers/environment/index.ts +++ b/e2e/helpers/environment/index.ts @@ -1,5 +1,3 @@ export * from './service-managers'; export * from './environment-manager'; export * from './environment-factory'; -export * from './service-availability'; - diff --git a/e2e/helpers/environment/service-availability.ts b/e2e/helpers/environment/service-availability.ts index 185baa2cc5a..bd234f15a6c 100644 --- a/e2e/helpers/environment/service-availability.ts +++ b/e2e/helpers/environment/service-availability.ts @@ -16,54 +16,6 @@ async function isServiceAvailable(docker: Docker, serviceName: string) { }); return containers.length > 0; } - -export async function isDevNetworkAvailable(docker: Docker): Promise { - try { - const networks = await docker.listNetworks({ - filters: {name: [DEV_ENVIRONMENT.networkName]} - }); - - if (networks.length === 0) { - debug('Dev environment not available: network not found'); - return false; - } - debug('Dev environment is available'); - return true; - } catch (error) { - debug('Error checking dev environment:', error); - return false; - } -} - -/** - * Check if the dev environment (yarn dev) is running. - * Detects by checking for the ghost_dev network and running MySQL container. - */ -export async function isDevEnvironmentAvailable(): Promise { - const docker = new Docker(); - - if (!await isDevNetworkAvailable(docker)) { - debug('Dev environment not available: network not found'); - return false; - } - - if (!await isServiceAvailable(docker, 'mysql')) { - debug('Dev environment not available: MySQL container not running'); - return false; - } - - if (!await isServiceAvailable(docker, 'redis')) { - debug('Dev environment not available: Redis container not running'); - return false; - } - - if (!await isServiceAvailable(docker, 'mailpit')) { - debug('Dev environment not available: Mailpit container not running'); - return false; - } - - return true; -} /** * Check if Tinybird is running. * Checks for tinybird-local service in ghost-dev compose project. diff --git a/e2e/package.json b/e2e/package.json index 0feacf3b728..00f820beca8 100644 --- a/e2e/package.json +++ b/e2e/package.json @@ -12,7 +12,7 @@ "build:apps": "nx run-many --target=build --projects=@tryghost/portal,@tryghost/comments-ui,@tryghost/sodo-search,@tryghost/signup-form,@tryghost/announcement-bar", "build:docker": "docker build -f Dockerfile.e2e --build-arg GHOST_IMAGE=${GHOST_E2E_BASE_IMAGE:?Set GHOST_E2E_BASE_IMAGE} -t ${GHOST_E2E_IMAGE:-ghost-e2e:local} ..", "prepare": "tsc --noEmit", - "pretest": "(test -n \"$GHOST_E2E_SKIP_BUILD\" || test -n \"$CI\") && echo 'Skipping Docker build' || echo 'Tip: run yarn dev or yarn workspace @tryghost/e2e infra:up before running tests'", + "pretest": "test -n \"$CI\" || echo 'Tip: run yarn dev or yarn workspace @tryghost/e2e infra:up before running tests'", "infra:up": "bash ./scripts/infra-up.sh", "infra:down": "bash ./scripts/infra-down.sh", "tinybird:sync": "node ./scripts/sync-tinybird-state.mjs", From 419726c6c70befdbfa1eb52b6a63de389f0de288 Mon Sep 17 00:00:00 2001 From: Jonatan Svennberg Date: Tue, 24 Feb 2026 16:02:59 +0100 Subject: [PATCH 17/22] Updated E2E docs wording and attribution wait comment ref https://linear.app/ghost/issue/BER-3366/rework-e2e-fixtures This keeps the README wording focused and clarifies why the temporary CI wait remains in PublicPage. --- e2e/README.md | 3 +-- e2e/helpers/pages/public/public-page.ts | 4 ++-- 2 files changed, 3 insertions(+), 4 deletions(-) diff --git a/e2e/README.md b/e2e/README.md index 51ad4a6ff6d..adecc0bae80 100644 --- a/e2e/README.md +++ b/e2e/README.md @@ -33,9 +33,8 @@ yarn test If infra is already running, `yarn workspace @tryghost/e2e infra:up` is safe to run again. For dev-mode test runs, `infra:up` also ensures required local Ghost/gateway dev images exist. -If you use a custom compose project name locally, set `COMPOSE_PROJECT_NAME` for both infra and tests so E2E-managed image/volume names stay aligned. -### Analytics Development Flow (No Extra Tinybird Steps) +### Analytics Development Flow When working on analytics locally, use: diff --git a/e2e/helpers/pages/public/public-page.ts b/e2e/helpers/pages/public/public-page.ts index 1e6fb74f7d2..ced8b499030 100644 --- a/e2e/helpers/pages/public/public-page.ts +++ b/e2e/helpers/pages/public/public-page.ts @@ -100,8 +100,8 @@ export class PublicPage extends BasePage { } protected async waitForMemberAttributionReady(): Promise { - // Test-only anti-pattern: we synchronize on async client bootstrap state - // to keep attribution-dependent assertions deterministic in CI. + // TODO: Ideally we should find a way to get rid of this. This is currently needed + // to prevent flaky attribution-dependent assertions in CI. await this.page.waitForFunction(() => { try { const raw = window.sessionStorage.getItem('ghost-history'); From 2c7c3131df4fc4b5b8db0ad8f16dcd8a6a39dd53 Mon Sep 17 00:00:00 2001 From: Jonatan Svennberg Date: Tue, 24 Feb 2026 16:39:30 +0100 Subject: [PATCH 18/22] Adjusted E2E infra wait targets for one-shot services ref https://linear.app/ghost/issue/BER-3363 Removed tb-cli from compose --wait targets in infra bootstrap because it is a one-shot setup job; analytics still pulls it in via depends_on and long-lived services remain the readiness gate. --- e2e/scripts/infra-up.sh | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/e2e/scripts/infra-up.sh b/e2e/scripts/infra-up.sh index 5b635ed5bd6..636e2376188 100755 --- a/e2e/scripts/infra-up.sh +++ b/e2e/scripts/infra-up.sh @@ -19,4 +19,4 @@ if [[ "$MODE" != "build" ]]; then fi docker compose -f compose.dev.yaml -f compose.dev.analytics.yaml up -d --wait \ - mysql redis mailpit tinybird-local tb-cli analytics + mysql redis mailpit tinybird-local analytics From 05be6e13826feea375725faafe844289927e9688 Mon Sep 17 00:00:00 2001 From: Jonatan Svennberg Date: Tue, 24 Feb 2026 17:07:51 +0100 Subject: [PATCH 19/22] Forwarded compose project env into Playwright container ref https://linear.app/ghost/issue/BER-3363 Passed COMPOSE_PROJECT_NAME through dockerized Playwright runs so E2E runtime naming stays aligned with infra when using non-default compose project names. --- e2e/scripts/run-playwright-container.sh | 1 + 1 file changed, 1 insertion(+) diff --git a/e2e/scripts/run-playwright-container.sh b/e2e/scripts/run-playwright-container.sh index 2f5ea9d06bc..81eee1f5173 100755 --- a/e2e/scripts/run-playwright-container.sh +++ b/e2e/scripts/run-playwright-container.sh @@ -18,6 +18,7 @@ docker run --rm --network host --ipc host \ -w "${WORKSPACE_PATH}/e2e" \ -e CI=true \ -e TEST_WORKERS_COUNT="${TEST_WORKERS_COUNT:-1}" \ + -e COMPOSE_PROJECT_NAME="${COMPOSE_PROJECT_NAME:-ghost-dev}" \ -e GHOST_E2E_MODE="${GHOST_E2E_MODE:-build}" \ -e GHOST_E2E_IMAGE="${GHOST_E2E_IMAGE:-ghost-e2e:local}" \ -e GHOST_E2E_GATEWAY_IMAGE="${GHOST_E2E_GATEWAY_IMAGE:-caddy:2-alpine}" \ From e44b4616ad24717958b5d71957afd1cd79c72e68 Mon Sep 17 00:00:00 2001 From: Jonatan Svennberg Date: Wed, 25 Feb 2026 08:26:18 +0100 Subject: [PATCH 20/22] Removed Tinybird event truncation from E2E prep ref https://linear.app/ghost/issue/BER-3363 Tinybird events are already isolated by site_uuid per test, so truncating analytics_events was unnecessary and created an unexpected destructive side effect for shared dev analytics data. --- e2e/README.md | 2 +- e2e/scripts/sync-tinybird-state.mjs | 23 ----------------------- 2 files changed, 1 insertion(+), 24 deletions(-) diff --git a/e2e/README.md b/e2e/README.md index adecc0bae80..8911bf583a6 100644 --- a/e2e/README.md +++ b/e2e/README.md @@ -46,7 +46,7 @@ yarn dev:analytics yarn workspace @tryghost/e2e test:analytics ``` -E2E test scripts automatically sync Tinybird tokens and reset analytics test state when Tinybird is running. +E2E test scripts automatically sync Tinybird tokens when Tinybird is running. ### Build Mode (Prebuilt Image) diff --git a/e2e/scripts/sync-tinybird-state.mjs b/e2e/scripts/sync-tinybird-state.mjs index 0ec0c4f9a25..c679b638a62 100644 --- a/e2e/scripts/sync-tinybird-state.mjs +++ b/e2e/scripts/sync-tinybird-state.mjs @@ -82,21 +82,6 @@ function fetchConfigFromTbCli() { ]); } -function truncateAnalyticsEvents() { - runCompose([ - 'run', - '--rm', - '-T', - 'tb-cli', - 'tb', - 'datasource', - 'truncate', - 'analytics_events', - '--yes', - '--cascade' - ]); -} - function writeConfig(env) { fs.mkdirSync(stateDir, {recursive: true}); fs.writeFileSync(configPath, JSON.stringify({ @@ -123,14 +108,6 @@ try { writeConfig(env); log(`Wrote Tinybird config to ${configPath}`); - - try { - truncateAnalyticsEvents(); - log('Truncated Tinybird analytics_events datasource'); - } catch (error) { - const message = error instanceof Error ? error.message : String(error); - throw new Error(`Failed to truncate Tinybird analytics_events datasource: ${message}`); - } } catch (error) { clearConfigIfPresent(); const message = error instanceof Error ? error.message : String(error); From 13f8c1f0506c751b451675f559d0ef26fcc5a3a0 Mon Sep 17 00:00:00 2001 From: Jonatan Svennberg Date: Wed, 25 Feb 2026 08:28:02 +0100 Subject: [PATCH 21/22] Removed unused legacy E2E compose file ref https://linear.app/ghost/issue/BER-3363 Unified E2E infra now uses compose.dev.yaml plus compose.dev.analytics.yaml via scripts, so e2e/compose.yml had no remaining references and was dead config. --- e2e/compose.yml | 120 ------------------------------------------------ 1 file changed, 120 deletions(-) delete mode 100644 e2e/compose.yml diff --git a/e2e/compose.yml b/e2e/compose.yml deleted file mode 100644 index fdebbece3a7..00000000000 --- a/e2e/compose.yml +++ /dev/null @@ -1,120 +0,0 @@ -name: ghost-e2e -services: - mysql: - image: mysql:8.4.5 - command: --innodb-buffer-pool-size=1G --innodb-log-buffer-size=500M --innodb-change-buffer-max-size=50 --innodb-flush-log-at-trx_commit=0 --innodb-flush-method=O_DIRECT - environment: - MYSQL_ROOT_PASSWORD: root - MYSQL_DATABASE: ghost_testing - MYSQL_USER: ghost - MYSQL_PASSWORD: ghost - tmpfs: - - /var/lib/mysql - healthcheck: - test: ["CMD", "mysql", "-h", "127.0.0.1", "-uroot", "-proot", "ghost_testing", "-e", "SELECT 1"] - interval: 1s - retries: 120 - timeout: 5s - start_period: 10s - - ghost-migrations: - image: ${GHOST_E2E_IMAGE:-ghost-e2e:local} - pull_policy: never - working_dir: /home/ghost - command: ["node", "node_modules/.bin/knex-migrator", "init"] - environment: - database__client: mysql2 - database__connection__host: mysql - database__connection__user: root - database__connection__password: root - database__connection__database: ghost_testing - restart: on-failure:5 - depends_on: - mysql: - condition: service_healthy - - caddy: - image: caddy:latest - ports: - - "8080:80" - volumes: - - ../docker/caddy/Caddyfile.e2e:/etc/caddy/Caddyfile:ro - environment: - - ANALYTICS_PROXY_TARGET=analytics:3000 - healthcheck: - test: ["CMD", "wget", "--spider", "-q", "http://localhost:80"] - interval: 1s - timeout: 5s - retries: 30 - depends_on: - analytics: - condition: service_healthy - - analytics: - image: ghost/traffic-analytics:1.0.97 - platform: linux/amd64 - command: ["node", "--enable-source-maps", "dist/server.js"] - entrypoint: [ "/app/entrypoint.sh" ] - expose: - - "3000" - healthcheck: - # Simpler: use Node's global fetch (Node 18+) - test: ["CMD-SHELL", "node -e \"fetch('http://localhost:3000').then(r=>process.exit(r.status<500?0:1)).catch(()=>process.exit(1))\"" ] - interval: 1s - retries: 120 - volumes: - - ../docker/analytics/entrypoint.sh:/app/entrypoint.sh:ro - - shared-config:/mnt/shared-config:ro - environment: - - PROXY_TARGET=http://tinybird-local:7181/v0/events - - TINYBIRD_WAIT=true - depends_on: - tinybird-local: - condition: service_healthy - tb-cli: - condition: service_completed_successfully - - tinybird-local: - image: tinybirdco/tinybird-local:latest - platform: linux/amd64 - stop_grace_period: 2s - ports: - - "7181:7181" - healthcheck: - test: ["CMD", "curl", "-f", "http://localhost:7181/v0/health" ] - interval: 1s - timeout: 5s - retries: 120 - - tb-cli: - build: - context: ../ - dockerfile: docker/tb-cli/Dockerfile - working_dir: /home/tinybird - environment: - - TB_HOST=http://tinybird-local:7181 - - TB_LOCAL_HOST=tinybird-local - volumes: - - ../ghost/core/core/server/data/tinybird:/home/tinybird - - shared-config:/mnt/shared-config - depends_on: - tinybird-local: - condition: service_healthy - - mailpit: - image: axllent/mailpit - platform: linux/amd64 - ports: - - "1026:1025" # SMTP server - - "8026:8025" # Web interface - healthcheck: - test: ["CMD", "wget", "-q", "--spider", "http://localhost:8025"] - interval: 1s - retries: 30 - -volumes: - shared-config: - -networks: - default: - name: ghost_e2e From 1370d469431554ecd126b274821dd49ee7c13b71 Mon Sep 17 00:00:00 2001 From: Jonatan Svennberg Date: Wed, 25 Feb 2026 08:40:02 +0100 Subject: [PATCH 22/22] Scoped analytics request enablement to analytics tests ref https://linear.app/ghost/issue/BER-3363 Moved synthetic monitoring init into the analytics-only branch in PublicPage.goto so non-analytics tests no longer enable analytics request behavior unnecessarily. --- e2e/helpers/pages/public/public-page.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/e2e/helpers/pages/public/public-page.ts b/e2e/helpers/pages/public/public-page.ts index ced8b499030..c43cde47812 100644 --- a/e2e/helpers/pages/public/public-page.ts +++ b/e2e/helpers/pages/public/public-page.ts @@ -81,9 +81,9 @@ export class PublicPage extends BasePage { const testInfo = test.info(); let pageHitPromise = null; if (testInfo.project.name === 'analytics') { + await this.enableAnalyticsRequests(); pageHitPromise = this.pageHitRequestPromise(); } - await this.enableAnalyticsRequests(); const result = await super.goto(url, options); if (pageHitPromise) { await pageHitPromise;