From 7eb14aeceb51ad6c9acfd757f9a2a725c890a2ec Mon Sep 17 00:00:00 2001 From: Jonatan Svennberg Date: Wed, 26 Nov 2025 09:29:59 +0100 Subject: [PATCH 1/5] Integrated local E2E test with dev env refs https://linear.app/ghost/issue/BER-3077/improve-e2e-testing-integration Detect the presence of containers initialized through yarn dev. Reuse existing containers when available and use the lightweight dev backend image + gateway setup for E2E test isolation. --- compose.dev.yaml | 1 + e2e/AGENTS.md | 12 + e2e/README.md | 40 ++- e2e/helpers/environment/constants.ts | 51 +++ .../environment/dev-environment-manager.ts | 126 +++++++ .../environment/environment-factory.ts | 19 ++ e2e/helpers/environment/index.ts | 3 + .../environment/service-availability.ts | 85 +++++ .../service-managers/dev-ghost-manager.ts | 318 ++++++++++++++++++ .../environment/service-managers/index.ts | 1 + .../service-managers/mysql-manager.ts | 4 +- e2e/helpers/playwright/fixture.ts | 17 +- e2e/tests/global.setup.ts | 6 +- e2e/tests/global.teardown.ts | 6 +- 14 files changed, 677 insertions(+), 12 deletions(-) create mode 100644 e2e/helpers/environment/dev-environment-manager.ts create mode 100644 e2e/helpers/environment/environment-factory.ts create mode 100644 e2e/helpers/environment/service-availability.ts create mode 100644 e2e/helpers/environment/service-managers/dev-ghost-manager.ts diff --git a/compose.dev.yaml b/compose.dev.yaml index 5b5224321ca..14ad1419ffe 100644 --- a/compose.dev.yaml +++ b/compose.dev.yaml @@ -44,6 +44,7 @@ services: ports: - "1025:1025" # SMTP server - "8025:8025" # Web interface + - "8026:8025" # Web interface (for e2e tests) healthcheck: test: ["CMD", "wget", "-q", "--spider", "http://localhost:8025"] interval: 1s diff --git a/e2e/AGENTS.md b/e2e/AGENTS.md index 06466f7541e..ce9d06817e2 100644 --- a/e2e/AGENTS.md +++ b/e2e/AGENTS.md @@ -22,6 +22,18 @@ yarn test --debug # See browser during execution, PRESERVE_ENV=true yarn test # Debug failed tests (keeps containers) ``` +## Dev Environment Mode (Recommended) + +When `yarn dev` is running, e2e tests automatically use a more efficient execution mode: + +```bash +# Terminal 1: Start dev environment +yarn dev + +# Terminal 2: Run e2e tests (automatically uses dev environment) +cd e2e && yarn test +``` + ## Test Structure ### Naming Conventions diff --git a/e2e/README.md b/e2e/README.md index aab88ee6df6..3b125c2b4e2 100644 --- a/e2e/README.md +++ b/e2e/README.md @@ -19,6 +19,19 @@ yarn 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: + +```bash +# Terminal 1: Start dev environment (from repository root) +yarn dev + +# Terminal 2: Run e2e tests (from e2e folder) +yarn test +``` + + ### Running Specific Tests ```bash @@ -134,7 +147,11 @@ For example, a `ghostInstance` fixture creates a new Ghost instance with its own ### Test Isolation -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: +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.) @@ -149,6 +166,27 @@ Test isolation is extremely important to avoid flaky tests that are hard to debu - 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. + ### Best Practices 1. **Use page object patterns** to separate page elements, actions on the pages, complex logic from tests. They should help you make them more readable and UI elements reusable. diff --git a/e2e/helpers/environment/constants.ts b/e2e/helpers/environment/constants.ts index 54beb456617..a21e33ad11e 100644 --- a/e2e/helpers/environment/constants.ts +++ b/e2e/helpers/environment/constants.ts @@ -40,3 +40,54 @@ export const MAILPIT = { PORT: 1025 }; +/** + * Configuration for dev environment mode. + * Used when yarn dev infrastructure is detected. + */ +export const DEV_ENVIRONMENT = { + projectNamespace: 'ghost-dev', + networkName: 'ghost_dev' +} as const; + +export const TEST_ENVIRONMENT = { + projectNamespace: 'ghost-dev-e2e', + gateway: { + image: 'ghost-dev-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=/ghost/assets/portal/portal.min.js', + 'comments__url=/ghost/assets/comments-ui/comments-ui.min.js', + 'sodoSearch__url=/ghost/assets/sodo-search/sodo-search.min.js', + 'sodoSearch__styles=/ghost/assets/sodo-search/main.css', + 'signupForm__url=/ghost/assets/signup-form/signup-form.min.js', + 'announcementBar__url=/ghost/assets/announcement-bar/announcement-bar.min.js' + ] + } +} as const; + diff --git a/e2e/helpers/environment/dev-environment-manager.ts b/e2e/helpers/environment/dev-environment-manager.ts new file mode 100644 index 00000000000..89d8a4afea4 --- /dev/null +++ b/e2e/helpers/environment/dev-environment-manager.ts @@ -0,0 +1,126 @@ +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/environment-factory.ts b/e2e/helpers/environment/environment-factory.ts new file mode 100644 index 00000000000..84a5b8320b2 --- /dev/null +++ b/e2e/helpers/environment/environment-factory.ts @@ -0,0 +1,19 @@ +import {DevEnvironmentManager} from './dev-environment-manager'; +import {EnvironmentManager} from './environment-manager'; +import {isDevEnvironmentAvailable} from './service-availability'; + +// Cached manager instance (one per worker process) +let cachedManager: EnvironmentManager | DevEnvironmentManager | null = null; + +/** + * Get the environment manager for this worker. + * Creates and caches a manager on first call, returns cached instance thereafter. + */ +export async function getEnvironmentManager(): Promise { + if (!cachedManager) { + const useDevEnv = await isDevEnvironmentAvailable(); + cachedManager = useDevEnv ? new DevEnvironmentManager() : new EnvironmentManager(); + } + return cachedManager; +} + diff --git a/e2e/helpers/environment/index.ts b/e2e/helpers/environment/index.ts index 6fd6b34bc4b..5c31d03f5e2 100644 --- a/e2e/helpers/environment/index.ts +++ b/e2e/helpers/environment/index.ts @@ -1,3 +1,6 @@ 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 new file mode 100644 index 00000000000..b9d8182623e --- /dev/null +++ b/e2e/helpers/environment/service-availability.ts @@ -0,0 +1,85 @@ +import Docker from 'dockerode'; +import baseDebug from '@tryghost/debug'; +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: { + label: [ + `com.docker.compose.service=${serviceName}`, + `com.docker.compose.project=${DEV_ENVIRONMENT.projectNamespace}` + ], + status: ['running'] + } + }); + 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); +} diff --git a/e2e/helpers/environment/service-managers/dev-ghost-manager.ts b/e2e/helpers/environment/service-managers/dev-ghost-manager.ts new file mode 100644 index 00000000000..9be629ade26 --- /dev/null +++ b/e2e/helpers/environment/service-managers/dev-ghost-manager.ts @@ -0,0 +1,318 @@ +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/index.ts b/e2e/helpers/environment/service-managers/index.ts index 5d7c86199e9..0f07756d0f6 100644 --- a/e2e/helpers/environment/service-managers/index.ts +++ b/e2e/helpers/environment/service-managers/index.ts @@ -1,3 +1,4 @@ +export * from './dev-ghost-manager'; export * from './ghost-manager'; export * from './mysql-manager'; export * from './portal-manager'; diff --git a/e2e/helpers/environment/service-managers/mysql-manager.ts b/e2e/helpers/environment/service-managers/mysql-manager.ts index e020e4be058..325ea087795 100644 --- a/e2e/helpers/environment/service-managers/mysql-manager.ts +++ b/e2e/helpers/environment/service-managers/mysql-manager.ts @@ -76,13 +76,13 @@ export class MySQLManager { /** * Used for cleanup of leftover databases from interrupted tests. - * This removes all databases matching the pattern 'ghost_%' except 'ghost_testing' (the base database). + * This removes all databases matching the pattern 'ghost_%' except base databases. */ async dropAllTestDatabases(): Promise { 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 != \'ghost_testing\''; + 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 output = await this.exec(`mysql -uroot -proot -N -e "${query}"`); const databaseNames = this.parseDatabaseNames(output); diff --git a/e2e/helpers/playwright/fixture.ts b/e2e/helpers/playwright/fixture.ts index 82367d1d9e5..de1af964b2f 100644 --- a/e2e/helpers/playwright/fixture.ts +++ b/e2e/helpers/playwright/fixture.ts @@ -1,13 +1,12 @@ import baseDebug from '@tryghost/debug'; import {Browser, BrowserContext, Page, TestInfo, test as base} from '@playwright/test'; -import {EnvironmentManager, GhostInstance} from '@/helpers/environment'; +import {GhostInstance, getEnvironmentManager} from '@/helpers/environment'; import {SettingsService} from '@/helpers/services/settings/settings-service'; import {faker} from '@faker-js/faker'; import {loginToGetAuthenticatedSession} from '@/helpers/playwright/flows/sign-in'; import {setupUser} from '@/helpers/utils'; const debug = baseDebug('e2e:ghost-fixture'); - export interface User { name: string; email: string; @@ -57,6 +56,10 @@ 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) + * * Optionally allows setting labs flags via test.use({labs: {featureName: true}}) * and Stripe connection via test.use({stripeConnected: true}) and Ghost config via config settings: @@ -70,22 +73,28 @@ export const test = base.extend({ config: [undefined, {option: true}], labs: [undefined, {option: true}], stripeConnected: [false, {option: true}], + + // Each test gets its own Ghost instance with isolated database ghostInstance: async ({config}, use, testInfo: TestInfo) => { debug('Setting up Ghost instance for test:', testInfo.title); - const environmentManager = new EnvironmentManager(); + const environmentManager = await getEnvironmentManager(); const ghostInstance = await environmentManager.perTestSetup({config}); + debug('Ghost instance ready for test:', { testTitle: testInfo.title, ...ghostInstance }); await use(ghostInstance); + debug('Tearing down Ghost instance for test:', testInfo.title); await environmentManager.perTestTeardown(ghostInstance); debug('Teardown completed for test:', testInfo.title); }, + baseURL: async ({ghostInstance}, use) => { await use(ghostInstance.baseUrl); }, + // Create user credentials only (no authentication) ghostAccountOwner: async ({baseURL}, use) => { if (!baseURL) { @@ -101,6 +110,7 @@ export const test = base.extend({ await setupUser(baseURL, ghostAccountOwner); await use(ghostAccountOwner); }, + // Intermediate fixture that sets up the page and returns all setup data pageWithAuthenticatedUser: async ({browser, baseURL, ghostAccountOwner}, use) => { if (!baseURL) { @@ -111,6 +121,7 @@ export const test = base.extend({ await use(pageWithAuthenticatedUser); await pageWithAuthenticatedUser.context.close(); }, + // Extract the page from pageWithAuthenticatedUser and apply labs/stripe settings page: async ({pageWithAuthenticatedUser, labs, stripeConnected}, use) => { const page = pageWithAuthenticatedUser.page; diff --git a/e2e/tests/global.setup.ts b/e2e/tests/global.setup.ts index f9236aea874..6a0f1a1e1be 100644 --- a/e2e/tests/global.setup.ts +++ b/e2e/tests/global.setup.ts @@ -1,10 +1,10 @@ -import {EnvironmentManager} from '@/helpers/environment'; +import {getEnvironmentManager} from '@/helpers/environment'; import {test as setup} from '@playwright/test'; const TIMEOUT = 2 * 60 * 1000; // 2 minutes setup('global environment setup', async () => { setup.setTimeout(TIMEOUT); - const environmentManager = new EnvironmentManager(); - await environmentManager.globalSetup(); + const manager = await getEnvironmentManager(); + await manager.globalSetup(); }); diff --git a/e2e/tests/global.teardown.ts b/e2e/tests/global.teardown.ts index a67016cf966..aff266a066b 100644 --- a/e2e/tests/global.teardown.ts +++ b/e2e/tests/global.teardown.ts @@ -1,7 +1,7 @@ -import {EnvironmentManager} from '@/helpers/environment'; +import {getEnvironmentManager} from '@/helpers/environment'; import {test as teardown} from '@playwright/test'; teardown('global environment cleanup', async () => { - const environmentManager = new EnvironmentManager(); - await environmentManager.globalTeardown(); + const manager = await getEnvironmentManager(); + await manager.globalTeardown(); }); From 5ab9006dcd720b91949484d113f7a20e46ba0278 Mon Sep 17 00:00:00 2001 From: Jonatan Svennberg Date: Tue, 9 Dec 2025 09:44:30 +0100 Subject: [PATCH 2/5] Set up separate Playwright project for analytics refs https://linear.app/ghost/issue/BER-3077/improve-e2e-testing-integration Make it possible to run analytics tests separately from the rest of the E2E test suite. We should likely extend this further and split up the main project into additional smaller projects and reorganize the tests accordingly. --- .github/workflows/ci.yml | 2 +- e2e/package.json | 2 ++ e2e/playwright.config.mjs | 11 ++++++++++- package.json | 2 ++ 4 files changed, 15 insertions(+), 2 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 924d60c50a4..521f55ac077 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1166,7 +1166,7 @@ jobs: env: GHOST_IMAGE_TAG: ${{ steps.load.outputs.image-tag }} TEST_WORKERS_COUNT: 1 - run: yarn test:e2e --shard=${{ matrix.shardIndex }}/${{ matrix.shardTotal }} + run: yarn test:e2e:all --shard=${{ matrix.shardIndex }}/${{ matrix.shardTotal }} - name: Upload blob report to GitHub Actions Artifacts if: failure() diff --git a/e2e/package.json b/e2e/package.json index 0b532795f4a..6e66567e4de 100644 --- a/e2e/package.json +++ b/e2e/package.json @@ -14,6 +14,8 @@ "prepare": "tsc --noEmit", "pretest": "(test -n \"$GHOST_E2E_SKIP_BUILD\" || test -n \"$CI\") && echo 'Skipping Docker build (GHOST_E2E_SKIP_BUILD or CI is set)' || docker compose -f ../compose.yml build ghost tb-cli", "test": "playwright test --project=main", + "test:analytics": "playwright test --project=analytics", + "test:all": "playwright test --project=main --project=analytics", "test:single": "playwright test --project=main -g", "test:debug": "playwright test --project=main --headed --timeout=60000 -g", "test:types": "tsc --noEmit", diff --git a/e2e/playwright.config.mjs b/e2e/playwright.config.mjs index ac7d505fbd5..b89006b7df6 100644 --- a/e2e/playwright.config.mjs +++ b/e2e/playwright.config.mjs @@ -44,13 +44,22 @@ const config = { }, { name: 'main', - testIgnore: ['**/*.setup.ts', '**/*.teardown.ts'], + testIgnore: ['**/*.setup.ts', '**/*.teardown.ts', 'analytics/**/*.test.ts'], testDir: './tests', use: { viewport: {width: 1920, height: 1080} }, dependencies: ['global-setup'] }, + { + name: 'analytics', + testDir: './tests', + testMatch: ['analytics/**/*.test.ts'], + use: { + viewport: {width: 1920, height: 1080} + }, + dependencies: ['global-setup'] + }, { name: 'global-teardown', testMatch: /global\.teardown\.ts/, diff --git a/package.json b/package.json index a434fe17136..78e6690ac6f 100644 --- a/package.json +++ b/package.json @@ -67,6 +67,8 @@ "test:unit": "nx run-many -t test:unit", "test:browser": "node .github/scripts/dev.js --browser-tests --all --", "test:e2e": "yarn workspace @tryghost/e2e test", + "test:e2e:analytics": "yarn workspace @tryghost/e2e test:analytics", + "test:e2e:all": "yarn workspace @tryghost/e2e test:all", "test:e2e:debug": "DEBUG=@tryghost/e2e:* yarn test:e2e", "main": "yarn main:monorepo && yarn main:submodules", "main:monorepo": "git checkout main && git pull ${GHOST_UPSTREAM:-origin} main && yarn", From a690f2f278ecf0d44a57c297d2e3111e63458fb4 Mon Sep 17 00:00:00 2001 From: Jonatan Svennberg Date: Wed, 26 Nov 2025 14:42:56 +0100 Subject: [PATCH 3/5] Added conditional await for pageHitPromise to base public-page POM refs https://linear.app/ghost/issue/BER-3077/improve-e2e-testing-integration Always waiting for the analytics request to succeed means any public test will fail if Tinybird or the analytics service isn't running. Co-authored-by: Chris Raible --- e2e/helpers/pages/public/public-page.ts | 13 ++++++++++--- 1 file changed, 10 insertions(+), 3 deletions(-) diff --git a/e2e/helpers/pages/public/public-page.ts b/e2e/helpers/pages/public/public-page.ts index 28520df3b57..c03bcab007a 100644 --- a/e2e/helpers/pages/public/public-page.ts +++ b/e2e/helpers/pages/public/public-page.ts @@ -1,5 +1,5 @@ import {BasePage, pageGotoOptions} from '@/helpers/pages'; -import {Locator, Page, Response} from '@playwright/test'; +import {Locator, Page, Response, test} from '@playwright/test'; declare global { interface Window { @@ -85,10 +85,17 @@ export class PublicPage extends BasePage { } async goto(url?: string, options?: pageGotoOptions): Promise { + const testInfo = test.info(); + let pageHitPromise = null; + if (testInfo.project.name === 'analytics') { + await this.enableAnalyticsRequests(); + pageHitPromise = this.pageHitRequestPromise(); + } await this.enableAnalyticsRequests(); - const pageHitPromise = this.pageHitRequestPromise(); await super.goto(url, options); - await pageHitPromise; + if (pageHitPromise) { + await pageHitPromise; + } } pageHitRequestPromise(): Promise { From 5fa271bcc16e40ed0fa35e1c2906ded560bb55bf Mon Sep 17 00:00:00 2001 From: Jonatan Svennberg Date: Thu, 27 Nov 2025 09:47:19 +0100 Subject: [PATCH 4/5] Rework fixtures --- .../environment/dev-environment-manager.ts | 41 +++++- e2e/helpers/pages/admin/index.ts | 1 + .../admin/settings/sections/staff-section.ts | 32 +++++ e2e/helpers/pages/admin/signup-page.ts | 50 ++++++++ e2e/helpers/playwright/context-with-route.ts | 53 ++++++++ e2e/helpers/playwright/fixture.ts | 64 +++++---- e2e/helpers/services/email/utils.ts | 33 +++++ e2e/tests/global.setup.ts | 121 +++++++++++++++++- 8 files changed, 350 insertions(+), 45 deletions(-) create mode 100644 e2e/helpers/pages/admin/signup-page.ts create mode 100644 e2e/helpers/playwright/context-with-route.ts diff --git a/e2e/helpers/environment/dev-environment-manager.ts b/e2e/helpers/environment/dev-environment-manager.ts index 89d8a4afea4..2c004fd975f 100644 --- a/e2e/helpers/environment/dev-environment-manager.ts +++ b/e2e/helpers/environment/dev-environment-manager.ts @@ -41,20 +41,47 @@ export class DevEnvironmentManager { /** * Global setup - creates database snapshot for test isolation. - * Mirrors the standalone environment: run migrations, then snapshot. + * 1. Create base database + * 2. Initialize Ghost containers + * 3. Start Ghost instance (migrations run automatically on startup) + * 4. Create snapshot of database + * 5. Keep instance running for reuse in per-test setup + * + * Note: User onboarding happens in global.setup.ts using parameterized tests */ - async globalSetup(): Promise { + async globalSetup(): Promise<{baseUrl: string}> { 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 + // Create base database 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'); + // Initialize Ghost containers (will be reused by perTestSetup) + debug('Initializing Ghost containers for global setup'); + await this.ghost.setup(); + this.initialized = true; + + // Start Ghost instance connected to base database (migrations run automatically) + const baseInstanceId = 'ghost_e2e_base'; + await this.ghost.restartWithDatabase(baseInstanceId); + await this.ghost.waitForReady(); + + const port = this.ghost.getGatewayPort(); + const baseUrl = `http://localhost:${port}`; + + logging.info('Ghost instance ready'); + + return {baseUrl}; + } + + /** + * Create snapshot after user onboarding is complete + */ + async createSnapshot(): Promise { + logging.info('Creating database snapshot...'); + await this.mysql.createSnapshot('ghost_e2e_base'); + logging.info('Database snapshot created'); } /** diff --git a/e2e/helpers/pages/admin/index.ts b/e2e/helpers/pages/admin/index.ts index a7e7409cde9..30fa0a5ca2b 100644 --- a/e2e/helpers/pages/admin/index.ts +++ b/e2e/helpers/pages/admin/index.ts @@ -11,3 +11,4 @@ export * from './tags'; export * from './sidebar'; export * from './billing'; export * from './comments'; +export * from './signup-page'; diff --git a/e2e/helpers/pages/admin/settings/sections/staff-section.ts b/e2e/helpers/pages/admin/settings/sections/staff-section.ts index 3ca346bdba9..3d37fd794a6 100644 --- a/e2e/helpers/pages/admin/settings/sections/staff-section.ts +++ b/e2e/helpers/pages/admin/settings/sections/staff-section.ts @@ -4,11 +4,15 @@ import {Locator, Page} from '@playwright/test'; export class StaffSection extends BasePage { readonly requireTwoFaButton: Locator; readonly ownerUser: Locator; + readonly invitePeopleButton: Locator; + readonly inviteModal: Locator; constructor(page: Page) { super(page, '/ghost/#/settings/staff'); this.ownerUser = this.page.getByTestId('owner-user'); this.requireTwoFaButton = page.getByTestId('users').getByRole('switch'); + this.invitePeopleButton = page.getByTestId('users').getByRole('button', {name: 'Invite people'}); + this.inviteModal = page.getByTestId('invite-user-modal'); } async waitForOwnerUser(): Promise { @@ -48,4 +52,32 @@ export class StaffSection extends BasePage { const switchState = this.page.getByTestId('users').getByRole('switch', {checked: checked}); await switchState.waitFor({state: 'visible'}); } + + /** + * Invite a user via the admin UI + * @param email - Email address of the user to invite + * @param role - Role to assign: 'administrator' | 'editor' | 'author' | 'contributor' + */ + async inviteUser(email: string, role: 'administrator' | 'editor' | 'author' | 'contributor'): Promise { + // Click "Invite people" button + await this.invitePeopleButton.click(); + + // Wait for modal to appear + await this.inviteModal.waitFor({state: 'visible'}); + + // Fill email field + await this.inviteModal.getByLabel('Email address').fill(email); + + // Select role by clicking the radio button with matching value + await this.inviteModal.locator(`button[value="${role}"]`).click(); + + // Submit the invitation + await this.inviteModal.getByRole('button', {name: 'Send invitation'}).click(); + + // Wait for success toast + await this.page.getByTestId('toast-success').waitFor({state: 'visible'}); + + // Wait for modal to close + await this.inviteModal.waitFor({state: 'hidden'}); + } } diff --git a/e2e/helpers/pages/admin/signup-page.ts b/e2e/helpers/pages/admin/signup-page.ts new file mode 100644 index 00000000000..9f573169c29 --- /dev/null +++ b/e2e/helpers/pages/admin/signup-page.ts @@ -0,0 +1,50 @@ +import {AdminPage} from './admin-page'; +import {AnalyticsOverviewPage} from '@/admin-pages'; +import {Locator, Page} from '@playwright/test'; + +export class SignupPage extends AdminPage { + readonly nameField: Locator; + readonly emailField: Locator; + readonly passwordField: Locator; + readonly submitButton: Locator; + + constructor(page: Page) { + super(page); + this.pageUrl = '/ghost/#/signup'; // Base URL, token will be appended + + this.nameField = page.locator('[data-test-input="name"]'); + this.emailField = page.locator('[data-test-input="email"]'); + this.passwordField = page.locator('[data-test-input="password"]'); + this.submitButton = page.locator('[data-test-button="signup"]'); + } + + /** + * Navigate to signup page with invitation token + * @param token - Base64 encoded invitation token + */ + async gotoWithToken(token: string): Promise { + await this.page.goto(`${this.pageUrl}/${token}/`); + await this.nameField.waitFor({state: 'visible'}); + } + + /** + * Complete the signup form and submit + * @param name - Full name for the user + * @param password - Password for the user (email is pre-filled from token) + */ + async completeSignup(name: string, password: string): Promise { + // Fill name field + await this.nameField.fill(name); + + // Fill password field + await this.passwordField.fill(password); + + // Submit the form + await this.submitButton.click(); + + // Wait for signup completion - should redirect to admin dashboard + const analyticsPage = new AnalyticsOverviewPage(this.page); + await analyticsPage.header.waitFor({state: 'visible'}); + } +} + diff --git a/e2e/helpers/playwright/context-with-route.ts b/e2e/helpers/playwright/context-with-route.ts new file mode 100644 index 00000000000..74801cedc5b --- /dev/null +++ b/e2e/helpers/playwright/context-with-route.ts @@ -0,0 +1,53 @@ +import * as fs from 'fs'; +import * as path from 'path'; +import {Browser, BrowserContext} from '@playwright/test'; + +/** + * Base URL used by Playwright for all test contexts. Requests to this hostname + * are routed to the actual Ghost backend instance (localhost:{port}) via route interception. + */ +export const PLAYWRIGHT_BASE_URL = 'http://ghost.test:2368'; + +const AUTH_STATE_DIR = path.join(process.cwd(), 'e2e', 'data', 'state', 'auth'); + +/** + * Creates a browser context with route interception to map ghost.test:2368 + * to localhost:{port} while preserving the Origin header for Ghost's CSRF protection. + */ +export async function createContextWithRoute( + browser: Browser, + backendURL: string, + options?: {role?: string} +): Promise { + const port = new URL(backendURL).port; + const storageState = options?.role ? path.join(AUTH_STATE_DIR, `${options.role}.json`) : undefined; + + if (storageState && !fs.existsSync(storageState)) { + throw new Error(`Storage state file not found: ${storageState}. Run global setup first.`); + } + + const context = await browser.newContext({ + baseURL: PLAYWRIGHT_BASE_URL, + storageState, + extraHTTPHeaders: { + Origin: PLAYWRIGHT_BASE_URL + } + }); + + await context.route('**/*', async (route) => { + const url = new URL(route.request().url()); + if (url.hostname === 'ghost.test') { + url.hostname = 'localhost'; + url.port = port; + const headers = route.request().headers(); + // Preserve Origin header to match what was used during authentication + headers.Origin = PLAYWRIGHT_BASE_URL; + await route.continue({url: url.toString(), headers}); + } else { + await route.continue(); + } + }); + + return context; +} + diff --git a/e2e/helpers/playwright/fixture.ts b/e2e/helpers/playwright/fixture.ts index de1af964b2f..7c619f84431 100644 --- a/e2e/helpers/playwright/fixture.ts +++ b/e2e/helpers/playwright/fixture.ts @@ -1,17 +1,15 @@ +import * as fs from 'fs'; +import * as path from 'path'; import baseDebug from '@tryghost/debug'; import {Browser, BrowserContext, Page, TestInfo, test as base} from '@playwright/test'; import {GhostInstance, getEnvironmentManager} from '@/helpers/environment'; import {SettingsService} from '@/helpers/services/settings/settings-service'; -import {faker} from '@faker-js/faker'; -import {loginToGetAuthenticatedSession} from '@/helpers/playwright/flows/sign-in'; -import {setupUser} from '@/helpers/utils'; +import {User} from '@/data-factory'; +import {createContextWithRoute} from '@/helpers/playwright/context-with-route'; const debug = baseDebug('e2e:ghost-fixture'); -export interface User { - name: string; - email: string; - password: string; -} +const AUTH_STATE_DIR = path.join(process.cwd(), 'e2e', 'data', 'state', 'auth'); +const USERS_STATE_FILE = path.join(process.cwd(), 'e2e', 'data', 'state', 'users.json'); export interface GhostConfig { memberWelcomeEmailSendInstantly?: string; @@ -25,31 +23,26 @@ export interface GhostInstanceFixture { ghostInstance: GhostInstance; labs?: Record; config?: GhostConfig; + role?: 'owner' | 'administrator' | 'editor' | 'author' | 'contributor'; stripeConnected?: boolean; ghostAccountOwner: User; pageWithAuthenticatedUser: { page: Page; context: BrowserContext; - ghostAccountOwner: User }; } -async function setupNewAuthenticatedPage(browser: Browser, baseURL: string, ghostAccountOwner: User) { - debug('Setting up authenticated page for Ghost instance:', baseURL); +async function setupNewAuthenticatedPage(browser: Browser, backendURL: string, role: string = 'owner') { + debug('Setting up authenticated page for Ghost instance:', backendURL, 'with role:', role); - // Create browser context with correct baseURL and extra HTTP headers - const context = await browser.newContext({ - baseURL: baseURL, - extraHTTPHeaders: { - Origin: baseURL - } + const context = await createContextWithRoute(browser, backendURL, { + role }); + const page = await context.newPage(); + debug('Authenticated page created using saved storageState with host aliasing'); - await loginToGetAuthenticatedSession(page, ghostAccountOwner.email, ghostAccountOwner.password); - debug('Authentication completed for Ghost instance'); - - return {page, context, ghostAccountOwner}; + return {page, context}; } /** @@ -72,6 +65,7 @@ export const test = base.extend({ // Define options that can be set per test or describe block config: [undefined, {option: true}], labs: [undefined, {option: true}], + role: ['owner', {option: true}], stripeConnected: [false, {option: true}], // Each test gets its own Ghost instance with isolated database @@ -95,29 +89,29 @@ export const test = base.extend({ await use(ghostInstance.baseUrl); }, - // Create user credentials only (no authentication) - ghostAccountOwner: async ({baseURL}, use) => { - if (!baseURL) { - throw new Error('baseURL is not defined'); + // Load owner user credentials from saved state (backward compatibility) + ghostAccountOwner: async ({}, use) => { + if (!fs.existsSync(USERS_STATE_FILE)) { + throw new Error(`User credentials file not found: ${USERS_STATE_FILE}. Run global setup first.`); } - // Create user in this Ghost instance - const ghostAccountOwner: User = { - name: 'Test User', - email: `test${faker.string.uuid()}@ghost.org`, - password: 'test@123@test' + const credentials = JSON.parse(fs.readFileSync(USERS_STATE_FILE, 'utf-8')); + const owner: User = { + name: credentials.owner.name, + email: credentials.owner.email, + password: credentials.owner.password, + blogTitle: credentials.owner.blogTitle }; - await setupUser(baseURL, ghostAccountOwner); - await use(ghostAccountOwner); + await use(owner); }, - // Intermediate fixture that sets up the page and returns all setup data - pageWithAuthenticatedUser: async ({browser, baseURL, ghostAccountOwner}, use) => { + // Intermediate fixture that sets up the page using saved authentication state + pageWithAuthenticatedUser: async ({browser, baseURL, role}, use) => { if (!baseURL) { throw new Error('baseURL is not defined'); } - const pageWithAuthenticatedUser = await setupNewAuthenticatedPage(browser, baseURL, ghostAccountOwner); + const pageWithAuthenticatedUser = await setupNewAuthenticatedPage(browser, baseURL, role); await use(pageWithAuthenticatedUser); await pageWithAuthenticatedUser.context.close(); }, diff --git a/e2e/helpers/services/email/utils.ts b/e2e/helpers/services/email/utils.ts index d36a3a5a622..c3148406e03 100644 --- a/e2e/helpers/services/email/utils.ts +++ b/e2e/helpers/services/email/utils.ts @@ -38,3 +38,36 @@ export function extractPasswordResetLink(message: EmailMessageDetailed): string return match[1]; } + +/** + * Extract invitation signup link from invitation email + * Pattern: /ghost/signup/{base64token}/ or /ghost/#/signup/{base64token}/ or full URL with that path + */ +export function extractInvitationLink(emailMessageBody: string): string { + // Try HTML first (more reliable) - match href attribute + // Pattern matches both /ghost/signup/ and /ghost/#/signup/ + const htmlMatch = emailMessageBody.match(/href="([^"]*\/ghost\/(?:#\/)?signup\/[^"]+)"/i); + if (htmlMatch && htmlMatch[1]) { + const link = htmlMatch[1]; + debug(`Found invitation link in HTML: ${link}`); + return link; + } + + // Try text version - full URL + const textMatch = emailMessageBody.match(/(https?:\/\/[^\s]+\/ghost\/(?:#\/)?signup\/[^\s\/]+)/i); + if (textMatch && textMatch[1]) { + const link = textMatch[1]; + debug(`Found invitation link in text: ${link}`); + return link; + } + + // Try relative URL pattern + const relativeMatch = emailMessageBody.match(/(\/ghost\/(?:#\/)?signup\/[^\s\/]+)/i); + if (relativeMatch && relativeMatch[1]) { + const link = relativeMatch[1]; + debug(`Found relative invitation link: ${link}`); + return link; + } + + throw new Error('No invitation link found in email'); +} diff --git a/e2e/tests/global.setup.ts b/e2e/tests/global.setup.ts index 6a0f1a1e1be..a4bf2663cb8 100644 --- a/e2e/tests/global.setup.ts +++ b/e2e/tests/global.setup.ts @@ -1,10 +1,125 @@ +import {expect, test as setup} from '@playwright/test'; import {getEnvironmentManager} from '@/helpers/environment'; -import {test as setup} from '@playwright/test'; const TIMEOUT = 2 * 60 * 1000; // 2 minutes +import * as path from 'path'; +import {MailPit} from '@/helpers/services/email/mail-pit'; +import {SettingsPage} from '@/helpers/pages'; +import {SignupPage} from '@/helpers/pages/admin/signup-page'; +import {createContextWithRoute} from '@/helpers/playwright/context-with-route'; +import {ensureDir} from '@/helpers/utils/ensure-dir'; +import {extractInvitationLink} from '@/helpers/services/email/utils'; +import {setupUser} from '@/helpers/utils/setup-user'; -setup('global environment setup', async () => { +const AUTH_STATE_DIR = path.join(process.cwd(), 'e2e', 'data', 'state', 'auth'); +const PASSWORD = 'test@123@test'; + +// Setup environment first +setup('environment setup', async () => { + const manager = await getEnvironmentManager(); + const result = await manager.globalSetup(); + + // Store baseUrl for use in user setup tests + // DevEnvironmentManager returns {baseUrl}, EnvironmentManager returns void + if (result && typeof result === 'object' && 'baseUrl' in result) { + process.env.E2E_BASE_URL = (result as {baseUrl: string}).baseUrl; + } else { + // Fallback to default or environment variable + process.env.E2E_BASE_URL = process.env.GHOST_BASE_URL || 'http://localhost:2368'; + } +}); + +// Setup owner user +setup('setup owner user', async ({browser}) => { + const backendURL = process.env.E2E_BASE_URL!; + const ownerEmail = 'owner@ghost.org'; + + await setupUser(backendURL, { + name: 'Test Owner', + email: ownerEmail, + password: PASSWORD, + blogTitle: 'Test Blog' + }); + + await ensureDir(AUTH_STATE_DIR); + const context = await createContextWithRoute(browser, backendURL); + const page = await context.newPage(); + + await loginToGetAuthenticatedSession(page, ownerEmail, PASSWORD); + + await context.storageState({path: path.join(AUTH_STATE_DIR, 'owner.json')}); + await context.close(); +}); + +// Invite and onboard staff members using parameterized tests +const staffRoles: Array<{role: 'administrator' | 'editor' | 'author' | 'contributor'; name: string; email: string}> = [ + {role: 'administrator', name: 'Test Administrator', email: 'administrator@ghost.org'}, + {role: 'editor', name: 'Test Editor', email: 'editor@ghost.org'}, + {role: 'author', name: 'Test Author', email: 'author@ghost.org'}, + {role: 'contributor', name: 'Test Contributor', email: 'contributor@ghost.org'} +]; + +for (const {role, name, email} of staffRoles) { + setup(`invite ${role}`, async ({browser}) => { + const backendURL = process.env.E2E_BASE_URL!; + + const context = await createContextWithRoute(browser, backendURL, { + role: 'owner' + }); + + const page = await context.newPage(); + const settingsPage = new SettingsPage(page); + await settingsPage.goto(); + await settingsPage.staffSection.goto(); + + await settingsPage.staffSection.inviteUser(email, role); + await context.close(); + }); + + setup(`complete ${role} signup`, async ({browser}) => { + const backendURL = process.env.E2E_BASE_URL!; + const emailClient = new MailPit(); + + const messages = await emailClient.searchByRecipient(email, {timeoutMs: 30000}); + expect(messages.length).toBeGreaterThan(0); + + const emailMessage = await emailClient.getMessageDetailed(messages[0]); + const invitationLink = extractInvitationLink(emailMessage.HTML || emailMessage.Text); + + let signupUrl = invitationLink.startsWith('http') + ? invitationLink + : `${backendURL}${invitationLink}`; + signupUrl = signupUrl.replace(/\/ghost\/signup\//, '/ghost/#/signup/'); + + const context = await createContextWithRoute(browser, backendURL); + const page = await context.newPage(); + + await page.goto(signupUrl); + const signupPage = new SignupPage(page); + await signupPage.nameField.waitFor({state: 'visible'}); + + await signupPage.completeSignup(name, PASSWORD); + await context.close(); + }); + + setup(`authenticate ${role}`, async ({browser}) => { + const backendURL = process.env.E2E_BASE_URL!; + + const context = await createContextWithRoute(browser, backendURL); + const page = await context.newPage(); + + await loginToGetAuthenticatedSession(page, email, PASSWORD); + + await context.storageState({path: path.join(AUTH_STATE_DIR, `${role}.json`)}); + await context.close(); + }); +} + +// Create database snapshot after all users are onboarded +setup('create database snapshot', async () => { setup.setTimeout(TIMEOUT); const manager = await getEnvironmentManager(); - await manager.globalSetup(); + if ('createSnapshot' in manager && typeof manager.createSnapshot === 'function') { + await manager.createSnapshot(); + } }); From a8a06f25db3d36e2f9f9ac93f28b1da29682f399 Mon Sep 17 00:00:00 2001 From: Jonatan Svennberg Date: Thu, 27 Nov 2025 13:37:13 +0100 Subject: [PATCH 5/5] Use Caddy to rewrite origin --- docker/dev-gateway/Caddyfile | 3 + .../admin/settings/sections/staff-section.ts | 3 - e2e/helpers/playwright/context-with-route.ts | 33 ++------ e2e/helpers/playwright/fixture.ts | 17 +--- e2e/tests/global.setup.ts | 79 ++++++++++--------- 5 files changed, 55 insertions(+), 80 deletions(-) diff --git a/docker/dev-gateway/Caddyfile b/docker/dev-gateway/Caddyfile index 9a302157fea..4962e15c3f8 100644 --- a/docker/dev-gateway/Caddyfile +++ b/docker/dev-gateway/Caddyfile @@ -25,6 +25,7 @@ handle /ghost/api/* { reverse_proxy {env.GHOST_BACKEND} { header_up Host {host} + header_up Origin http://localhost:2368 header_up X-Real-IP {remote_host} header_up X-Forwarded-For {remote_host} @@ -192,6 +193,7 @@ handle { reverse_proxy {env.GHOST_BACKEND} { header_up Host {host} + header_up Origin http://localhost:2368 header_up X-Real-IP {remote_host} header_up X-Forwarded-For {remote_host} @@ -209,6 +211,7 @@ rewrite * {http.request.orig_uri.path} reverse_proxy {env.GHOST_BACKEND} { header_up Host {host} + header_up Origin http://localhost:2368 header_up X-Forwarded-Proto https } } diff --git a/e2e/helpers/pages/admin/settings/sections/staff-section.ts b/e2e/helpers/pages/admin/settings/sections/staff-section.ts index 3d37fd794a6..39dfdb923be 100644 --- a/e2e/helpers/pages/admin/settings/sections/staff-section.ts +++ b/e2e/helpers/pages/admin/settings/sections/staff-section.ts @@ -74,9 +74,6 @@ export class StaffSection extends BasePage { // Submit the invitation await this.inviteModal.getByRole('button', {name: 'Send invitation'}).click(); - // Wait for success toast - await this.page.getByTestId('toast-success').waitFor({state: 'visible'}); - // Wait for modal to close await this.inviteModal.waitFor({state: 'hidden'}); } diff --git a/e2e/helpers/playwright/context-with-route.ts b/e2e/helpers/playwright/context-with-route.ts index 74801cedc5b..d31020ddf18 100644 --- a/e2e/helpers/playwright/context-with-route.ts +++ b/e2e/helpers/playwright/context-with-route.ts @@ -11,43 +11,26 @@ export const PLAYWRIGHT_BASE_URL = 'http://ghost.test:2368'; const AUTH_STATE_DIR = path.join(process.cwd(), 'e2e', 'data', 'state', 'auth'); /** - * Creates a browser context with route interception to map ghost.test:2368 - * to localhost:{port} while preserving the Origin header for Ghost's CSRF protection. + * Creates a browser context using the backend URL directly. Caddy proxy handles + * Origin header rewriting for CSRF protection, so no route interception needed. + * Also creates a separate APIRequestContext that uses the actual backend URL for direct HTTP requests. */ export async function createContextWithRoute( browser: Browser, backendURL: string, options?: {role?: string} ): Promise { - const port = new URL(backendURL).port; const storageState = options?.role ? path.join(AUTH_STATE_DIR, `${options.role}.json`) : undefined; if (storageState && !fs.existsSync(storageState)) { throw new Error(`Storage state file not found: ${storageState}. Run global setup first.`); } - const context = await browser.newContext({ - baseURL: PLAYWRIGHT_BASE_URL, - storageState, - extraHTTPHeaders: { - Origin: PLAYWRIGHT_BASE_URL - } + // Context for page navigation - uses backend URL directly + // Caddy proxy handles Origin header rewriting + return await browser.newContext({ + baseURL: backendURL, + storageState }); - - await context.route('**/*', async (route) => { - const url = new URL(route.request().url()); - if (url.hostname === 'ghost.test') { - url.hostname = 'localhost'; - url.port = port; - const headers = route.request().headers(); - // Preserve Origin header to match what was used during authentication - headers.Origin = PLAYWRIGHT_BASE_URL; - await route.continue({url: url.toString(), headers}); - } else { - await route.continue(); - } - }); - - return context; } diff --git a/e2e/helpers/playwright/fixture.ts b/e2e/helpers/playwright/fixture.ts index 7c619f84431..bb7238d745b 100644 --- a/e2e/helpers/playwright/fixture.ts +++ b/e2e/helpers/playwright/fixture.ts @@ -8,8 +8,6 @@ import {User} from '@/data-factory'; import {createContextWithRoute} from '@/helpers/playwright/context-with-route'; const debug = baseDebug('e2e:ghost-fixture'); -const AUTH_STATE_DIR = path.join(process.cwd(), 'e2e', 'data', 'state', 'auth'); -const USERS_STATE_FILE = path.join(process.cwd(), 'e2e', 'data', 'state', 'users.json'); export interface GhostConfig { memberWelcomeEmailSendInstantly?: string; @@ -40,7 +38,6 @@ async function setupNewAuthenticatedPage(browser: Browser, backendURL: string, r }); const page = await context.newPage(); - debug('Authenticated page created using saved storageState with host aliasing'); return {page, context}; } @@ -89,18 +86,12 @@ export const test = base.extend({ await use(ghostInstance.baseUrl); }, - // Load owner user credentials from saved state (backward compatibility) ghostAccountOwner: async ({}, use) => { - if (!fs.existsSync(USERS_STATE_FILE)) { - throw new Error(`User credentials file not found: ${USERS_STATE_FILE}. Run global setup first.`); - } - - const credentials = JSON.parse(fs.readFileSync(USERS_STATE_FILE, 'utf-8')); const owner: User = { - name: credentials.owner.name, - email: credentials.owner.email, - password: credentials.owner.password, - blogTitle: credentials.owner.blogTitle + name: 'Test Owner', + email: 'owner@ghost.org', + password: 'test@123@test', + blogTitle: 'Test Blog' }; await use(owner); }, diff --git a/e2e/tests/global.setup.ts b/e2e/tests/global.setup.ts index a4bf2663cb8..2d91f21e1a1 100644 --- a/e2e/tests/global.setup.ts +++ b/e2e/tests/global.setup.ts @@ -1,21 +1,21 @@ -import {expect, test as setup} from '@playwright/test'; -import {getEnvironmentManager} from '@/helpers/environment'; - -const TIMEOUT = 2 * 60 * 1000; // 2 minutes import * as path from 'path'; import {MailPit} from '@/helpers/services/email/mail-pit'; -import {SettingsPage} from '@/helpers/pages'; -import {SignupPage} from '@/helpers/pages/admin/signup-page'; +import {SettingsPage, SignupPage} from '@/helpers/pages'; import {createContextWithRoute} from '@/helpers/playwright/context-with-route'; import {ensureDir} from '@/helpers/utils/ensure-dir'; +import {expect, test as setup} from '@playwright/test'; import {extractInvitationLink} from '@/helpers/services/email/utils'; +import {getEnvironmentManager} from '@/helpers/environment'; +import {loginToGetAuthenticatedSession} from '@/helpers/playwright/flows/sign-in'; import {setupUser} from '@/helpers/utils/setup-user'; const AUTH_STATE_DIR = path.join(process.cwd(), 'e2e', 'data', 'state', 'auth'); const PASSWORD = 'test@123@test'; +const TIMEOUT = 2 * 60 * 1000; // 2 minutes +setup.describe.configure({mode: 'serial'}); // Setup environment first -setup('environment setup', async () => { +setup('setup environment', async () => { const manager = await getEnvironmentManager(); const result = await manager.globalSetup(); @@ -30,7 +30,7 @@ setup('environment setup', async () => { }); // Setup owner user -setup('setup owner user', async ({browser}) => { +setup('create owner user', async ({browser}) => { const backendURL = process.env.E2E_BASE_URL!; const ownerEmail = 'owner@ghost.org'; @@ -51,64 +51,65 @@ setup('setup owner user', async ({browser}) => { await context.close(); }); -// Invite and onboard staff members using parameterized tests const staffRoles: Array<{role: 'administrator' | 'editor' | 'author' | 'contributor'; name: string; email: string}> = [ {role: 'administrator', name: 'Test Administrator', email: 'administrator@ghost.org'}, {role: 'editor', name: 'Test Editor', email: 'editor@ghost.org'}, {role: 'author', name: 'Test Author', email: 'author@ghost.org'}, {role: 'contributor', name: 'Test Contributor', email: 'contributor@ghost.org'} ]; +setup(`invite staff users`, async ({browser}) => { + const backendURL = process.env.E2E_BASE_URL!; + + const context = await createContextWithRoute(browser, backendURL, { + role: 'owner' + }); -for (const {role, name, email} of staffRoles) { - setup(`invite ${role}`, async ({browser}) => { - const backendURL = process.env.E2E_BASE_URL!; - - const context = await createContextWithRoute(browser, backendURL, { - role: 'owner' - }); - - const page = await context.newPage(); - const settingsPage = new SettingsPage(page); - await settingsPage.goto(); - await settingsPage.staffSection.goto(); + const page = await context.newPage(); + const settingsPage = new SettingsPage(page); + await settingsPage.goto(); + await settingsPage.staffSection.goto(); + for (const {role, email} of staffRoles) { await settingsPage.staffSection.inviteUser(email, role); - await context.close(); - }); + } + + await context.close(); +}); - setup(`complete ${role} signup`, async ({browser}) => { +for (const {role, name, email} of staffRoles) { + setup(`create ${role} user`, async ({browser}) => { const backendURL = process.env.E2E_BASE_URL!; const emailClient = new MailPit(); const messages = await emailClient.searchByRecipient(email, {timeoutMs: 30000}); + + // We are within a test block, so we can use expect directly + // eslint-disable-next-line playwright/no-standalone-expect expect(messages.length).toBeGreaterThan(0); const emailMessage = await emailClient.getMessageDetailed(messages[0]); const invitationLink = extractInvitationLink(emailMessage.HTML || emailMessage.Text); - let signupUrl = invitationLink.startsWith('http') + // Extract the path from the invitation link and use consistent baseURL + const invitationUrl = new URL(invitationLink.startsWith('http') ? invitationLink - : `${backendURL}${invitationLink}`; - signupUrl = signupUrl.replace(/\/ghost\/signup\//, '/ghost/#/signup/'); + : `${backendURL}${invitationLink}`); + const signupPath = invitationUrl.pathname.replace(/\/ghost\/signup\//, '/ghost/#/signup/'); const context = await createContextWithRoute(browser, backendURL); const page = await context.newPage(); - await page.goto(signupUrl); + // Use relative path so it goes through our route interception + await page.goto(signupPath); const signupPage = new SignupPage(page); await signupPage.nameField.waitFor({state: 'visible'}); - await signupPage.completeSignup(name, PASSWORD); - await context.close(); - }); - - setup(`authenticate ${role}`, async ({browser}) => { - const backendURL = process.env.E2E_BASE_URL!; - - const context = await createContextWithRoute(browser, backendURL); - const page = await context.newPage(); + await signupPage.nameField.fill(name); + await signupPage.emailField.fill(email); + await signupPage.passwordField.fill(PASSWORD); + await signupPage.submitButton.click(); - await loginToGetAuthenticatedSession(page, email, PASSWORD); + await page.waitForURL(/\/ghost\/#\/(analytics|posts|site)/); await context.storageState({path: path.join(AUTH_STATE_DIR, `${role}.json`)}); await context.close(); @@ -116,7 +117,7 @@ for (const {role, name, email} of staffRoles) { } // Create database snapshot after all users are onboarded -setup('create database snapshot', async () => { +setup('save database snapshot', async () => { setup.setTimeout(TIMEOUT); const manager = await getEnvironmentManager(); if ('createSnapshot' in manager && typeof manager.createSnapshot === 'function') {