e2e/README.md
This top-level workspace is Ghost's browser end-to-end test suite. It runs automated browser tests against a complete, running Ghost instance to verify critical user journeys across packages and applications. A package's own Playwright suite is an acceptance suite, not an E2E one — see the testing guide for how the layers differ.
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
# Run the Admin sign-in test
pnpm test tests/admin/signin.test.ts
# Matching a pattern
pnpm test --grep "homepage"
# With browser visible (for debugging)
pnpm test --debug
See Writing Browser E2E Tests for the canonical conventions, Page Object pattern, locator priority, waiting patterns, and worked examples. This README covers the local workspace, infrastructure, fixtures, and commands.
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)tests/portal/ - Portal member journey testsWe can decide whether to add additional sub-folders as we add more tests.
Project folder structure can be seen below:
e2e/
├── tests/ # All the tests
│ ├── public/ # Public site tests
│ │ └── member-signup.test.ts
│ ├── admin/ # Admin site tests
│ │ └── signin.test.ts
│ ├── portal/ # Portal tests
│ ├── global.setup.ts # Global setup script
│ └── global.teardown.ts # Global teardown script
├── helpers/ # All helpers that support the tests, utilities, fixtures, page objects etc.
│ ├── playwright/ # Playwright specific helpers
│ │ └── fixture.ts # Playwright fixtures
│ ├── pages/ # Page Object Models, grouped by area
│ │ ├── base-page.ts # Base class for all page objects
│ │ └── admin/ # e.g. login-page.ts, admin-page.ts
│ ├── environment/ # Ghost container/database lifecycle
│ ├── services/ # Test doubles (fake Stripe, Mailgun, etc.)
│ └── utils/ # Shared utilities
├── data-factory/ # Test data factories (see its own README)
├── visual-regression/ # Screenshot baseline suite (separate config)
├── scripts/ # Infra and runner shell scripts
├── playwright.config.mjs # Playwright configuration
├── eslint.config.js # Lint config for the workspace
├── package.json # Dependencies and scripts
└── tsconfig.json # TypeScript configuration and path aliases
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 fixtures a test usually reaches for:
page - browser page against this test's Ghost instancepageWithAuthenticatedUser - the same, already signed in to Ghost AdminghostAccountOwner - the owner account's credentialsghostInstance - the running instance: baseUrl, database, port, siteUuid, containerId, instanceIdresolvedIsolation - 'per-file' | 'per-test' for the current testresetEnvironment() - force an environment recycle (see the escape hatch below)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
# Check the fake Stripe server against captured Stripe responses (no infra, ~1s)
pnpm test:fixtures
# Put a Stripe test account into the state fixtures are captured from
pnpm stripe:provision
# Re-capture Stripe fixtures from test mode (needs STRIPE_SECRET_KEY)
pnpm stripe:fixtures
# Re-measure the checkout limits the fake server enforces
pnpm stripe:probe
# 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
The fake Stripe server in helpers/services/stripe/ hand-builds the objects Stripe
would return. Those shapes were originally written from the docs rather than from
Stripe, so nothing checked them against the real API.
helpers/services/stripe/fixtures/ holds responses captured from Stripe test mode at
API version 2020-08-27, the version ghost/core pins. pnpm test:fixtures asserts
the builders against them, and needs no Ghost, no Docker and no browser.
Two failures are worth catching. A builder emitting a key Stripe does not return means
the fake describes an API that does not exist. A builder omitting a key Ghost reads is
worse, because it is silent: the property access yields undefined, the branch behind
it never runs, and the suite stays green.
The same suite checks that the fake refuses requests Stripe refuses. Those constraints were measured, not read from the docs, because the docs and Stripe's published OpenAPI spec each disagree with the API on at least one of them.
STRIPE_SECRET_KEY=sk_test_... pnpm stripe:provision # once per account
STRIPE_SECRET_KEY=sk_test_... pnpm stripe:fixtures
Test-mode keys only; a live key is refused.
stripe:provision puts the account into the state fixtures are captured from: a tier
product, its Monthly, Yearly and Complimentary prices, and a coupon. The nicknames are
the point, because Ghost's own code matches on them. Capturing runs it first, so the
two commands are only separate when you want to inspect what an account holds.
Both are idempotent and reuse what is already there, since Stripe cannot delete a product that has prices.
A completed checkout cannot be captured with the above, because Stripe blocks automating
its hosted payment page. pnpm stripe:fixtures:checkout does everything either side of
the payment and asks for one card entry:
STRIPE_SECRET_KEY=sk_test_... pnpm stripe:fixtures:checkout
It prints a Checkout URL, waits, then captures the completed session once you have paid
with 4242 4242 4242 4242. Shipping address, tax ID and custom field collection are all
requested on the same session, so one payment captures every shape the API cannot give
us. Fill all of them in, or the fields come back null and the fixture answers nothing.
The event envelope is deliberately not captured. An Event is an immutable snapshot
rendered at the account's default API version when it was created, and fetching it with a
pinned client does not re-render it. Ghost pins its webhook endpoint to the same version
its client uses, so what Ghost receives and what the API can hand back are different
renderings: at Stripe's current default the shipping address moves to
collected_information.shipping_details, which Ghost never sees. Ghost reads only
event.type and event.data.object, so the envelope carries nothing worth pinning.
test-results/ directorypnpm test --debug or pnpm test --ui to see browser