Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions compose.dev.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
18 changes: 18 additions & 0 deletions e2e/AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,24 @@ yarn test --debug # See browser during execution,
PRESERVE_ENV=true yarn test # Debug failed tests (keeps containers)
```

## Dev Environment Mode (Recommended)

When `yarn dev:forward` is running, e2e tests automatically use a more efficient execution mode:

```bash
# Terminal 1: Start dev environment
yarn dev:forward

# Terminal 2: Run e2e tests (automatically uses dev environment)
cd e2e && yarn test
```

Benefits:
- Uses existing MySQL, Redis, and other infrastructure
- Worker-scoped Ghost containers (faster than per-test containers)
- Can run tests alongside active development
- Easy cleanup: `docker compose -p ghost-dev-e2e down`

## Test Structure

### Naming Conventions
Expand Down
50 changes: 49 additions & 1 deletion e2e/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,29 @@ yarn
yarn test
```

### Dev Environment Mode (Recommended for Development)

When `yarn dev:forward` 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:forward

# Terminal 2: Run e2e tests (from e2e folder)
yarn test
```

**Benefits of dev environment mode:**
- Uses existing MySQL, Redis, and other infrastructure (no duplicate containers)
- Worker-scoped Ghost containers (faster than per-test containers)
- Can run tests alongside active development
- Shared frontend dev servers for Portal, Comments UI, etc.

**Cleanup:** If tests are interrupted, clean up e2e containers with:
```bash
docker compose -p ghost-dev-e2e down
```

### Running Specific Tests

```bash
Expand Down Expand Up @@ -146,7 +169,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.)
Expand All @@ -161,6 +188,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:forward` 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.
Expand Down
27 changes: 27 additions & 0 deletions e2e/helpers/environment/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,3 +40,30 @@ export const MAILPIT = {
PORT: 1025
};

/**
* Configuration for dev environment mode.
* Used when yarn dev:forward infrastructure is detected.
*/
export const DEV_ENVIRONMENT = {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

To avoid confusion, these seem to make more sense to be a separate file, so we have something like:

  • constants.test-env.ts
  • constants.dev-end.ts

we will have some duplicates, but cleaner separation between environment configurations and cleaner switch between them in future. At the moment, this looks confusing, with having configurations overlap in a single file and no clear shape, which will differ depending on file from which you are loading them

networkName: 'ghost_dev',
projectNamespace: 'ghost-dev-e2e',
mysql: {
host: 'ghost-dev-mysql',
port: 3306,
user: 'root',
password: 'root'
},
redis: {
host: 'ghost-dev-redis',
port: 6379
},
images: {
ghost: 'ghost-dev-ghost-dev',
gateway: 'ghost-dev-ghost-dev-gateway'
},
ghost: {
port: 2368,
workdir: '/home/ghost/ghost/core'
}
};

128 changes: 128 additions & 0 deletions e2e/helpers/environment/dev-environment-manager.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,128 @@
import Docker from 'dockerode';
import baseDebug from '@tryghost/debug';
import logging from '@tryghost/logging';
import {DEV_ENVIRONMENT} from './constants';
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({
...DEV_ENVIRONMENT,
workerIndex: this.workerIndex
});
}

/**
* Global setup - creates database snapshot for test isolation.
* Mirrors the standalone environment: run migrations, then snapshot.
*/
async globalSetup(): Promise<void> {
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<void> {
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<GhostInstance> {
// 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<string, string> | 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<void> {
await this.mysql.cleanupTestDatabase(instance.database);
}

private async cleanupResources(): Promise<void> {
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';
}
}
60 changes: 60 additions & 0 deletions e2e/helpers/environment/environment-factory.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
import Docker from 'dockerode';
import baseDebug from '@tryghost/debug';
import {DEV_ENVIRONMENT} from './constants';
import {DevEnvironmentManager} from './dev-environment-manager';
import {EnvironmentManager} from './environment-manager';

const debug = baseDebug('e2e:EnvironmentFactory');

// Cached manager instance (one per worker process)
let cachedManager: EnvironmentManager | DevEnvironmentManager | null = null;

/**
* Check if the dev environment (yarn dev:forward) is running.
* Detects by checking for the ghost_dev network and running MySQL container.
*/
export async function isDevEnvironmentAvailable(): Promise<boolean> {
const docker = new Docker();

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;
}

const containers = await docker.listContainers({
filters: {
name: [DEV_ENVIRONMENT.mysql.host],
status: ['running']
}
});

if (containers.length === 0) {
debug('Dev environment not available: MySQL container not running');
return false;
}

debug('Dev environment is available');
return true;
} catch (error) {
debug('Error checking dev environment:', error);
return false;
}
}

/**
* Get the environment manager for this worker.
* Creates and caches a manager on first call, returns cached instance thereafter.
*/
export async function getEnvironmentManager(): Promise<EnvironmentManager | DevEnvironmentManager> {
if (!cachedManager) {
const useDevEnv = await isDevEnvironmentAvailable();
cachedManager = useDevEnv ? new DevEnvironmentManager() : new EnvironmentManager();
}
return cachedManager;
}

3 changes: 3 additions & 0 deletions e2e/helpers/environment/index.ts
Original file line number Diff line number Diff line change
@@ -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';

Loading
Loading