e2e/README.md
This test suite runs automated browser tests against a running Ghost instance to ensure critical user journeys work correctly.
corepack enable pnpm first)To run the test, within this e2e folder run:
# Install dependencies
pnpm
# All tests
pnpm test
If GHOST_E2E_MODE is unset, the e2e shell entrypoints auto-select:
dev when the local admin dev server is reachable on http://127.0.0.1:5174build otherwiseTo use dev mode, start pnpm dev before running tests:
# Terminal 1: Start dev environment (from repository root)
pnpm dev
# Terminal 2: Run e2e tests (from e2e folder)
pnpm test
If infra is already running, pnpm infra:up is safe to run again.
For dev-mode test runs, infra:up also ensures required local Ghost/gateway dev images exist.
If you want to force a mode, set GHOST_E2E_MODE=dev or GHOST_E2E_MODE=build explicitly.
When working on analytics locally, use:
# Terminal 1 (repo root)
pnpm dev:analytics
# Terminal 2
pnpm test:analytics
E2E test scripts automatically sync Tinybird tokens when Tinybird is running.
Use build mode when you don’t want to run dev servers. It uses a prebuilt Ghost image and serves public assets from /content/files.
# From repository root
pnpm build
pnpm --filter @tryghost/e2e build:apps
GHOST_E2E_BASE_IMAGE=<ghost-image> pnpm --filter @tryghost/e2e build:docker
GHOST_E2E_MODE=build pnpm --filter @tryghost/e2e infra:up
# Run tests
GHOST_E2E_MODE=build GHOST_E2E_IMAGE=ghost-e2e:local pnpm --filter @tryghost/e2e test
Build-mode E2E infra uses tmpfs-backed MySQL storage by default so database
snapshot restore cycles stay fast and isolated from local development data.
Set GHOST_E2E_MYSQL_TMPFS=false to use the normal Docker volume instead, or
GHOST_E2E_MYSQL_TMPFS_SIZE=4g to adjust the tmpfs size.
For a CI-like local preflight (pulls Playwright + gateway images and starts infra), run:
pnpm --filter @tryghost/e2e preflight:build
# Specific test file
pnpm test specific/folder/testfile.spec.ts
# Matching a pattern
pnpm test --grep "homepage"
# With browser visible (for debugging)
pnpm test --debug
The test suite is organized into separate directories for different areas/functions:
tests/public/ - Public-facing site tests (homepage, posts, etc.)tests/admin/ - Ghost admin panel tests (login, content creation, settings)We can decide whether to add additional sub-folders as we add more tests.
Example structure for admin tests:
tests/admin/
├── login.spec.ts
├── posts.spec.ts
└── settings.spec.ts
Project folder structure can be seen below:
e2e/
├── tests/ # All the tests
│ ├── public/ # Public site tests
│ │ └── testname.spec.ts # Test cases
│ ├── admin/ # Admin site tests
│ │ └── testname.spec.ts # Test cases
│ ├── global.setup.ts # Global setup script
│ ├── global.teardown.ts # Global teardown script
│ └── .eslintrc.js # Test-specific ESLint config
├── helpers/ # All helpers that support the tests, utilities, fixtures, page objects etc.
│ ├── playwright/ # Playwright specific helpers
│ │ └── fixture.ts # Playwright fixtures
│ ├── pages/ # Page Object Models
│ │ └── HomePage.ts # Page Object
│ ├── utils/ # Utils
│ │ └── math.ts # Math related utils
│ └── index.ts # Main exports
├── playwright.config.mjs # Playwright configuration
├── package.json # Dependencies and scripts
└── tsconfig.json # TypeScript configuration
Tests use Playwright Test. Use Arrange–Act–Assert (AAA) as a readability heuristic: set up the scenario, perform the behavior under test, then verify the outcome. Keep those phases clear through test structure and naming; comments are only useful when the boundaries would otherwise be unclear.
test.describe('Ghost Homepage', () => {
test('loads correctly', async ({page}) => {
const homePage = new HomePage(page);
await homePage.goto();
await expect(homePage.title).toBeVisible();
});
});
Page Objects are the default home for reusable knowledge about a page or major UI component. They encapsulate locators, readiness guards, and semantic interactions so tests can describe behavior rather than DOM structure. Assertions stay in test files.
Prefer an existing Page Object when a test exercises reusable UI behavior. A direct semantic locator in a test is acceptable for a small, one-off assertion or interaction when creating a Page Object would add indirection without reuse. Structural selectors sometimes remain necessary for iframes, editor internals, generated theme markup, and elements without an accessible role. Keep those inside Page Objects where practical and prefer, in order:
Avoid selectors coupled to visual styling, DOM position, or incidental class names. See Playwright's locator guidance and Martin Fowler's Page Object description for background.
// Create a page object for admin login
import type {Locator, Page} from '@playwright/test';
export class AdminLoginPage {
private readonly pageUrl = '/ghost';
public readonly emailInput: Locator;
public readonly passwordInput: Locator;
public readonly signInButton: Locator;
constructor(private readonly page: Page) {
this.emailInput = page.getByLabel('Email address');
this.passwordInput = page.getByLabel('Password');
this.signInButton = page.getByRole('button', {name: 'Sign in'});
}
async goto(urlToVisit = this.pageUrl) {
await this.page.goto(urlToVisit);
}
async login(email: string, password: string) {
await this.emailInput.fill(email);
await this.passwordInput.fill(password);
await this.signInButton.click();
}
}
Tests use Project Dependencies to define special tests as global setup and teardown tests:
tests/global.setup.ts - runs once before all teststests/global.teardown.ts - runs once after all testsPlaywright Fixtures are defined in helpers/playwright/fixture.ts and provide reusable test setup/teardown logic.
The fixture resolves isolation mode per test file:
usePerTestIsolation() from @/helpers/playwright/isolation at the root of the filefullyParallel: trueTest isolation is still automatic, but no longer always per-test.
Infrastructure (MySQL, Redis, Mailpit, Tinybird) must already be running before tests start. Use pnpm dev or pnpm --filter @tryghost/e2e infra:up.
Global setup (tests/global.setup.ts) does:
Per-file mode (helpers/playwright/fixture.ts) does:
Per-test mode (helpers/playwright/fixture.ts) does:
Environment identity for per-file reuse:
config participates in the environment identity.labs participates in the environment identity.stripeEnabled does not participate in per-file reuse. It always forces per-test isolation because Ghost must boot against a per-test fake Stripe server.Fixture option behavior:
config: use for boot-time Ghost config that should get a fresh environment when it changes.labs: use for labs flags that should get a fresh environment when they change.stripeEnabled: use for Stripe-backed tests; this always runs each test with a fully isolated Ghost environment.Escape hatch:
resetEnvironment() is supported only in beforeEach hooks for per-file tests.baseURL, page, pageWithAuthenticatedUser, or ghostAccountOwner.test.beforeEach(async ({resetEnvironment}) => { ... })resetEnvironment() after page or an authenticated session has already been created.Opting into per-test isolation:
usePerTestIsolation() from @/helpers/playwright/isolation at the root of the file.Global teardown (tests/global.teardown.ts) does:
Modes:
/content/filesgetByRole() and getByLabel(). Use stable test IDs when semantic locators are unavailable. Avoid selectors coupled to styling or DOM position.Tests run automatically in GitHub Actions on every PR and commit to main.
pnpm --filter @tryghost/e2e build:docker (layers public apps into /content/files)pnpm --filter @tryghost/e2e preflight:build)Within the e2e directory:
# Run all tests
pnpm test
# Start/stop test infra (MySQL/Redis/Mailpit/Tinybird)
pnpm infra:up
pnpm infra:down
# CI-like preflight for build mode (pulls images + starts infra)
pnpm preflight:build
# Debug failed tests (keeps containers)
PRESERVE_ENV=true pnpm test
# Run TypeScript type checking
pnpm test:types
# Lint code and tests
pnpm lint
# Build (for utilities)
pnpm build
pnpm dev # Watch mode for TypeScript compilation
test-results/ directorypnpm test --debug or pnpm test --ui to see browser