first commit
Copilot Setup Steps / copilot-setup-steps (push) Has been cancelled

This commit is contained in:
2026-04-22 19:51:20 +07:00
commit 93d1b7c3d3
579 changed files with 99797 additions and 0 deletions
+98
View File
@@ -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';
}
}
+3
View File
@@ -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;
}
+46
View File
@@ -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;
}
}
+5
View File
@@ -0,0 +1,5 @@
export * from './base-page';
export * from './admin';
export * from './portal';
export * from './public';
export * from './stripe';
+41
View File
@@ -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}`);
};
}
+510
View File
@@ -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';
+3
View File
@@ -0,0 +1,3 @@
export * from './fixture';
export * from './with-isolated-page';
export * from './flows';
+38
View File
@@ -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();
}
}
+56
View File
@@ -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;
}
+13
View File
@@ -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'
};
+17
View File
@@ -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});
}
};
+3
View File
@@ -0,0 +1,3 @@
export * from './app-config';
export * from './setup-user';
export * from './ensure-dir';
+60
View File
@@ -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);
}