diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 30a73bafb40..7ad4f04233a 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1261,27 +1261,28 @@ 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 + run: bash ./e2e/scripts/prepare-ci-e2e-job.sh - - name: Pull images + - name: Run e2e tests in Playwright container env: + TEST_WORKERS_COUNT: 1 + GHOST_E2E_MODE: build GHOST_E2E_IMAGE: ghost-e2e:local - run: docker compose -f e2e/compose.yml pull + E2E_SHARD_INDEX: ${{ matrix.shardIndex }} + E2E_SHARD_TOTAL: ${{ matrix.shardTotal }} + E2E_RETRIES: 2 + run: bash ./e2e/scripts/run-playwright-container.sh - - name: Setup Playwright - uses: ./.github/actions/setup-playwright + - name: Dump E2E docker logs + if: failure() + run: bash ./e2e/scripts/dump-e2e-docker-logs.sh - - name: Run e2e tests - env: - 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() 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/dev-gateway/Caddyfile.build b/docker/dev-gateway/Caddyfile.build new file mode 100644 index 00000000000..7e736b7d3d1 --- /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 console + } + + # 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/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/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..8911bf583a6 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) @@ -31,6 +31,44 @@ yarn dev 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. + +### Analytics Development Flow + +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 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`. + +```bash +# From repository root +yarn build +yarn workspace @tryghost/e2e build:apps +GHOST_E2E_BASE_IMAGE= yarn workspace @tryghost/e2e build:docker +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 +``` + +For a CI-like local preflight (pulls Playwright + gateway images and starts infra), run: + +```bash +yarn workspace @tryghost/e2e preflight:build +``` + ### Running Specific Tests @@ -149,43 +187,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 +219,12 @@ 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. **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 @@ -218,6 +234,13 @@ Within the e2e directory: # Run all tests yarn test +# Start/stop test infra (MySQL/Redis/Mailpit/Tinybird) +yarn infra:up +yarn infra:down + +# 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/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 diff --git a/e2e/helpers/environment/constants.ts b/e2e/helpers/environment/constants.ts index 24f7ca5c1d4..7c909c2bcb6 100644 --- a/e2e/helpers/environment/constants.ts +++ b/e2e/helpers/environment/constants.ts @@ -6,55 +6,49 @@ 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' -}; +// Repository root path (for source mounting and config files) +export const REPO_ROOT = path.resolve(__dirname, '../../..'); -export const GHOST_DEFAULTS = { - PORT: 2368 -}; +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'; -export interface GhostImageProfile { - image: string; - workdir: string; - command: string[]; -} +/** + * 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 function getImageProfile(): GhostImageProfile { - const image = process.env.GHOST_E2E_IMAGE || 'ghost-e2e:local'; - return { - image, - workdir: '/home/ghost', - command: ['node', 'index.js'] - }; -} +/** + * 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 MYSQL = { - HOST: 'mysql', - PORT: 3306, - USER: 'root', - PASSWORD: 'root' -}; +/** + * 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 -}; - -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 + JSON_PATH: path.resolve(CONFIG_DIR, 'tinybird.json') }; /** @@ -62,49 +56,43 @@ export const MAILPIT = { * 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; +/** + * 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; + 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: [ - // 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', - - // 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}` - ] + image: `${DEV_COMPOSE_PROJECT}-ghost-dev`, + port: 2368 } } 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..70ef4e42746 100644 --- a/e2e/helpers/environment/index.ts +++ b/e2e/helpers/environment/index.ts @@ -1,6 +1,3 @@ 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..bd234f15a6c 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: { @@ -19,67 +16,13 @@ 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; -} - -// 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..d429b8b0d0f 100644 --- a/e2e/helpers/environment/service-managers/ghost-manager.ts +++ b/e2e/helpers/environment/service-managers/ghost-manager.ts @@ -1,211 +1,450 @@ 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_GATEWAY_IMAGE, + BUILD_IMAGE, + CADDYFILE_PATHS, + DEV_ENVIRONMENT, + DEV_SHARED_CONFIG_VOLUME, + 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; +interface TinybirdConfigFile { + workspaceId?: string; + adminToken?: string; + trackerToken?: string; +} +/** + * 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 E2E tests across dev/build modes. + * 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; + } - constructor(docker: Docker, dockerCompose: DockerCompose, tinybird: TinybirdManager, profile: GhostImageProfile) { - this.docker = docker; - this.dockerCompose = dockerCompose; - this.tinybird = tinybird; - this.profile = profile; + getGatewayPort(): number { + return 30000 + this.config.workerIndex; } - private async createAndStart(config: GhostStartConfig): Promise { + /** + * 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(); + } + + 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`); + } + + /** + * 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 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_MODE=build GHOST_E2E_IMAGE= yarn workspace @tryghost/e2e test` + ); + } + + 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_MODE=build GHOST_E2E_GATEWAY_IMAGE= yarn workspace @tryghost/e2e test` + ); + } + } + + /** + * 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) { + 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('Ghost container started:', container.id); + 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?: GhostEnvOverrides): 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); + } + + /** + * 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()}` + ]; + + // 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. + 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' + ); + + 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) { + for (const [key, value] of Object.entries(extraConfig)) { + 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})`); + } + } + } + + 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) { - logging.error('Failed to create Ghost container:', error); - throw new Error(`Failed to create Ghost container: ${error}`); + debug(`Tinybird config not available at ${TINYBIRD.JSON_PATH}:`, error); + return null; } } - 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); + 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; - return { - containerId: container.id, - instanceId, - database: instanceId, - port: hostPort, - baseUrl: `http://localhost:${hostPort}`, - siteUuid + // 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}` + } }; + + return this.docker.createContainer(config); } - async removeAll(): Promise { + /** + * 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) + `${DEV_SHARED_CONFIG_VOLUME}:/mnt/shared-config:ro` + ]; + + if (this.config.mode === 'dev') { + binds.push(`${REPO_ROOT}/ghost:/home/ghost/ghost`); + } + + return binds; + } + + 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' + ]; + + // 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: 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}); + } catch { + debug('Failed to remove container:', container.id); + } + } + + /** + * Remove all e2e containers by project label. + */ + async cleanupAllContainers(): Promise { try { - debug('Finding all Ghost containers...'); const containers = await this.docker.listContainers({ all: true, filters: { - label: ['tryghost/e2e=ghost'] + label: [`com.docker.compose.project=${TEST_ENVIRONMENT.projectNamespace}`] } }); - if (containers.length === 0) { - debug('No Ghost containers found'); - return; - } - - debug(`Found ${containers.length} Ghost container(s) to remove`); - for (const containerInfo of containers) { - await this.stopAndRemoveInstance(containerInfo.Id); - } - 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); - } - } + const results = await Promise.allSettled( + containers.map(c => this.docker.getContainer(c.Id).remove({force: true})) + ); - 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); + for (const [index, result] of results.entries()) { + if (result.status === 'rejected') { + debug('cleanupAllContainers: failed to remove container', containers[index]?.Id, result.reason); + } } - await container.remove({force: true}); - debug('Container removed:', containerId); } 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; + } + + 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'); } - await new Promise((resolve) => { - setTimeout(resolve, 200); + + 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..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 {DockerCompose} from '@/helpers/environment/docker-compose'; +import {DEV_PRIMARY_DATABASE} from '@/helpers/environment/constants'; import {PassThrough} from 'stream'; import type {Container} from 'dockerode'; @@ -13,20 +14,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 +37,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)}`); } } @@ -82,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); @@ -127,17 +129,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 +168,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; - } -} diff --git a/e2e/helpers/pages/public/public-page.ts b/e2e/helpers/pages/public/public-page.ts index f348e1fa8d7..c43cde47812 100644 --- a/e2e/helpers/pages/public/public-page.ts +++ b/e2e/helpers/pages/public/public-page.ts @@ -84,7 +84,6 @@ export class PublicPage extends BasePage { await this.enableAnalyticsRequests(); pageHitPromise = this.pageHitRequestPromise(); } - await this.enableAnalyticsRequests(); const result = await super.goto(url, options); if (pageHitPromise) { await pageHitPromise; @@ -100,11 +99,32 @@ export class PublicPage extends BasePage { }); } + protected async waitForMemberAttributionReady(): Promise { + // 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'); + + 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); } } 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}) diff --git a/e2e/package.json b/e2e/package.json index cca4008e0db..00f820beca8 100644 --- a/e2e/package.json +++ b/e2e/package.json @@ -11,14 +11,17 @@ "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'", - "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", + "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", + "preflight:build": "bash ./scripts/prepare-ci-e2e-build-mode.sh", + "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/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/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..636e2376188 --- /dev/null +++ b/e2e/scripts/infra-up.sh @@ -0,0 +1,22 @@ +#!/usr/bin/env bash +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +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 analytics 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..383045fed17 --- /dev/null +++ b/e2e/scripts/prepare-ci-e2e-build-mode.sh @@ -0,0 +1,37 @@ +#!/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}" + +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" env GHOST_E2E_MODE=build 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 + +node "$REPO_ROOT/e2e/scripts/sync-tinybird-state.mjs" 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-container.sh b/e2e/scripts/run-playwright-container.sh new file mode 100755 index 00000000000..81eee1f5173 --- /dev/null +++ b/e2e/scripts/run-playwright-container.sh @@ -0,0 +1,26 @@ +#!/usr/bin/env bash +set -euo pipefail + +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 + +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 \ + -v "${WORKSPACE_PATH}:${WORKSPACE_PATH}" \ + -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}" \ + "$PLAYWRIGHT_IMAGE" \ + yarn test:all --shard="${SHARD_INDEX}/${SHARD_TOTAL}" --retries="${RETRIES}" diff --git a/e2e/scripts/run-playwright-host.sh b/e2e/scripts/run-playwright-host.sh new file mode 100755 index 00000000000..f7116a939c8 --- /dev/null +++ b/e2e/scripts/run-playwright-host.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 "$@" diff --git a/e2e/scripts/sync-tinybird-state.mjs b/e2e/scripts/sync-tinybird-state.mjs new file mode 100644 index 00000000000..c679b638a62 --- /dev/null +++ b/e2e/scripts/sync-tinybird-state.mjs @@ -0,0 +1,116 @@ +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 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}`); +} catch (error) { + clearConfigIfPresent(); + const message = error instanceof Error ? error.message : String(error); + log(`Tinybird state sync failed: ${message}`); + process.exit(1); +}