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
+498
View File
@@ -0,0 +1,498 @@
# E2E Test Writing Guide
## Overview
This guide provides instructions for writing E2E tests in the `/e2e/` directory using TypeScript and Playwright. Tests follow the Page Object Model pattern and utilize data factories for test data management.
## Environment Setup
### Running Tests
```bash
# From the e2e directory
cd e2e
# Run all tests
pnpm test
# Run specific test file
pnpm test tests/admin/feature.test.ts
# Run with visible browser (debugging)
pnpm test --debug
# Run with specific timeout
pnpm test --timeout=60000
# Keep environment running after test (useful for Playwright MCP exploration)
PRESERVE_ENV=true pnpm test
# Enable debug logging
DEBUG=@tryghost/e2e:* pnpm test
```
## Test Organization
### Directory Structure
```
e2e/
├── tests/
│ ├── admin/ # Admin panel tests
│ ├── public/ # Public site tests
│ └── [area]/ # Other test areas
├── helpers/
│ ├── pages/ # Page Objects
│ │ ├── admin/ # Admin page objects
│ │ └── public/ # Public page objects
│ └── playwright/ # Test fixtures and setup
└── data-factory/ # Test data generators
```
### Test File Naming
- Test files: `[PageName].test.ts` - Named after the page being tested (e.g., `PostEditor.test.ts`, `MembersList.test.ts`)
- Page objects: `[Feature]Page.ts` (PascalCase)
- Use descriptive names that clearly indicate what's being tested
## Page Object Pattern
### Core Principles
1. **ALL selectors must be in Page Objects** - Never put selectors in test files
2. **Page Objects encapsulate page structure and interactions**
3. **Reuse existing Page Objects when possible**
4. **Create focused, single-responsibility Page Objects**
### Creating a Page Object
```typescript
// e2e/helpers/pages/admin/FeaturePage.ts
import {Page, Locator} from '@playwright/test';
import {AdminPage} from './AdminPage';
export class FeaturePage extends AdminPage {
// Define locators as readonly properties
readonly elementName: Locator;
readonly buttonName: Locator;
readonly modalDialog: Locator;
constructor(page: Page) {
super(page);
this.pageUrl = '/ghost/#/[path]';
// Selector priority (use in this order):
// 1. data-testid
this.elementName = page.getByTestId('element-id');
// 2. ARIA roles with accessible names
this.buttonName = page.getByRole('button', {name: 'Button Text'});
// 3. Labels for form elements
this.elementName = page.getByLabel('Field Label');
// 4. Text content (for unique text)
this.elementName = page.getByText('Unique text');
// 5. Avoid CSS/XPath selectors unless absolutely necessary
}
// Action methods
async performAction(): Promise<void> {
await this.buttonName.click();
}
async fillForm(data: {field1: string; field2: string}): Promise<void> {
await this.field1Input.fill(data.field1);
await this.field2Input.fill(data.field2);
}
// State verification methods
async isElementVisible(): Promise<boolean> {
return await this.elementName.isVisible();
}
async getElementText(): Promise<string> {
return await this.elementName.textContent() || '';
}
// Common utility methods (add to AdminPage or BasePage for reuse)
async pressEscape(): Promise<void> {
await this.page.keyboard.press('Escape');
}
async waitForAutoSave(): Promise<void> {
await this.page.waitForFunction(() => {
const status = document.querySelector('[data-test="status"]');
return status?.textContent?.includes('Saved');
});
}
}
```
### Modal/Dialog Pattern
```typescript
export class FeatureModal {
private readonly page: Page;
readonly modal: Locator;
readonly closeButton: Locator;
readonly saveButton: Locator;
constructor(page: Page) {
this.page = page;
this.modal = page.getByRole('dialog');
this.closeButton = this.modal.getByRole('button', {name: 'Close'});
this.saveButton = this.modal.getByRole('button', {name: 'Save'});
}
async waitForVisible(): Promise<void> {
await this.modal.waitFor({state: 'visible'});
}
async waitForHidden(): Promise<void> {
await this.modal.waitFor({state: 'hidden'});
}
async close(): Promise<void> {
await this.closeButton.click();
await this.waitForHidden();
}
async isVisible(): Promise<boolean> {
return await this.modal.isVisible();
}
}
```
### Extending Base Pages
```typescript
// Admin pages extend AdminPage
export class PostEditorPage extends AdminPage {
// Implementation
}
// Public pages extend BasePage
export class PublicHomePage extends BasePage {
// Implementation
}
```
## Writing Tests
### Test Structure (AAA Pattern)
**Important: Write self-documenting tests without comments. Test names and method names should clearly express intent. If complex logic is needed, extract it to a well-named method in the Page Object.**
Tests should follow the **Arrange-Act-Assert (AAA)** pattern:
- **Arrange**: Set up test data and page objects
- **Act**: Perform the actions being tested
- **Assert**: Verify the expected outcomes
The structure should be visually clear through spacing, not comments:
```typescript
import {test, expect} from '../../helpers/playwright';
import {FeaturePage} from '../../helpers/pages/admin/FeaturePage';
import {createPostFactory} from '../../data-factory';
test.describe('Feature Name', () => {
test('should perform expected behavior', async ({page, ghostInstance}) => {
// Arrange
const featurePage = new FeaturePage(page);
const postFactory = createPostFactory(page.request);
const post = await postFactory.create({title: 'Test Post'});
// Act
await featurePage.goto();
await featurePage.performAction();
// Assert
expect(await featurePage.isElementVisible()).toBe(true);
expect(await featurePage.getResultText()).toContain('Expected text');
});
});
```
### Test Fixtures
The `page` fixture provides:
- Pre-authenticated browser session (logged into Ghost admin)
- Automatic cleanup after test
The `ghostInstance` fixture provides:
- `baseUrl`: The URL of the Ghost instance
- `database`: Database name for this test
- `port`: Port number the instance is running on
Additional standalone fixtures exported from `helpers/playwright/fixture.ts` and re-exported by `@/helpers/playwright`:
- `resolvedIsolation`: `'per-file' | 'per-test'`
- `resetEnvironment()`: force a full environment recycle in per-file mode before stateful fixtures are resolved
```typescript
test.beforeEach(async ({resetEnvironment, resolvedIsolation}) => {
if (resolvedIsolation === 'per-file') {
await resetEnvironment();
}
});
```
Isolation rules:
- Default is per-file isolation, so the underlying Ghost environment can be reused across tests in the same file.
- Call `usePerTestIsolation()` at the root of a file to switch to per-test isolation and force a fresh Ghost environment for each test.
- Import it from `@/helpers/playwright/isolation`.
- `config` and `labs` participate in the per-file environment identity. If either changes, the shared environment is recycled.
- `stripeEnabled` always forces per-test isolation because Ghost must boot against a per-test fake Stripe server.
- `resetEnvironment()` is a hook-only escape hatch. Do not call it after `baseURL`, `page`, `pageWithAuthenticatedUser`, or `ghostAccountOwner` has already been resolved.
- Do not treat `resetEnvironment()` as an in-test cleanup step. If you recycle the environment, you must re-establish any stateful fixtures, and the supported pattern is to call it in `beforeEach` before those fixtures are created.
- ESLint catches direct misuse, but the runtime guard in the fixture is the final enforcement.
When to use each option:
- `config`: for boot-time Ghost config such as billing URLs or force-upgrade flags.
- `labs`: for tests that need specific labs flags on or off.
- `stripeEnabled`: for tests that need the fake Stripe server and Stripe-backed Ghost boot config.
- `usePerTestIsolation()`: for whole files that mutate shared state heavily and should never reuse a Ghost environment across tests.
## Data Factories
### Using Data Factories
Data factories provide a clean way to create test data. Import the factory you need and use it to generate data with specific attributes.
```typescript
import {createPostFactory, createMemberFactory} from '../../data-factory';
test('test with data', async ({page}) => {
const postFactory = createPostFactory(page.request);
const memberFactory = createMemberFactory(page);
const post = await postFactory.create({
title: 'Test Post',
content: 'Test content',
status: 'published'
});
const member = await memberFactory.create({
name: 'Test Member',
email: 'test@example.com'
});
const postEditorPage = new PostEditorPage(page);
await postEditorPage.gotoExistingPost(post.id);
});
```
### Factory Pattern
Factories are available for various Ghost entities. Check the `data-factory` directory for available factories. Common examples include:
- Creating posts with different statuses and content
- Creating members with subscriptions
- Creating staff users with specific roles
- Creating tags, offers, and other entities
New factories are added as needed. When you need test data that doesn't have a factory yet, consider creating one rather than manually constructing the data.
## Best Practices
### DO's
**Use Page Objects for all selectors**
**Write self-documenting tests** with clear method and test names
**Check existing Page Objects before creating new ones**
**Use proper waits** (`waitForLoadState`, `waitFor`, etc.)
**Keep tests isolated** - Each test gets its own Ghost instance
**Use descriptive test names** that explain what's being tested
**Extract complex logic to well-named methods** in Page Objects
**Use data factories** for complex test data
**Add meaningful assertions** beyond just visibility checks
### DON'Ts
**Never put selectors in test files**
**Don't write comments** - make code self-documenting instead
**Don't use hardcoded waits** (`page.waitForTimeout`)
**Don't use networkidle in waits** (`page.waitForLoadState('networkidle')`) - rely on web assertions to assess readiness instead
**Don't depend on test execution order**
**Don't manually log in** - use the pre-authenticated fixture
**Avoid CSS/XPath selectors** - use semantic selectors
**Don't create test data manually** if a factory exists
## Common Patterns
### Waiting for Elements
```typescript
// Good - explicit waits
await element.waitFor({state: 'visible'});
await page.waitForSelector('[data-test="element"]');
// Bad - arbitrary timeouts
await page.waitForTimeout(5000); // Avoid this!
```
### Handling Async Operations
```typescript
// Wait for save to complete
await page.waitForFunction(() => {
const status = document.querySelector('[data-test="status"]');
return status?.textContent?.includes('Saved');
});
```
### Working with iframes
```typescript
// Access iframe content
const iframe = page.locator('iframe[title="preview"]');
const frameContent = iframe.contentFrame();
await frameContent.click('button');
```
### Keyboard Shortcuts
```typescript
// Press keyboard keys
await page.keyboard.press('Escape');
await page.keyboard.press('Control+S');
await page.keyboard.type('Hello World');
```
## Ghost-Specific Patterns
### Common Selectors
- Navigation: `data-test-nav="[section]"`
- Buttons: `data-test-button="[action]"`
- Lists: `data-test-list="[name]"`
- Modals: `[role="dialog"]` or `.gh-modal`
- Loading states: `.gh-loading-spinner`
### Admin URLs
- Editor: `/ghost/#/editor/post/[id]`
- Posts list: `/ghost/#/posts`
- Settings: `/ghost/#/settings`
- Members: `/ghost/#/members`
### Common UI Elements
- Buttons: `.gh-btn-[color]` (e.g., `.gh-btn-primary`)
- Inputs: Often use `name` or `placeholder` attributes
- Status indicators: `[data-test="status"]`
## Using Playwright MCP for Page Object Discovery
When creating new Page Objects or discovering selectors for unfamiliar UI:
### 1. Start Ghost with Preserved Environment
```bash
# Start Ghost and keep it running
PRESERVE_ENV=true pnpm test
# The test will output the Ghost instance URL (usually http://localhost:2369)
```
### 2. Use Playwright MCP to Explore
```javascript
// Navigate to the Ghost instance
mcp__playwright__browser_navigate({url: "http://localhost:2369/ghost"})
// Capture the current DOM structure
mcp__playwright__browser_snapshot()
// Interact with elements to discover selectors
mcp__playwright__browser_click({element: "Button description", ref: "selector-from-snapshot"})
// Take screenshots for reference
mcp__playwright__browser_take_screenshot({filename: "feature-state.png"})
```
### 3. Extract Selectors for Page Objects
Based on your exploration, create the Page Object with discovered selectors:
- Note the element references from snapshots
- Identify the best selector strategy (testId, role, label, text)
- Test interactions before finalizing the Page Object
## Debugging
### Debug Mode
```bash
# See browser while test runs
pnpm test --debug
# UI mode for interactive debugging
pnpm test --ui
```
### Debug Logging
```bash
# Enable all e2e debug logs
DEBUG=@tryghost/e2e:* pnpm test
# Specific debug namespace
DEBUG=@tryghost/e2e:ghost-fixture pnpm test
```
### Preserve Environment
```bash
# Keep containers running after test
PRESERVE_ENV=true pnpm test
```
### Test Artifacts
- Screenshots on failure: `test-results/`
- Playwright traces: `test-results/`
## Test Isolation
Each test automatically gets:
1. **Fresh Ghost instance** with unique database
2. **Unique port** to avoid conflicts
3. **Pre-authenticated session**
4. **Automatic cleanup** after test completion
You don't need to worry about:
- Database cleanup
- Port conflicts
- Login/logout
- Test data pollution
## Validation Checklist
Before submitting a test:
- [ ] All selectors are in Page Objects
- [ ] Test follows AAA pattern
- [ ] Test is deterministic (not flaky)
- [ ] Uses proper waits (no arbitrary timeouts)
- [ ] Has meaningful assertions
- [ ] Follows naming conventions
- [ ] Reuses existing Page Objects where possible
- [ ] Test passes locally
- [ ] Test fails for the right reason (if demonstrating a bug)
## Quick Reference
### Essential Imports
```typescript
import {test, expect} from '../../helpers/playwright';
import {PageName} from '../../helpers/pages/admin/PageName';
import {createPostFactory} from '../../data-factory';
```
### Test Template
```typescript
test.describe('Feature', () => {
test('specific behavior', async ({page, ghostInstance}) => {
// Arrange
const pageObject = new PageObject(page);
// Act
await pageObject.goto();
await pageObject.action();
// Assert
expect(await pageObject.getState()).toBe(expected);
});
});
```
### Run Commands
```bash
pnpm test # All tests
pnpm test path/to/test.ts # Specific test
pnpm test --debug # With browser
pnpm test --grep "pattern" # Pattern matching
PRESERVE_ENV=true pnpm test # Keep environment
DEBUG=@tryghost/e2e:* pnpm test # Debug logs
```
+7
View File
@@ -0,0 +1,7 @@
# Debug failed tests (keeps containers)
PRESERVE_ENV=false
# define only if you want custom number of workers for tests to run in parallel locally
TEST_WORKERS_COUNT=5
# Skips the docker build pretest command, if you don't need to rebuild the container
GHOST_E2E_SKIP_BUILD=1
+150
View File
@@ -0,0 +1,150 @@
# AGENTS.md
E2E testing guidance for AI assistants (Claude, Codex, etc.) working with Ghost tests.
**IMPORTANT**: When creating or modifying E2E tests, always refer to `.claude/E2E_TEST_WRITING_GUIDE.md` for comprehensive testing guidelines and patterns.
## Critical Rules
1. **Always follow ADRs** in `../adr/` folder (ADR-0001: AAA pattern, ADR-0002: Page Objects)
2. **Always use pnpm**, never npm
3. **Always run after changes**: `pnpm lint` and `pnpm test:types`
4. **Never use CSS/XPath selectors** - only semantic locators or data-testid
5. **Prefer less comments and giving things clear names**
## Running E2E Tests
**`pnpm dev` must be running before you run E2E tests.** The E2E test runner auto-detects
whether the admin dev server is reachable at `http://127.0.0.1:5174`. If it is, tests run
in **dev mode** (fast, no pre-built Docker image required). If not, tests fall back to
**build mode** which requires a `ghost-e2e:local` Docker image that is only built in CI.
**If you see the error `Build image not found: ghost-e2e:local`, it means `pnpm dev` is
not running.** Start it first, wait for the admin dev server to be ready, then re-run tests.
```bash
# Terminal 1 (or background): Start dev environment from the repo root
pnpm dev
# Wait for the admin dev server to be reachable (http://127.0.0.1:5174)
# Terminal 2: Run e2e tests from the e2e/ directory
pnpm test # Run all tests
pnpm test tests/path/to/test.ts # Run specific test
pnpm lint # Required after writing tests
pnpm test:types # Check TypeScript errors
pnpm build # Required after factory changes
pnpm test --debug # See browser during execution, for debugging
PRESERVE_ENV=true pnpm test # Debug failed tests (keeps containers)
```
## Test Structure
### Naming Conventions
- **Test suites**: `Ghost Admin - Feature` or `Ghost Public - Feature`
- **Test names**: `what is tested - expected outcome` (lowercase)
- **One test = one scenario** (never mix multiple scenarios)
### AAA Pattern
```typescript
test('action performed - expected result', async ({page}) => {
const analyticsPage = new AnalyticsGrowthPage(page);
const postFactory = createPostFactory(page.request);
const post = await postFactory.create({status: 'published'});
await analyticsPage.goto();
await analyticsPage.topContent.postsButton.click();
await expect(analyticsPage.topContent.contentCard).toContainText('No conversions');
});
```
## Page Objects
### Structure
```typescript
export class AnalyticsPage extends AdminPage {
// Public readonly locators only
public readonly saveButton = this.page.getByRole('button', {name: 'Save'});
public readonly emailInput = this.page.getByLabel('Email');
// Semantic action methods
async saveSettings() {
await this.saveButton.click();
}
}
```
### Rules
- Page Objects are located in `helpers/pages/`
- Expose locators as `public readonly` when used with assertions
- Methods use semantic names (`login()` not `clickLoginButton()`)
- Use `waitFor()` for guards, never `expect()` in page objects
- Keep all assertions in test files
## Locators (Strict Priority)
1. **Semantic** (always prefer):
- `getByRole('button', {name: 'Save'})`
- `getByLabel('Email')`
- `getByText('Success')`
2. **Test IDs** (when semantic unavailable):
- `getByTestId('analytics-card')`
- Suggest adding `data-testid` to Ghost codebase when needed
3. **Never use**: CSS selectors, XPath, nth-child, class names
### Playwright MCP Usage
- Use `mcp__playwright__browser_snapshot` to find elements
- Use `mcp__playwright__browser_click` with semantic descriptions
- If no good locator exists, suggest `data-testid` addition to Ghost
## Test Data
### Factory Pattern (Required)
```typescript
import {PostFactory, UserFactory} from '../data-factory';
const postFactory = createPostFactory(page.request);
const post = await postFactory.create({userId: user.id});
```
## Best Practices
### DO ✅
- Use `usePerTestIsolation()` from `@/helpers/playwright/isolation` if a file needs per-test isolation
- Treat `config` and `labs` as environment-identity inputs: changing them should be an intentional part of test setup
- Use `resetEnvironment()` only in `beforeEach` hooks when you need a forced recycle inside per-file mode
- Keep `stripeEnabled` tests in per-test mode; the fixture forces this automatically
- Use factories for all test data
- Use Playwright's auto-waiting
- Run tests multiple times to ensure stability
- Use `test.only()` for debugging single tests
### DON'T ❌
- Use `test.describe.parallel(...)` or `test.describe.serial(...)` in e2e tests
- Use nested `test.describe.configure({mode: ...})` (mode toggles are root-level only)
- Call `resetEnvironment()` after resolving `baseURL`, `page`, `pageWithAuthenticatedUser`, or `ghostAccountOwner`
- Hard-coded waits (`waitForTimeout`)
- networkidle in waits (`networkidle`)
- Test dependencies (Test B needs Test A)
- Direct database manipulation
- Multiple scenarios in one test
- Assertions in page objects
- Manual login (auto-authenticated via fixture)
## Project Structure
- `tests/admin/` - Admin area tests
- `tests/public/` - Public site tests
- `helpers/pages/` - Page objects
- `helpers/environment/` - Container management
- `data-factory/` - Test data factories
## Validation Checklist
After writing tests, verify:
1. Test passes: `pnpm test path/to/test.ts`
2. Linting passes: `pnpm lint`
3. Types check: `pnpm test:types`
4. Follows AAA pattern with clear sections
5. Uses page objects appropriately
6. Uses semantic locators or data-testid only
7. No hard-coded waits or CSS selectors
+1
View File
@@ -0,0 +1 @@
AGENTS.md
+26
View File
@@ -0,0 +1,26 @@
# E2E test layer: copies locally-built public apps into Ghost's content folder
# so Ghost serves them from /content/files/* (same origin, no CORS).
#
# Usage:
# docker build -f e2e/Dockerfile.e2e \
# --build-arg GHOST_IMAGE=ghost-monorepo:latest \
# -t ghost-e2e:local .
#
# Intended for the production Ghost image built in CI.
ARG GHOST_IMAGE=ghost-monorepo:latest
FROM $GHOST_IMAGE
# Public app UMD bundles — Ghost serves these from /content/files/
COPY apps/portal/umd /home/ghost/content/files/portal
COPY apps/comments-ui/umd /home/ghost/content/files/comments-ui
COPY apps/sodo-search/umd /home/ghost/content/files/sodo-search
COPY apps/signup-form/umd /home/ghost/content/files/signup-form
COPY apps/announcement-bar/umd /home/ghost/content/files/announcement-bar
ENV portal__url=/content/files/portal/portal.min.js
ENV comments__url=/content/files/comments-ui/comments-ui.min.js
ENV sodoSearch__url=/content/files/sodo-search/sodo-search.min.js
ENV sodoSearch__styles=/content/files/sodo-search/main.css
ENV signupForm__url=/content/files/signup-form/signup-form.min.js
ENV announcementBar__url=/content/files/announcement-bar/announcement-bar.min.js
+300
View File
@@ -0,0 +1,300 @@
# Ghost End-To-End Test Suite
This test suite runs automated browser tests against a running Ghost instance to ensure critical user journeys work correctly.
## Quick Start
### Prerequisites
- Docker and Docker Compose installed
- Node.js installed (pnpm is managed via corepack — run `corepack enable pnpm` first)
### Running Tests
To run the test, within this `e2e` folder run:
```bash
# Install dependencies
pnpm
# All tests
pnpm test
```
### Dev Environment Mode (Recommended for Development)
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:5174`
- `build` otherwise
To use dev mode, start `pnpm dev` before running tests:
```bash
# 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.
### Analytics Development Flow
When working on analytics locally, use:
```bash
# Terminal 1 (repo root)
pnpm dev:analytics
# Terminal 2
pnpm test:analytics
```
E2E test scripts automatically sync Tinybird tokens when Tinybird is running.
### Build Mode (Prebuilt Image)
Use build mode when you dont want to run dev servers. It uses a prebuilt Ghost image and serves public assets from `/content/files`.
```bash
# 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
```
For a CI-like local preflight (pulls Playwright + gateway images and starts infra), run:
```bash
pnpm --filter @tryghost/e2e preflight:build
```
### Running Specific Tests
```bash
# 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
```
## Tests Development
The test suite is organized into separate directories for different areas/functions:
### **Current Test Suites**
- `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:
```text
tests/admin/
├── login.spec.ts
├── posts.spec.ts
└── settings.spec.ts
```
Project folder structure can be seen below:
```text
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
```
### Writing Tests
Tests use [Playwright Test](https://playwright.dev/docs/writing-tests) framework with page objects.
Aim to format tests in Arrange Act Assert style - it will help you with directions when writing your tests.
```typescript
test.describe('Ghost Homepage', () => {
test('loads correctly', async ({page}) => {
// ARRANGE - setup fixtures, create helpers, prepare things that helps will need to be executed
const homePage = new HomePage(page);
// ACT - do the actions you need to do, to verify certain behaviour
await homePage.goto();
// ASSERT
await expect(homePage.title).toBeVisible();
});
});
```
### Using Page Objects
Page objects encapsulate page elements, and interactions. To read more about them, check [this link out](https://www.selenium.dev/documentation/test_practices/encouraged/page_object_models/) and [this link](https://martinfowler.com/bliki/PageObject.html).
```typescript
// Create a page object for admin login
export class AdminLoginPage {
private pageUrl:string;
constructor(private page: Page) {
this.pageUrl = '/ghost'
}
async goto(urlToVisit = this.pageUrl) {
await this.page.goto(urlToVisit);
}
async login(email: string, password: string) {
await this.page.fill('[name="identification"]', email);
await this.page.fill('[name="password"]', password);
await this.page.click('button[type="submit"]');
}
}
```
### Global Setup and Teardown
Tests use [Project Dependencies](https://playwright.dev/docs/test-global-setup-teardown#option-1-project-dependencies) to define special tests as global setup and teardown tests:
- Global Setup: `tests/global.setup.ts` - runs once before all tests
- Global Teardown: `tests/global.teardown.ts` - runs once after all tests
### Playwright Fixtures
[Playwright Fixtures](https://playwright.dev/docs/test-fixtures) are defined in `helpers/playwright/fixture.ts` and provide reusable test setup/teardown logic.
The fixture resolves isolation mode per test file:
- Default: per-file isolation (one Ghost environment cycle per file)
- Opt-in per-test: call `usePerTestIsolation()` from `@/helpers/playwright/isolation` at the root of the file
- Forced per-test: any run with `fullyParallel: true`
### Test Isolation
Test 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:
- Cleans up e2e containers and test databases
- Creates a base database, starts Ghost, waits for health, snapshots the DB
Per-file mode (`helpers/playwright/fixture.ts`) does:
- Clones a new database from snapshot at file boundary
- Restarts Ghost with the new database and waits for readiness
- Reuses that environment for tests in the file
Per-test mode (`helpers/playwright/fixture.ts`) does:
- Clones a new database from snapshot for each test
- Restarts Ghost with the new database and waits for readiness
Environment identity for per-file reuse:
- `config` participates in the environment identity.
- `labs` participates in the environment identity.
- If either changes between tests in the same file, the shared per-file Ghost environment is recycled before reuse.
- `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.
- Use it only before resolving stateful fixtures such as `baseURL`, `page`, `pageWithAuthenticatedUser`, or `ghostAccountOwner`.
- Safe hook pattern: `test.beforeEach(async ({resetEnvironment}) => { ... })`
- Unsupported pattern: calling `resetEnvironment()` after `page` or an authenticated session has already been created.
- ESLint catches the obvious misuse cases, but the runtime guard in the fixture remains the hard safety check.
Opting into per-test isolation:
- Use `usePerTestIsolation()` from `@/helpers/playwright/isolation` at the root of the file.
- This configures both Playwright parallel mode and the fixture isolation in one call.
Global teardown (`tests/global.teardown.ts`) does:
- Cleans up e2e containers and test databases (infra services stay running)
Modes:
- Dev mode: Ghost mounts source code and proxies assets to host dev servers
- Build mode: Ghost uses a prebuilt image and serves assets from `/content/files`
### Best Practices
1. **Use page object patterns** to separate page elements, actions on the pages, complex logic from tests. They should help you make them more readable and UI elements reusable.
2. **Add meaningful assertions** beyond just page loads. Keep assertions in tests.
3. **Use `data-testid` attributes** for reliable element selection, in case you **cannot** locate elements in a simple way. Example: `page.getByLabel('User Name')`. Avoid, css, xpath locators - they make tests brittle.
4. **Clean up test data** when tests modify Ghost state
5. **Group related tests** in describe blocks
6. **Do not use should to describe test scenarios**
## CI Integration
Tests run automatically in GitHub Actions on every PR and commit to `main`.
### CI Process
1. **Setup**: Ubuntu runner with Node.js and Docker
2. **Build Assets**: Build server/admin assets and public app UMD bundles
3. **Build E2E Image**: `pnpm --filter @tryghost/e2e build:docker` (layers public apps into `/content/files`)
4. **Prepare E2E Runtime**: Pull Playwright/gateway images in parallel, start infra, and sync Tinybird state (`pnpm --filter @tryghost/e2e preflight:build`)
5. **Test Execution**: Run Playwright E2E tests inside the official Playwright container
6. **Artifacts**: Upload Playwright traces and reports on failure
## Available Scripts
Within the e2e directory:
```bash
# 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
```
## Resolving issues
### Test Failures
1. **Screenshots**: Playwright captures screenshots on failure
2. **Traces**: Available in `test-results/` directory
3. **Debug Mode**: Run with `pnpm test --debug` or `pnpm test --ui` to see browser
4. **Verbose Logging**: Check CI logs for detailed error information
+115
View File
@@ -0,0 +1,115 @@
# Ghost Data Factory
A minimal test data factory for Ghost e2e tests, written in TypeScript.
## Project Structure
```
e2e/data-factory/ # Source files (TypeScript) - committed to git
├── factory.ts # Base factory class
├── factories/ # Factory implementations
│ ├── post-factory.ts
│ ├── tag-factory.ts
│ └── user-factory.ts
├── persistence/
│ ├── adapter.ts # Persistence interface
│ └── adapters/ # Adapter implementations (API, Knex, etc)
├── setup.ts # Setup helper functions
├── index.ts # Main exports
└── utils.ts # Utility functions
```
## Setup
This is part of the Ghost e2e test suite. All dependencies are managed by the main Ghost monorepo.
1. **Start Ghost development server** (provides database):
```bash
pnpm dev
```
2. **Configure database connection** (optional - uses Ghost's database by default):
```bash
cp e2e/data-factory/.env.example e2e/data-factory/.env
# Edit .env if using different database credentials
```
3. **Build the e2e package** (includes data-factory):
```bash
cd e2e && pnpm build
```
## Usage
### In Tests
**Option 1: Use setup helpers (recommended)**
```typescript
import {createPostFactory, PostFactory} from '../data-factory';
// Create factory with API persistence
const postFactory: PostFactory = createPostFactory(page.request);
// Build in-memory only (not persisted)
const draftPost = postFactory.build({
title: 'My Draft',
status: 'draft'
});
// Create and persist to database
const publishedPost = await postFactory.create({
title: 'My Published Post',
status: 'published'
});
```
**Option 2: Manual setup**
```typescript
import {PostFactory} from '../data-factory/factories/post-factory';
import {GhostAdminApiAdapter} from '../data-factory/persistence/adapters/ghost-api';
const adapter = new GhostAdminApiAdapter(page.request, 'posts');
const postFactory = new PostFactory(adapter);
// Now you can build or create
const post = await postFactory.create({
title: 'My Published Post',
status: 'published'
});
```
## Development
### Adding New Factories
1. Create a new factory class extending `Factory<TOptions, TResult>`
2. Implement the `build()` method (returns in-memory object)
3. Set `entityType` property (used for persistence)
4. Create a setup helper in `setup.ts` for convenient usage in tests
Example:
```typescript
import {Factory} from '../factory';
export class MemberFactory extends Factory<Partial<Member>, Member> {
entityType = 'members';
build(options: Partial<Member> = {}): Member {
return {
id: generateId(),
email: options.email || faker.internet.email(),
name: options.name || faker.person.fullName(),
// ... more fields
};
}
}
```
Then create a setup helper:
```typescript
// In setup.ts
export function createMemberFactory(httpClient: HttpClient): MemberFactory {
const adapter = new GhostAdminApiAdapter(httpClient, 'members');
return new MemberFactory(adapter);
}
```
@@ -0,0 +1,60 @@
import {Factory} from '@/data-factory';
import {generateId} from '@/data-factory';
export interface AutomatedEmail {
id: string;
status: 'active' | 'inactive';
name: string;
slug: string;
subject: string;
lexical: string;
sender_name: string | null;
sender_email: string | null;
sender_reply_to: string | null;
created_at: Date;
updated_at: Date | null;
}
export class AutomatedEmailFactory extends Factory<Partial<AutomatedEmail>, AutomatedEmail> {
entityType = 'automated_emails';
build(options: Partial<AutomatedEmail> = {}): AutomatedEmail {
const now = new Date();
const defaults: AutomatedEmail = {
id: generateId(),
status: 'active',
name: 'Welcome Email (Free)',
slug: 'member-welcome-email-free',
subject: 'Welcome to {site_title}!',
lexical: JSON.stringify(this.defaultLexicalContent()),
sender_name: null,
sender_email: null,
sender_reply_to: null,
created_at: now,
updated_at: null
};
return {...defaults, ...options} as AutomatedEmail;
}
private defaultLexicalContent() {
return {
root: {
children: [{
type: 'paragraph',
children: [{
type: 'text',
text: 'Welcome to {site_title}!'
}]
}],
direction: null,
format: '',
indent: 0,
type: 'root',
version: 1
}
};
}
}
@@ -0,0 +1,32 @@
import {Factory} from '@/data-factory';
import {faker} from '@faker-js/faker';
import {generateId} from '@/data-factory';
export interface Comment {
id: string;
post_id: string;
member_id: string;
parent_id?: string;
in_reply_to_id?: string;
status: 'published' | 'hidden' | 'deleted';
html: string;
created_at?: string;
edited_at?: string;
}
export class CommentFactory extends Factory<Partial<Comment>, Comment> {
entityType = 'comments';
build(options: Partial<Comment> = {}): Comment {
const content = options.html || `<p>${faker.lorem.sentence()}</p>`;
return {
id: generateId(),
post_id: options.post_id || '',
member_id: options.member_id || '',
status: 'published',
html: content,
...options
};
}
}
+108
View File
@@ -0,0 +1,108 @@
import {faker} from '@faker-js/faker';
interface LexicalTextNode {
detail: number;
format: number;
mode: string;
style: string;
text: string;
type: 'text';
version: number;
}
interface LexicalParagraphNode {
children: LexicalTextNode[];
direction: string;
format: string;
indent: number;
type: 'paragraph';
version: number;
}
interface CardNode {
type: string;
[key: string]: unknown;
}
const CARD_DEFAULTS: Record<string, CardNode> = {
transistor: {
type: 'transistor',
version: 1,
accentColor: '#15171A',
backgroundColor: '#FFFFFF',
visibility: {
web: {
nonMember: false,
memberSegment: 'status:free,status:-free'
},
email: {
memberSegment: 'status:free,status:-free'
}
}
}
};
export type CardSpec = string | {[cardType: string]: Record<string, unknown>};
function resolveCard(spec: CardSpec): CardNode {
if (typeof spec === 'string') {
const defaults = CARD_DEFAULTS[spec];
if (!defaults) {
throw new Error(`Unknown card type: "${spec}". Register it in CARD_DEFAULTS in lexical.ts.`);
}
return {...defaults};
}
const [cardType, overrides] = Object.entries(spec)[0];
const defaults = CARD_DEFAULTS[cardType];
if (!defaults) {
throw new Error(`Unknown card type: "${cardType}". Register it in CARD_DEFAULTS in lexical.ts.`);
}
return {...defaults, ...overrides};
}
function buildParagraphNode(text: string): LexicalParagraphNode {
return {
children: [{
detail: 0,
format: 0,
mode: 'normal',
style: '',
text,
type: 'text',
version: 1
}],
direction: 'ltr',
format: '',
indent: 0,
type: 'paragraph',
version: 1
};
}
export function buildLexical(...cards: CardSpec[]): string {
let children: (LexicalParagraphNode | CardNode)[];
if (cards.length === 0) {
children = [buildParagraphNode(faker.lorem.paragraphs(3))];
} else {
children = [];
for (const spec of cards) {
const card = resolveCard(spec);
children.push(buildParagraphNode(`Before ${card.type}`));
children.push(card);
children.push(buildParagraphNode(`After ${card.type}`));
}
}
return JSON.stringify({
root: {
children,
direction: 'ltr',
format: '',
indent: 0,
type: 'root',
version: 1
}
});
}
@@ -0,0 +1,68 @@
import {Factory} from '@/data-factory';
import {faker} from '@faker-js/faker';
import {generateId, generateUuid} from '@/data-factory';
export interface Tier {
id: string;
name: string;
slug: string;
type: 'free' | 'paid';
active: boolean;
}
export interface Member {
id: string;
uuid: string;
name: string | null;
email: string;
note?: string | null;
geolocation: string | null;
labels?: string[];
email_count: number;
email_opened_count: number;
email_open_rate: number | null;
status: 'free' | 'paid' | 'comped';
last_seen_at: Date | null;
last_commented_at: Date | null;
newsletters: string[];
tiers?: Partial<Tier>[];
created_at?: string; // ISO 8601 format for backdating
complimentary_plan?: boolean;
stripe_customer_id?: string;
subscribed_to_emails?: string;
}
export class MemberFactory extends Factory<Partial<Member>, Member> {
entityType = 'members';
build(options: Partial<Member> = {}): Member {
return {
...this.buildDefaultMember(),
...options
};
}
private buildDefaultMember(): Member {
const firstName = faker.person.firstName();
const lastName = faker.person.lastName();
const name = `${firstName} ${lastName}`;
return {
id: generateId(),
uuid: generateUuid(),
name: name,
email: faker.internet.email({firstName, lastName}).toLowerCase(),
note: faker.lorem.sentence(),
geolocation: null,
labels: [],
email_count: 0,
email_opened_count: 0,
email_open_rate: null,
status: 'free',
last_seen_at: null,
last_commented_at: null,
newsletters: [],
subscribed_to_emails: 'false'
};
}
}
+169
View File
@@ -0,0 +1,169 @@
import {Factory, generateId, generateSlug} from '@/data-factory';
import {faker} from '@faker-js/faker';
import type {HttpClient, PersistenceAdapter} from '@/data-factory';
export interface AdminOffer {
id: string;
name: string;
code: string;
cadence: 'month' | 'year';
redemption_type?: 'signup' | 'retention';
status: 'active' | 'archived';
display_title: string | null;
display_description: string | null;
type: 'fixed' | 'percent' | 'trial';
amount: number;
duration: 'once' | 'repeating' | 'forever' | 'trial';
duration_in_months: number | null;
currency: string | null;
stripe_coupon_id?: string | null;
tier: {
id: string;
} | null;
}
export interface OfferCreateInput {
[key: string]: unknown;
name: string;
code: string;
cadence: 'month' | 'year';
amount: number;
duration: 'once' | 'repeating' | 'forever' | 'trial';
type: 'fixed' | 'percent' | 'trial';
tierId?: string | null;
currency?: string | null;
display_title?: string | null;
display_description?: string | null;
duration_in_months?: number | null;
redemption_type?: 'signup' | 'retention';
status?: 'active' | 'archived';
}
export interface OfferUpdateInput {
status?: 'active' | 'archived';
}
export class OfferFactory extends Factory<OfferCreateInput, AdminOffer> {
entityType = 'offers';
private readonly request?: HttpClient;
constructor(adapter?: PersistenceAdapter, request?: HttpClient) {
super(adapter);
this.request = request;
}
build(options: Partial<OfferCreateInput> = {}): AdminOffer {
const name = options.name ?? `Offer ${faker.commerce.productName()}`;
const code = options.code ?? `${generateSlug(name)}-${faker.string.alphanumeric(6).toLowerCase()}`;
const redemptionType = options.redemption_type ?? (options.tierId ? 'signup' : 'retention');
return {
id: generateId(),
name,
code,
cadence: options.cadence ?? 'month',
redemption_type: redemptionType,
status: options.status ?? 'active',
display_title: options.display_title ?? name,
display_description: options.display_description ?? null,
type: options.type ?? 'percent',
amount: options.amount ?? 10,
duration: options.duration ?? 'once',
duration_in_months: options.duration_in_months ?? null,
currency: options.currency ?? null,
stripe_coupon_id: null,
tier: options.tierId ? {id: options.tierId} : null
};
}
async create(options: Partial<OfferCreateInput> = {}): Promise<AdminOffer> {
if (!this.request) {
throw new Error('Cannot create without an HTTP client. Use createOfferFactory() for persisted test data access.');
}
const offer = this.build(options);
const response = await this.request.post('/ghost/api/admin/offers', {
data: {
offers: [{
name: offer.name,
code: offer.code,
cadence: offer.cadence,
status: offer.status,
redemption_type: offer.redemption_type ?? 'signup',
currency: offer.currency,
type: offer.type,
amount: offer.amount,
duration: offer.duration,
duration_in_months: offer.duration_in_months,
display_title: offer.display_title,
display_description: offer.display_description,
tier: offer.tier
}]
}
});
return await this.extractFirstOfferOrThrow('create offer', response.status(), response);
}
async update(id: string, input: OfferUpdateInput): Promise<AdminOffer> {
if (!this.request) {
throw new Error('Cannot update without an HTTP client. Use createOfferFactory() for persisted test data access.');
}
const response = await this.request.put(`/ghost/api/admin/offers/${id}`, {
data: {
offers: [input]
}
});
return await this.extractFirstOfferOrThrow('update offer', response.status(), response);
}
async getOffers(): Promise<AdminOffer[]> {
if (!this.request) {
throw new Error('Cannot fetch offers without an HTTP client. Use createOfferFactory() for persisted test data access.');
}
const response = await this.request.get('/ghost/api/admin/offers');
if (!response.ok()) {
throw new Error(`Failed to fetch offers: ${response.status()}`);
}
const data = await response.json() as {offers: AdminOffer[]};
return data.offers;
}
async getById(id: string): Promise<AdminOffer> {
if (!this.request) {
throw new Error('Cannot fetch an offer without an HTTP client. Use createOfferFactory() for persisted test data access.');
}
const response = await this.request.get(`/ghost/api/admin/offers/${id}`);
return await this.extractFirstOfferOrThrow('fetch offer', response.status(), response);
}
private async extractFirstOfferOrThrow(action: string, status: number, response: {ok(): boolean; json(): Promise<unknown>}): Promise<AdminOffer> {
if (!response.ok()) {
throw new Error(`Failed to ${action}: ${status}`);
}
const data = await response.json() as {offers?: AdminOffer[]};
const offers = data.offers;
if (!Array.isArray(offers) || offers.length === 0) {
let responseBody = '[unserializable]';
try {
responseBody = JSON.stringify(data);
} catch {
// Ignore serialization errors and keep fallback marker.
}
throw new Error(
`Failed to ${action}: expected response.offers to be a non-empty array (status ${status}). Response: ${responseBody}`
);
}
return offers[0];
}
}
@@ -0,0 +1,89 @@
import {Factory} from '@/data-factory';
import {buildLexical} from './lexical';
import {faker} from '@faker-js/faker';
import {generateId, generateSlug, generateUuid} from '@/data-factory';
import type {CardSpec} from './lexical';
export interface Post {
id: string;
uuid: string;
title: string;
slug: string;
mobiledoc: string | null;
lexical: string | null;
html: string;
comment_id: string;
plaintext: string;
feature_image: string | null;
featured: boolean;
type: string;
status: 'draft' | 'published' | 'scheduled';
locale: string | null;
visibility: string;
email_recipient_filter: string;
created_at: Date;
updated_at: Date;
published_at: Date | null;
custom_excerpt: string;
codeinjection_head: string | null;
codeinjection_foot: string | null;
custom_template: string | null;
canonical_url: string | null;
newsletter_id: string | null;
show_title_and_feature_image: boolean;
tags?: Array<{id: string}>;
tiers?: Array<{id: string}>;
}
export class PostFactory extends Factory<Partial<Post>, Post> {
entityType = 'posts'; // Entity name (for adapter; currently API endpoint)
build(options: Partial<Post> = {}): Post {
const now = new Date();
const title = options.title || faker.lorem.sentence();
const content = faker.lorem.paragraphs(3);
const defaults: Post = {
id: generateId(),
uuid: generateUuid(),
title: title,
slug: options.slug || generateSlug(title) + '-' + Date.now().toString(16),
mobiledoc: null,
lexical: buildLexical(),
html: `<p>${content}</p>`,
comment_id: generateId(),
plaintext: content,
feature_image: null,
featured: faker.datatype.boolean(),
type: 'post',
status: 'draft',
locale: null,
visibility: 'public',
email_recipient_filter: 'none',
created_at: now,
updated_at: now,
published_at: null,
custom_excerpt: faker.lorem.paragraph(),
codeinjection_head: null,
codeinjection_foot: null,
custom_template: null,
canonical_url: null,
newsletter_id: null,
show_title_and_feature_image: true,
tags: undefined
};
// Determine published_at based on status and user options
let publishedAt = options.published_at ?? defaults.published_at;
if (options.status === 'published' && !options.published_at) {
publishedAt = now;
}
return {...defaults, ...options, published_at: publishedAt} as Post;
}
async createWithCards(cards: CardSpec | CardSpec[], options: Partial<Post> = {}): Promise<Post> {
const cardArray = Array.isArray(cards) ? cards : [cards];
return this.create({...options, lexical: buildLexical(...cardArray)});
}
}
+72
View File
@@ -0,0 +1,72 @@
import {Factory} from '@/data-factory';
import {faker} from '@faker-js/faker';
import {generateId, generateSlug} from '@/data-factory';
export interface Tag {
id: string;
name: string;
slug: string;
description: string | null;
feature_image: string | null;
parent_id: string | null;
visibility: 'public' | 'internal';
url?: string;
og_image: string | null;
og_title: string | null;
og_description: string | null;
twitter_image: string | null;
twitter_title: string | null;
twitter_description: string | null;
meta_title: string | null;
meta_description: string | null;
codeinjection_head: string | null;
codeinjection_foot: string | null;
canonical_url: string | null;
accent_color: string | null;
count?: {
posts: number;
};
created_at: Date;
updated_at: Date | null;
}
export class TagFactory extends Factory<Partial<Tag>, Tag> {
entityType = 'tags';
build(options: Partial<Tag> = {}): Tag {
return {
...this.buildDefaultTag(),
...options
};
}
private buildDefaultTag(): Tag {
const now = new Date();
const tagName = faker.commerce.department();
return {
id: generateId(),
name: tagName,
slug: `${generateSlug(tagName)}-${faker.string.alphanumeric(6).toLowerCase()}`,
description: faker.lorem.sentence(),
feature_image: `https://picsum.photos/seed/tag-${faker.string.alphanumeric(8)}/1200/630`,
parent_id: null,
visibility: 'public',
url: undefined,
og_image: null,
og_title: null,
og_description: faker.lorem.sentence(),
twitter_image: null,
twitter_title: null,
twitter_description: faker.lorem.sentence(),
meta_title: null,
meta_description: faker.lorem.sentence(),
codeinjection_head: null,
codeinjection_foot: null,
canonical_url: null,
accent_color: null,
count: {posts: 0},
created_at: now,
updated_at: now
};
}
}
@@ -0,0 +1,83 @@
import {Factory} from '@/data-factory';
import {faker} from '@faker-js/faker';
import {generateId, generateSlug} from '@/data-factory';
import type {HttpClient, PersistenceAdapter} from '@/data-factory';
import type {Tier} from './member-factory';
export interface AdminTier extends Tier {
description?: string | null;
visibility?: 'public' | 'none';
welcome_page_url?: string | null;
benefits?: string[] | null;
currency?: string;
monthly_price?: number;
yearly_price?: number;
trial_days?: number;
created_at?: Date;
updated_at?: Date | null;
}
export interface TierCreateInput {
name: string;
description?: string;
visibility?: 'public' | 'none';
welcome_page_url?: string;
benefits?: string[];
currency: string;
monthly_price: number;
yearly_price: number;
trial_days?: number;
}
export class TierFactory extends Factory<Partial<AdminTier>, AdminTier> {
entityType = 'tiers';
private readonly request?: HttpClient;
constructor(adapter?: PersistenceAdapter, request?: HttpClient) {
super(adapter);
this.request = request;
}
build(options: Partial<AdminTier> = {}): AdminTier {
const tierName = options.name ?? `Tier ${faker.commerce.productName()}`;
const now = new Date();
const defaults: AdminTier = {
id: generateId(),
name: tierName,
slug: `${generateSlug(tierName)}-${faker.string.alphanumeric(6).toLowerCase()}`,
type: 'paid',
active: true,
description: faker.lorem.sentence(),
visibility: 'public',
welcome_page_url: null,
benefits: [],
currency: 'usd',
monthly_price: 500,
yearly_price: 5000,
trial_days: 0,
created_at: now,
updated_at: now
};
return {...defaults, ...options};
}
async getFirstPaidTier(): Promise<AdminTier> {
if (!this.request) {
throw new Error('Cannot fetch tiers without an HTTP client. Use createTierFactory() for persisted test data access.');
}
const response = await this.request.get('/ghost/api/admin/tiers');
if (!response.ok()) {
throw new Error(`Failed to fetch tiers: ${response.status()}`);
}
const {tiers} = await response.json() as {tiers: AdminTier[]};
const paidTier = tiers.find(tier => tier.type === 'paid' && tier.active);
if (!paidTier) {
throw new Error('No paid tiers found');
}
return paidTier;
}
}
@@ -0,0 +1,26 @@
import {Factory} from '@/data-factory';
export interface User {
name: string;
email: string;
password: string;
blogTitle: string;
}
export class UserFactory extends Factory<Partial<User>, User> {
entityType = 'users';
public build(overrides: Partial<User> = {}): User {
return {
...this.defaults,
...overrides
};
}
private defaults: User = {
name: 'Test Admin',
email: 'test@example.com',
password: 'test123',
blogTitle: 'Test Blog'
};
}
+38
View File
@@ -0,0 +1,38 @@
import type {PersistenceAdapter} from './persistence/adapter';
export abstract class Factory<TOptions extends Record<string, unknown> = Record<string, unknown>, TResult = TOptions> {
abstract entityType: string;
protected adapter?: PersistenceAdapter;
constructor(adapter?: PersistenceAdapter) {
this.adapter = adapter;
}
abstract build(options?: Partial<TOptions>): TResult;
buildMany(optionsList: Partial<TOptions>[]): TResult[] {
return optionsList.map(options => this.build(options));
}
async create(options?: Partial<TOptions>): Promise<TResult> {
if (!this.adapter) {
throw new Error('Cannot create without a persistence adapter. Use build() for in-memory objects.');
}
const data = this.build(options);
return await this.adapter.insert(this.entityType, data) as Promise<TResult>;
}
async createMany(optionsList: Partial<TOptions>[]): Promise<TResult[]> {
if (!this.adapter) {
throw new Error('Cannot create without a persistence adapter. Use buildMany() for in-memory objects.');
}
const results: TResult[] = [];
for (const options of optionsList) {
const result = await this.create(options);
results.push(result);
}
return results;
}
}
+37
View File
@@ -0,0 +1,37 @@
// Core Factory exports
export {Factory} from './factory';
export {PostFactory} from './factories/post-factory';
export type {Post} from './factories/post-factory';
export {TagFactory} from './factories/tag-factory';
export type {Tag} from './factories/tag-factory';
export {MemberFactory} from './factories/member-factory';
export type {Member, Tier} from './factories/member-factory';
export {TierFactory} from './factories/tier-factory';
export type {AdminTier, TierCreateInput} from './factories/tier-factory';
export {OfferFactory} from './factories/offer-factory';
export type {AdminOffer, OfferCreateInput, OfferUpdateInput} from './factories/offer-factory';
export {AutomatedEmailFactory} from './factories/automated-email-factory';
export type {AutomatedEmail} from './factories/automated-email-factory';
export {CommentFactory} from './factories/comment-factory';
export type {Comment} from './factories/comment-factory';
export * from './factories/user-factory';
// Persistence Adapters
export {KnexPersistenceAdapter} from './persistence/adapters/knex';
export {ApiPersistenceAdapter} from './persistence/adapters/api';
export type {HttpClient, HttpResponse} from './persistence/adapters/http-client';
export {GhostAdminApiAdapter} from './persistence/adapters/ghost-api';
export type {PersistenceAdapter} from './persistence/adapter';
// Utilities
export {generateId, generateUuid, generateSlug} from './utils';
// Factory Setup Helpers
export {createPostFactory} from './setup';
export {createTagFactory} from './setup';
export {createMemberFactory} from './setup';
export {createTierFactory} from './setup';
export {createOfferFactory} from './setup';
export {createAutomatedEmailFactory} from './setup';
export {createCommentFactory} from './setup';
export {createFactories} from './setup';
+12
View File
@@ -0,0 +1,12 @@
/**
* Core persistence adapter interface
*/
export interface PersistenceAdapter {
insert<T>(entityType: string, data: T): Promise<T>;
update<T>(entityType: string, id: string, data: Partial<T>): Promise<T>;
delete(entityType: string, id: string): Promise<void>;
findById<T>(entityType: string, id: string): Promise<T>;
// Optional methods - implement as needed
deleteMany?(entityType: string, ids: string[]): Promise<void>;
findMany?<T>(entityType: string, query?: Record<string, unknown>): Promise<T[]>;
}
+102
View File
@@ -0,0 +1,102 @@
import {AutomatedEmailFactory} from './factories/automated-email-factory';
import {CommentFactory} from './factories/comment-factory';
import {GhostAdminApiAdapter} from './persistence/adapters/ghost-api';
import {HttpClient} from './persistence/adapters/http-client';
import {MemberFactory} from './factories/member-factory';
import {OfferFactory} from './factories/offer-factory';
import {PostFactory} from './factories/post-factory';
import {TagFactory} from './factories/tag-factory';
import {TierFactory} from './factories/tier-factory';
/**
* Create a new PostFactory with API persistence
* Uses the http client which already has the proper authentication headers and baseURL
* configured (this would be Playwright's page.request)
*
* @param httpClient - client for requests with pre-defined authorization and base url
* @returns PostFactory ready to use with the specified Ghost backend
*/
export function createPostFactory(httpClient: HttpClient): PostFactory {
const adapter = new GhostAdminApiAdapter(
httpClient,
'posts',
{formats: 'mobiledoc,lexical,html'}
);
return new PostFactory(adapter);
}
export function createTagFactory(httpClient: HttpClient): TagFactory {
const adapter = new GhostAdminApiAdapter(
httpClient,
'tags'
);
return new TagFactory(adapter);
}
export function createMemberFactory(httpClient: HttpClient): MemberFactory {
const adapter = new GhostAdminApiAdapter(
httpClient,
'members'
);
return new MemberFactory(adapter);
}
export function createTierFactory(httpClient: HttpClient): TierFactory {
const adapter = new GhostAdminApiAdapter(
httpClient,
'tiers'
);
return new TierFactory(adapter, httpClient);
}
export function createOfferFactory(httpClient: HttpClient): OfferFactory {
const adapter = new GhostAdminApiAdapter(
httpClient,
'offers'
);
return new OfferFactory(adapter, httpClient);
}
export function createAutomatedEmailFactory(httpClient: HttpClient): AutomatedEmailFactory {
const adapter = new GhostAdminApiAdapter(
httpClient,
'automated_emails'
);
return new AutomatedEmailFactory(adapter);
}
export function createCommentFactory(httpClient: HttpClient): CommentFactory {
const adapter = new GhostAdminApiAdapter(
httpClient,
'comments'
);
return new CommentFactory(adapter);
}
export interface Factories {
postFactory: PostFactory;
tagFactory: TagFactory;
memberFactory: MemberFactory;
tierFactory: TierFactory;
offerFactory: OfferFactory;
automatedEmailFactory: AutomatedEmailFactory;
commentFactory: CommentFactory;
}
/**
* Helper for creating all factories with the same http client
* @param httpClient - client for requests with pre-defined authorization and base url
*
* @returns All factories ready to use with the specified Ghost backend
*/
export function createFactories(httpClient: HttpClient): Factories {
return {
postFactory: createPostFactory(httpClient),
tagFactory: createTagFactory(httpClient),
memberFactory: createMemberFactory(httpClient),
tierFactory: createTierFactory(httpClient),
offerFactory: createOfferFactory(httpClient),
automatedEmailFactory: createAutomatedEmailFactory(httpClient),
commentFactory: createCommentFactory(httpClient)
};
}
+25
View File
@@ -0,0 +1,25 @@
import {faker} from '@faker-js/faker';
import {randomBytes} from 'crypto';
/**
* Generate a MongoDB-style ObjectId
*/
export function generateId(): string {
const timestamp = Math.floor(Date.now() / 1000).toString(16);
const randomHex = randomBytes(8).toString('hex');
return timestamp + randomHex;
}
export function generateUuid(): string {
return faker.string.uuid();
}
export function generateSlug(text: string): string {
return text
.toLowerCase()
.replace(/[^\w\s-]/g, '')
.replace(/\s+/g, '-')
.replace(/--+/g, '-')
.replace(/^-+/, '')
.replace(/-+$/, '');
}
+234
View File
@@ -0,0 +1,234 @@
import eslint from '@eslint/js';
import ghostPlugin from 'eslint-plugin-ghost';
import playwrightPlugin from 'eslint-plugin-playwright';
import tseslint from 'typescript-eslint';
import noRelativeImportPaths from 'eslint-plugin-no-relative-import-paths'
const resetEnvironmentStaleFixtures = ['baseURL', 'ghostAccountOwner', 'page', 'pageWithAuthenticatedUser'];
function isBeforeEachHookCall(node) {
if (node.type !== 'CallExpression') {
return false;
}
if (node.callee.type === 'Identifier') {
return node.callee.name === 'beforeEach';
}
return node.callee.type === 'MemberExpression' &&
node.callee.property.type === 'Identifier' &&
node.callee.property.name === 'beforeEach';
}
function isFunctionNode(node) {
return node.type === 'ArrowFunctionExpression' ||
node.type === 'FunctionExpression' ||
node.type === 'FunctionDeclaration';
}
function getDestructuredFixtureNames(functionNode) {
const [firstParam] = functionNode.params;
if (!firstParam || firstParam.type !== 'ObjectPattern') {
return new Set();
}
const fixtureNames = new Set();
for (const property of firstParam.properties) {
if (property.type !== 'Property') {
continue;
}
if (property.key.type === 'Identifier') {
fixtureNames.add(property.key.name);
}
}
return fixtureNames;
}
const noUnsafeResetEnvironment = {
meta: {
type: 'problem',
docs: {
description: 'Restrict resetEnvironment() to supported beforeEach hooks'
},
messages: {
invalidLocation: 'resetEnvironment() is only supported inside beforeEach hooks. Use a beforeEach hook or switch the file to usePerTestIsolation().',
invalidFixtures: 'Do not resolve {{fixtures}} in the same beforeEach hook as resetEnvironment(); those fixtures become stale after a recycle.'
}
},
create(context) {
return {
CallExpression(node) {
if (isBeforeEachHookCall(node)) {
const callback = node.arguments.find(argument => isFunctionNode(argument));
if (!callback) {
return;
}
const fixtureNames = getDestructuredFixtureNames(callback);
if (!fixtureNames.has('resetEnvironment')) {
return;
}
const staleFixtures = resetEnvironmentStaleFixtures.filter(fixtureName => fixtureNames.has(fixtureName));
if (staleFixtures.length > 0) {
context.report({
node: callback,
messageId: 'invalidFixtures',
data: {
fixtures: staleFixtures.map(fixtureName => `"${fixtureName}"`).join(', ')
}
});
}
return;
}
if (node.callee.type !== 'Identifier' || node.callee.name !== 'resetEnvironment') {
return;
}
const ancestors = context.sourceCode.getAncestors(node);
const enclosingBeforeEachHook = [...ancestors]
.reverse()
.find((ancestor) => isFunctionNode(ancestor) &&
ancestor.parent &&
isBeforeEachHookCall(ancestor.parent));
if (!enclosingBeforeEachHook) {
context.report({
node,
messageId: 'invalidLocation'
});
}
}
};
}
};
const localPlugin = {
rules: {
'no-unsafe-reset-environment': noUnsafeResetEnvironment
}
};
export default tseslint.config([
// Ignore patterns
{
ignores: [
'build/**',
'data/**',
'playwright/**',
'playwright-report/**',
'test-results/**'
]
},
// Base config for all TypeScript files
{
files: ['**/*.ts', '**/*.mjs'],
extends: [
eslint.configs.recommended,
tseslint.configs.recommended
],
languageOptions: {
parserOptions: {
ecmaVersion: 2022,
sourceType: 'module'
}
},
plugins: {
ghost: ghostPlugin,
playwright: playwrightPlugin,
'no-relative-import-paths': noRelativeImportPaths,
local: localPlugin,
},
rules: {
// Manually include rules from plugin:ghost/ts and plugin:ghost/ts-test
// These would normally come from the extends, but flat config requires explicit inclusion
...ghostPlugin.configs.ts.rules,
// Sort multiple import lines into alphabetical groups
'ghost/sort-imports-es6-autofix/sort-imports-es6': ['error', {
memberSyntaxSortOrder: ['none', 'all', 'single', 'multiple']
}],
// Enforce kebab-case (lowercase with hyphens) for all filenames
'ghost/filenames/match-regex': ['error', '^[a-z0-9.-]+$', false],
// Apply no-relative-import-paths rule
'no-relative-import-paths/no-relative-import-paths': [
'error',
{ allowSameFolder: true, rootDir: './', prefix: '@' },
],
// Restrict imports to specific directories
'no-restricted-imports': ['error', {
patterns: ['@/helpers/pages/*']
}],
// Disable all mocha rules from ghost plugin since this package uses playwright instead
...Object.fromEntries(
Object.keys(ghostPlugin.rules || {})
.filter(rule => rule.startsWith('mocha/'))
.map(rule => [`ghost/${rule}`, 'off'])
)
}
},
// Keep assertions in test files and Playwright-specific helpers.
{
files: ['**/*.ts', '**/*.mjs'],
ignores: [
'tests/**/*.ts',
'helpers/playwright/**/*.ts',
'visual-regression/**/*.ts'
],
rules: {
'no-restricted-syntax': ['error',
{
selector: "ImportSpecifier[imported.name='expect'][parent.source.value='@playwright/test']",
message: 'Keep Playwright expect assertions in test files.'
},
{
selector: "ImportSpecifier[imported.name='expect'][parent.source.value='@/helpers/playwright']",
message: 'Keep Playwright expect assertions in test files.'
}
]
}
},
// Playwright-specific recommended rules config for test files
{
files: ['tests/**/*.ts', 'helpers/playwright/**/*.ts', 'helpers/pages/**/*.ts'],
rules: {
...playwrightPlugin.configs.recommended.rules,
'playwright/expect-expect': ['warn', {
assertFunctionPatterns: ['^expect[A-Z].*']
}]
}
},
// Keep test files on page objects and the supported isolation APIs.
{
files: ['tests/**/*.ts'],
rules: {
'local/no-unsafe-reset-environment': 'error',
'no-restricted-syntax': ['error',
{
selector: "CallExpression[callee.object.name='page'][callee.property.name='locator']",
message: 'Use page objects or higher-level page methods instead of page.locator() in test files.'
},
{
selector: 'MemberExpression[object.property.name="describe"][property.name="parallel"]',
message: 'test.describe.parallel() is deprecated. Use usePerTestIsolation() from @/helpers/playwright/isolation instead.'
},
{
selector: 'MemberExpression[object.property.name="describe"][property.name="serial"]',
message: 'test.describe.serial() is deprecated. Use test.describe.configure({mode: "serial"}) if needed.'
}
]
}
}
]);
+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);
}
+53
View File
@@ -0,0 +1,53 @@
{
"name": "@tryghost/e2e",
"version": "0.0.0",
"repository": "https://github.com/TryGhost/Ghost/tree/main/e2e",
"author": "Ghost Foundation",
"private": true,
"type": "module",
"scripts": {
"dev": "tsc --watch --preserveWatchOutput --noEmit",
"build": "pnpm test:types",
"build:ts": "tsc --noEmit",
"build:apps": "pnpm nx run-many --target=build --projects=@tryghost/portal,@tryghost/comments-ui,@tryghost/sodo-search,@tryghost/signup-form,@tryghost/announcement-bar",
"build:docker": "docker build -f Dockerfile.e2e --build-arg GHOST_IMAGE=${GHOST_E2E_BASE_IMAGE:?Set GHOST_E2E_BASE_IMAGE} -t ${GHOST_E2E_IMAGE:-ghost-e2e:local} ..",
"pretest": "test -n \"$CI\" || echo 'Tip: run pnpm dev or pnpm --filter @tryghost/e2e infra:up before running tests'",
"infra:up": "bash ./scripts/infra-up.sh",
"infra:down": "bash ./scripts/infra-down.sh",
"tinybird:sync": "node ./scripts/sync-tinybird-state.mjs",
"preflight:build": "bash ./scripts/prepare-ci-e2e-build-mode.sh",
"test": "bash ./scripts/run-playwright-host.sh playwright test --project=main",
"test:analytics": "bash ./scripts/run-playwright-host.sh playwright test --project=analytics",
"test:all": "bash ./scripts/run-playwright-host.sh playwright test --project=main --project=analytics",
"test:single": "bash ./scripts/run-playwright-host.sh playwright test --project=main -g",
"test:debug": "bash ./scripts/run-playwright-host.sh playwright test --project=main --headed --timeout=60000 -g",
"test:types": "tsc --noEmit",
"lint": "eslint . --cache"
},
"files": [
"build"
],
"devDependencies": {
"@eslint/js": "catalog:",
"@faker-js/faker": "8.4.1",
"@playwright/test": "1.59.1",
"@tryghost/debug": "0.1.40",
"@tryghost/logging": "2.5.5",
"@types/dockerode": "3.3.47",
"@types/express": "4.17.25",
"busboy": "^1.6.0",
"c8": "10.1.3",
"dockerode": "4.0.10",
"dotenv": "17.3.1",
"eslint": "catalog:",
"eslint-plugin-no-relative-import-paths": "1.6.1",
"eslint-plugin-playwright": "2.10.1",
"express": "4.21.2",
"knex": "3.1.0",
"mysql2": "3.18.1",
"stripe": "8.222.0",
"ts-node": "10.9.2",
"typescript": "5.9.3",
"typescript-eslint": "8.58.0"
}
}
+71
View File
@@ -0,0 +1,71 @@
import dotenv from 'dotenv';
import os from 'os';
dotenv.config({quiet: true});
/*
* 1/3 of the number of CPU cores seems to strike a good balance. Each worker
* runs in its own process (1 core) and gets its own Ghost instance (1 core)
* while leaving some head room for DBs, frontend dev servers, etc.
*
* It's possible to use more workers, but then the total test time and flakiness
* goes up dramatically.
*/
const getWorkerCount = () => {
const cpuCount = os.cpus().length;
return Math.floor(cpuCount / 3) || 1;
};
/** @type {import('@playwright/test').PlaywrightTestConfig} */
const config = {
timeout: process.env.CI ? 30 * 1000 : 30 * 1000,
expect: {
timeout: process.env.CI ? 10 * 1000 : 10 * 1000
},
retries: 0, // Retries open the door to flaky tests. If the test needs retries, it's not a good test or the app is broken.
maxFailures: process.argv.includes('--ui') ? 0 : 1,
workers: parseInt(process.env.TEST_WORKERS_COUNT, 10) || getWorkerCount(),
fullyParallel: false,
reporter: process.env.CI ? [['list', {printSteps: true}], ['blob']] : [['list', {printSteps: true}], ['html', {open: 'never'}]],
use: {
// Base URL will be set dynamically per test via fixture
baseURL: process.env.GHOST_BASE_URL || 'http://localhost:2368',
trace: 'retain-on-failure',
browserName: 'chromium'
},
testDir: './',
testMatch: ['tests/**/*.test.{js,ts}'],
projects: [
{
name: 'global-setup',
testMatch: /global\.setup\.ts/,
testDir: './tests',
teardown: 'global-teardown',
timeout: 60 * 1000 // 60 seconds for setup
},
{
name: 'main',
testIgnore: ['**/*.setup.ts', '**/*.teardown.ts', 'analytics/**/*.test.ts'],
testDir: './tests',
use: {
viewport: {width: 1920, height: 1080}
},
dependencies: ['global-setup']
},
{
name: 'analytics',
testDir: './tests',
testMatch: ['analytics/**/*.test.ts'],
use: {
viewport: {width: 1920, height: 1080}
},
dependencies: ['global-setup']
},
{
name: 'global-teardown',
testMatch: /global\.teardown\.ts/,
testDir: './tests'
}
]
};
export default config;
+32
View File
@@ -0,0 +1,32 @@
#!/usr/bin/env bash
set -euo pipefail
echo "::group::docker ps -a"
docker ps -a --format 'table {{.Names}}\t{{.Image}}\t{{.Status}}'
echo "::endgroup::"
dump_container_logs() {
local pattern="$1"
local label="$2"
local found=0
while IFS= read -r container_name; do
if [[ -z "$container_name" ]]; then
continue
fi
found=1
echo "::group::${label}: ${container_name}"
docker inspect "$container_name" --format 'State={{json .State}}' || true
docker logs --tail=500 "$container_name" || true
echo "::endgroup::"
done < <(docker ps -a --format '{{.Names}}' | grep -E "$pattern" || true)
if [[ "$found" -eq 0 ]]; then
echo "No containers matched ${label} pattern: ${pattern}"
fi
}
dump_container_logs '^ghost-e2e-worker-' 'Ghost worker'
dump_container_logs '^ghost-e2e-gateway-' 'E2E gateway'
dump_container_logs '^ghost-dev-(mysql|redis|mailpit|analytics|analytics-db|tinybird-local|tb-cli)$' 'E2E infra'
+10
View File
@@ -0,0 +1,10 @@
#!/usr/bin/env bash
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
REPO_ROOT="$(cd "$SCRIPT_DIR/../.." && pwd)"
cd "$REPO_ROOT"
docker compose -f compose.dev.yaml -f compose.dev.analytics.yaml stop \
analytics tb-cli tinybird-local mailpit redis mysql
+25
View File
@@ -0,0 +1,25 @@
#!/usr/bin/env bash
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
REPO_ROOT="$(cd "$SCRIPT_DIR/../.." && pwd)"
source "$SCRIPT_DIR/resolve-e2e-mode.sh"
cd "$REPO_ROOT"
MODE="$(resolve_e2e_mode)"
export GHOST_E2E_MODE="$MODE"
if [[ "$MODE" != "build" ]]; then
DEV_COMPOSE_PROJECT="${COMPOSE_PROJECT_NAME:-ghost-dev}"
GHOST_DEV_IMAGE="${DEV_COMPOSE_PROJECT}-ghost-dev"
GATEWAY_IMAGE="${DEV_COMPOSE_PROJECT}-ghost-dev-gateway"
if ! docker image inspect "$GHOST_DEV_IMAGE" >/dev/null 2>&1 || ! docker image inspect "$GATEWAY_IMAGE" >/dev/null 2>&1; then
echo "Building missing dev images for E2E (${GHOST_DEV_IMAGE}, ${GATEWAY_IMAGE})..."
docker compose -f compose.dev.yaml -f compose.dev.analytics.yaml build ghost-dev ghost-dev-gateway
fi
fi
docker compose -f compose.dev.yaml -f compose.dev.analytics.yaml up -d --wait \
mysql redis mailpit tinybird-local analytics
@@ -0,0 +1,21 @@
#!/usr/bin/env bash
if [[ "${BASH_SOURCE[0]}" == "$0" ]]; then
echo "This script must be sourced, not executed" >&2
exit 1
fi
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
REPO_ROOT="$(cd "$SCRIPT_DIR/../.." && pwd)"
cd "$REPO_ROOT"
PLAYWRIGHT_VERSION="$(node -p 'require("./e2e/package.json").devDependencies["@playwright/test"]')"
PLAYWRIGHT_IMAGE="mcr.microsoft.com/playwright:v${PLAYWRIGHT_VERSION}-noble"
WORKSPACE_PATH="${GITHUB_WORKSPACE:-$REPO_ROOT}"
export SCRIPT_DIR
export REPO_ROOT
export PLAYWRIGHT_VERSION
export PLAYWRIGHT_IMAGE
export WORKSPACE_PATH
+37
View File
@@ -0,0 +1,37 @@
#!/usr/bin/env bash
set -euo pipefail
source "$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/load-playwright-container-env.sh"
GATEWAY_IMAGE="${GHOST_E2E_GATEWAY_IMAGE:-caddy:2-alpine}"
echo "Preparing E2E build-mode runtime"
echo "Playwright image: ${PLAYWRIGHT_IMAGE}"
echo "Gateway image: ${GATEWAY_IMAGE}"
pids=()
labels=()
run_bg() {
local label="$1"
shift
labels+=("$label")
(
echo "[${label}] starting"
"$@"
echo "[${label}] done"
) &
pids+=("$!")
}
run_bg "pull-gateway-image" docker pull "$GATEWAY_IMAGE"
run_bg "pull-playwright-image" docker pull "$PLAYWRIGHT_IMAGE"
run_bg "start-infra" env GHOST_E2E_MODE=build bash "$REPO_ROOT/e2e/scripts/infra-up.sh"
for i in "${!pids[@]}"; do
if ! wait "${pids[$i]}"; then
echo "[${labels[$i]}] failed" >&2
exit 1
fi
done
node "$REPO_ROOT/e2e/scripts/sync-tinybird-state.mjs"
+56
View File
@@ -0,0 +1,56 @@
#!/usr/bin/env bash
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
REPO_ROOT="$(cd "$SCRIPT_DIR/../.." && pwd)"
SKIP_IMAGE_BUILD="${GHOST_E2E_SKIP_IMAGE_BUILD:-false}"
if [[ "$SKIP_IMAGE_BUILD" != "true" && -z "${GHOST_E2E_BASE_IMAGE:-}" ]]; then
echo "GHOST_E2E_BASE_IMAGE is required when building the E2E image in-job" >&2
exit 1
fi
cd "$REPO_ROOT"
echo "Preparing CI E2E job"
echo "E2E image: ${GHOST_E2E_IMAGE:-ghost-e2e:local}"
echo "Skip image build: ${SKIP_IMAGE_BUILD}"
if [[ "$SKIP_IMAGE_BUILD" != "true" ]]; then
echo "Base image: ${GHOST_E2E_BASE_IMAGE}"
fi
pids=()
labels=()
run_bg() {
local label="$1"
shift
labels+=("$label")
(
echo "[${label}] starting"
"$@"
echo "[${label}] done"
) &
pids+=("$!")
}
# Mostly IO-bound runtime prep (image pulls + infra startup + Tinybird sync) can
# overlap with the app/docker builds.
run_bg "runtime-preflight" bash "$REPO_ROOT/e2e/scripts/prepare-ci-e2e-build-mode.sh"
if [[ "$SKIP_IMAGE_BUILD" == "true" ]]; then
echo "Using prebuilt E2E image; skipping app and Docker image build."
else
# Build the assets + E2E image layer while IO-heavy prep is running.
pnpm --filter @tryghost/e2e build:apps
pnpm --filter @tryghost/e2e build:docker
fi
for i in "${!pids[@]}"; do
if ! wait "${pids[$i]}"; then
echo "[${labels[$i]}] failed" >&2
exit 1
fi
done
+26
View File
@@ -0,0 +1,26 @@
#!/usr/bin/env bash
set -euo pipefail
LOCAL_ADMIN_DEV_SERVER_URL="${LOCAL_ADMIN_DEV_SERVER_URL:-http://127.0.0.1:5174}"
resolve_e2e_mode() {
if [[ -n "${GHOST_E2E_MODE:-}" ]]; then
case "$GHOST_E2E_MODE" in
dev|build)
printf '%s' "$GHOST_E2E_MODE"
return
;;
*)
echo "Invalid GHOST_E2E_MODE: '$GHOST_E2E_MODE'. Expected one of: dev, build." >&2
return 1
;;
esac
fi
if curl --silent --fail --max-time 1 "$LOCAL_ADMIN_DEV_SERVER_URL" >/dev/null 2>&1; then
printf 'dev'
return
fi
printf 'build'
}
+26
View File
@@ -0,0 +1,26 @@
#!/usr/bin/env bash
set -euo pipefail
SHARD_INDEX="${E2E_SHARD_INDEX:-}"
SHARD_TOTAL="${E2E_SHARD_TOTAL:-}"
RETRIES="${E2E_RETRIES:-2}"
if [[ -z "$SHARD_INDEX" || -z "$SHARD_TOTAL" ]]; then
echo "Missing E2E_SHARD_INDEX or E2E_SHARD_TOTAL environment variables" >&2
exit 1
fi
source "$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/load-playwright-container-env.sh"
docker run --rm --network host --ipc host \
-v /var/run/docker.sock:/var/run/docker.sock \
-v "${WORKSPACE_PATH}:${WORKSPACE_PATH}" \
-w "${WORKSPACE_PATH}/e2e" \
-e CI=true \
-e TEST_WORKERS_COUNT="${TEST_WORKERS_COUNT:-1}" \
-e COMPOSE_PROJECT_NAME="${COMPOSE_PROJECT_NAME:-ghost-dev}" \
-e GHOST_E2E_MODE="${GHOST_E2E_MODE:-build}" \
-e GHOST_E2E_IMAGE="${GHOST_E2E_IMAGE:-ghost-e2e:local}" \
-e GHOST_E2E_GATEWAY_IMAGE="${GHOST_E2E_GATEWAY_IMAGE:-caddy:2-alpine}" \
"$PLAYWRIGHT_IMAGE" \
bash -c "corepack enable && pnpm test:all --shard=${SHARD_INDEX}/${SHARD_TOTAL} --retries=${RETRIES}"
+31
View File
@@ -0,0 +1,31 @@
#!/usr/bin/env bash
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
REPO_ROOT="$(cd "$SCRIPT_DIR/../.." && pwd)"
source "$SCRIPT_DIR/resolve-e2e-mode.sh"
cd "$REPO_ROOT"
GHOST_E2E_MODE="$(resolve_e2e_mode)"
export GHOST_E2E_MODE
if [[ "$GHOST_E2E_MODE" == "dev" ]]; then
echo "E2E mode: dev (detected admin dev server at $LOCAL_ADMIN_DEV_SERVER_URL)"
else
echo "E2E mode: build (admin dev server not detected at $LOCAL_ADMIN_DEV_SERVER_URL)"
echo " Tip: For local development, run 'pnpm dev' first — dev mode is faster and doesn't require a pre-built Docker image."
fi
# Dev-mode E2E Ghost containers mount the local workspace package, which needs a
# built entrypoint before Ghost can require it during boot.
if [[ "$GHOST_E2E_MODE" == "dev" ]]; then
pnpm --filter @tryghost/parse-email-address build >/dev/null
fi
if [[ "${CI:-}" != "true" ]]; then
node "$REPO_ROOT/e2e/scripts/sync-tinybird-state.mjs"
fi
cd "$REPO_ROOT/e2e"
exec "$@"
+116
View File
@@ -0,0 +1,116 @@
import fs from 'node:fs';
import path from 'node:path';
import {execFileSync} from 'node:child_process';
import {fileURLToPath} from 'node:url';
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
const repoRoot = path.resolve(__dirname, '../..');
const stateDir = path.resolve(repoRoot, 'e2e/data/state');
const configPath = path.resolve(stateDir, 'tinybird.json');
const composeArgs = [
'compose',
'-f', path.resolve(repoRoot, 'compose.dev.yaml'),
'-f', path.resolve(repoRoot, 'compose.dev.analytics.yaml')
];
const composeProject = process.env.COMPOSE_PROJECT_NAME || 'ghost-dev';
function log(message) {
process.stdout.write(`${message}\n`);
}
function parseEnv(raw) {
const vars = {};
for (const line of raw.split('\n')) {
const trimmed = line.trim();
if (!trimmed || trimmed.startsWith('#')) {
continue;
}
const separatorIndex = trimmed.indexOf('=');
if (separatorIndex === -1) {
continue;
}
vars[trimmed.slice(0, separatorIndex).trim()] = trimmed.slice(separatorIndex + 1).trim();
}
return vars;
}
function clearConfigIfPresent() {
if (fs.existsSync(configPath)) {
fs.rmSync(configPath, {force: true});
log(`Removed stale Tinybird config at ${configPath}`);
}
}
function runCompose(args) {
return execFileSync('docker', [...composeArgs, ...args], {
cwd: repoRoot,
encoding: 'utf8',
stdio: ['ignore', 'pipe', 'pipe']
});
}
function isTinybirdRunning() {
const output = execFileSync('docker', [
'ps',
'--filter', `label=com.docker.compose.project=${composeProject}`,
'--filter', 'label=com.docker.compose.service=tinybird-local',
'--filter', 'status=running',
'--format', '{{.Names}}'
], {
cwd: repoRoot,
encoding: 'utf8',
stdio: ['ignore', 'pipe', 'pipe']
});
return Boolean(output.trim());
}
function fetchConfigFromTbCli() {
return runCompose([
'run',
'--rm',
'-T',
'tb-cli',
'cat',
'/mnt/shared-config/.env.tinybird'
]);
}
function writeConfig(env) {
fs.mkdirSync(stateDir, {recursive: true});
fs.writeFileSync(configPath, JSON.stringify({
workspaceId: env.TINYBIRD_WORKSPACE_ID,
adminToken: env.TINYBIRD_ADMIN_TOKEN,
trackerToken: env.TINYBIRD_TRACKER_TOKEN
}, null, 2));
}
try {
if (!isTinybirdRunning()) {
clearConfigIfPresent();
log(`Tinybird is not running for compose project ${composeProject}; skipping Tinybird state sync (non-analytics runs are allowed)`);
process.exit(0);
}
const rawEnv = fetchConfigFromTbCli();
const env = parseEnv(rawEnv);
if (!env.TINYBIRD_WORKSPACE_ID || !env.TINYBIRD_ADMIN_TOKEN) {
clearConfigIfPresent();
throw new Error('Tinybird is running but required config values are missing in /mnt/shared-config/.env.tinybird');
}
writeConfig(env);
log(`Wrote Tinybird config to ${configPath}`);
} catch (error) {
clearConfigIfPresent();
const message = error instanceof Error ? error.message : String(error);
log(`Tinybird state sync failed: ${message}`);
process.exit(1);
}
+59
View File
@@ -0,0 +1,59 @@
import {AnalyticsOverviewPage, LoginPage, PasswordResetPage, SettingsPage} from '@/admin-pages';
import {EmailClient, MailPit} from '@/helpers/services/email/mail-pit';
import {Page} from '@playwright/test';
import {expect, test} from '@/helpers/playwright';
import {extractPasswordResetLink} from '@/helpers/services/email/utils';
test.describe('Ghost Admin - Reset Password', () => {
const emailClient: EmailClient = new MailPit();
async function logout(page: Page) {
const loginPage = new LoginPage(page);
await loginPage.logout();
}
test('resets account owner password', async ({page, ghostAccountOwner}) => {
await logout(page);
const {email} = ghostAccountOwner;
const newPassword = 'test@lginSecure@123';
const loginPage = new LoginPage(page);
await loginPage.requestPasswordReset(ghostAccountOwner.email);
await expect.soft(loginPage.body).toContainText('An email with password reset instructions has been sent.');
const messages = await emailClient.search({subject: 'Reset Password', to: email});
const latestMessage = await emailClient.getMessageDetailed(messages[0]);
const passwordResetUrl = extractPasswordResetLink(latestMessage);
await loginPage.goto(passwordResetUrl);
const passwordResetPage = new PasswordResetPage(page);
await passwordResetPage.resetPassword(newPassword, newPassword);
const analyticsPage = new AnalyticsOverviewPage(page);
await expect(analyticsPage.header).toBeVisible();
});
test('resets account owner password when 2FA enabled', async ({page, ghostAccountOwner}) => {
const newPassword = 'test@lginSecure@123';
const settingsPage = new SettingsPage(page);
await settingsPage.staffSection.goto();
await settingsPage.staffSection.enableRequireTwoFa();
await logout(page);
const loginPage = new LoginPage(page);
await loginPage.requestPasswordReset(ghostAccountOwner.email);
await expect.soft(loginPage.body).toContainText('An email with password reset instructions has been sent.');
const messages = await emailClient.search({subject: 'Reset Password', to: ghostAccountOwner.email});
const latestMessage = await emailClient.getMessageDetailed(messages[0]);
const passwordResetUrl = extractPasswordResetLink(latestMessage);
await loginPage.goto(passwordResetUrl);
const passwordResetPage = new PasswordResetPage(page);
await passwordResetPage.resetPassword(newPassword, newPassword);
const analyticsPage = new AnalyticsOverviewPage(page);
await expect(analyticsPage.header).toBeVisible();
});
});
+38
View File
@@ -0,0 +1,38 @@
import {LoginPage, PostsPage, TagsPage} from '@/admin-pages';
import {Page} from '@playwright/test';
import {expect, test} from '@/helpers/playwright';
test.describe('Ghost Admin - Signin Redirect', () => {
async function logout(page: Page) {
const loginPage = new LoginPage(page);
await loginPage.logout();
}
test('deep-linking to a React route while logged out redirects back after signin', async ({page, ghostAccountOwner}) => {
await logout(page);
const tagsPage = new TagsPage(page);
await tagsPage.goto();
const loginPage = new LoginPage(page);
await expect(loginPage.signInButton).toBeVisible();
await loginPage.signIn(ghostAccountOwner.email, ghostAccountOwner.password);
await tagsPage.waitForPageToFullyLoad();
});
test('deep-linking to an Ember route while logged out redirects back after signin', async ({page, ghostAccountOwner}) => {
await logout(page);
const postsPage = new PostsPage(page);
await postsPage.goto();
const loginPage = new LoginPage(page);
await expect(loginPage.signInButton).toBeVisible();
await loginPage.signIn(ghostAccountOwner.email, ghostAccountOwner.password);
await postsPage.waitForPageToFullyLoad();
});
});
+77
View File
@@ -0,0 +1,77 @@
import {AnalyticsOverviewPage, LoginPage, LoginVerifyPage} from '@/admin-pages';
import {EmailClient, EmailMessage, MailPit} from '@/helpers/services/email/mail-pit';
import {expect, test, withIsolatedPage} from '@/helpers/playwright';
test.describe('Two-Factor authentication', () => {
const emailClient: EmailClient = new MailPit();
function parseCodeFromMessageSubject(message: EmailMessage) {
const subject = message.Subject;
const match = subject.match(/\d+/);
if (!match) {
throw new Error(`No verification code found in subject: ${subject}`);
}
return match[0];
}
test.beforeEach(async ({page}) => {
const loginPage = new LoginPage(page);
await loginPage.goto();
});
test('authenticates with 2FA token', async ({browser, baseURL, ghostAccountOwner}) => {
await withIsolatedPage(browser, {baseURL}, async ({page: page}) => {
const {email, password} = ghostAccountOwner;
const adminLoginPage = new LoginPage(page);
await adminLoginPage.goto();
await adminLoginPage.signIn(email, password);
const messages = await emailClient.search({
subject: 'verification code',
to: ghostAccountOwner.email
});
const code = parseCodeFromMessageSubject(messages[0]);
const verifyPage = new LoginVerifyPage(page);
await verifyPage.twoFactorTokenField.fill(code);
await verifyPage.twoFactorVerifyButton.click();
const adminAnalyticsPage = new AnalyticsOverviewPage(page);
await expect(adminAnalyticsPage.header).toBeVisible();
});
});
test('authenticates with 2FA token that was resent', async ({browser, baseURL,ghostAccountOwner}) => {
await withIsolatedPage(browser, {baseURL}, async ({page: page}) => {
const {email, password} = ghostAccountOwner;
const adminLoginPage = new LoginPage(page);
await adminLoginPage.goto();
await adminLoginPage.signIn(email, password);
let messages = await emailClient.search({
subject: 'verification code',
to: ghostAccountOwner.email
});
expect(messages.length).toBe(1);
const verifyPage = new LoginVerifyPage(page);
await verifyPage.resendTwoFactorCodeButton.click();
messages = await emailClient.search({
subject: 'verification code',
to: ghostAccountOwner.email
}, {numberOfMessages: 2});
expect(messages.length).toBe(2);
const code = parseCodeFromMessageSubject(messages[0]);
await verifyPage.twoFactorTokenField.fill(code);
await verifyPage.twoFactorVerifyButton.click();
const adminAnalyticsPage = new AnalyticsOverviewPage(page);
await expect(adminAnalyticsPage.header).toBeVisible();
});
});
});
+301
View File
@@ -0,0 +1,301 @@
import {WhatsNewBanner, WhatsNewMenu} from '@/admin-pages';
import {expect, test} from '@/helpers/playwright/fixture';
import type {Page} from '@playwright/test';
// Local type definition matching the API response format
type RawChangelogEntry = {
slug: string;
title: string;
custom_excerpt: string;
published_at: string;
url: string;
featured: string;
feature_image?: string;
html?: string;
};
function daysAgo(days: number): Date {
const date = new Date();
date.setDate(date.getDate() - days);
return date;
}
function daysFromNow(days: number): Date {
const date = new Date();
date.setDate(date.getDate() + days);
return date;
}
function createEntry(publishedAt: Date, options: {
featured?: boolean;
title?: string;
excerpt?: string;
feature_image?: string;
} = {}): RawChangelogEntry {
const title = options.title ?? 'Test Update';
const slug = title.toLowerCase().replace(/\s+/g, '-');
return {
slug,
title,
custom_excerpt: options.excerpt ?? 'Test feature',
published_at: publishedAt.toISOString(),
url: `https://ghost.org/changelog/${slug}`,
featured: (options.featured ?? false) ? 'true' : 'false',
...(options.feature_image && {feature_image: options.feature_image})
};
}
async function mockChangelog(page: Page, entries: RawChangelogEntry[]): Promise<void> {
await page.route('https://ghost.org/changelog.json', async (route) => {
await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({
posts: entries,
changelogUrl: 'https://ghost.org/changelog/'
})
});
});
}
test.describe('Ghost Admin - What\'s New', () => {
test.describe('banner notification', () => {
test('shows banner for new entries the user has not seen', async ({page}) => {
await mockChangelog(page, [
createEntry(daysFromNow(1), {
title: 'New Update',
excerpt: 'This is an exciting new feature'
})
]);
const banner = new WhatsNewBanner(page);
await banner.goto();
await banner.waitForBanner();
await expect(banner.container).toBeVisible();
await expect(banner.title).toHaveText('New Update');
await expect(banner.excerpt).toHaveText('This is an exciting new feature');
});
test('does not show banner for entries from before user joined', async ({page}) => {
await mockChangelog(page, [createEntry(daysAgo(30))]);
const banner = new WhatsNewBanner(page);
await banner.goto();
await expect(banner.container).toBeHidden();
});
test('does not show banner when there are no entries', async ({page}) => {
await mockChangelog(page, []);
const banner = new WhatsNewBanner(page);
await banner.goto();
await expect(banner.container).toBeHidden();
});
test.describe('dismissal behavior', () => {
test('hides banner immediately when close button is clicked', async ({page}) => {
await mockChangelog(page, [createEntry(daysFromNow(1))]);
const banner = new WhatsNewBanner(page);
await banner.goto();
await banner.waitForBanner();
await expect(banner.container).toBeVisible();
await banner.dismiss();
await expect(banner.container).toBeHidden();
});
test('hides banner immediately when link is clicked', async ({page}) => {
await mockChangelog(page, [createEntry(daysFromNow(1))]);
const banner = new WhatsNewBanner(page);
await banner.goto();
await banner.waitForBanner();
await expect(banner.container).toBeVisible();
await banner.clickLinkAndClosePopup();
await expect(banner.container).toBeHidden();
});
test('hides banner immediately when modal is opened', async ({page}) => {
await mockChangelog(page, [
createEntry(daysFromNow(1), {feature_image: 'https://ghost.org/image1.jpg'}),
createEntry(daysAgo(5))
]);
const banner = new WhatsNewBanner(page);
const menu = new WhatsNewMenu(page);
await banner.goto();
await banner.waitForBanner();
await expect(banner.container).toBeVisible();
const modal = await menu.openWhatsNewModal();
await modal.close();
await expect(banner.container).toBeHidden();
});
test('banner remains hidden after reload when dismissed', async ({page}) => {
await mockChangelog(page, [createEntry(daysFromNow(1))]);
const banner = new WhatsNewBanner(page);
await banner.goto();
await banner.waitForBanner();
await banner.dismiss();
await banner.goto();
await expect(banner.container).toBeHidden();
});
test('banner reappears when a new entry is published after dismissal', async ({page}) => {
await mockChangelog(page, [createEntry(daysFromNow(1))]);
const banner = new WhatsNewBanner(page);
await banner.goto();
await banner.waitForBanner();
await banner.dismiss();
await banner.goto();
await expect(banner.container).toBeHidden();
await mockChangelog(page, [
createEntry(daysFromNow(2), {
title: 'Second Update'
})
]);
await banner.goto();
await banner.waitForBanner();
await expect(banner.container).toBeVisible();
await expect(banner.title).toHaveText('Second Update');
});
});
});
test.describe('modal', () => {
test('shows modal with all entries when opened from user menu', async ({page}) => {
await mockChangelog(page, [
createEntry(daysFromNow(1), {
title: 'Latest Update',
excerpt: 'Latest feature',
feature_image: 'https://ghost.org/image1.jpg'
}),
createEntry(daysAgo(5), {
title: 'Previous Update',
excerpt: 'Previous feature'
})
]);
const menu = new WhatsNewMenu(page);
await menu.goto();
const modal = await menu.openWhatsNewModal();
await expect(modal.modal).toBeVisible();
await expect(modal.title).toBeVisible();
const entries = await modal.getEntries();
expect(entries.length).toBe(2);
expect(entries[0].title).toBe('Latest Update');
expect(entries[0].excerpt).toBe('Latest feature');
expect(entries[0].hasImage).toBe(true);
expect(entries[1].title).toBe('Previous Update');
expect(entries[1].excerpt).toBe('Previous feature');
});
});
test.describe('badge indicators', () => {
test('shows badge for new non-featured entries the user has not seen', async ({page}) => {
await mockChangelog(page, [createEntry(daysFromNow(1))]);
const menu = new WhatsNewMenu(page);
await menu.goto();
await expect(menu.avatarBadge).toBeVisible();
});
test('shows badge in user menu when there are new entries', async ({page}) => {
await mockChangelog(page, [createEntry(daysFromNow(1))]);
const menu = new WhatsNewMenu(page);
await menu.goto();
await menu.openUserMenu();
await expect(menu.menuBadge).toBeVisible();
});
test('does not show badges for entries from before user joined', async ({page}) => {
await mockChangelog(page, [createEntry(daysAgo(30))]);
const menu = new WhatsNewMenu(page);
await menu.goto();
await expect(menu.avatarBadge).toBeHidden();
await menu.openUserMenu();
await expect(menu.menuBadge).toBeHidden();
});
test.describe('dismissal behavior', () => {
test('hides badges immediately when What\'s new modal is opened', async ({page}) => {
await mockChangelog(page, [createEntry(daysFromNow(1))]);
const menu = new WhatsNewMenu(page);
await menu.goto();
await expect(menu.avatarBadge).toBeVisible();
const modal = await menu.openWhatsNewModal();
await modal.close();
await expect(menu.avatarBadge).toBeHidden();
});
test('badges remain hidden after reload when What\'s new has been viewed', async ({page}) => {
await mockChangelog(page, [createEntry(daysFromNow(1))]);
const menu = new WhatsNewMenu(page);
await menu.goto();
const modal = await menu.openWhatsNewModal();
await modal.close();
await menu.goto();
await expect(menu.avatarBadge).toBeHidden();
});
test('badges reappear when a new entry is published after viewing', async ({page}) => {
await mockChangelog(page, [createEntry(daysFromNow(1))]);
const menu = new WhatsNewMenu(page);
await menu.goto();
const modal = await menu.openWhatsNewModal();
await modal.close();
await menu.goto();
await expect(menu.avatarBadge).toBeHidden();
await mockChangelog(page, [createEntry(daysFromNow(2))]);
await menu.goto();
await expect(menu.avatarBadge).toBeVisible();
});
});
});
});
+10
View File
@@ -0,0 +1,10 @@
import {getEnvironmentManager} from '@/helpers/environment';
import {test as setup} from '@playwright/test';
const TIMEOUT = 2 * 60 * 1000; // 2 minutes
setup('global environment setup', async () => {
setup.setTimeout(TIMEOUT);
const manager = await getEnvironmentManager();
await manager.globalSetup();
});
+7
View File
@@ -0,0 +1,7 @@
import {getEnvironmentManager} from '@/helpers/environment';
import {test as teardown} from '@playwright/test';
teardown('global environment cleanup', async () => {
const manager = await getEnvironmentManager();
await manager.globalTeardown();
});
+126
View File
@@ -0,0 +1,126 @@
import {APIRequestContext, Page} from '@playwright/test';
import {HomePage, MemberDetailsPage, MembersPage} from '@/helpers/pages';
import {MemberFactory, createMemberFactory} from '@/data-factory';
import {PortalAccountHomePage, PortalNewsletterManagementPage} from '@/portal-pages';
import {expect, test} from '@/helpers/playwright';
import {usePerTestIsolation} from '@/helpers/playwright/isolation';
usePerTestIsolation();
async function getNewsletterIds(request: APIRequestContext): Promise<string[]> {
const response = await request.get('/ghost/api/admin/newsletters/?status=active&limit=all');
const data = await response.json();
return data.newsletters.map((n: {id: string}) => n.id);
}
async function createNewsletter(request: APIRequestContext, name: string): Promise<string> {
const response = await request.post('/ghost/api/admin/newsletters/', {
data: {newsletters: [{name}]}
});
const data = await response.json();
return data.newsletters[0].id;
}
async function createSubscribedMember(request: APIRequestContext, memberFactory: MemberFactory) {
const newsletterIds = await getNewsletterIds(request);
const newsletters = newsletterIds.map(id => ({id}));
const member = await memberFactory.create({
// eslint-disable-next-line @typescript-eslint/no-explicit-any
newsletters: newsletters as any
});
return member;
}
async function impersonateMember(page: Page, memberName: string): Promise<void> {
const membersPage = new MembersPage(page);
await membersPage.goto();
await membersPage.getMemberByName(memberName).click();
const memberDetailsPage = new MemberDetailsPage(page);
await memberDetailsPage.settingsSection.memberActionsButton.click();
await memberDetailsPage.settingsSection.impersonateButton.click();
await expect(memberDetailsPage.magicLinkInput).not.toHaveValue('');
const magicLink = await memberDetailsPage.magicLinkInput.inputValue();
await memberDetailsPage.goto(magicLink);
const homePage = new HomePage(page);
await homePage.waitUntilLoaded();
}
async function getMemberNewsletters(request: APIRequestContext, memberId: string): Promise<{id: string}[]> {
const response = await request.get(`/ghost/api/admin/members/${memberId}/`);
const data = await response.json();
return data.members[0].newsletters;
}
test.describe('Portal - Member Actions', () => {
let memberFactory: MemberFactory;
test.beforeEach(async ({page}) => {
memberFactory = createMemberFactory(page.request);
});
test('can log out', async ({page}) => {
const member = await memberFactory.create();
await impersonateMember(page, member.name!);
const homePage = new HomePage(page);
await homePage.openAccountPortal();
const accountHome = new PortalAccountHomePage(page);
await accountHome.signOut();
await homePage.openPortal();
await expect(accountHome.signinSwitchButton).toBeVisible();
});
test('can unsubscribe from newsletter', async ({page}) => {
const member = await createSubscribedMember(page.request, memberFactory);
await impersonateMember(page, member.name!);
const homePage = new HomePage(page);
await homePage.openAccountPortal();
const accountHome = new PortalAccountHomePage(page);
await expect(accountHome.defaultNewsletterCheckbox).toBeChecked();
await accountHome.defaultNewsletterToggle.click();
await expect(accountHome.defaultNewsletterCheckbox).not.toBeChecked();
await expect(async () => {
const memberNewsletters = await getMemberNewsletters(page.request, member.id);
expect(memberNewsletters).toHaveLength(0);
}).toPass();
});
test('can unsubscribe from all newsletters', async ({page}) => {
await createNewsletter(page.request, 'Second newsletter');
const member = await createSubscribedMember(page.request, memberFactory);
await impersonateMember(page, member.name!);
const homePage = new HomePage(page);
await homePage.openAccountPortal();
const accountHome = new PortalAccountHomePage(page);
await accountHome.manageNewslettersButton.click();
const newsletterManagement = new PortalNewsletterManagementPage(page);
await expect(newsletterManagement.newsletterToggles).toHaveCount(2);
await expect(newsletterManagement.newsletterToggleCheckbox(0)).toBeChecked();
await expect(newsletterManagement.newsletterToggleCheckbox(1)).toBeChecked();
await newsletterManagement.unsubscribeFromAllButton.click();
await expect(newsletterManagement.successNotification).toBeVisible();
await expect(newsletterManagement.newsletterToggleCheckbox(0)).not.toBeChecked();
await expect(newsletterManagement.newsletterToggleCheckbox(1)).not.toBeChecked();
const memberNewsletters = await getMemberNewsletters(page.request, member.id);
expect(memberNewsletters).toHaveLength(0);
});
});
+56
View File
@@ -0,0 +1,56 @@
import {PostPage} from '@/helpers/pages';
import {PostsPage} from '@/admin-pages';
import {createPostFactory} from '@/data-factory';
import {expect, test} from '@/helpers/playwright';
import type {PostFactory} from '@/data-factory';
test.describe('Post Factory API Integration', () => {
let postFactory: PostFactory;
test.beforeEach(async ({page}) => {
postFactory = createPostFactory(page.request);
});
test('create a post and view it on the frontend', async ({page}) => {
const post = await postFactory.create({
title: 'Test Post from Factory',
status: 'published'
});
expect(post.id).toBeTruthy();
expect(post.slug).toBeTruthy();
expect(post.status).toBe('published');
const postPage = new PostPage(page);
await postPage.gotoPost(post.slug);
await expect(postPage.postTitle).toContainText('Test Post from Factory');
});
test('create a post visible in Ghost Admin', async ({page}) => {
const uniqueTitle = `Admin Test Post ${Date.now()}`;
const post = await postFactory.create({
title: uniqueTitle,
status: 'published'
});
const postsPage = new PostsPage(page);
await postsPage.goto();
await expect(postsPage.getPostByTitle(post.title)).toBeVisible();
});
test('create draft post that is not accessible on frontend', async ({page}) => {
const draftPost = await postFactory.create({
title: 'Draft Post from Factory',
status: 'draft'
});
expect(draftPost.status).toBe('draft');
expect(draftPost.published_at).toBeNull();
// TODO: Replace this with a 404 page object
const response = await page.goto(`/${draftPost.slug}/`, {
waitUntil: 'domcontentloaded'
});
expect(response?.status()).toBe(404);
});
});
+134
View File
@@ -0,0 +1,134 @@
import {
CommentFactory,
MemberFactory,
PostFactory,
TierFactory,
createFactories
} from '@/data-factory';
import {PostPage} from '@/public-pages';
import {SettingsService} from '@/helpers/services/settings/settings-service';
import {expect, signInAsMember, test} from '@/helpers/playwright';
test.describe('Ghost Public - Comments - Replies', () => {
let commentFactory: CommentFactory;
let postFactory: PostFactory;
let memberFactory: MemberFactory;
let tierFactory: TierFactory;
let settingsService: SettingsService;
test.beforeEach(async ({page}) => {
({postFactory, memberFactory, commentFactory, tierFactory} = createFactories(page.request));
settingsService = new SettingsService(page.request);
});
test.beforeEach(async () => {
await settingsService.setCommentsEnabled('all');
});
test('reply to top comment', async ({page}) => {
const post = await postFactory.create({status: 'published'});
const member = await memberFactory.create({status: 'free'});
const paidTier = await tierFactory.getFirstPaidTier();
const paidMember = await memberFactory.create({status: 'comped', tiers: [{id: paidTier.id}]});
const comment = await commentFactory.create({
html: 'Main comment',
post_id: post.id,
member_id: member.id
});
await commentFactory.create({
html: 'Reply to main comment',
post_id: post.id,
member_id: paidMember.id,
parent_id: comment.id
});
await signInAsMember(page, paidMember);
const postPage = new PostPage(page);
await postPage.gotoPost(post.slug);
await postPage.waitForCommentsToLoad();
const postCommentsSection = postPage.commentsSection;
await expect(postCommentsSection.comments).toHaveCount(2);
await expect(postCommentsSection.comments.first()).toContainText('Main comment');
await expect(postCommentsSection.comments.last()).toContainText('Reply to main comment');
await postCommentsSection.replyToComment('Main comment', 'Reply to main comment 2');
await expect(postCommentsSection.comments).toHaveCount(3);
await expect(postCommentsSection.comments.first()).toContainText('Main comment');
await expect(postCommentsSection.comments.last()).toContainText('Reply to main comment 2');
});
test('reply to reply comment', async ({page}) => {
const post = await postFactory.create({status: 'published'});
const member = await memberFactory.create({status: 'free'});
const paidTier = await tierFactory.getFirstPaidTier();
const paidMember = await memberFactory.create({status: 'comped', tiers: [{id: paidTier.id}]});
const comment = await commentFactory.create({
html: 'Main comment',
post_id: post.id,
member_id: member.id
});
await commentFactory.create({
html: 'Reply to main comment',
post_id: post.id,
member_id: paidMember.id,
parent_id: comment.id
});
await signInAsMember(page, paidMember);
const postPage = new PostPage(page);
await postPage.gotoPost(post.slug);
await postPage.waitForCommentsToLoad();
const postCommentsSection = postPage.commentsSection;
await expect(postCommentsSection.comments).toHaveCount(2);
await postCommentsSection.replyToComment('Reply to main comment', 'My reply');
await expect(postCommentsSection.comments).toHaveCount(3);
await expect(postCommentsSection.comments.first()).toContainText('Main comment');
await expect(postCommentsSection.comments.last()).toContainText('My reply');
await expect(postCommentsSection.comments.last()).toContainText('Replied to: Reply to main comment');
});
test('show replies and load more replies', async ({page}) => {
const post = await postFactory.create({status: 'published'});
const member = await memberFactory.create({status: 'free'});
const comment = await commentFactory.create({
html: 'Test comment 1',
post_id: post.id,
member_id: member.id
});
const replies = Array.from({length: 5}, (_, index) => {
return {
html: `reply ${index + 1} to comment 1`,
post_id: post.id,
member_id: member.id,
parent_id: comment.id
};
});
await commentFactory.createMany(replies);
const postPage = new PostPage(page);
await postPage.gotoPost(post.slug);
await postPage.waitForCommentsToLoad();
const postCommentsSection = postPage.commentsSection;
await expect(postCommentsSection.comments).toHaveCount(4);
await expect(postCommentsSection.comments.last()).toContainText('reply 3 to comment 1');
await expect(postCommentsSection.showMoreRepliesButton).toBeVisible();
await expect(postCommentsSection.showMoreRepliesButton).toContainText('Show 2 more replies');
await postCommentsSection.showMoreRepliesButton.click();
await expect(postCommentsSection.comments.last()).toContainText('reply 5 to comment 1');
await expect(postCommentsSection.comments).toHaveCount(6);
});
});
+109
View File
@@ -0,0 +1,109 @@
import {
CommentFactory,
MemberFactory,
PostFactory,
TierFactory,
createFactories
} from '@/data-factory';
import {PostPage} from '@/public-pages';
import {SettingsService} from '@/helpers/services/settings/settings-service';
import {expect, signInAsMember, test} from '@/helpers/playwright';
test.describe('Ghost Public - Comments - Manage', () => {
let commentFactory: CommentFactory;
let postFactory: PostFactory;
let memberFactory: MemberFactory;
let tierFactory: TierFactory;
let settingsService: SettingsService;
test.beforeEach(async ({page}) => {
({postFactory, memberFactory, commentFactory, tierFactory} = createFactories(page.request));
settingsService = new SettingsService(page.request);
});
test.beforeEach(async () => {
await settingsService.setCommentsEnabled('all');
});
test('no comment management buttons for non comment author', async ({page}) => {
const post = await postFactory.create({status: 'published'});
const paidTier = await tierFactory.getFirstPaidTier();
const paidMember = await memberFactory.create({status: 'comped', tiers: [{id: paidTier.id}]});
const anotherPaidMember = await memberFactory.create({status: 'comped', tiers: [{id: paidTier.id}]});
await commentFactory.create({
html: 'Comment to edit',
post_id: post.id,
member_id: paidMember.id
});
await signInAsMember(page, anotherPaidMember);
const postPage = new PostPage(page);
const postCommentsSection = postPage.commentsSection;
await postPage.gotoPost(post.slug);
await postPage.waitForCommentsToLoad();
const {
editCommentButton, deleteCommentButton, hideCommentButton, showCommentButton
} = await postCommentsSection.getCommentActionButtons('Comment to edit');
await expect(editCommentButton).toBeHidden();
await expect(deleteCommentButton).toBeHidden();
await expect(hideCommentButton).toBeVisible();
await expect(showCommentButton).toBeHidden();
});
test('edit comment', async ({page}) => {
const post = await postFactory.create({status: 'published'});
const paidTier = await tierFactory.getFirstPaidTier();
const paidMember = await memberFactory.create({status: 'comped', tiers: [{id: paidTier.id}]});
await commentFactory.create({
html: 'Comment to edit',
post_id: post.id,
member_id: paidMember.id
});
await signInAsMember(page, paidMember);
const postPage = new PostPage(page);
const postCommentsSection = postPage.commentsSection;
await postPage.gotoPost(post.slug);
await postPage.waitForCommentsToLoad();
await postCommentsSection.editComment('Comment to edit', 'Updated comment');
await expect(postCommentsSection.comments).toHaveCount(1);
await expect(postCommentsSection.comments.first()).toContainText('Updated comment');
});
test('delete comment', async ({page}) => {
const post = await postFactory.create({status: 'published'});
const paidTier = await tierFactory.getFirstPaidTier();
const paidMember = await memberFactory.create({status: 'comped', tiers: [{id: paidTier.id}]});
await commentFactory.create({
html: 'First comment',
post_id: post.id,
member_id: paidMember.id
});
await commentFactory.create({
html: 'Comment to delete',
post_id: post.id,
member_id: paidMember.id
});
await signInAsMember(page, paidMember);
const postPage = new PostPage(page);
await postPage.gotoPost(post.slug);
await postPage.waitForCommentsToLoad();
const postCommentsSection = postPage.commentsSection;
await postCommentsSection.deleteComment('Comment to delete');
await expect(postCommentsSection.comments).toHaveCount(1);
await expect(postCommentsSection.comments.first()).toContainText('First comment');
});
});
@@ -0,0 +1,136 @@
import {MemberFactory, PostFactory, TierFactory, createMemberFactory, createPostFactory, createTierFactory} from '@/data-factory';
import {PostPage} from '@/public-pages';
import {SettingsService} from '@/helpers/services/settings/settings-service';
import {expect, test} from '@/helpers/playwright';
import {signInAsMember} from '@/helpers/playwright/flows/sign-in';
test.describe('Ghost Public - Comments - Permission', () => {
let postFactory: PostFactory;
let memberFactory: MemberFactory;
let tierFactory: TierFactory;
let settingsService: SettingsService;
test.beforeEach(async ({page}) => {
postFactory = createPostFactory(page.request);
memberFactory = createMemberFactory(page.request);
tierFactory = createTierFactory(page.request);
settingsService = new SettingsService(page.request);
});
test.describe('comments enabled for all members', () => {
test.beforeEach(async () => {
await settingsService.setCommentsEnabled('all');
});
test('anonymous user - can not add a comment', async ({page}) => {
const post = await postFactory.create({status: 'published'});
const postPage = new PostPage(page);
await postPage.gotoPost(post.slug);
await postPage.commentsSection.waitForCommentsToLoad();
await expect(postPage.commentsSection.ctaBox).toBeVisible();
await expect(postPage.commentsSection.signUpButton).toBeVisible();
await expect(postPage.commentsSection.signInButton).toBeVisible();
await expect(postPage.commentsSection.mainForm).toBeHidden();
});
test('free member - can add a comment', async ({page}) => {
const post = await postFactory.create({status: 'published'});
const freeMember = await memberFactory.create({status: 'free'});
const commentTexts = ['Test comment by free member', 'Another Test comment by free member'];
await signInAsMember(page, freeMember);
const postPage = new PostPage(page);
await postPage.gotoPost(post.slug);
await postPage.commentsSection.waitForCommentsToLoad();
await postPage.commentsSection.addComment(commentTexts[0]);
await postPage.commentsSection.addComment(commentTexts[1]);
await expect(postPage.commentsSection.mainForm).toBeVisible();
await expect(postPage.commentsSection.ctaBox).toBeHidden();
// assert comment details
await expect(postPage.commentsSection.commentCountText).toHaveText('2 comments');
await expect(postPage.commentsSection.comments).toHaveCount(2);
await expect(postPage.commentsSection.comments.first()).toContainText(commentTexts[1]);
await expect(postPage.commentsSection.comments.last()).toContainText(commentTexts[0]);
});
test('paid member - can add a comment', async ({page}) => {
const post = await postFactory.create({status: 'published'});
const paidTier = await tierFactory.getFirstPaidTier();
const paidMember = await memberFactory.create({status: 'comped', tiers: [{id: paidTier.id}]});
const commentText = 'This is a test comment from a paid member';
await signInAsMember(page, paidMember);
const postPage = new PostPage(page);
await postPage.gotoPost(post.slug);
await postPage.waitForPostToLoad();
await postPage.commentsSection.waitForCommentsToLoad();
await postPage.commentsSection.addComment(commentText);
await expect(postPage.commentsSection.mainForm).toBeVisible();
await expect(postPage.commentsSection.ctaBox).toBeHidden();
// assert comment details
await expect(postPage.commentsSection.comments).toHaveCount(1);
await expect(postPage.commentsSection.comments.first()).toContainText(commentText);
});
});
test.describe('comments enabled for paid members only', () => {
test.beforeEach(async () => {
await settingsService.setCommentsEnabled('paid');
});
test('free member - can not add a comment', async ({page}) => {
const post = await postFactory.create({status: 'published'});
const member = await memberFactory.create({status: 'free'});
await signInAsMember(page, member);
const postPage = new PostPage(page);
await postPage.gotoPost(post.slug);
await postPage.waitForPostToLoad();
await postPage.commentsSection.waitForCommentsToLoad();
await expect(postPage.commentsSection.ctaBox).toBeVisible();
await expect(postPage.commentsSection.mainForm).toBeHidden();
});
test('paid member - can add a comment', async ({page}) => {
const post = await postFactory.create({status: 'published'});
const paidTier = await tierFactory.getFirstPaidTier();
const member = await memberFactory.create({status: 'comped', tiers: [{id: paidTier.id}]});
const commentText = 'This is a test comment from a paid member';
await signInAsMember(page, member);
const postPage = new PostPage(page);
await postPage.gotoPost(post.slug);
await postPage.waitForPostToLoad();
await postPage.commentsSection.waitForCommentsToLoad();
await postPage.commentsSection.addComment(commentText);
await expect(postPage.commentsSection.mainForm).toBeVisible();
await expect(postPage.commentsSection.ctaBox).toBeHidden();
await expect(postPage.commentsSection.comments.first()).toContainText(commentText);
});
});
test.describe('comments disabled', () => {
test.beforeEach(async () => {
await settingsService.setCommentsEnabled('off');
});
test('comments section is not visible', async ({page}) => {
const post = await postFactory.create({status: 'published'});
const postPage = new PostPage(page);
await postPage.gotoPost(post.slug);
await postPage.waitForPostToLoad();
await expect(postPage.commentsSection.commentsIframe).toBeHidden();
});
});
});
+80
View File
@@ -0,0 +1,80 @@
import {
CommentFactory,
MemberFactory,
PostFactory,
TierFactory,
createCommentFactory,
createMemberFactory,
createPostFactory,
createTierFactory
} from '@/data-factory';
import {PostPage} from '@/public-pages';
import {SettingsService} from '@/helpers/services/settings/settings-service';
import {expect, test} from '@/helpers/playwright';
test.describe('Ghost Public - Comments - Sorting', () => {
let commentFactory: CommentFactory;
let postFactory: PostFactory;
let memberFactory: MemberFactory;
let tierFactory: TierFactory;
let settingsService: SettingsService;
test.beforeEach(async ({page}) => {
postFactory = createPostFactory(page.request);
memberFactory = createMemberFactory(page.request);
commentFactory = createCommentFactory(page.request);
tierFactory = createTierFactory(page.request);
settingsService = new SettingsService(page.request);
});
test.beforeEach(async () => {
await settingsService.setCommentsEnabled('all');
});
test('sort comments by date and show more', async ({page}) => {
const post = await postFactory.create({status: 'published'});
const member = await memberFactory.create({status: 'free'});
const paidTier = await tierFactory.getFirstPaidTier();
const paidMember = await memberFactory.create({status: 'comped', tiers: [{id: paidTier.id}]});
const comments = Array.from({length: 25}, (_, index) => {
return {
html: `Test comment ${index + 1}`,
post_id: post.id,
member_id: Math.random() > 0.5 ? member.id : paidMember.id,
created_at: new Date(Date.now() - index * 1000).toISOString()
};
});
await commentFactory.createMany(comments);
const postPage = new PostPage(page);
await postPage.gotoPost(post.slug);
await postPage.waitForCommentsToLoad();
const postCommentsSection = postPage.commentsSection;
// verify sorting by oldest comments and load more comments
await postCommentsSection.sortBy('Oldest');
await expect(postCommentsSection.sortingButton).toContainText('Oldest');
await expect(postCommentsSection.comments.first()).toContainText('Test comment 25');
await expect(postCommentsSection.comments.last()).toContainText('Test comment 6');
await expect(postCommentsSection.showMoreCommentsButton).toBeVisible();
await expect(postCommentsSection.showMoreCommentsButton).toContainText('Load more (5)');
await postCommentsSection.showMoreCommentsButton.click();
await expect(postCommentsSection.comments).toHaveCount(25);
await expect(postCommentsSection.comments.last()).toContainText('Test comment 1');
// verify sorting by newest comments and load more comments
await postCommentsSection.sortBy('Newest');
await expect(postCommentsSection.sortingButton).toContainText('Newest');
await expect(postCommentsSection.comments.first()).toContainText('Test comment 1');
await expect(postCommentsSection.comments.last()).toContainText('Test comment 20');
await expect(postCommentsSection.showMoreCommentsButton).toBeVisible();
await expect(postCommentsSection.showMoreCommentsButton).toContainText('Load more (5)');
await postCommentsSection.showMoreCommentsButton.click();
await expect(postCommentsSection.comments).toHaveCount(25);
await expect(postCommentsSection.comments.last()).toContainText('Test comment 25');
});
});
+12
View File
@@ -0,0 +1,12 @@
import {HomePage} from '@/public-pages';
import {expect, test} from '@/helpers/playwright';
test.describe('Ghost Public - Homepage', () => {
test('loads correctly', async ({page}) => {
const homePage = new HomePage(page);
await homePage.goto();
await expect(homePage.title).toBeVisible();
await expect(homePage.mainSubscribeButton).toBeVisible();
});
});
@@ -0,0 +1,132 @@
import {EmailClient, MailPit} from '@/helpers/services/email/mail-pit';
import {HomePage, PublicPage} from '@/public-pages';
import {MemberDetailsPage, MembersPage} from '@/admin-pages';
import {Page} from '@playwright/test';
import {PostFactory, createPostFactory} from '@/data-factory';
import {expect, signupViaPortal, test} from '@/helpers/playwright';
import {extractMagicLink} from '@/helpers/services/email/utils';
test.describe('Ghost Public - Member Signup - Types', () => {
let emailClient: EmailClient;
test.beforeEach(async () => {
emailClient = new MailPit();
});
async function finishSignupByMagicLinkInEmail(page: Page, emailAddress: string) {
const messages = await emailClient.searchByRecipient(emailAddress);
const latestMessage = await emailClient.getMessageDetailed(messages[0]);
const emailTextBody = latestMessage.Text;
const magicLink = extractMagicLink(emailTextBody);
const publicPage = new PublicPage(page);
await publicPage.goto(magicLink);
await new HomePage(page).waitUntilLoaded();
}
test('signed up with magic link - direct', async ({page}) => {
await new HomePage(page).goto();
const {emailAddress, name} = await signupViaPortal(page);
await finishSignupByMagicLinkInEmail(page, emailAddress);
const homePage = new HomePage(page);
await expect(homePage.accountButton).toBeVisible();
const membersPage = new MembersPage(page);
await membersPage.goto();
await membersPage.clickMemberByEmail(emailAddress);
const membersDetailsPage = new MemberDetailsPage(page);
await expect(membersDetailsPage.body).toContainText(/Source.*—.*Direct/);
await expect(membersDetailsPage.body).toContainText(/Page.*—.*homepage/);
await expect(membersDetailsPage.nameInput).toHaveValue(name);
});
test('signed up with magic link - direct from post', async ({page}) => {
const postFactory: PostFactory = createPostFactory(page.request);
const post = await postFactory.create({title: 'Test Post', status: 'published'});
const homePage = new HomePage(page);
await homePage.goto();
await homePage.linkWithPostName(post.title).click();
const {emailAddress, name} = await signupViaPortal(page);
await finishSignupByMagicLinkInEmail(page, emailAddress);
const membersPage = new MembersPage(page);
await membersPage.goto();
await membersPage.clickMemberByEmail(emailAddress);
const membersDetailsPage = new MemberDetailsPage(page);
await expect(membersDetailsPage.body).toContainText(/Source.*—.*Direct/);
await expect(membersDetailsPage.body).toContainText(/Page.*—.*Test Post/);
await expect(membersDetailsPage.nameInput).toHaveValue(name);
});
test('signed up with magic link - from referrer', async ({page}) => {
const postFactory: PostFactory = createPostFactory(page.request);
const post = await postFactory.create({title: 'Google Test Post', status: 'published'});
const homePage = new HomePage(page);
await homePage.goto('/', {referer: 'https://www.google.com', waitUntil: 'domcontentloaded'});
await homePage.linkWithPostName(post.title).click();
const {emailAddress, name} = await signupViaPortal(page);
await finishSignupByMagicLinkInEmail(page, emailAddress);
const membersPage = new MembersPage(page);
await membersPage.goto();
await membersPage.clickMemberByEmail(emailAddress);
const membersDetailsPage = new MemberDetailsPage(page);
await expect(membersDetailsPage.body).toContainText(/Source.*—.*Google/);
await expect(membersDetailsPage.body).toContainText(/Page.*—.*Google Test Post/);
await expect(membersDetailsPage.nameInput).toHaveValue(name);
});
test('signed up with magic link - direct from newsletter', async ({page}) => {
const postFactory: PostFactory = createPostFactory(page.request);
const post = await postFactory.create({title: 'Newsletter Post', status: 'published'});
const homePage = new HomePage(page);
await homePage.goto(`${post.slug}?ref=ghost-newsletter`);
const {emailAddress, name} = await signupViaPortal(page);
await finishSignupByMagicLinkInEmail(page, emailAddress);
const membersPage = new MembersPage(page);
await membersPage.goto();
await membersPage.clickMemberByEmail(emailAddress);
const membersDetailsPage = new MemberDetailsPage(page);
await expect(membersDetailsPage.body).toContainText(/Source.*—.*ghost newsletter/);
await expect(membersDetailsPage.body).toContainText(/Page.*—.*Newsletter Post/);
await expect(membersDetailsPage.nameInput).toHaveValue(name);
});
test('signed up with magic link - utm_source=twitter', async ({page}) => {
const postFactory: PostFactory = createPostFactory(page.request);
const post = await postFactory.create({title: 'UTM Source Post', status: 'published'});
const homePage = new HomePage(page);
await homePage.goto(`${post.slug}?utm_source=twitter`);
const {emailAddress, name} = await signupViaPortal(page);
await finishSignupByMagicLinkInEmail(page, emailAddress);
const membersPage = new MembersPage(page);
await membersPage.goto();
await membersPage.clickMemberByEmail(emailAddress);
const membersDetailsPage = new MemberDetailsPage(page);
await expect(membersDetailsPage.body).toContainText(/Source.*—.*Twitter/);
await expect(membersDetailsPage.body).toContainText(/Page.*—.*UTM Source Post/);
await expect(membersDetailsPage.nameInput).toHaveValue(name);
});
});
+44
View File
@@ -0,0 +1,44 @@
import {EmailClient, MailPit} from '@/helpers/services/email/mail-pit';
import {HomePage, PublicPage} from '@/public-pages';
import {expect, test} from '@/helpers/playwright';
import {extractMagicLink} from '@/helpers/services/email/utils';
import {signupViaPortal} from '@/helpers/playwright/flows/signup';
test.describe('Ghost Public - Member Signup', () => {
let emailClient: EmailClient;
test.beforeEach(async () => {
emailClient = new MailPit();
});
async function retrieveLatestEmailMessage(emailAddress: string, timeoutMs: number = 10000) {
const messages = await emailClient.searchByRecipient(emailAddress, {timeoutMs: timeoutMs});
return await emailClient.getMessageDetailed(messages[0]);
}
test('signed up with magic link in email', async ({page}) => {
const homePage = new HomePage(page);
await homePage.goto();
const {emailAddress} = await signupViaPortal(page);
const latestMessage = await retrieveLatestEmailMessage(emailAddress);
const emailTextBody = latestMessage.Text;
const magicLink = extractMagicLink(emailTextBody);
const publicPage = new PublicPage(page);
await publicPage.goto(magicLink);
await homePage.waitUntilLoaded();
await expect(homePage.accountButton).toBeVisible();
});
test('received complete the signup email', async ({page}) => {
await new HomePage(page).goto();
const {emailAddress} = await signupViaPortal(page);
const latestMessage = await retrieveLatestEmailMessage(emailAddress);
expect(latestMessage.Subject.toLowerCase()).toContain('complete');
const emailTextBody = latestMessage.Text;
expect(emailTextBody).toContain('complete the signup process');
});
});
+92
View File
@@ -0,0 +1,92 @@
import {
FakeStripeCheckoutPage,
HomePage,
SignUpPage,
SupportNotificationPage,
SupportSuccessPage
} from '@/helpers/pages';
import {SettingsService} from '@/helpers/services/settings/settings-service';
import {
completeDonationViaFakeCheckout,
expect,
signInAsMember,
test
} from '@/helpers/playwright';
import {createMemberFactory} from '@/data-factory';
test.describe('Ghost Public - Portal Donations', () => {
test.use({stripeEnabled: true});
test('anonymous donation completes in portal - shows donation success page', async ({page, stripe}) => {
const homePage = new HomePage(page);
await homePage.gotoPortalSupport();
const checkoutPage = new FakeStripeCheckoutPage(page);
await checkoutPage.waitUntilDonationReady();
await expect(checkoutPage.totalAmount).toHaveText('$5.00');
await completeDonationViaFakeCheckout(page, stripe!, {
amount: '12.50',
email: `member-donation-${Date.now()}@ghost.org`,
name: 'Test Member Donations'
});
const supportSuccessPage = new SupportSuccessPage(page);
await supportSuccessPage.waitForPortalToOpen();
await expect(supportSuccessPage.title).toBeVisible();
await supportSuccessPage.signUpButton.click();
const signUpPage = new SignUpPage(page);
await expect(signUpPage.emailInput).toBeVisible();
});
test('free member donation completes in portal - shows donation notification', async ({page, stripe}) => {
const memberFactory = createMemberFactory(page.request);
const member = await memberFactory.create({
email: `test.member.donations.${Date.now()}@example.com`,
name: 'Test Member Donations',
note: 'Test Member',
status: 'free'
});
await signInAsMember(page, member);
const homePage = new HomePage(page);
await homePage.gotoPortalSupport();
const checkoutPage = new FakeStripeCheckoutPage(page);
await checkoutPage.waitUntilDonationReady();
await expect(checkoutPage.emailInput).toHaveValue(member.email);
await completeDonationViaFakeCheckout(page, stripe!, {
amount: '12.50',
name: member.name ?? 'Test Member Donations'
});
const notificationPage = new SupportNotificationPage(page);
await expect(notificationPage.successMessage).toBeVisible();
});
test('fixed donation amount and currency open donation checkout - shows fixed euro amount', async ({page, stripe}) => {
const settingsService = new SettingsService(page.request);
await settingsService.setDonationsSuggestedAmount(9800);
await settingsService.setDonationsCurrency('EUR');
const homePage = new HomePage(page);
await homePage.gotoPortalSupport();
const checkoutPage = new FakeStripeCheckoutPage(page);
await checkoutPage.waitUntilDonationReady();
await expect(checkoutPage.totalAmount).toHaveText('€98.00');
await completeDonationViaFakeCheckout(page, stripe!, {
email: `member-donation-fixed-${Date.now()}@ghost.org`,
name: 'Fixed Amount Donor'
});
const supportSuccessPage = new SupportSuccessPage(page);
await supportSuccessPage.waitForPortalToOpen();
await expect(supportSuccessPage.title).toBeVisible();
});
});
+70
View File
@@ -0,0 +1,70 @@
import {HomePage, SignInPage, SignUpPage} from '@/helpers/pages';
import {SettingsService} from '@/helpers/services/settings/settings-service';
import {createPaidPortalTier, expect, test} from '@/helpers/playwright';
test.describe('Portal Loading', () => {
test.describe('opened Portal', function () {
test('via Subscribe button', async ({page}) => {
const homePage = new HomePage(page);
await homePage.goto();
await homePage.openPortalViaSubscribeButton();
const signUpPage = new SignUpPage(page);
await expect(signUpPage.emailInput).toBeVisible();
await expect(signUpPage.signupButton).toBeVisible();
});
test('via Sign in link', async ({page}) => {
const homePage = new HomePage(page);
await homePage.goto();
await homePage.openPortalViaSignInLink();
const signInPage = new SignInPage(page);
await expect(signInPage.emailInput).toBeVisible();
await expect(signInPage.continueButton).toBeVisible();
});
});
test('switch between signup and sign in modes', async ({page}) => {
const homePage = new HomePage(page);
await homePage.goto();
await homePage.openPortalViaSubscribeButton();
const signUpPage = new SignUpPage(page);
await expect(signUpPage.emailInput).toBeVisible();
await expect(signUpPage.signupButton).toBeVisible();
await signUpPage.signinLink.click();
const signInPage = new SignInPage(page);
await expect(signInPage.emailInput).toBeVisible();
await expect(signInPage.continueButton).toBeVisible();
});
test.describe('signup access', () => {
test.use({stripeEnabled: true});
test('invite-only access with paid trial tier - hides free trial message', async ({page}) => {
const settingsService = new SettingsService(page.request);
await createPaidPortalTier(page.request, {
name: `Invite Only Trial Tier ${Date.now()}`,
currency: 'usd',
monthly_price: 100,
yearly_price: 1000,
trial_days: 5
});
await settingsService.setMembersSignupAccess('invite');
const homePage = new HomePage(page);
await homePage.gotoPortalSignup();
const signUpPage = new SignUpPage(page);
await signUpPage.waitForPortalToOpen();
await expect(signUpPage.inviteOnlyNotification).toHaveText(/contact the owner for access/i);
await expect(signUpPage.freeTrialNotification).toBeHidden();
});
});
});
+223
View File
@@ -0,0 +1,223 @@
import {type AdminOffer, createOfferFactory} from '@/data-factory';
import {PortalOfferPage, PublicPage} from '@/helpers/pages';
import {createPaidPortalTier, createPortalSignupOffer, expect, redeemOfferViaPortal, test} from '@/helpers/playwright';
const MEMBER_NAME = 'Testy McTesterson';
type OfferLandingExpectation = {
title: string;
discountLabel: string | RegExp;
message: string | RegExp;
updatedPrice?: string | RegExp;
};
type RedeemedOfferResult = Awaited<ReturnType<typeof redeemOfferViaPortal>>;
type DiscountOfferExpectation = {
duration: 'once' | 'repeating' | 'forever';
durationInMonths?: number | null;
priceLabel: string;
timingLabel: string;
};
async function expectOfferLandingPage(offerPage: PortalOfferPage, expected: OfferLandingExpectation): Promise<void> {
await offerPage.waitForOfferPage();
await expect(offerPage.offerTitle).toHaveText(expected.title);
await expect(offerPage.discountLabel).toContainText(expected.discountLabel);
await expect(offerPage.offerMessage).toContainText(expected.message);
if (expected.updatedPrice) {
await expect(offerPage.updatedPrice).toContainText(expected.updatedPrice);
}
}
function expectOfferMetadata(result: RedeemedOfferResult, offer: AdminOffer): void {
expect(result.subscription.offer?.id).toBe(offer.id);
expect(result.subscription.offer_redemptions?.some(item => item.id === offer.id)).toBe(true);
}
async function expectTrialOfferRedemption(result: RedeemedOfferResult, offer: AdminOffer): Promise<void> {
await expect(result.accountPage.freeTrialLabel).toBeVisible();
expectOfferMetadata(result, offer);
expect(result.subscription.status).toBe('trialing');
}
async function expectDiscountOfferRedemption(
result: RedeemedOfferResult,
offer: AdminOffer,
expected: DiscountOfferExpectation
): Promise<void> {
await expect(result.accountPage.offerLabel).toContainText(expected.priceLabel);
await expect(result.accountPage.offerLabel).toContainText(expected.timingLabel);
expectOfferMetadata(result, offer);
expect(result.subscription.offer?.duration).toBe(expected.duration);
if (expected.durationInMonths !== undefined) {
expect(result.subscription.offer?.duration_in_months).toBe(expected.durationInMonths);
}
}
test.describe('Ghost Public - Portal Offers', () => {
test.use({stripeEnabled: true});
test('archived offer link opens site - does not open portal offer flow', async ({page, stripe}) => {
const offerFactory = createOfferFactory(page.request);
const publicPage = new PublicPage(page);
const tier = await createPaidPortalTier(page.request, {
name: `Archived Offer Tier ${Date.now()}`,
currency: 'usd',
monthly_price: 600,
yearly_price: 6000
}, {
stripe: stripe!
});
const offer = await offerFactory.create({
name: 'Archived Offer',
code: `archived-offer-${Date.now()}`,
cadence: 'month',
amount: 10,
duration: 'once',
type: 'percent',
tierId: tier.id
});
await offerFactory.update(offer.id, {status: 'archived'});
await publicPage.gotoOfferCode(offer.code);
await publicPage.portalRoot.waitFor({state: 'attached'});
await expect(publicPage.portalPopupFrame).toHaveCount(0);
await expect(page).not.toHaveURL(/#\/portal\/offers\//);
});
test('retention offer link opens site - does not open portal offer flow', async ({page}) => {
const offerFactory = createOfferFactory(page.request);
const publicPage = new PublicPage(page);
const offer = await offerFactory.create({
name: 'Retention Offer',
code: `retention-offer-${Date.now()}`,
cadence: 'month',
amount: 10,
duration: 'once',
type: 'percent',
redemption_type: 'retention',
tierId: null
});
await publicPage.gotoOfferCode(offer.code);
await publicPage.portalRoot.waitFor({state: 'attached'});
await expect(publicPage.portalPopupFrame).toHaveCount(0);
await expect(page).not.toHaveURL(/#\/portal\/offers\//);
});
test('free trial offer opens in portal - redemption shows free trial state', async ({page, stripe}) => {
const offer = await createPortalSignupOffer(page.request, stripe!, {
amount: 14,
codePrefix: 'trial-offer',
duration: 'trial',
tierNamePrefix: 'Trial Offer Tier',
type: 'trial'
});
const publicPage = new PublicPage(page);
await publicPage.gotoOfferCode(offer.code);
const offerPage = new PortalOfferPage(page);
await expectOfferLandingPage(offerPage, {
title: offer.display_title ?? offer.name,
discountLabel: '14 days free',
message: 'Try free for 14 days'
});
const redemption = await redeemOfferViaPortal(page, stripe!, {name: MEMBER_NAME});
await expectTrialOfferRedemption(redemption, offer);
});
test('one-time discount offer opens in portal - redemption shows discounted plan label', async ({page, stripe}) => {
const offer = await createPortalSignupOffer(page.request, stripe!, {
amount: 10,
codePrefix: 'once-offer',
duration: 'once',
tierNamePrefix: 'One-time Offer Tier',
type: 'percent'
});
const publicPage = new PublicPage(page);
await publicPage.gotoOfferCode(offer.code);
const offerPage = new PortalOfferPage(page);
await expectOfferLandingPage(offerPage, {
title: offer.display_title ?? offer.name,
discountLabel: '10% off',
message: '10% off for first month',
updatedPrice: /\$5\.40/
});
const redemption = await redeemOfferViaPortal(page, stripe!, {name: MEMBER_NAME});
await expectDiscountOfferRedemption(redemption, offer, {
duration: 'once',
priceLabel: '$5.40/month',
timingLabel: 'Ends'
});
});
test('repeating discount offer opens in portal - redemption shows discounted plan label', async ({page, stripe}) => {
const offer = await createPortalSignupOffer(page.request, stripe!, {
amount: 10,
codePrefix: 'repeating-offer',
duration: 'repeating',
duration_in_months: 3,
tierNamePrefix: 'Repeating Offer Tier',
type: 'percent'
});
const publicPage = new PublicPage(page);
await publicPage.gotoOfferCode(offer.code);
const offerPage = new PortalOfferPage(page);
await expectOfferLandingPage(offerPage, {
title: offer.display_title ?? offer.name,
discountLabel: '10% off',
message: '10% off for first 3 months',
updatedPrice: /\$5\.40/
});
const redemption = await redeemOfferViaPortal(page, stripe!, {name: MEMBER_NAME});
await expectDiscountOfferRedemption(redemption, offer, {
duration: 'repeating',
durationInMonths: 3,
priceLabel: '$5.40/month',
timingLabel: 'Ends'
});
});
test('forever discount offer opens in portal - redemption shows discounted plan label', async ({page, stripe}) => {
const offer = await createPortalSignupOffer(page.request, stripe!, {
amount: 10,
codePrefix: 'forever-offer',
duration: 'forever',
tierNamePrefix: 'Forever Offer Tier',
type: 'percent'
});
const publicPage = new PublicPage(page);
await publicPage.gotoOfferCode(offer.code);
const offerPage = new PortalOfferPage(page);
await expectOfferLandingPage(offerPage, {
title: offer.display_title ?? offer.name,
discountLabel: '10% off',
message: '10% off forever',
updatedPrice: /\$5\.40/
});
const redemption = await redeemOfferViaPortal(page, stripe!, {name: MEMBER_NAME});
await expectDiscountOfferRedemption(redemption, offer, {
duration: 'forever',
priceLabel: '$5.40/month',
timingLabel: 'Forever'
});
});
});
@@ -0,0 +1,42 @@
import {HomePage} from '@/helpers/pages';
import {SettingsService} from '@/helpers/services/settings/settings-service';
import {expect, test} from '@/helpers/playwright';
test.describe('Portal Script Loading', () => {
test('memberships enabled - loads portal script', async ({page}) => {
const settingsService = new SettingsService(page.request);
await settingsService.setMembersSignupAccess('all');
const homePage = new HomePage(page);
await homePage.gotoPortalSignup();
await expect(homePage.portalScript).toHaveAttribute('src', /\/portal\.min\.js$/);
await expect(homePage.portalIframe).toHaveCount(1);
});
test.describe('with stripe connected', () => {
test.use({stripeEnabled: true});
test('memberships disabled - loads portal script', async ({page}) => {
const settingsService = new SettingsService(page.request);
await settingsService.setMembersSignupAccess('none');
const homePage = new HomePage(page);
await homePage.gotoPortalSignup();
await expect(homePage.portalScript).toHaveAttribute('src', /\/portal\.min\.js$/);
await expect(homePage.portalIframe).toHaveCount(1);
});
});
test('memberships and donations disabled - does not load portal script', async ({page}) => {
const settingsService = new SettingsService(page.request);
await settingsService.setMembersSignupAccess('none');
const homePage = new HomePage(page);
await homePage.gotoPortalSignup();
await expect(homePage.portalScript).toHaveCount(0);
await expect(homePage.portalIframe).toHaveCount(0);
});
});
+29
View File
@@ -0,0 +1,29 @@
import {HomePage, PortalAccountPage} from '@/helpers/pages';
import {completePaidSignupViaPortal, createPaidPortalTier, expect, test} from '@/helpers/playwright';
test.describe('Ghost Public - Portal Tiers', () => {
test.use({stripeEnabled: true});
test('single paid tier signup via portal completes checkout - portal shows billing info', async ({page, stripe}) => {
await createPaidPortalTier(page.request, {
name: `Portal Tier ${Date.now()}`,
visibility: 'public',
currency: 'usd',
monthly_price: 500,
yearly_price: 5000
});
const name = 'Testy McTesterson';
const {emailAddress} = await completePaidSignupViaPortal(page, stripe!, {name});
const homePage = new HomePage(page);
await homePage.goto();
await homePage.openAccountPortal();
const portalAccountPage = new PortalAccountPage(page);
await portalAccountPage.waitForPortalToOpen();
await expect(portalAccountPage.emailText(emailAddress)).toBeVisible();
await expect(portalAccountPage.billingInfoHeading).toBeVisible();
await expect(portalAccountPage.cardLast4('4242')).toBeVisible();
});
});
+108
View File
@@ -0,0 +1,108 @@
import {HomePage, PortalAccountPage} from '@/helpers/pages';
import {
completePaidSignupViaPortal,
completePaidUpgradeViaPortal,
createPaidPortalTier,
expect,
switchPlanViaPortal,
test
} from '@/helpers/playwright';
import {createMemberFactory} from '@/data-factory';
test.describe('Ghost Public - Portal Upgrade', () => {
test.use({stripeEnabled: true});
test('free member upgrades to paid via portal - portal shows billing info', async ({page, stripe}) => {
const memberFactory = createMemberFactory(page.request);
const tier = await createPaidPortalTier(page.request, {
name: `Free Upgrade Tier ${Date.now()}`,
currency: 'usd',
monthly_price: 500,
yearly_price: 5000
});
const member = await memberFactory.create({
email: `free-upgrade-${Date.now()}@example.com`,
name: 'Free Upgrade Member',
status: 'free'
});
await completePaidUpgradeViaPortal(page, stripe!, member, {
cadence: 'yearly',
tierName: tier.name
});
const homePage = new HomePage(page);
await homePage.goto();
await homePage.openAccountPortal();
const portalAccountPage = new PortalAccountPage(page);
await portalAccountPage.waitForPortalToOpen();
await expect(portalAccountPage.emailText(member.email)).toBeVisible();
await expect(portalAccountPage.planPrice('$50.00/year')).toBeVisible();
await expect(portalAccountPage.billingInfoHeading).toBeVisible();
await expect(portalAccountPage.cardLast4('4242')).toBeVisible();
});
test('comped member upgrades to paid via portal - portal shows billing info', async ({page, stripe}) => {
const memberFactory = createMemberFactory(page.request);
const tier = await createPaidPortalTier(page.request, {
name: `Comped Upgrade Tier ${Date.now()}`,
currency: 'usd',
monthly_price: 500,
yearly_price: 5000
});
const member = await memberFactory.create({
email: `comped-upgrade-${Date.now()}@example.com`,
name: 'Comped Upgrade Member',
status: 'comped',
tiers: [{id: tier.id}]
});
await completePaidUpgradeViaPortal(page, stripe!, member, {
cadence: 'yearly',
tierName: tier.name
});
const homePage = new HomePage(page);
await homePage.goto();
await homePage.openAccountPortal();
const portalAccountPage = new PortalAccountPage(page);
await portalAccountPage.waitForPortalToOpen();
await expect(portalAccountPage.emailText(member.email)).toBeVisible();
await expect(portalAccountPage.planPrice('$50.00/year')).toBeVisible();
await expect(portalAccountPage.billingInfoHeading).toBeVisible();
await expect(portalAccountPage.cardLast4('4242')).toBeVisible();
});
test('paid member changes plan in portal - subscription switches between monthly and yearly', async ({page, stripe}) => {
const tier = await createPaidPortalTier(page.request, {
name: `Upgrade Tier ${Date.now()}`,
currency: 'usd',
monthly_price: 500,
yearly_price: 5000
});
const name = 'Portal Plan Switch Member';
const {emailAddress} = await completePaidSignupViaPortal(page, stripe!, {
cadence: 'yearly',
name,
tierName: tier.name
});
await switchPlanViaPortal(page, {
cadence: 'monthly',
tierName: tier.name
});
const portalAccountPage = new PortalAccountPage(page);
await expect(portalAccountPage.emailText(emailAddress)).toBeVisible();
await expect(portalAccountPage.planPrice('$5.00/month')).toBeVisible();
await switchPlanViaPortal(page, {
cadence: 'yearly',
tierName: tier.name
});
await expect(portalAccountPage.planPrice('$50.00/year')).toBeVisible();
});
});
@@ -0,0 +1,93 @@
import {EmailClient, MailPit} from '@/helpers/services/email/mail-pit';
import {FakeStripeCheckoutPage} from '@/helpers/pages';
import {expect, test} from '@/helpers/playwright';
interface CheckoutSessionResponse {
url: string;
}
// This is a harness smoke test for the e2e Stripe tooling rather than a long-term
// product-facing spec. Migrated donation tests should carry the readable behavior
// coverage, and this should stay thin or be removed if it becomes redundant.
// TODO: REMOVE TEST
test.describe('Ghost Public - Stripe Donation Checkout', () => {
test.use({stripeEnabled: true});
let emailClient: EmailClient;
test.beforeEach(async () => {
emailClient = new MailPit();
});
test('donation checkout uses fake stripe payment mode - completed webhook sends staff email', async ({page, stripe}) => {
const donorName = `Donation Donor ${Date.now()}`;
const donorEmail = `donation-${Date.now()}@example.com`;
const donationMessage = `Keep building ${Date.now()}`;
const personalNote = 'Leave a note for the publisher';
const response = await page.request.post('/members/api/create-stripe-checkout-session/', {
data: {
type: 'donation',
customerEmail: donorEmail,
successUrl: 'http://localhost/success',
cancelUrl: 'http://localhost/cancel',
personalNote
}
});
expect(response.ok()).toBe(true);
const sessionResponse = await response.json() as CheckoutSessionResponse;
const products = stripe!.getProducts();
const prices = stripe!.getPrices();
const sessions = stripe!.getCheckoutSessions();
expect(products).toHaveLength(1);
expect(prices).toHaveLength(1);
expect(sessions).toHaveLength(1);
const price = prices[0];
const session = sessions[0];
expect(price.type).toBe('one_time');
expect(price.currency).toBe('usd');
expect(price.unit_amount).toBeNull();
expect(price.custom_unit_amount?.enabled).toBe(true);
expect(price.custom_unit_amount?.preset).toBe(500);
expect(session.request.line_items?.[0]?.price).toBe(price.id);
expect(session.request.submit_type).toBe('pay');
expect(session.request.invoice_creation?.enabled).toBe(true);
expect(session.request.invoice_creation?.invoice_data?.metadata.ghost_donation).toBe('true');
expect(session.request.custom_fields?.[0]?.key).toBe('donation_message');
expect(session.request.custom_fields?.[0]?.label?.custom).toBe(personalNote);
expect(session.response.mode).toBe('payment');
expect(session.response.customer).toBeNull();
expect(session.response.customer_email).toBe(donorEmail);
expect(session.response.metadata.ghost_donation).toBe('true');
expect(sessionResponse.url).toBe(session.response.url);
const fakeCheckoutPage = new FakeStripeCheckoutPage(page);
await fakeCheckoutPage.goto(sessionResponse.url);
await fakeCheckoutPage.waitUntilDonationReady();
await stripe!.completeLatestDonationCheckout({
donationMessage,
email: donorEmail,
name: donorName
});
const messages = await emailClient.search({
subject: donorName
}, {
timeoutMs: 10000
});
expect(messages.length).toBeGreaterThan(0);
const latestMessage = await emailClient.getMessageDetailed(messages[0]);
expect(latestMessage.Subject).toContain('One-time payment received');
expect(latestMessage.Subject).toContain(donorName);
expect(latestMessage.Text).toContain(donationMessage);
});
});
@@ -0,0 +1,83 @@
import {MembersService} from '@/helpers/services/members/members-service';
import {createOfferFactory, createTierFactory} from '@/data-factory';
import {expect, test} from '@/helpers/playwright';
interface CheckoutSessionResponse {
url: string;
}
// This is a harness smoke test for the e2e Stripe tooling rather than a long-term
// product-facing spec. Migrated offer tests should carry the readable behavior
// coverage, and this should stay thin or be removed if it becomes redundant.
// TODO: REMOVE TEST
test.describe('Ghost Public - Stripe Offer Checkout', () => {
test.use({stripeEnabled: true});
test('offer checkout creates a fake stripe coupon - redeemed offer is linked to the subscription', async ({page, stripe}) => {
const offerFactory = createOfferFactory(page.request);
const tierFactory = createTierFactory(page.request);
const membersService = new MembersService(page.request);
const tierName = `Offer Tier ${Date.now()}`;
const memberEmail = `offer-checkout-${Date.now()}@example.com`;
const tier = await tierFactory.create({
name: tierName,
currency: 'usd',
monthly_price: 600,
yearly_price: 6000
});
const offer = await offerFactory.create({
name: 'Spring Offer',
code: `spring-offer-${Date.now()}`,
cadence: 'month',
amount: 10,
duration: 'repeating',
duration_in_months: 3,
type: 'percent',
tierId: tier.id
});
const response = await page.request.post('/members/api/create-stripe-checkout-session/', {
data: {
customerEmail: memberEmail,
offerId: offer.id,
successUrl: 'http://localhost/success',
cancelUrl: 'http://localhost/cancel'
}
});
expect(response.ok()).toBe(true);
const sessionResponse = await response.json() as CheckoutSessionResponse;
const session = stripe!.getCheckoutSessions().at(-1);
expect(session).toBeDefined();
expect(sessionResponse.url).toBe(session?.response.url);
expect(session?.response.metadata.offer).toBe(offer.id);
const couponId = session?.request.discounts?.[0]?.coupon;
expect(couponId).toBeDefined();
const coupon = stripe!.getCoupons().find(item => item.id === couponId);
expect(coupon).toBeDefined();
expect(coupon?.duration).toBe('repeating');
expect(coupon?.duration_in_months).toBe(3);
expect(coupon?.percent_off).toBe(10);
expect(session?.request.subscription_data?.items).toHaveLength(1);
const createdMember = await stripe!.completeLatestSubscriptionCheckout({name: 'Offer Member'});
expect(createdMember.subscription.discount?.coupon.id).toBe(coupon?.id);
expect(createdMember.subscription.discount?.end).not.toBeNull();
const member = await membersService.getByEmailWithSubscriptions(memberEmail);
const subscription = member.subscriptions[0];
expect(subscription.offer?.id).toBe(offer.id);
expect(subscription.offer_redemptions?.some(item => item.id === offer.id)).toBe(true);
expect(subscription.next_payment?.original_amount).toBe(600);
expect(subscription.next_payment?.amount).toBe(540);
expect(subscription.next_payment?.discount?.offer_id).toBe(offer.id);
});
});
@@ -0,0 +1,123 @@
import {FakeStripeCheckoutPage, PublicPage} from '@/helpers/pages';
import {SignUpPage} from '@/portal-pages';
import {createPaidPortalTier, expect, test} from '@/helpers/playwright';
import type {Page} from '@playwright/test';
async function getMemberIdentityToken(page: Page): Promise<string> {
const response = await page.context().request.get('/members/api/session');
expect(response.ok()).toBe(true);
const identity = await response.text();
expect(identity).not.toBe('');
return identity;
}
function getAlternateCadence(interval: string | undefined): 'month' | 'year' {
if (interval === 'month') {
return 'year';
}
if (interval === 'year') {
return 'month';
}
throw new Error(`Unsupported subscription cadence: ${interval ?? 'missing'}`);
}
function getLatestCheckoutSuccessUrl(stripeCheckoutCount: {response: {success_url: string}}[]): string {
const successUrl = stripeCheckoutCount.at(-1)?.response.success_url;
if (!successUrl) {
throw new Error('Latest Stripe checkout session does not include a success URL');
}
return successUrl;
}
function getTargetPrice(targetCadence: 'month' | 'year', prices: {
monthly: {id: string};
yearly: {id: string};
}): {id: string} {
if (targetCadence === 'month') {
return prices.monthly;
}
return prices.yearly;
}
test.describe('Ghost Public - Stripe Subscription Mutations', () => {
test.use({stripeEnabled: true});
test('paid member subscription update via ghost - switches the fake stripe price', async ({page, stripe}) => {
const memberEmail = `stripe-mutation-${Date.now()}@example.com`;
const tier = await createPaidPortalTier(page.request, {
name: `Mutation Tier ${Date.now()}`,
currency: 'usd',
monthly_price: 500,
yearly_price: 5000
});
await expect.poll(() => {
return stripe!.getProducts().find(item => item.name === tier.name);
}, {timeout: 10000}).toBeDefined();
await expect.poll(() => {
return stripe!.getPrices().filter(item => item.product === stripe!.getProducts().find(product => product.name === tier.name)?.id).length;
}, {timeout: 10000}).toBe(2);
const product = stripe!.getProducts().find(item => item.name === tier.name);
const monthlyPrice = stripe!.getPrices().find((item) => {
return item.product === product?.id && item.recurring?.interval === 'month';
});
const yearlyPrice = stripe!.getPrices().find((item) => {
return item.product === product?.id && item.recurring?.interval === 'year';
});
expect(product).toBeDefined();
expect(monthlyPrice).toBeDefined();
expect(yearlyPrice).toBeDefined();
const publicPage = new PublicPage(page);
await publicPage.gotoPortalSignup();
const signUpPage = new SignUpPage(page);
await signUpPage.waitForPortalToOpen();
await signUpPage.fillAndSubmitPaidSignup(memberEmail, 'Stripe Mutation Member', tier.name);
const fakeCheckoutPage = new FakeStripeCheckoutPage(page);
await fakeCheckoutPage.waitUntilLoaded();
await stripe!.completeLatestSubscriptionCheckout({name: 'Stripe Mutation Member'});
await page.goto(getLatestCheckoutSuccessUrl(stripe!.getCheckoutSessions()));
const subscription = stripe!.getSubscriptions().at(-1);
expect(subscription).toBeDefined();
const currentPrice = subscription!.items.data[0]?.price;
expect(currentPrice).toBeDefined();
const targetCadence = getAlternateCadence(currentPrice!.recurring?.interval);
const targetPrice = getTargetPrice(targetCadence, {
monthly: monthlyPrice!,
yearly: yearlyPrice!
});
expect(targetPrice).toBeDefined();
const identity = await getMemberIdentityToken(page);
const response = await page.context().request.put(`/members/api/subscriptions/${subscription!.id}/`, {
data: {
identity,
tierId: tier.id,
cadence: targetCadence
}
});
expect(response.status()).toBe(204);
const updatedSubscription = stripe!.getSubscriptions().find(item => item.id === subscription!.id);
expect(updatedSubscription?.items.data[0]?.price.id).toBe(targetPrice!.id);
expect(updatedSubscription?.items.data[0]?.price.recurring?.interval).toBe(targetCadence);
});
});
@@ -0,0 +1,102 @@
import {APIRequestContext, Page} from '@playwright/test';
import {HomePage, MemberDetailsPage, MembersPage, PortalAccountPage} from '@/helpers/pages';
import {MembersService} from '@/helpers/services/members';
import {expect, test} from '@/helpers/playwright';
async function waitForMemberStatus(request: APIRequestContext, email: string, status: string) {
const membersService = new MembersService(request);
await expect.poll(async () => {
try {
const member = await membersService.getByEmail(email);
return member.status;
} catch {
return null;
}
}, {timeout: 10000}).toBe(status);
}
async function waitForCanceledSubscription(request: APIRequestContext, email: string) {
const membersService = new MembersService(request);
await expect.poll(async () => {
try {
const member = await membersService.getByEmailWithSubscriptions(email);
return member.subscriptions[0]?.cancel_at_period_end ?? null;
} catch {
return null;
}
}, {timeout: 10000}).toBe(true);
}
async function openPortalAsMember(page: Page, email: string) {
const membersPage = new MembersPage(page);
await membersPage.goto();
await membersPage.clickMemberByEmail(email);
const memberDetailsPage = new MemberDetailsPage(page);
await memberDetailsPage.settingsSection.memberActionsButton.click();
await memberDetailsPage.settingsSection.impersonateButton.click();
await expect(memberDetailsPage.magicLinkInput).not.toHaveValue('');
const magicLink = await memberDetailsPage.magicLinkInput.inputValue();
await memberDetailsPage.goto(magicLink);
const homePage = new HomePage(page);
await homePage.openAccountPortal();
const portalAccountPage = new PortalAccountPage(page);
await portalAccountPage.waitForPortalToOpen();
return portalAccountPage;
}
test.describe('Portal - Stripe Subscription Lifecycle via Webhooks', () => {
test.use({stripeEnabled: true});
test('webhook-seeded paid member - sees billing details in portal', async ({page, stripe}) => {
const email = `portal-paid-${Date.now()}@example.com`;
await stripe!.createPaidMemberViaWebhooks({email, name: 'Portal Paid Member'});
await waitForMemberStatus(page.request, email, 'paid');
const portalAccountPage = await openPortalAsMember(page, email);
await expect(portalAccountPage.title).toBeVisible();
await expect(portalAccountPage.emailText(email)).toBeVisible();
await expect(portalAccountPage.planPrice('$5.00/month')).toBeVisible();
await expect(portalAccountPage.billingInfoHeading).toBeVisible();
await expect(portalAccountPage.cardLast4('4242')).toBeVisible();
});
test('cancel-at-period-end webhook - shows canceled state in portal', async ({page, stripe}) => {
const email = `portal-cancel-${Date.now()}@example.com`;
const {subscription} = await stripe!.createPaidMemberViaWebhooks({email, name: 'Portal Cancel Member'});
await waitForMemberStatus(page.request, email, 'paid');
await stripe!.cancelSubscription({subscription});
await waitForCanceledSubscription(page.request, email);
const portalAccountPage = await openPortalAsMember(page, email);
await expect(portalAccountPage.cancellationNotice).toBeVisible();
await expect(portalAccountPage.resumeSubscriptionButton).toBeVisible();
await expect(portalAccountPage.canceledBadge).toBeVisible();
});
test('subscription-deleted webhook - shows free membership in portal', async ({page, stripe}) => {
const email = `portal-free-${Date.now()}@example.com`;
const {subscription} = await stripe!.createPaidMemberViaWebhooks({email, name: 'Portal Free Member'});
await waitForMemberStatus(page.request, email, 'paid');
await stripe!.deleteSubscription({subscription});
await waitForMemberStatus(page.request, email, 'free');
const portalAccountPage = await openPortalAsMember(page, email);
await expect(portalAccountPage.title).toBeVisible();
await expect(portalAccountPage.emailText(email)).toBeVisible();
await expect(portalAccountPage.emailNewsletterHeading).toBeVisible();
await expect(portalAccountPage.billingInfoHeading).toHaveCount(0);
await expect(portalAccountPage.planPrice('$5.00/month')).toHaveCount(0);
});
});
+90
View File
@@ -0,0 +1,90 @@
import {MemberFactory, PostFactory, TierFactory, createMemberFactory, createPostFactory, createTierFactory} from '@/data-factory';
import {PostPage} from '@/public-pages';
import {SettingsService} from '@/helpers/services/settings/settings-service';
import {expect, test} from '@/helpers/playwright';
import {signInAsMember} from '@/helpers/playwright/flows/sign-in';
test.describe('Ghost Public - Transistor', () => {
test.use({labs: {transistor: true}});
let postFactory: PostFactory;
let memberFactory: MemberFactory;
let tierFactory: TierFactory;
let settingsService: SettingsService;
test.beforeEach(async ({page}) => {
postFactory = createPostFactory(page.request);
memberFactory = createMemberFactory(page.request);
tierFactory = createTierFactory(page.request);
settingsService = new SettingsService(page.request);
await settingsService.setTransistorEnabled(true);
});
test('anonymous visitor - transistor embed is not visible', async ({page}) => {
const post = await postFactory.createWithCards('transistor', {status: 'published'});
const postPage = new PostPage(page);
await postPage.gotoPost(post.slug);
await expect(postPage.postContent).toContainText('Before transistor');
await expect(postPage.postContent).toContainText('After transistor');
await expect(postPage.transistorCard).toBeHidden();
await expect(postPage.transistorIframe).toBeHidden();
});
test('free member - transistor embed is visible', async ({page}) => {
const post = await postFactory.createWithCards('transistor', {status: 'published'});
const member = await memberFactory.create({status: 'free'});
await signInAsMember(page, member);
const postPage = new PostPage(page);
await postPage.gotoPost(post.slug);
await expect(postPage.postContent).toContainText('Before transistor');
await expect(postPage.postContent).toContainText('After transistor');
await expect(postPage.transistorIframe).toBeVisible();
// The data-src should contain the member's UUID (server-side replacement of %7Buuid%7D)
const dataSrc = await postPage.transistorIframe.getAttribute('data-src');
expect(dataSrc).not.toContain('%7Buuid%7D');
expect(dataSrc).toContain(member.uuid);
});
test('paid member - transistor embed is visible', async ({page}) => {
const post = await postFactory.createWithCards('transistor', {status: 'published'});
const paidTier = await tierFactory.getFirstPaidTier();
const paidMember = await memberFactory.create({
status: 'comped',
tiers: [{id: paidTier.id}]
});
await signInAsMember(page, paidMember);
const postPage = new PostPage(page);
await postPage.gotoPost(post.slug);
await expect(postPage.postContent).toContainText('Before transistor');
await expect(postPage.postContent).toContainText('After transistor');
await expect(postPage.transistorIframe).toBeVisible();
const dataSrc = await postPage.transistorIframe.getAttribute('data-src');
expect(dataSrc).not.toContain('%7Buuid%7D');
expect(dataSrc).toContain(paidMember.uuid);
});
test('preview mode - shows placeholder instead of iframe', async ({page}) => {
const post = await postFactory.createWithCards('transistor', {status: 'draft'});
const postPage = new PostPage(page);
await postPage.goto(`/p/${post.uuid}/?member_status=free`);
await postPage.waitForPostToLoad();
await expect(postPage.postContent).toContainText('Before transistor');
await expect(postPage.postContent).toContainText('After transistor');
await expect(postPage.transistorPlaceholder).toBeVisible();
await expect(postPage.transistorPlaceholder).toContainText('Members-only podcasts');
await expect(postPage.transistorIframe).toBeHidden();
});
});
+118
View File
@@ -0,0 +1,118 @@
{
"compilerOptions": {
/* Visit https://aka.ms/tsconfig to read more about this file */
/* Projects */
"incremental": true, /* Save .tsbuildinfo files to allow for incremental compilation of projects. */
// "composite": true, /* Enable constraints that allow a TypeScript project to be used with project references. */
// "tsBuildInfoFile": "./.tsbuildinfo", /* Specify the path to .tsbuildinfo incremental compilation file. */
// "disableSourceOfProjectReferenceRedirect": true, /* Disable preferring source files instead of declaration files when referencing composite projects. */
// "disableSolutionSearching": true, /* Opt a project out of multi-project reference checking when editing. */
// "disableReferencedProjectLoad": true, /* Reduce the number of projects loaded automatically by TypeScript. */
/* Language and Environment */
"target": "es2022", /* Set the JavaScript language version for emitted JavaScript and include compatible library declarations. */
// "lib": ["es2019"], /* Specify a set of bundled library declaration files that describe the target runtime environment. */
// "jsx": "preserve", /* Specify what JSX code is generated. */
// "experimentalDecorators": true, /* Enable experimental support for legacy experimental decorators. */
// "emitDecoratorMetadata": true, /* Emit design-type metadata for decorated declarations in source files. */
// "jsxFactory": "", /* Specify the JSX factory function used when targeting React JSX emit, e.g. 'React.createElement' or 'h'. */
// "jsxFragmentFactory": "", /* Specify the JSX Fragment reference used for fragments when targeting React JSX emit e.g. 'React.Fragment' or 'Fragment'. */
// "jsxImportSource": "", /* Specify module specifier used to import the JSX factory functions when using 'jsx: react-jsx*'. */
// "reactNamespace": "", /* Specify the object invoked for 'createElement'. This only applies when targeting 'react' JSX emit. */
// "noLib": true, /* Disable including any library files, including the default lib.d.ts. */
// "useDefineForClassFields": true, /* Emit ECMAScript-standard-compliant class fields. */
// "moduleDetection": "auto", /* Control what method is used to detect module-format JS files. */
/* Modules */
"module": "ESNext", /* Specify what module code is generated. */
"moduleResolution": "bundler", /* Specify how TypeScript looks up a file from a given module specifier. */
"baseUrl": "./", /* Specify the base directory to resolve non-relative module names. */
"paths": {
"@/admin-pages": ["./helpers/pages/admin/index"],
"@/public-pages": ["./helpers/pages/public/index"],
"@/portal-pages": ["./helpers/pages/portal/index"],
"@/helpers/*": ["./helpers/*"],
"@/data-factory": ["./data-factory/index.ts"],
"@/data-factory/*": ["./data-factory/*"]
}, /* Specify a set of entries that re-map imports to additional lookup locations. */
// "rootDirs": [], /* Allow multiple folders to be treated as one when resolving modules. */
// "typeRoots": [], /* Specify multiple folders that act like './node_modules/@types'. */
"types": [], /* Specify type package names to be included without being referenced in a source file. */
// "allowUmdGlobalAccess": true, /* Allow accessing UMD globals from modules. */
// "moduleSuffixes": [], /* List of file name suffixes to search when resolving a module. */
// "allowImportingTsExtensions": true, /* Allow imports to include TypeScript file extensions. Requires '--moduleResolution bundler' and either '--noEmit' or '--emitDeclarationOnly' to be set. */
// "resolvePackageJsonExports": true, /* Use the package.json 'exports' field when resolving package imports. */
// "resolvePackageJsonImports": true, /* Use the package.json 'imports' field when resolving imports. */
// "customConditions": [], /* Conditions to set in addition to the resolver-specific defaults when resolving imports. */
"resolveJsonModule": true, /* Enable importing .json files. */
// "allowArbitraryExtensions": true, /* Enable importing files with any extension, provided a declaration file is present. */
// "noResolve": true, /* Disallow 'import's, 'require's or '<reference>'s from expanding the number of files TypeScript should add to a project. */
/* JavaScript Support */
// "allowJs": true, /* Allow JavaScript files to be a part of your program. Use the 'checkJS' option to get errors from these files. */
// "checkJs": true, /* Enable error reporting in type-checked JavaScript files. */
// "maxNodeModuleJsDepth": 1, /* Specify the maximum folder depth used for checking JavaScript files from 'node_modules'. Only applicable with 'allowJs'. */
/* Emit */
"declaration": true, /* Generate .d.ts files from TypeScript and JavaScript files in your project. */
// "declarationMap": true, /* Create sourcemaps for d.ts files. */
// "emitDeclarationOnly": true, /* Only output d.ts files and not JavaScript files. */
// "sourceMap": true, /* Create source map files for emitted JavaScript files. */
// "inlineSourceMap": true, /* Include sourcemap files inside the emitted JavaScript. */
// "outFile": "./", /* Specify a file that bundles all outputs into one JavaScript file. If 'declaration' is true, also designates a file that bundles all .d.ts output. */
"outDir": "build", /* Specify an output folder for all emitted files. */
// "removeComments": true, /* Disable emitting comments. */
// "noEmit": true, /* Disable emitting files from a compilation. */
// "importHelpers": true, /* Allow importing helper functions from tslib once per project, instead of including them per-file. */
// "importsNotUsedAsValues": "remove", /* Specify emit/checking behavior for imports that are only used for types. */
// "downlevelIteration": true, /* Emit more compliant, but verbose and less performant JavaScript for iteration. */
// "sourceRoot": "", /* Specify the root path for debuggers to find the reference source code. */
// "mapRoot": "", /* Specify the location where debugger should locate map files instead of generated locations. */
// "inlineSources": true, /* Include source code in the sourcemaps inside the emitted JavaScript. */
// "emitBOM": true, /* Emit a UTF-8 Byte Order Mark (BOM) in the beginning of output files. */
// "newLine": "crlf", /* Set the newline character for emitting files. */
// "stripInternal": true, /* Disable emitting declarations that have '@internal' in their JSDoc comments. */
// "noEmitHelpers": true, /* Disable generating custom helper functions like '__extends' in compiled output. */
// "noEmitOnError": true, /* Disable emitting files if any type checking errors are reported. */
// "preserveConstEnums": true, /* Disable erasing 'const enum' declarations in generated code. */
// "declarationDir": "./", /* Specify the output directory for generated declaration files. */
// "preserveValueImports": true, /* Preserve unused imported values in the JavaScript output that would otherwise be removed. */
/* Interop Constraints */
// "isolatedModules": true, /* Ensure that each file can be safely transpiled without relying on other imports. */
// "verbatimModuleSyntax": true, /* Do not transform or elide any imports or exports not marked as type-only, ensuring they are written in the output file's format based on the 'module' setting. */
"erasableSyntaxOnly": true, /* Do not allow runtime constructs that are not part of ECMAScript. */
// "allowSyntheticDefaultImports": true, /* Allow 'import x from y' when a module doesn't have a default export. */
"esModuleInterop": true, /* Emit additional JavaScript to ease support for importing CommonJS modules. This enables 'allowSyntheticDefaultImports' for type compatibility. */
// "preserveSymlinks": true, /* Disable resolving symlinks to their realpath. This correlates to the same flag in node. */
"forceConsistentCasingInFileNames": true, /* Ensure that casing is correct in imports. */
/* Type Checking */
"strict": true, /* Enable all strict type-checking options. */
"noImplicitAny": true, /* Enable error reporting for expressions and declarations with an implied 'any' type. */
// "strictNullChecks": true, /* When type checking, take into account 'null' and 'undefined'. */
// "strictFunctionTypes": true, /* When assigning functions, check to ensure parameters and the return values are subtype-compatible. */
// "strictBindCallApply": true, /* Check that the arguments for 'bind', 'call', and 'apply' methods match the original function. */
// "strictPropertyInitialization": true, /* Check for class properties that are declared but not set in the constructor. */
// "noImplicitThis": true, /* Enable error reporting when 'this' is given the type 'any'. */
// "useUnknownInCatchVariables": true, /* Default catch clause variables as 'unknown' instead of 'any'. */
// "alwaysStrict": true, /* Ensure 'use strict' is always emitted. */
// "noUnusedLocals": true, /* Enable error reporting when local variables aren't read. */
// "noUnusedParameters": true, /* Raise an error when a function parameter isn't read. */
// "exactOptionalPropertyTypes": true, /* Interpret optional property types as written, rather than adding 'undefined'. */
// "noImplicitReturns": true, /* Enable error reporting for codepaths that do not explicitly return in a function. */
// "noFallthroughCasesInSwitch": true, /* Enable error reporting for fallthrough cases in switch statements. */
// "noUncheckedIndexedAccess": true, /* Add 'undefined' to a type when accessed using an index. */
// "noImplicitOverride": true, /* Ensure overriding members in derived classes are marked with an override modifier. */
// "noPropertyAccessFromIndexSignature": true, /* Enforces using indexed accessors for keys declared using an indexed type. */
// "allowUnusedLabels": true, /* Disable error reporting for unused labels. */
// "allowUnreachableCode": true, /* Disable error reporting for unreachable code. */
/* Completeness */
// "skipDefaultLibCheck": true, /* Skip type checking .d.ts files that are included with TypeScript. */
"skipLibCheck": true /* Skip type checking all .d.ts files. */
},
"include": ["helpers/**/*", "tests/**/*", "data-factory/**/*", "types.d.ts"],
"exclude": ["node_modules"]
}
+44
View File
@@ -0,0 +1,44 @@
declare module '@tryghost/logging' {
export function error(...args: unknown[]): void;
export function warn(...args: unknown[]): void;
export function info(...args: unknown[]): void;
export function debug(...args: unknown[]): void;
}
declare module '@tryghost/debug' {
function debug(namespace: string): (...args: unknown[]) => void;
export = debug;
}
declare module 'busboy' {
import {IncomingHttpHeaders} from 'http';
import {Writable} from 'stream';
interface BusboyConfig {
headers: IncomingHttpHeaders;
highWaterMark?: number;
fileHwm?: number;
defCharset?: string;
preservePath?: boolean;
limits?: {
fieldNameSize?: number;
fieldSize?: number;
fields?: number;
fileSize?: number;
files?: number;
parts?: number;
headerPairs?: number;
};
}
interface Busboy extends Writable {
on(event: 'field', listener: (name: string, val: string, info: {nameTruncated: boolean; valueTruncated: boolean; encoding: string; mimeType: string}) => void): this;
on(event: 'file', listener: (name: string, file: NodeJS.ReadableStream, info: {filename: string; encoding: string; mimeType: string}) => void): this;
on(event: 'close', listener: () => void): this;
on(event: 'error', listener: (err: Error) => void): this;
on(event: string, listener: (...args: unknown[]) => void): this;
}
function busboy(config: BusboyConfig): Busboy;
export = busboy;
}
+6
View File
@@ -0,0 +1,6 @@
# Auth state from Playwright setup
.auth/state.json
# Playwright test results and reports
test-results/
playwright-report/
+29
View File
@@ -0,0 +1,29 @@
import {expect, test as setup} from '@playwright/test';
/**
* Authenticates once against the running Ghost instance and saves
* the session to a storage-state file so all visual-regression specs
* can reuse it without logging in again.
*
* Expects the default Ghost development credentials:
* Email: ghost-author@example.com
* Password: Sl1m3rson99
*/
const AUTH_FILE = './e2e/visual-regression/.auth/state.json';
setup('authenticate', async ({page}) => {
const email = process.env.GHOST_ADMIN_EMAIL || 'ghost-author@example.com';
const password = process.env.GHOST_ADMIN_PASSWORD || 'Sl1m3rson99';
await page.goto('/ghost/#/signin');
await page.waitForLoadState('load');
await page.getByRole('textbox', {name: 'Email address'}).fill(email);
await page.getByRole('textbox', {name: 'Password'}).fill(password);
await page.getByRole('button', {name: /Sign in/}).click();
// Wait for the admin to fully load (sidebar with navigation links)
await expect(page.getByRole('link', {name: 'Posts'})).toBeVisible({timeout: 30_000});
await page.context().storageState({path: AUTH_FILE});
});
Binary file not shown.

After

Width:  |  Height:  |  Size: 178 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 178 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 178 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 71 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 179 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 45 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 45 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 45 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 45 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 129 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 163 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 180 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 56 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 139 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 110 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 102 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 81 KiB

Some files were not shown because too many files have changed in this diff Show More