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