This commit is contained in:
@@ -0,0 +1,98 @@
|
||||
import path from 'path';
|
||||
import {fileURLToPath} from 'url';
|
||||
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = path.dirname(__filename);
|
||||
|
||||
export const CONFIG_DIR = path.resolve(__dirname, '../../data/state');
|
||||
|
||||
// Repository root path (for source mounting and config files)
|
||||
export const REPO_ROOT = path.resolve(__dirname, '../../..');
|
||||
|
||||
export const DEV_COMPOSE_PROJECT = process.env.COMPOSE_PROJECT_NAME || 'ghost-dev';
|
||||
// compose.dev.yaml pins the network name explicitly, so this does not follow COMPOSE_PROJECT_NAME.
|
||||
export const DEV_NETWORK_NAME = 'ghost_dev';
|
||||
export const DEV_SHARED_CONFIG_VOLUME = `${DEV_COMPOSE_PROJECT}_shared-config`;
|
||||
export const DEV_PRIMARY_DATABASE = process.env.MYSQL_DATABASE || 'ghost_dev';
|
||||
|
||||
/**
|
||||
* Caddyfile paths for different modes.
|
||||
* - dev: Proxies to host dev servers for HMR
|
||||
* - build: Minimal passthrough (assets served by Ghost from /content/files/)
|
||||
*/
|
||||
export const CADDYFILE_PATHS = {
|
||||
dev: path.resolve(REPO_ROOT, 'docker/dev-gateway/Caddyfile'),
|
||||
build: path.resolve(REPO_ROOT, 'docker/dev-gateway/Caddyfile.build')
|
||||
} as const;
|
||||
|
||||
/**
|
||||
* Build mode image configuration.
|
||||
* Used for build mode - can be locally built or pulled from registry.
|
||||
*
|
||||
* Override with environment variable:
|
||||
* - GHOST_E2E_IMAGE: Image name (default: ghost-e2e:local)
|
||||
*
|
||||
* Examples:
|
||||
* - Local: ghost-e2e:local (built from e2e/Dockerfile.e2e)
|
||||
* - Registry: ghcr.io/tryghost/ghost:latest (as E2E base image)
|
||||
* - Community: ghost
|
||||
*/
|
||||
export const BUILD_IMAGE = process.env.GHOST_E2E_IMAGE || 'ghost-e2e:local';
|
||||
|
||||
/**
|
||||
* Build mode gateway image.
|
||||
* Uses stock Caddy by default so CI does not need a custom gateway build.
|
||||
*/
|
||||
export const BUILD_GATEWAY_IMAGE = process.env.GHOST_E2E_GATEWAY_IMAGE || 'caddy:2-alpine';
|
||||
|
||||
export const TINYBIRD = {
|
||||
LOCAL_HOST: 'tinybird-local',
|
||||
PORT: 7181,
|
||||
JSON_PATH: path.resolve(CONFIG_DIR, 'tinybird.json')
|
||||
};
|
||||
|
||||
/**
|
||||
* Configuration for dev environment mode.
|
||||
* Used when pnpm dev infrastructure is detected.
|
||||
*/
|
||||
export const DEV_ENVIRONMENT = {
|
||||
projectNamespace: DEV_COMPOSE_PROJECT,
|
||||
networkName: DEV_NETWORK_NAME
|
||||
} as const;
|
||||
|
||||
/**
|
||||
* Base environment variables shared by all modes.
|
||||
*/
|
||||
export const BASE_GHOST_ENV = [
|
||||
// Environment configuration
|
||||
'NODE_ENV=development',
|
||||
'server__host=0.0.0.0',
|
||||
'server__port=2368',
|
||||
|
||||
// Database configuration (database name is set per container)
|
||||
'database__client=mysql2',
|
||||
'database__connection__host=ghost-dev-mysql',
|
||||
'database__connection__port=3306',
|
||||
'database__connection__user=root',
|
||||
'database__connection__password=root',
|
||||
|
||||
// Redis configuration
|
||||
'adapters__cache__Redis__host=ghost-dev-redis',
|
||||
'adapters__cache__Redis__port=6379',
|
||||
|
||||
// Email configuration
|
||||
'mail__transport=SMTP',
|
||||
'mail__options__host=ghost-dev-mailpit',
|
||||
'mail__options__port=1025'
|
||||
] as const;
|
||||
|
||||
export const TEST_ENVIRONMENT = {
|
||||
projectNamespace: 'ghost-dev-e2e',
|
||||
gateway: {
|
||||
image: `${DEV_COMPOSE_PROJECT}-ghost-dev-gateway`
|
||||
},
|
||||
ghost: {
|
||||
image: `${DEV_COMPOSE_PROJECT}-ghost-dev`,
|
||||
port: 2368
|
||||
}
|
||||
} as const;
|
||||
@@ -0,0 +1,15 @@
|
||||
import {EnvironmentManager} from './environment-manager';
|
||||
|
||||
// Cached manager instance (one per worker process)
|
||||
let cachedManager: EnvironmentManager | 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<EnvironmentManager> {
|
||||
if (!cachedManager) {
|
||||
cachedManager = new EnvironmentManager();
|
||||
}
|
||||
return cachedManager;
|
||||
}
|
||||
@@ -0,0 +1,160 @@
|
||||
import baseDebug from '@tryghost/debug';
|
||||
import logging from '@tryghost/logging';
|
||||
import {GhostInstance, MySQLManager} from './service-managers';
|
||||
import {GhostManager} from './service-managers/ghost-manager';
|
||||
import {randomUUID} from 'crypto';
|
||||
import type {GhostConfig} from '@/helpers/playwright/fixture';
|
||||
|
||||
const debug = baseDebug('e2e:EnvironmentManager');
|
||||
|
||||
/**
|
||||
* Environment modes for E2E testing.
|
||||
*
|
||||
* - dev: Uses dev infrastructure with hot-reloading dev servers
|
||||
* - build: Uses pre-built image (local or registry, controlled by GHOST_E2E_IMAGE)
|
||||
*/
|
||||
export type EnvironmentMode = 'dev' | 'build';
|
||||
type GhostEnvOverrides = GhostConfig | Record<string, string>;
|
||||
|
||||
/**
|
||||
* Orchestrates e2e test environment.
|
||||
*
|
||||
* Supports two modes controlled by GHOST_E2E_MODE environment variable:
|
||||
* - dev: Uses dev infrastructure with hot-reloading
|
||||
* - build: Uses pre-built image (set GHOST_E2E_IMAGE for registry images)
|
||||
*
|
||||
* All modes use the same infrastructure (MySQL, Redis, Mailpit, Tinybird)
|
||||
* started via docker compose. Ghost and gateway containers are created
|
||||
* dynamically per-worker for test isolation.
|
||||
*/
|
||||
export class EnvironmentManager {
|
||||
private readonly mode: EnvironmentMode;
|
||||
private readonly workerIndex: number;
|
||||
private readonly mysql: MySQLManager;
|
||||
private readonly ghost: GhostManager;
|
||||
private initialized = false;
|
||||
|
||||
constructor() {
|
||||
this.mode = this.detectMode();
|
||||
this.workerIndex = parseInt(process.env.TEST_PARALLEL_INDEX || '0', 10);
|
||||
|
||||
this.mysql = new MySQLManager();
|
||||
this.ghost = new GhostManager({
|
||||
workerIndex: this.workerIndex,
|
||||
mode: this.mode
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Detect environment mode from GHOST_E2E_MODE environment variable.
|
||||
*/
|
||||
private detectMode(): EnvironmentMode {
|
||||
const envMode = process.env.GHOST_E2E_MODE;
|
||||
if (envMode === 'build' || envMode === 'dev') {
|
||||
return envMode;
|
||||
}
|
||||
|
||||
logging.warn('GHOST_E2E_MODE is not set; defaulting to build mode. Use the e2e shell entrypoints for automatic mode resolution.');
|
||||
return 'build';
|
||||
}
|
||||
|
||||
/**
|
||||
* Global setup - creates database snapshot for test isolation.
|
||||
*
|
||||
* Creates the worker 0 containers (Ghost + Gateway) and waits for Ghost to
|
||||
* become healthy. Ghost automatically runs migrations on startup. Once healthy,
|
||||
* we snapshot the database for test isolation.
|
||||
*/
|
||||
async globalSetup(): Promise<void> {
|
||||
logging.info(`Starting ${this.mode} environment global setup...`);
|
||||
|
||||
await this.cleanupResources();
|
||||
|
||||
// Create base database
|
||||
await this.mysql.recreateBaseDatabase('ghost_e2e_base');
|
||||
|
||||
// Create containers and wait for Ghost to be healthy (runs migrations)
|
||||
await this.ghost.setup('ghost_e2e_base');
|
||||
await this.ghost.waitForReady();
|
||||
this.initialized = true;
|
||||
|
||||
// Snapshot the migrated database for test isolation
|
||||
await this.mysql.createSnapshot('ghost_e2e_base');
|
||||
|
||||
logging.info(`${this.mode} environment global setup complete`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Global teardown - cleanup resources.
|
||||
*/
|
||||
async globalTeardown(): Promise<void> {
|
||||
if (this.shouldPreserveEnvironment()) {
|
||||
logging.info('PRESERVE_ENV is set - skipping teardown');
|
||||
return;
|
||||
}
|
||||
|
||||
logging.info(`Starting ${this.mode} environment global teardown...`);
|
||||
await this.cleanupResources();
|
||||
logging.info(`${this.mode} environment global teardown complete`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Per-test setup - creates containers on first call, then clones database and restarts Ghost.
|
||||
*/
|
||||
async perTestSetup(options: {
|
||||
config?: GhostEnvOverrides;
|
||||
stripe?: {
|
||||
secretKey: string;
|
||||
publishableKey: string;
|
||||
};
|
||||
} = {}): Promise<GhostInstance> {
|
||||
// Lazy initialization of Ghost containers (once per worker)
|
||||
if (!this.initialized) {
|
||||
debug('Initializing Ghost containers for worker', this.workerIndex, 'in mode', this.mode);
|
||||
await this.ghost.setup();
|
||||
this.initialized = true;
|
||||
}
|
||||
|
||||
const siteUuid = randomUUID();
|
||||
const instanceId = `ghost_e2e_${siteUuid.replace(/-/g, '_')}`;
|
||||
|
||||
// Setup database
|
||||
await this.mysql.setupTestDatabase(instanceId, siteUuid, {
|
||||
stripe: options.stripe
|
||||
});
|
||||
|
||||
// Restart Ghost with new database
|
||||
await this.ghost.restartWithDatabase(instanceId, options.config);
|
||||
await this.ghost.waitForReady();
|
||||
|
||||
const port = this.ghost.getGatewayPort();
|
||||
|
||||
return {
|
||||
containerId: this.ghost.ghostContainerId!,
|
||||
instanceId,
|
||||
database: instanceId,
|
||||
port,
|
||||
baseUrl: `http://localhost:${port}`,
|
||||
siteUuid
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 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';
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
export * from './service-managers';
|
||||
export * from './environment-manager';
|
||||
export * from './environment-factory';
|
||||
@@ -0,0 +1,28 @@
|
||||
import Docker from 'dockerode';
|
||||
import baseDebug from '@tryghost/debug';
|
||||
import {DEV_ENVIRONMENT, TINYBIRD} from './constants';
|
||||
|
||||
const debug = baseDebug('e2e:ServiceAvailability');
|
||||
|
||||
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;
|
||||
}
|
||||
/**
|
||||
* Check if Tinybird is running.
|
||||
* Checks for tinybird-local service in ghost-dev compose project.
|
||||
*/
|
||||
export async function isTinybirdAvailable(): Promise<boolean> {
|
||||
const docker = new Docker();
|
||||
const tinybirdAvailable = await isServiceAvailable(docker, TINYBIRD.LOCAL_HOST);
|
||||
debug(`Tinybird availability for compose project ${DEV_ENVIRONMENT.projectNamespace}:`, tinybirdAvailable);
|
||||
return tinybirdAvailable;
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
import {PageHttpLogger} from './page-http-logger';
|
||||
import {appConfig} from '@/helpers/utils/app-config';
|
||||
import type {Locator, Page, Response} from '@playwright/test';
|
||||
|
||||
export interface pageGotoOptions {
|
||||
referer?: string;
|
||||
timeout?: number;
|
||||
waitUntil?: 'load' | 'domcontentloaded'|'networkidle'|'commit';
|
||||
}
|
||||
|
||||
export class BasePage {
|
||||
private logger?: PageHttpLogger;
|
||||
private readonly debugLogs = appConfig.debugLogs;
|
||||
|
||||
public pageUrl: string = '';
|
||||
protected readonly page: Page;
|
||||
public readonly body: Locator;
|
||||
|
||||
constructor(page: Page, pageUrl: string = '') {
|
||||
this.page = page;
|
||||
this.pageUrl = pageUrl;
|
||||
this.body = page.locator('body');
|
||||
|
||||
if (this.isDebugEnabled()) {
|
||||
this.logger = new PageHttpLogger(page);
|
||||
this.logger.setup();
|
||||
}
|
||||
}
|
||||
|
||||
async refresh() {
|
||||
await this.page.reload();
|
||||
}
|
||||
|
||||
async goto(url?: string, options?: pageGotoOptions): Promise<null | Response> {
|
||||
const urlToVisit = url || this.pageUrl;
|
||||
return await this.page.goto(urlToVisit, options);
|
||||
}
|
||||
|
||||
async pressKey(key: string) {
|
||||
await this.page.keyboard.press(key);
|
||||
}
|
||||
|
||||
private isDebugEnabled(): boolean {
|
||||
return this.debugLogs;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
export * from './base-page';
|
||||
export * from './admin';
|
||||
export * from './portal';
|
||||
export * from './public';
|
||||
export * from './stripe';
|
||||
@@ -0,0 +1,41 @@
|
||||
import {Page, Request, Response} from '@playwright/test';
|
||||
|
||||
export class PageHttpLogger {
|
||||
private page: Page;
|
||||
|
||||
constructor(page: Page) {
|
||||
this.page = page;
|
||||
}
|
||||
|
||||
public setup() {
|
||||
this.page.on('response', this.onResponse);
|
||||
this.page.on('requestfailed', this.onRequestFailed);
|
||||
this.page.on('pageerror', this.onPageError);
|
||||
}
|
||||
|
||||
public destroy() {
|
||||
this.page.off('response', this.onResponse);
|
||||
this.page.off('requestfailed', this.onRequestFailed);
|
||||
this.page.off('pageerror', this.onPageError);
|
||||
}
|
||||
|
||||
private onResponse = (response: Response) => {
|
||||
if (response.status() >= 400) {
|
||||
this.logError(`ERROR - HTTP: ${response.status()} ${response.url()}`);
|
||||
}
|
||||
};
|
||||
|
||||
private onRequestFailed = (request: Request) => {
|
||||
this.logError(`ERROR - NETWORK: ${request.method()} ${request.url()} - ${request.failure()?.errorText}`);
|
||||
};
|
||||
|
||||
private onPageError = (error: Error) => {
|
||||
this.logError(`ERROR - JS: ${error.message}`);
|
||||
};
|
||||
|
||||
private logError = (message: string) => {
|
||||
const timestamp = new Date().toISOString();
|
||||
// eslint-disable-next-line no-console
|
||||
console.error(`[${timestamp}] ${message}`);
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,510 @@
|
||||
import baseDebug from '@tryghost/debug';
|
||||
import {AnalyticsOverviewPage} from '@/helpers/pages';
|
||||
import {Browser, BrowserContext, Page, TestInfo, test as base} from '@playwright/test';
|
||||
import {EmailClient, MailPit} from '@/helpers/services/email/mail-pit';
|
||||
import {FakeMailgunServer, MailgunTestService} from '@/helpers/services/mailgun';
|
||||
import {FakeStripeServer, StripeTestService, WebhookClient} from '@/helpers/services/stripe';
|
||||
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');
|
||||
const STRIPE_SECRET_KEY = 'sk_test_e2eTestKey';
|
||||
const STRIPE_PUBLISHABLE_KEY = 'pk_test_e2eTestKey';
|
||||
|
||||
type ResolvedIsolation = 'per-file' | 'per-test';
|
||||
type LabsFlags = Record<string, boolean>;
|
||||
|
||||
/**
|
||||
* The subset of fixture options that defines whether a per-file environment can
|
||||
* be reused for the next test in the same file.
|
||||
*
|
||||
* Any new fixture option that changes persistent Ghost state or boot-time config
|
||||
* must make an explicit choice:
|
||||
* - include it here so it participates in environment reuse, or
|
||||
* - force per-test isolation instead of participating in per-file reuse.
|
||||
*/
|
||||
interface EnvironmentIdentity {
|
||||
config?: GhostConfig;
|
||||
labs?: LabsFlags;
|
||||
}
|
||||
|
||||
interface PerFileInstanceCache {
|
||||
suiteKey: string;
|
||||
environmentSignature: string;
|
||||
instance: GhostInstance;
|
||||
}
|
||||
|
||||
interface PerFileAuthenticatedSessionCache {
|
||||
ghostAccountOwner: User;
|
||||
storageState: Awaited<ReturnType<BrowserContext['storageState']>>;
|
||||
}
|
||||
|
||||
interface TestEnvironmentContext {
|
||||
holder: GhostInstance;
|
||||
resolvedIsolation: ResolvedIsolation;
|
||||
cycle: () => Promise<void>;
|
||||
getResetEnvironmentBlocker: () => string | null;
|
||||
markResetEnvironmentBlocker: (fixtureName: string) => void;
|
||||
}
|
||||
|
||||
interface InternalFixtures {
|
||||
_testEnvironmentContext: TestEnvironmentContext;
|
||||
}
|
||||
|
||||
interface WorkerFixtures {
|
||||
_cleanupPerFileInstance: void;
|
||||
}
|
||||
|
||||
let cachedPerFileInstance: PerFileInstanceCache | null = null;
|
||||
let cachedPerFileGhostAccountOwner: User | null = null;
|
||||
let cachedPerFileAuthenticatedSession: PerFileAuthenticatedSessionCache | null = null;
|
||||
|
||||
export interface User {
|
||||
name: string;
|
||||
email: string;
|
||||
password: string;
|
||||
}
|
||||
|
||||
export interface GhostConfig {
|
||||
hostSettings__billing__enabled?: string;
|
||||
hostSettings__billing__url?: string;
|
||||
hostSettings__forceUpgrade?: string;
|
||||
hostSettings__limits__customIntegrations__disabled?: string;
|
||||
hostSettings__limits__customIntegrations__error?: string;
|
||||
}
|
||||
|
||||
export interface GhostInstanceFixture {
|
||||
ghostInstance: GhostInstance;
|
||||
// Opt a file into per-test isolation without relying on Playwright-wide fullyParallel.
|
||||
isolation?: 'per-test';
|
||||
resolvedIsolation: ResolvedIsolation;
|
||||
// Hook-only escape hatch for per-file mode before stateful fixtures are resolved.
|
||||
resetEnvironment: () => Promise<void>;
|
||||
// Participates in per-file environment identity.
|
||||
labs?: LabsFlags;
|
||||
// Participates in per-file environment identity.
|
||||
config?: GhostConfig;
|
||||
// Forces per-test isolation because Ghost boots against a per-test fake Stripe server.
|
||||
stripeEnabled?: boolean;
|
||||
stripeServer?: FakeStripeServer;
|
||||
stripe?: StripeTestService;
|
||||
mailgunEnabled?: boolean;
|
||||
mailgunServer?: FakeMailgunServer;
|
||||
mailgun?: MailgunTestService;
|
||||
emailClient: EmailClient;
|
||||
ghostAccountOwner: User;
|
||||
pageWithAuthenticatedUser: {
|
||||
page: Page;
|
||||
context: BrowserContext;
|
||||
ghostAccountOwner: User
|
||||
};
|
||||
}
|
||||
|
||||
function getStableObjectSignature<T extends object>(values?: T): string {
|
||||
return JSON.stringify(
|
||||
Object.fromEntries(
|
||||
Object.entries(values ?? {})
|
||||
.sort(([leftKey], [rightKey]) => leftKey.localeCompare(rightKey))
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
function getEnvironmentSignature(identity: EnvironmentIdentity): string {
|
||||
return JSON.stringify({
|
||||
config: getStableObjectSignature(identity.config),
|
||||
labs: getStableObjectSignature(identity.labs)
|
||||
});
|
||||
}
|
||||
|
||||
function getSuiteKey(testInfo: TestInfo): string {
|
||||
return `${testInfo.project.name}:${testInfo.file}`;
|
||||
}
|
||||
|
||||
function getResolvedIsolation(testInfo: TestInfo, isolation?: 'per-test'): ResolvedIsolation {
|
||||
if (testInfo.config.fullyParallel || isolation === 'per-test') {
|
||||
return 'per-test';
|
||||
}
|
||||
|
||||
return 'per-file';
|
||||
}
|
||||
|
||||
async function setupNewAuthenticatedPage(browser: Browser, baseURL: string, ghostAccountOwner: User) {
|
||||
debug('Setting up authenticated page for Ghost instance:', baseURL);
|
||||
|
||||
// Create browser context with correct baseURL and extra HTTP headers
|
||||
const context = await browser.newContext({
|
||||
baseURL: baseURL,
|
||||
extraHTTPHeaders: {
|
||||
Origin: baseURL
|
||||
}
|
||||
});
|
||||
const page = await context.newPage();
|
||||
|
||||
await loginToGetAuthenticatedSession(page, ghostAccountOwner.email, ghostAccountOwner.password);
|
||||
debug('Authentication completed for Ghost instance');
|
||||
|
||||
return {page, context, ghostAccountOwner};
|
||||
}
|
||||
|
||||
async function setupAuthenticatedPageFromStorageState(browser: Browser, baseURL: string, authenticatedSession: PerFileAuthenticatedSessionCache) {
|
||||
debug('Reusing authenticated storage state for Ghost instance:', baseURL);
|
||||
|
||||
const context = await browser.newContext({
|
||||
baseURL: baseURL,
|
||||
extraHTTPHeaders: {
|
||||
Origin: baseURL
|
||||
},
|
||||
storageState: authenticatedSession.storageState
|
||||
});
|
||||
const page = await context.newPage();
|
||||
await page.goto('/ghost/#/');
|
||||
|
||||
const analyticsPage = new AnalyticsOverviewPage(page);
|
||||
const billingIframe = page.getByTitle('Billing');
|
||||
await Promise.race([
|
||||
analyticsPage.header.waitFor({state: 'visible'}),
|
||||
billingIframe.waitFor({state: 'visible'})
|
||||
]);
|
||||
|
||||
return {
|
||||
page,
|
||||
context,
|
||||
ghostAccountOwner: authenticatedSession.ghostAccountOwner
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 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
|
||||
*
|
||||
* Uses the unified E2E environment manager:
|
||||
* - Dev mode (default): Worker-scoped containers with per-test database cloning
|
||||
* - Build mode: Same isolation model, but Ghost runs from a prebuilt image
|
||||
*
|
||||
* Optionally allows setting labs flags via test.use({labs: {featureName: true}})
|
||||
* and Stripe connection via test.use({stripeEnabled: true})
|
||||
*/
|
||||
export const test = base.extend<GhostInstanceFixture & InternalFixtures, WorkerFixtures>({
|
||||
_cleanupPerFileInstance: [async ({}, use) => {
|
||||
await use();
|
||||
|
||||
if (!cachedPerFileInstance) {
|
||||
return;
|
||||
}
|
||||
|
||||
const environmentManager = await getEnvironmentManager();
|
||||
await environmentManager.perTestTeardown(cachedPerFileInstance.instance);
|
||||
cachedPerFileInstance = null;
|
||||
cachedPerFileGhostAccountOwner = null;
|
||||
cachedPerFileAuthenticatedSession = null;
|
||||
}, {
|
||||
scope: 'worker',
|
||||
auto: true
|
||||
}],
|
||||
|
||||
_testEnvironmentContext: async ({config, isolation, labs, stripeEnabled, stripeServer, mailgunEnabled, mailgunServer}, use, testInfo: TestInfo) => {
|
||||
const environmentManager = await getEnvironmentManager();
|
||||
const requestedIsolation = getResolvedIsolation(testInfo, isolation);
|
||||
// Stripe-enabled tests boot Ghost against a per-test fake Stripe server,
|
||||
// so they cannot safely participate in per-file environment reuse.
|
||||
const resolvedIsolation = stripeEnabled ? 'per-test' : requestedIsolation;
|
||||
const suiteKey = getSuiteKey(testInfo);
|
||||
const stripeConfig = stripeEnabled && stripeServer ? {
|
||||
STRIPE_API_HOST: 'host.docker.internal',
|
||||
STRIPE_API_PORT: String(stripeServer.port),
|
||||
STRIPE_API_PROTOCOL: 'http'
|
||||
} : {};
|
||||
const mailgunConfig = mailgunEnabled && mailgunServer ? {
|
||||
bulkEmail__mailgun__apiKey: 'fake-mailgun-api-key',
|
||||
bulkEmail__mailgun__domain: 'fake.mailgun.test',
|
||||
bulkEmail__mailgun__baseUrl: `http://host.docker.internal:${mailgunServer.port}/v3`
|
||||
} : {};
|
||||
const mergedConfig = {...(config || {}), ...stripeConfig, ...mailgunConfig};
|
||||
const stripe = stripeServer ? {
|
||||
secretKey: STRIPE_SECRET_KEY,
|
||||
publishableKey: STRIPE_PUBLISHABLE_KEY
|
||||
} : undefined;
|
||||
const environmentIdentity: EnvironmentIdentity = {
|
||||
config: mergedConfig,
|
||||
labs
|
||||
};
|
||||
const environmentSignature = getEnvironmentSignature(environmentIdentity);
|
||||
const resetEnvironmentGuard = {
|
||||
blocker: null as string | null
|
||||
};
|
||||
|
||||
if (resolvedIsolation === 'per-test') {
|
||||
const perTestInstance = await environmentManager.perTestSetup({
|
||||
config: mergedConfig,
|
||||
stripe
|
||||
});
|
||||
const previousPerFileInstance = cachedPerFileInstance?.instance;
|
||||
cachedPerFileInstance = null;
|
||||
cachedPerFileGhostAccountOwner = null;
|
||||
cachedPerFileAuthenticatedSession = null;
|
||||
|
||||
if (previousPerFileInstance) {
|
||||
await environmentManager.perTestTeardown(previousPerFileInstance);
|
||||
}
|
||||
|
||||
await use({
|
||||
holder: perTestInstance,
|
||||
resolvedIsolation,
|
||||
cycle: async () => {
|
||||
debug('resetEnvironment() is a no-op in per-test isolation mode');
|
||||
},
|
||||
getResetEnvironmentBlocker: () => resetEnvironmentGuard.blocker,
|
||||
markResetEnvironmentBlocker: (fixtureName: string) => {
|
||||
resetEnvironmentGuard.blocker ??= fixtureName;
|
||||
}
|
||||
});
|
||||
|
||||
await environmentManager.perTestTeardown(perTestInstance);
|
||||
return;
|
||||
}
|
||||
|
||||
const mustRecyclePerFileInstance = !cachedPerFileInstance ||
|
||||
cachedPerFileInstance.suiteKey !== suiteKey ||
|
||||
cachedPerFileInstance.environmentSignature !== environmentSignature;
|
||||
|
||||
if (mustRecyclePerFileInstance) {
|
||||
const previousPerFileInstance = cachedPerFileInstance?.instance;
|
||||
const nextPerFileInstance = await environmentManager.perTestSetup({
|
||||
config: mergedConfig,
|
||||
stripe
|
||||
});
|
||||
cachedPerFileInstance = {
|
||||
suiteKey,
|
||||
environmentSignature,
|
||||
instance: nextPerFileInstance
|
||||
};
|
||||
cachedPerFileGhostAccountOwner = null;
|
||||
cachedPerFileAuthenticatedSession = null;
|
||||
|
||||
if (previousPerFileInstance) {
|
||||
await environmentManager.perTestTeardown(previousPerFileInstance);
|
||||
}
|
||||
}
|
||||
|
||||
const activePerFileInstance = cachedPerFileInstance;
|
||||
if (!activePerFileInstance) {
|
||||
throw new Error('[e2e fixture] Failed to initialize per-file Ghost instance.');
|
||||
}
|
||||
|
||||
const holder = {...activePerFileInstance.instance};
|
||||
const cycle = async () => {
|
||||
const previousInstance = cachedPerFileInstance?.instance;
|
||||
const nextInstance = await environmentManager.perTestSetup({
|
||||
config: mergedConfig,
|
||||
stripe
|
||||
});
|
||||
|
||||
if (previousInstance) {
|
||||
await environmentManager.perTestTeardown(previousInstance);
|
||||
}
|
||||
|
||||
cachedPerFileInstance = {
|
||||
suiteKey,
|
||||
environmentSignature,
|
||||
instance: nextInstance
|
||||
};
|
||||
cachedPerFileGhostAccountOwner = null;
|
||||
cachedPerFileAuthenticatedSession = null;
|
||||
|
||||
Object.assign(holder, nextInstance);
|
||||
};
|
||||
|
||||
await use({
|
||||
holder,
|
||||
resolvedIsolation,
|
||||
cycle,
|
||||
getResetEnvironmentBlocker: () => resetEnvironmentGuard.blocker,
|
||||
markResetEnvironmentBlocker: (fixtureName: string) => {
|
||||
resetEnvironmentGuard.blocker ??= fixtureName;
|
||||
}
|
||||
});
|
||||
},
|
||||
|
||||
// Define options that can be set per test or describe block
|
||||
config: [undefined, {option: true}],
|
||||
isolation: [undefined, {option: true}],
|
||||
labs: [undefined, {option: true}],
|
||||
stripeEnabled: [false, {option: true}],
|
||||
mailgunEnabled: [false, {option: true}],
|
||||
|
||||
stripeServer: async ({stripeEnabled}, use) => {
|
||||
if (!stripeEnabled) {
|
||||
await use(undefined);
|
||||
return;
|
||||
}
|
||||
|
||||
const server = new FakeStripeServer();
|
||||
await server.start();
|
||||
debug('Fake Stripe server started on port', server.port);
|
||||
|
||||
await use(server);
|
||||
|
||||
await server.stop();
|
||||
debug('Fake Stripe server stopped');
|
||||
},
|
||||
|
||||
mailgunServer: async ({mailgunEnabled}, use) => {
|
||||
if (!mailgunEnabled) {
|
||||
await use(undefined);
|
||||
return;
|
||||
}
|
||||
|
||||
const server = new FakeMailgunServer();
|
||||
await server.start();
|
||||
debug('Fake Mailgun server started on port', server.port);
|
||||
|
||||
await use(server);
|
||||
|
||||
await server.stop();
|
||||
debug('Fake Mailgun server stopped');
|
||||
},
|
||||
|
||||
mailgun: async ({mailgunEnabled, mailgunServer}, use) => {
|
||||
if (!mailgunEnabled || !mailgunServer) {
|
||||
await use(undefined);
|
||||
return;
|
||||
}
|
||||
|
||||
const service = new MailgunTestService(mailgunServer);
|
||||
await use(service);
|
||||
},
|
||||
|
||||
emailClient: async ({}, use) => {
|
||||
await use(new MailPit());
|
||||
},
|
||||
|
||||
ghostInstance: async ({_testEnvironmentContext}, use, testInfo: TestInfo) => {
|
||||
debug('Using Ghost instance for test:', {
|
||||
testTitle: testInfo.title,
|
||||
resolvedIsolation: _testEnvironmentContext.resolvedIsolation,
|
||||
..._testEnvironmentContext.holder
|
||||
});
|
||||
await use(_testEnvironmentContext.holder);
|
||||
},
|
||||
|
||||
resolvedIsolation: async ({_testEnvironmentContext}, use) => {
|
||||
await use(_testEnvironmentContext.resolvedIsolation);
|
||||
},
|
||||
|
||||
resetEnvironment: async ({_testEnvironmentContext}, use) => {
|
||||
await use(async () => {
|
||||
if (_testEnvironmentContext.resolvedIsolation === 'per-test') {
|
||||
debug('resetEnvironment() is a no-op in per-test isolation mode');
|
||||
return;
|
||||
}
|
||||
|
||||
// Only support resetEnvironment() before stateful fixtures such as the
|
||||
// baseURL, authenticated user session, or page have been materialized.
|
||||
const blocker = _testEnvironmentContext.getResetEnvironmentBlocker();
|
||||
if (blocker) {
|
||||
throw new Error(
|
||||
`[e2e fixture] resetEnvironment() must be called before resolving ` +
|
||||
`"${blocker}". Use it in a beforeEach hook that only depends on ` +
|
||||
'resetEnvironment and fixtures that remain valid after a recycle.'
|
||||
);
|
||||
}
|
||||
|
||||
await _testEnvironmentContext.cycle();
|
||||
});
|
||||
},
|
||||
|
||||
stripe: async ({stripeEnabled, baseURL, stripeServer}, use) => {
|
||||
if (!stripeEnabled || !baseURL || !stripeServer) {
|
||||
await use(undefined);
|
||||
return;
|
||||
}
|
||||
|
||||
const webhookClient = new WebhookClient(baseURL);
|
||||
const service = new StripeTestService(stripeServer, webhookClient);
|
||||
await use(service);
|
||||
},
|
||||
|
||||
baseURL: async ({ghostInstance, _testEnvironmentContext}, use) => {
|
||||
_testEnvironmentContext.markResetEnvironmentBlocker('baseURL');
|
||||
await use(ghostInstance.baseUrl);
|
||||
},
|
||||
|
||||
// Create user credentials only (no authentication)
|
||||
ghostAccountOwner: async ({ghostInstance, _testEnvironmentContext}, use) => {
|
||||
if (!ghostInstance.baseUrl) {
|
||||
throw new Error('baseURL is not defined');
|
||||
}
|
||||
|
||||
_testEnvironmentContext.markResetEnvironmentBlocker('ghostAccountOwner');
|
||||
|
||||
if (_testEnvironmentContext.resolvedIsolation === 'per-file' && cachedPerFileGhostAccountOwner) {
|
||||
await use(cachedPerFileGhostAccountOwner);
|
||||
return;
|
||||
}
|
||||
|
||||
// Create user in this Ghost instance
|
||||
const ghostAccountOwner: User = {
|
||||
name: 'Test User',
|
||||
email: `test${faker.string.uuid()}@ghost.org`,
|
||||
password: 'test@123@test'
|
||||
};
|
||||
await setupUser(ghostInstance.baseUrl, ghostAccountOwner);
|
||||
|
||||
if (_testEnvironmentContext.resolvedIsolation === 'per-file') {
|
||||
cachedPerFileGhostAccountOwner = ghostAccountOwner;
|
||||
}
|
||||
|
||||
await use(ghostAccountOwner);
|
||||
},
|
||||
|
||||
// Intermediate fixture that sets up the page and returns all setup data
|
||||
pageWithAuthenticatedUser: async ({browser, ghostInstance, ghostAccountOwner, _testEnvironmentContext}, use) => {
|
||||
if (!ghostInstance.baseUrl) {
|
||||
throw new Error('baseURL is not defined');
|
||||
}
|
||||
|
||||
_testEnvironmentContext.markResetEnvironmentBlocker('pageWithAuthenticatedUser');
|
||||
|
||||
const pageWithAuthenticatedUser =
|
||||
_testEnvironmentContext.resolvedIsolation === 'per-file' && cachedPerFileAuthenticatedSession
|
||||
? await setupAuthenticatedPageFromStorageState(browser, ghostInstance.baseUrl, cachedPerFileAuthenticatedSession)
|
||||
: await setupNewAuthenticatedPage(browser, ghostInstance.baseUrl, ghostAccountOwner);
|
||||
|
||||
if (_testEnvironmentContext.resolvedIsolation === 'per-file' && !cachedPerFileAuthenticatedSession) {
|
||||
cachedPerFileAuthenticatedSession = {
|
||||
ghostAccountOwner: pageWithAuthenticatedUser.ghostAccountOwner,
|
||||
storageState: await pageWithAuthenticatedUser.context.storageState()
|
||||
};
|
||||
}
|
||||
|
||||
await use(pageWithAuthenticatedUser);
|
||||
await pageWithAuthenticatedUser.context.close();
|
||||
},
|
||||
|
||||
// Extract the page from pageWithAuthenticatedUser and apply labs/stripe settings
|
||||
page: async ({pageWithAuthenticatedUser, labs, _testEnvironmentContext}, use) => {
|
||||
_testEnvironmentContext.markResetEnvironmentBlocker('page');
|
||||
|
||||
const page = pageWithAuthenticatedUser.page;
|
||||
|
||||
const labsFlagsSpecified = labs && Object.keys(labs).length > 0;
|
||||
if (labsFlagsSpecified) {
|
||||
const settingsService = new SettingsService(page.request);
|
||||
debug('Updating labs settings:', labs);
|
||||
await settingsService.updateLabsSettings(labs);
|
||||
}
|
||||
|
||||
const needsReload = labsFlagsSpecified;
|
||||
if (needsReload) {
|
||||
await page.reload({waitUntil: 'load'});
|
||||
debug('Settings applied and page reloaded');
|
||||
}
|
||||
|
||||
await use(page);
|
||||
}
|
||||
});
|
||||
|
||||
export {expect} from '@playwright/test';
|
||||
@@ -0,0 +1,3 @@
|
||||
export * from './fixture';
|
||||
export * from './with-isolated-page';
|
||||
export * from './flows';
|
||||
@@ -0,0 +1,38 @@
|
||||
import {test} from './fixture';
|
||||
|
||||
/**
|
||||
* Opts a test file into per-test isolation (one Ghost environment per test).
|
||||
*
|
||||
* By default, e2e tests use per-file isolation: all tests in a file share a
|
||||
* single Ghost instance and database, which is significantly faster on CI.
|
||||
*
|
||||
* Call this at the root level of any test file that needs a fresh Ghost
|
||||
* environment for every test — typically files where tests mutate shared
|
||||
* state (members, billing, settings) in ways that would interfere with
|
||||
* each other.
|
||||
*
|
||||
* Under the hood this does two things via standard Playwright APIs:
|
||||
* 1. `test.describe.configure({mode: 'parallel'})` — tells Playwright to
|
||||
* run the file's tests concurrently across workers.
|
||||
* 2. `test.use({isolation: 'per-test'})` — tells our fixture layer to
|
||||
* spin up a dedicated Ghost instance per test instead of reusing one.
|
||||
*
|
||||
* Keeping both calls together avoids mismatches (e.g. parallel mode without
|
||||
* per-test isolation) and replaces the previous monkey-patching approach
|
||||
* that intercepted test.describe.configure() and parsed stack traces to
|
||||
* detect the caller file. This is intentionally two standard Playwright
|
||||
* calls wrapped in a single helper — no runtime patching required.
|
||||
*
|
||||
* @example
|
||||
* ```ts
|
||||
* import {usePerTestIsolation} from '@/helpers/playwright/isolation';
|
||||
*
|
||||
* usePerTestIsolation();
|
||||
*
|
||||
* test.describe('Ghost Admin - Members', () => { ... });
|
||||
* ```
|
||||
*/
|
||||
export function usePerTestIsolation() {
|
||||
test.describe.configure({mode: 'parallel'});
|
||||
test.use({isolation: 'per-test'});
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
import {Browser, BrowserContext, Page} from '@playwright/test';
|
||||
|
||||
export async function withIsolatedPage<T>(
|
||||
browser: Browser,
|
||||
opts: Parameters<Browser['newContext']>[0],
|
||||
run: ({page, context}: {page: Page, context: BrowserContext}) => Promise<T>
|
||||
): Promise<T> {
|
||||
const context = await browser.newContext(opts);
|
||||
const page = await context.newPage();
|
||||
try {
|
||||
return await run({page, context});
|
||||
} finally {
|
||||
await page.close();
|
||||
await context.close();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
import baseDebug from '@tryghost/debug';
|
||||
import express from 'express';
|
||||
import http from 'http';
|
||||
|
||||
export abstract class FakeServer {
|
||||
private server: http.Server | null = null;
|
||||
protected readonly app: express.Express = express();
|
||||
private _port: number;
|
||||
protected readonly debug: (...args: unknown[]) => void;
|
||||
|
||||
constructor(options: {port?: number; debugNamespace: string}) {
|
||||
this._port = options.port ?? 0;
|
||||
this.debug = baseDebug(options.debugNamespace);
|
||||
this.app.use((req, _res, next) => {
|
||||
this.debug(`${req.method} ${req.originalUrl}`);
|
||||
next();
|
||||
});
|
||||
this.setupRoutes();
|
||||
}
|
||||
|
||||
get port(): number {
|
||||
return this._port;
|
||||
}
|
||||
|
||||
async start(): Promise<void> {
|
||||
return new Promise((resolve, reject) => {
|
||||
this.server = this.app.listen(this._port, () => {
|
||||
const address = this.server?.address();
|
||||
|
||||
if (!address || typeof address === 'string') {
|
||||
reject(new Error(`${this.constructor.name} did not expose a TCP port`));
|
||||
return;
|
||||
}
|
||||
|
||||
this._port = address.port;
|
||||
resolve();
|
||||
});
|
||||
this.server.on('error', reject);
|
||||
});
|
||||
}
|
||||
|
||||
async stop(): Promise<void> {
|
||||
return new Promise((resolve) => {
|
||||
if (!this.server) {
|
||||
resolve();
|
||||
return;
|
||||
}
|
||||
this.server.close(() => {
|
||||
this.server = null;
|
||||
resolve();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
protected abstract setupRoutes(): void;
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
import dotenv from 'dotenv';
|
||||
// load environment variables from .env file
|
||||
dotenv.config({quiet: true});
|
||||
|
||||
// Simple config object with just the values
|
||||
export const appConfig = {
|
||||
baseURL: process.env.GHOST_BASE_URL || 'http://localhost:2368',
|
||||
|
||||
auth: {
|
||||
storageFile: 'playwright/.auth/user.json'
|
||||
},
|
||||
debugLogs: process.env.E2E_DEBUG_LOGS === 'true' || process.env.E2E_DEBUG_LOGS === '1'
|
||||
};
|
||||
@@ -0,0 +1,17 @@
|
||||
import * as fs from 'fs';
|
||||
import logging from '@tryghost/logging';
|
||||
|
||||
/** Ensure the state directory exists. */
|
||||
export const ensureDir = (dirPath: string) => {
|
||||
try {
|
||||
fs.mkdirSync(dirPath, {recursive: true});
|
||||
} catch (error) {
|
||||
// Log with structured context and rethrow preserving the original error as the cause
|
||||
logging.error({
|
||||
message: 'Failed to ensure directory exists',
|
||||
dirPath,
|
||||
err: error
|
||||
});
|
||||
throw new Error(`failed to ensure directory ${dirPath} exists`, {cause: error as Error});
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,3 @@
|
||||
export * from './app-config';
|
||||
export * from './setup-user';
|
||||
export * from './ensure-dir';
|
||||
@@ -0,0 +1,60 @@
|
||||
import baseDebug from '@tryghost/debug';
|
||||
import {User, UserFactory} from '@/data-factory';
|
||||
|
||||
const debug = baseDebug('e2e:helpers:utils:setup-user');
|
||||
|
||||
export class GhostUserSetup {
|
||||
private readonly baseURL: string;
|
||||
private readonly headers: Record<string, string>;
|
||||
private readonly setupAuthEndpoint = '/ghost/api/admin/authentication/setup';
|
||||
|
||||
constructor(baseURL: string) {
|
||||
this.baseURL = baseURL;
|
||||
this.headers = {'Content-Type': 'application/json'};
|
||||
}
|
||||
|
||||
async setup(userOverrides: Partial<User> = {}): Promise<void> {
|
||||
debug('setup-user called');
|
||||
if (await this.isSetupAlreadyCompleted()) {
|
||||
debug('Ghost user setup is already completed.');
|
||||
return;
|
||||
}
|
||||
|
||||
const user = new UserFactory().build(userOverrides);
|
||||
await this.createUser(user);
|
||||
}
|
||||
|
||||
private async isSetupAlreadyCompleted(): Promise<boolean> {
|
||||
const response = await this.makeRequest('GET');
|
||||
const data = await response.json();
|
||||
debug('Setup status response:', data);
|
||||
return data.setup?.[0]?.status === true;
|
||||
}
|
||||
|
||||
private async createUser(user: User): Promise<void> {
|
||||
await this.makeRequest('POST', {setup: [user]});
|
||||
debug('Ghost user created successfully.');
|
||||
}
|
||||
|
||||
private async makeRequest(method: 'GET' | 'POST', body?: unknown): Promise<Response> {
|
||||
const options: RequestInit = {method, headers: this.headers};
|
||||
|
||||
if (body) {
|
||||
options.body = JSON.stringify(body);
|
||||
}
|
||||
|
||||
const response = await fetch(`${this.baseURL}${this.setupAuthEndpoint}`, options);
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await response.text();
|
||||
throw new Error(`Ghost setup ${method} failed (${response.status}): ${error}`);
|
||||
}
|
||||
|
||||
return response;
|
||||
}
|
||||
}
|
||||
|
||||
export async function setupUser(baseGhostUrl: string, user: Partial<User> = {}): Promise<void> {
|
||||
const ghostUserSetup = new GhostUserSetup(baseGhostUrl);
|
||||
await ghostUserSetup.setup(user);
|
||||
}
|
||||
Reference in New Issue
Block a user