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/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/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/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..2c004fd975f --- /dev/null +++ b/e2e/helpers/environment/dev-environment-manager.ts @@ -0,0 +1,153 @@ +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. + * 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<{baseUrl: string}> { + logging.info('Starting dev environment global setup...'); + + await this.cleanupResources(); + + // Create base database + await this.mysql.recreateBaseDatabase('ghost_e2e_base'); + + // 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'); + } + + /** + * 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/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..39dfdb923be 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,29 @@ 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 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/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 { diff --git a/e2e/helpers/playwright/context-with-route.ts b/e2e/helpers/playwright/context-with-route.ts new file mode 100644 index 00000000000..d31020ddf18 --- /dev/null +++ b/e2e/helpers/playwright/context-with-route.ts @@ -0,0 +1,36 @@ +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 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 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.`); + } + + // Context for page navigation - uses backend URL directly + // Caddy proxy handles Origin header rewriting + return await browser.newContext({ + baseURL: backendURL, + storageState + }); +} + diff --git a/e2e/helpers/playwright/fixture.ts b/e2e/helpers/playwright/fixture.ts index 82367d1d9e5..bb7238d745b 100644 --- a/e2e/helpers/playwright/fixture.ts +++ b/e2e/helpers/playwright/fixture.ts @@ -1,19 +1,14 @@ +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 {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'; +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; -} - export interface GhostConfig { memberWelcomeEmailSendInstantly?: string; memberWelcomeEmailTestInbox?: string; @@ -26,37 +21,35 @@ 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(); - await loginToGetAuthenticatedSession(page, ghostAccountOwner.email, ghostAccountOwner.password); - debug('Authentication completed for Ghost instance'); - - return {page, context, ghostAccountOwner}; + return {page, context}; } /** * 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: @@ -69,48 +62,51 @@ 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 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) { - throw new Error('baseURL is not defined'); - } - // Create user in this Ghost instance - const ghostAccountOwner: User = { - name: 'Test User', - email: `test${faker.string.uuid()}@ghost.org`, - password: 'test@123@test' + ghostAccountOwner: async ({}, use) => { + const owner: User = { + name: 'Test Owner', + email: 'owner@ghost.org', + password: 'test@123@test', + blogTitle: 'Test Blog' }; - 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(); }, + // Extract the page from pageWithAuthenticatedUser and apply labs/stripe settings page: async ({pageWithAuthenticatedUser, labs, stripeConnected}, use) => { const page = pageWithAuthenticatedUser.page; 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/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/e2e/tests/global.setup.ts b/e2e/tests/global.setup.ts index f9236aea874..2d91f21e1a1 100644 --- a/e2e/tests/global.setup.ts +++ b/e2e/tests/global.setup.ts @@ -1,10 +1,126 @@ -import {EnvironmentManager} from '@/helpers/environment'; -import {test as setup} from '@playwright/test'; +import * as path from 'path'; +import {MailPit} from '@/helpers/services/email/mail-pit'; +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('global environment setup', async () => { +setup.describe.configure({mode: 'serial'}); +// Setup environment first +setup('setup environment', 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('create 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(); +}); + +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' + }); + + 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(); +}); + +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); + + // Extract the path from the invitation link and use consistent baseURL + const invitationUrl = new URL(invitationLink.startsWith('http') + ? invitationLink + : `${backendURL}${invitationLink}`); + const signupPath = invitationUrl.pathname.replace(/\/ghost\/signup\//, '/ghost/#/signup/'); + + const context = await createContextWithRoute(browser, backendURL); + const page = await context.newPage(); + + // 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.nameField.fill(name); + await signupPage.emailField.fill(email); + await signupPage.passwordField.fill(PASSWORD); + await signupPage.submitButton.click(); + + await page.waitForURL(/\/ghost\/#\/(analytics|posts|site)/); + + await context.storageState({path: path.join(AUTH_STATE_DIR, `${role}.json`)}); + await context.close(); + }); +} + +// Create database snapshot after all users are onboarded +setup('save database snapshot', async () => { setup.setTimeout(TIMEOUT); - const environmentManager = new EnvironmentManager(); - await environmentManager.globalSetup(); + const manager = await getEnvironmentManager(); + if ('createSnapshot' in manager && typeof manager.createSnapshot === 'function') { + await manager.createSnapshot(); + } }); 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(); }); 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",