Ghost 浏览器端到端测试编写与运行全指南:E2E 工作区规范、Page Object 模式与基础设施实操
本文基于 Ghost monorepo 中 e2e/ 工作区及其配套的 e2e/CLAUDE.md(即 e2e/AGENTS.md,两者内容一致)所锚定的「浏览器端到端测试开发规范」,系统讲解如何在 Ghost 中编写、运行与维护一套覆盖 Admin 后台与公共站点的 Playwright E2E 测试:从目录结构、命名与 Page Object 模式,到定位器优先级、测试数据工厂、Fixture 隔离机制、本地/CI 基础设施与验证命令。读完本文,你将能依据仓库「单一事实来源」文档,独立新增一个场景测试并在本地跑通 lint、类型检查与定向测试。
一、为什么需要 e2e/ 工作区:E2E 与 acceptance 测试的分层
Ghost 的浏览器端到端测试全部位于仓库根目录的 e2e/ 工作区,使用 TypeScript + Playwright,面向一个完整运行中的 Ghost 实例验证跨包、跨应用的关键用户旅程(覆盖 Ghost Admin 与公共站点)。
需要特别澄清的是它的定位:某个应用自身目录下的 Playwright 套件属于 acceptance(验收)测试,而不是 E2E——如何区分 unit / integration / acceptance / E2E 各测试层,参见 docs/contributing/testing.md。e2e/ 工作区是唯一验证"多个真实包(服务端 + 前端应用)协同工作"的浏览器套件。
从目录结构(见 e2e/README.md 的工程图)可以看到清晰的职责分区:
e2e/
├── tests/ # 全部测试用例
│ ├── public/ # 公共站点测试(首页、文章等)
│ ├── admin/ # Admin 后台测试(登录、内容创作、设置等)
│ ├── portal/ # Portal 会员旅程测试
│ ├── global.setup.ts # 全局 setup(Project Dependencies)
│ └── global.teardown.ts # 全局 teardown
├── helpers/
│ ├── playwright/ # Playwright fixture 与工具
│ ├── pages/ # Page Object,按区域分组(base-page.ts、admin/ 等)
│ ├── environment/ # Ghost 容器与数据库生命周期管理
│ ├── services/ # 测试替身(fake Stripe、Mailgun 等)
│ └── utils/ # 共享工具
├── data-factory/ # 测试数据工厂(见其独立 README)
├── visual-regression/ # 独立配置的截图基线套件
├── scripts/ # 基础设施与 runner 脚本
├── playwright.config.mjs # Playwright 配置
├── eslint.config.js # 工作区 lint 规则
├── package.json # 依赖与脚本
└── tsconfig.json # TS 配置与路径别名
当前套件按功能划分为 tests/public/、tests/admin/、tests/portal/ 三个子目录,可视需要继续扩展子目录。
二、文档地图:以「规范」为单一事实来源,避免工具专属副本
e2e/CLAUDE.md 首先立下一条核心协作纪律:在改动本工作区之前,先阅读人类可读的权威文档(canonical human documentation),并且当共享约定变化时,更新权威指南,而不是创建或依赖任何"仅工具可见"的规范副本。这样 Agent、CLI 助手与人类开发者共享同一套事实来源:
| 主题 | 权威文档(仓库根相对路径) |
|---|---|
| 测试结构、Page Object、定位器优先级、等待与校验 | Writing Browser E2E Tests |
| 基础设施模式、fixtures、隔离、命令与故障排查 | E2E workspace README |
| 测试数据辅助(工厂)用法 | Data factory README |
下面各章将依次把这些规范展开,并结合仓库源码印证实现细节。
三、必备工作流约定:pnpm、@/ 别名与改动后验证
e2e/CLAUDE.md 明确列出改代码时必须遵守的工作流(Required workflow),这些约定在 e2e/eslint.config.js 中被固化为错误级规则,属于"改了不遵守就过不了 lint"的硬约束:
- 永远使用
pnpm,不要使用 npm 或 Yarn——这也是 monorepo 通过 e2e/package.json 中@tryghost/e2e脚本统一入口的原因。 - 共享测试辅助一律通过
@/路径别名导入,别名在 e2e/tsconfig.json 中定义:
"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/*"]
}
对应地,测试文件与 page object 中的典型导入写法为:
import {expect, test} from '@/helpers/playwright';
import {LoginPage, PostsPage} from '@/admin-pages';
import {createPostFactory} from '@/data-factory';
import {usePerTestIsolation} from '@/helpers/playwright/isolation';
eslint 通过 eslint-plugin-no-relative-import-paths 在测试目录强制使用别名导入(rootDir: './'、prefix: '@');page object 内部对相邻基类/兄弟模块则允许相对导入。
- 改动 E2E 测试后,必须在本工作区运行三项验证:定向测试(focused test)、
pnpm lint、pnpm test:types:
cd e2e
pnpm test tests/admin/signin.test.ts
pnpm lint
pnpm test:types
- 改动 data factory 后,除上述命令外还要额外运行
pnpm build(e2e包内build脚本即tsc类型检查,见 e2e/package.json),确保工厂代码可编译。
四、测试文件的命名与组织规范
命名规范由 e2e/eslint.config.js 的 ghost/filenames/match-regex 规则以 error 级别强制为 kebab-case:^[a-z0-9.-]+$,因此 FeaturePage.ts 这种写法会直接报错。具体约定(见 docs/contributing/e2e-testing.md):
- 测试文件:
<行为>.test.ts,按被测"行为"而非页面命名,例如two-factor-auth.test.ts、member-signup.test.ts;真实示例见 e2e/tests/admin/signin.test.ts。 - Page Object:
<功能>-page.ts,例如login-page.ts、admin-page.ts。 - 类名:保持 PascalCase,
login-page.ts导出class LoginPage。
测试正文采用 Arrange–Act–Assert 作为可读性启发式:先搭建场景、再执行被测行为、最后校验结果。仅当阶段边界不清晰时才加注释;每个测试聚焦单一场景,命名与流程应让不了解实现细节的读者也能读懂被测行为。数据一律使用工厂准备,只覆盖影响场景与断言的字段,其余保持工厂默认值。
五、Page Object 模式
5.1 四条核心原则
- Page Object 封装可复用的页面结构与交互;
- 优先复用现有 Page Object;
- 创建聚焦、单一职责的 Page Object;
- 必要的结构型定位器尽量收进 Page Object。
如果只是测试中一次性的小断言/小交互,直接使用语义定位器也可接受——当 Page Object 只增加间接性而没有复用价值时不必强行抽象。
5.2 基类继承体系与 Page Object 写法
基类体系在源码中可以完整印证:
- e2e/helpers/pages/base-page.ts 提供
goto()、refresh()、pressKey()与body定位器,并在构造函数中设置pageUrl;goto()默认访问pageUrl,也支持传入 URL 与waitUntil等选项。 - e2e/helpers/pages/admin/admin-page.ts 继承
BasePage,以/ghost作为基础 URL;子类只需覆写pageUrl指向自己的路由。
新建 Page Object 的完整模板(来自权威指南,可直接套用):
// e2e/helpers/pages/admin/feature-page.ts
import {AdminPage} from './admin-page';
import {Locator, Page} from '@playwright/test';
export class FeaturePage extends AdminPage {
// Define locators as readonly properties
readonly nameInput: Locator;
readonly saveButton: Locator;
readonly statusMessage: Locator;
constructor(page: Page) {
super(page);
this.pageUrl = '/ghost/#/[path]';
// Selector priority (use in this order):
// 1. ARIA roles with accessible names
this.saveButton = page.getByRole('button', {name: 'Save'});
// 2. Labels for form elements
this.nameInput = page.getByLabel('Name');
// 3. Text content when the text is unique
this.statusMessage = page.getByText('Saved');
// 4. Stable test IDs when semantic locators are unavailable
// page.getByTestId('element-id');
// 5. Stable structural selectors only when necessary
}
// Action methods
async save(): Promise<void> {
await this.saveButton.click();
await this.statusMessage.waitFor({state: 'visible'});
}
async fillForm(data: {name: string}): Promise<void> {
await this.nameInput.fill(data.name);
}
// State verification methods — return locators or values, never assert
async getStatusText(): Promise<string> {
return await this.statusMessage.textContent() || '';
}
}
注意其中一条重要纪律:Page Object 的"状态校验方法"只返回 locator 或值,绝不包含断言——expect 断言被 eslint 限制在测试文件与 Playwright helper 中(见 e2e/eslint.config.js)。
5.3 Modal/Dialog 模式
模态框不继承 Page,而是写成普通类,把定位器作用域收束到 dialog 本身,并在每个动作里等待可见状态。仓库中的真实范例是 e2e/helpers/pages/admin/posts/custom-view-modal.ts,其模式骨架为:
import {Locator, Page} from '@playwright/test';
export class FeatureModal {
private readonly page: Page;
public readonly modal: Locator;
public readonly saveButton: Locator;
public readonly cancelButton: Locator;
constructor(page: Page) {
this.page = page;
this.modal = page.getByRole('dialog');
this.saveButton = this.modal.getByRole('button', {name: 'Save'});
this.cancelButton = this.modal.getByRole('button', {name: 'Cancel'});
}
async waitForModal(): Promise<void> {
await this.modal.waitFor({state: 'visible'});
}
async save(): Promise<void> {
await this.saveButton.click();
await this.modal.waitFor({state: 'hidden'});
}
}
5.4 扩展基类:Admin 与公共页面分层
Admin 系页面继承 AdminPage(自动获得 /ghost 基础),公共站点与 Portal 页面直接继承 BasePage:
// Admin pages extend AdminPage
export class PostEditorPage extends AdminPage {
// Implementation
}
// Public and portal pages extend BasePage
export class PublicHomePage extends BasePage {
// Implementation
}
5.5 常用 URL 速查(Ghost Admin)
- 编辑器:
/ghost/#/editor/post/[id] - 文章列表:
/ghost/#/posts - 设置:
/ghost/#/settings - 会员:
/ghost/#/members
六、定位器优先级、等待与异步处理
6.1 定位器优先级(Selectors in order)
- 带可访问名称的 ARIA role:
getByRole('button', {name: 'Save'}) - 表单元素的 label:
getByLabel('Name') - 唯一文本内容:
getByText('Saved') - 语义定位器不可用时的稳定 test ID:
page.getByTestId('element-id') - 仅在必要时使用稳定的结构型选择器
Ghost 特有背景(见 docs/contributing/e2e-testing.md):Ember Admin 普遍使用 data-test-* 属性,而 React Admin 应用使用 data-testid;但无论哪种,只要存在 role、label 或唯一可见文本就优先使用它们。若 UI 实在没有可靠语义定位器,正确做法是在产品代码中新增稳定 test ID,而不是把测试耦合到样式或 DOM 位置。
当某个元素没有可靠定位器时,在真实 DOM 里追查定位器的过程会用到 page.getByTestId() 或诸如 [data-test-error="custom-view-name"] 这类带属性选择器的写法,这也印证了第 4/5 级定位器的实际使用场景。
6.2 等待元素与异步保存
好的写法是等待 locator 状态或用 web assertion,坏的写法是任意超时与 networkidle:
// Good - wait on a locator's state, or use a web assertion
await element.waitFor({state: 'visible'});
await expect(page.getByRole('status')).toContainText('Saved');
// Bad - arbitrary timeouts and networkidle
await page.waitForTimeout(5000);
await page.waitForLoadState('networkidle');
处理异步操作(如保存后 UI 反馈)时,等待用户会看的 UI 信号,而非固定延迟:
async waitForSave(): Promise<void> {
await this.saveButton.click();
await this.statusMessage.waitFor({state: 'visible'});
}
6.3 iframe 与键盘
- iframe 使用
frameLocator()——它和任何 locator 一样会自动重试:
this.portalFrame = page.frameLocator('[data-testid="portal-popup-frame"]');
await this.portalFrame.getByRole('button', {name: 'Continue'}).click();
- 键盘快捷键直接作用于
page.keyboard:
await page.keyboard.press('Escape');
await page.keyboard.press('Control+S');
await page.keyboard.type('Hello World');
七、定位器发现:Playwright MCP 与 PRESERVE_ENV=true
为不熟悉的 UI 新建 Page Object 前,需要先观察真实渲染结果。推荐的定位器发现流程(见 e2e/CLAUDE.md)在 Playwright MCP 可用时:
- 用
PRESERVE_ENV=true跑一个定向测试,读取 runner 打印的实例 URL(通常为http://localhost:2369); - 打开该实例,在选择定位器前先截取 accessibility snapshot;
- 实际交互以验证定位器可用,并在渲染状态有上下文价值时截图;
- 始终遵守上述定位器优先级,不直接照抄生成的选择器,除非确认其稳定。
当 Playwright MCP 不可用时,退回 Playwright Inspector 或浏览器开发者工具。
PRESERVE_ENV=true 的语义在源码中很直接:环境管理器在 teardown 时检测到该变量即跳过清理(e2e/helpers/environment/environment-manager.ts 与 shouldPreserveEnvironment()),从而保留测试实例供人工检视:
cd e2e
# Start Ghost and keep it running
PRESERVE_ENV=true pnpm test
# The test output provides the preserved Ghost instance URL (usually http://localhost:2369)
打开保留的实例 URL,实际操作一遍交互并检查可访问性树及相关属性,再按优先级选定定位器写回 Page Object。
八、用数据工厂准备测试数据
测试数据的创建由 e2e/data-factory/README.md 描述的 data factory 负责。其核心价值是区分两种形态:
build():仅在内存中构造对象(不持久化),例如准备一个 draft 状态的文章;create():构造并通过 Admin API 持久化到被测数据库。
8.1 目录结构
e2e/data-factory/
├── factory.ts # 基类(追加持久化通道)
├── factories/ # 工厂实现(member-factory.ts、post-factory.ts、tag-factory.ts ...)
├── persistence/
│ ├── adapter.ts # 持久化接口
│ └── adapters/ # 适配器实现(API、Knex 等)
├── setup.ts # setup 辅助函数
└── index.ts # 主要导出
实体形状(response 形态)、随机默认值与共享辅助(id/slug 生成器、Lexical 文档构建器)收敛在 monorepo 的 @tryghost/test-data 包(位于 packages/testing/test-data,其 package.json 确认了包名),并从 @/data-factory barrel 再导出。
8.2 在测试中使用
推荐方式:使用 setup 辅助
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'
});
手动方式(需要显式控制适配器时):
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);
const post = await postFactory.create({
title: 'My Published Post',
status: 'published'
});
在 @/ 别名体系下,测试内推荐写作 import {createPostFactory} from '@/data-factory'。
8.3 新增一个工厂
新增工厂遵循以下步骤:
- 在
@tryghost/test-data中新增(或复用)一个 canonical builder——它拥有该实体的 Admin API response 形态与随机默认值; - 新建继承
Factory<TOptions, TResult>的工厂类,其build()从该 builder 派生出写入/创建载荷(参考 e2e/data-factory/factories/tag-factory.ts 的 1:1 委托,以及member-factory.ts、post-factory.ts中写入载荷与 response 不同的形态——展平关系、去掉仅 response 字段); - 设置
entityType属性(用于持久化); - 在 e2e/data-factory/setup.ts 中添加 setup 辅助。
模板如下:
import {Factory} from '../factory';
import {member} from '@tryghost/test-data';
export class MemberFactory extends Factory<Partial<Member>, Member> {
entityType = 'members';
build(options: Partial<Member> = {}): Member {
return {
...toCreatePayload(member()), // response shape -> write payload
...options
};
}
}
// In setup.ts
export function createMemberFactory(httpClient: HttpClient): MemberFactory {
const adapter = new GhostAdminApiAdapter(httpClient, 'members');
return new MemberFactory(adapter);
}
本地开发 data factory 时,先运行 pnpm dev 提供数据库;如需覆盖数据库连接可复制 .env.example 为 .env 后修改;改完工厂后运行 cd e2e && pnpm build。
九、Fixtures 与测试隔离
9.1 常用 Fixtures
Fixtures 定义在 e2e/helpers/playwright/fixture.ts(README 中声明),测试通常触及这些:
page——针对本测试的 Ghost 实例的浏览器页面;pageWithAuthenticatedUser——已登录 Ghost Admin 的同款页面;ghostAccountOwner——owner 账号的凭据;ghostInstance——运行中的实例:baseUrl、database、port、siteUuid、containerId、instanceId;resolvedIsolation——当前测试的隔离级别:'per-file' | 'per-test';resetEnvironment()——强制环境回收(escape hatch,见下)。
在 e2e/tests/admin/signin.test.ts 中可以看到这些 fixture 的真实用法:文件顶部先调用 usePerTestIsolation(),随后在测试签名中解构 page、ghostAccountOwner、ghostAccountContributor、ghostAccountAuthor 等凭据完成登出-登录-深链跳转的场景。
9.2 隔离机制:per-file 默认、per-test 选择
从源码可确认(见 e2e/helpers/environment/environment-manager.ts):
- 全局 setup(e2e/tests/global.setup.ts):清理旧容器与测试库 → 创建
ghost_e2e_base基础库 → 启动 Ghost 并等待健康(启动时自动执行迁移)→ 对迁移完成的库做快照; - per-file 模式(默认):在文件边界从快照克隆新库 → 以新库重启 Ghost 并等待就绪 → 文件内测试复用该环境;
- per-test 模式:每个测试都从快照克隆新库并重启 Ghost。
隔离模式的解析规则:
- 默认 per-file(每个文件一个 Ghost 环境周期,CI 上明显更快);
- 在文件根部调用
usePerTestIsolation()(从@/helpers/playwright/isolation导入)即可选择 per-test; - 任何
fullyParallel: true的运行会强制 per-test。
usePerTestIsolation() 的实现(e2e/helpers/playwright/isolation.ts)完全基于两条标准 Playwright API,替代了此前拦截 test.describe.configure() 并解析堆栈的 monkey-patch 方案:
export function usePerTestIsolation() {
test.describe.configure({ mode: 'parallel' });
test.use({ isolation: 'per-test' });
}
9.3 环境身份与 Fixture 选项行为
同文件内 per-file 复用要关注"环境身份"(environment identity):
config参与环境身份:用于需要在变化时获得全新环境的启动期 Ghost 配置;labs参与环境身份:用于需要新环境的 labs 开关;- 若文件内测试间二者发生变化,共享的 per-file Ghost 环境会在复用前被回收;
stripeEnabled不参与 per-file 复用,它总是强制 per-test 隔离——因为 Ghost 必须对着每个测试独立的 fake Stripe 服务器启动。
9.4 resetEnvironment() escape hatch
resetEnvironment() 仅在 per-file 测试的 beforeEach 钩子中受支持:
- 只允许在解析
baseURL、page、pageWithAuthenticatedUser、ghostAccountOwner等有状态 fixture 之前使用; - 安全用法:
test.beforeEach(async ({resetEnvironment}) => { ... }); - 禁止用法:在
page或已认证会话创建之后再调用resetEnvironment()。
这一规则由 e2e/eslint.config.js 中本地自定义规则 no-unsafe-reset-environment 在静态分析层把关(检查调用是否位于 beforeEach 内、是否与有状态 fixture 同钩子解构),而 fixture 内的运行时守卫仍是最后一道硬校验。同文件还通过 no-restricted-syntax 禁止在测试文件中使用 page.locator()(应使用 page object 或更高层方法)、禁止已废弃的 test.describe.parallel() 与 test.describe.serial()。
9.5 Playwright 配置要点
e2e/playwright.config.mjs 中值得注意的工程决策:
- workers:取
max(1, ⌊CPU 核数 / 3⌋)——每个 worker 进程占 1 核、其专属 Ghost 实例再占 1 核,为数据库与前端 dev server 留出余量;TEST_WORKERS_COUNT可覆盖; - retries: 0——注释明确写道"重试是产生 flaky 测试的大门,需要重试说明测试不好或应用坏了";
- maxFailures: 1(
--ui模式下为 0); - 超时:CI 下单测 60s、本地 30s,
expect统一 10s; - 浏览器固定 chromium,
colorScheme: 'light'保证 Admin 主题状态确定; - 通过 Project Dependencies 组织全局 setup/teardown:
global-setup项目声明teardown: 'global-teardown',main/analytics项目声明dependencies: ['global-setup'](见 e2e/tests/global.setup.ts 与 e2e/tests/global.teardown.ts 的角色分工); - 测试文件匹配
tests/**/*.test.{js,ts},fixturesproject 专门跑 e2e/tests/stripe-fixtures。
十、本地运行与基础设施
10.1 快速开始
前置条件:安装 Docker 与 Docker Compose;Node.js 下用 corepack 管理 pnpm(先运行 corepack enable pnpm)。
cd e2e
# Install dependencies
pnpm
# All tests
pnpm test
pnpm test 底层经由 e2e/scripts/run-playwright-host.sh 执行 playwright test --project=main。
10.2 Dev 模式与 Build 模式
dev 模式(本地开发推荐):当 GHOST_E2E_MODE 未设置时,shell 入口会自动选择——本地 admin dev server 在 http://127.0.0.1:5174 可达则选 dev,否则选 build。dev 模式下 Ghost 容器挂载源码并将资产代理到宿主的 dev server:
# Terminal 1: Start dev environment (from repository root)
pnpm dev
# Terminal 2: Run e2e tests (from e2e folder)
pnpm test
基础设施已启动时 pnpm infra:up 可以安全重复运行;dev 模式运行前它还会确保本地 Ghost/gateway dev 镜像存在。需要强制模式时显式设置 GHOST_E2E_MODE=dev 或 GHOST_E2E_MODE=build。
build 模式(预构建镜像):不想跑 dev server 时使用,基于预构建 Ghost 镜像,公共资产从 /content/files 提供:
# From repository root
pnpm build
pnpm --filter @tryghost/e2e build:apps
GHOST_E2E_BASE_IMAGE=<ghost-image> pnpm --filter @tryghost/e2e build:docker
GHOST_E2E_MODE=build pnpm --filter @tryghost/e2e infra:up
# Run tests
GHOST_E2E_MODE=build GHOST_E2E_IMAGE=ghost-e2e:local pnpm --filter @tryghost/e2e test
build 模式默认使用 tmpfs 支撑的 MySQL 存储(数据库快照恢复周期更快且与本地开发数据隔离),可用 GHOST_E2E_MYSQL_TMPFS=false 切回普通 Docker volume,或用 GHOST_E2E_MYSQL_TMPFS_SIZE=4g 调整 tmpfs 大小。
分析(analytics)本地开发时使用:
# Terminal 1 (repo root)
pnpm dev:analytics
# Terminal 2
pnpm test:analytics
E2E 测试脚本在 Tinybird 运行时自动同步 Tinybird token(见 e2e/scripts/run-playwright-host.sh 中非 CI 环境调用 e2e/scripts/sync-tinybird-state.mjs)。
Tinybird slim 镜像:设置 GHOST_E2E_TINYBIRD_SLIM=true 可将 Tinybird 服务替换为蒸馏过的 slim 镜像(磁盘占用远小于上游),CI 即启用此选项以适配 runner 磁盘预算;GHOST_E2E_TINYBIRD_SLIM_IMAGE 可覆盖镜像/tag。注意该 slim 镜像是 GHCR 内部包,PR(尤其是公共 fork 的 PR)拉取可能失败——e2e/scripts/infra-up.sh 会告警并回退到上游镜像而不是让运行失败。
10.3 运行定向测试与调试
# Run the Admin sign-in test
pnpm test tests/admin/signin.test.ts
# Matching a pattern
pnpm test --grep "homepage"
# With browser visible (for debugging)
pnpm test --debug
调试失败用例时可结合 pnpm test --ui 查看浏览器;失败截图由 Playwright 自动捕获,trace 位于 test-results/ 目录,CI 日志提供详细错误信息。
10.4 脚本速查
在 e2e/ 目录内可用的脚本(来自 e2e/package.json 与 e2e/README.md):
# Run all tests
pnpm test
# Start/stop test infra (MySQL/Redis/Mailpit/Tinybird)
pnpm infra:up
pnpm infra:down
# CI-like preflight for build mode (pulls images + starts infra)
pnpm preflight:build
# Debug failed tests (keeps containers)
PRESERVE_ENV=true pnpm test
# Check the fake Stripe server against captured Stripe responses (no infra, ~1s)
pnpm test:fixtures
# Put a Stripe test account into the state fixtures are captured from
pnpm stripe:provision
# Re-capture Stripe fixtures from test mode (needs STRIPE_SECRET_KEY)
pnpm stripe:fixtures
# Re-measure the checkout limits the fake server enforces
pnpm stripe:probe
# Run TypeScript type checking
pnpm test:types
# Lint code and tests
pnpm lint
# Build (for utilities)
pnpm build
pnpm dev # Watch mode for TypeScript compilation
十一、Stripe fixtures:用真实响应校验测试替身
helpers/services/stripe 下的 fake Stripe server 手工构造 Stripe 会返回的对象。e2e/helpers/services/stripe/fixtures 存放的是从 Stripe test 模式按 API 版本 2020-08-27(ghost/core 锁定的版本)抓取的真实响应;pnpm test:fixtures 断言构造器与这些 fixtures 一致,且无需 Ghost、Docker 与浏览器。
之所以值得捕捉两类失败:构造器多返回 Stripe 不存在的 key,意味着 fake 描述了一个不存在的 API;构造器漏掉 Ghost 会读取的 key 更隐蔽——属性访问得到 undefined、分支永不执行、套件保持绿色,属于静默失真。同一套件还会校验 fake 拒绝 Stripe 会拒绝的请求,这些约束是实测而来而非照抄文档。重新抓取时:
STRIPE_SECRET_KEY=sk_test_... pnpm stripe:provision # once per account
STRIPE_SECRET_KEY=sk_test_... pnpm stripe:fixtures
仅接受 test-mode key。因为 Stripe 不允许自动化其托管支付页,完整的 checkout 需要人工配合:STRIPE_SECRET_KEY=sk_test_... pnpm stripe:fixtures:checkout 会打印 Checkout URL 并等待你用 4242 4242 4242 4242 完成支付后抓取 session(同一 session 会收集配送地址、税号与自定义字段)。事件 envelope 刻意不抓取——Event 是创建时按账户默认 API 版本渲染的不可变快照,与 Ghost 收到的按固定版本渲染的 webhook 载荷并不相同。
十二、CI 集成流程
测试在每个 PR 与对 main 的提交上通过 GitHub Actions 自动运行。CI 流程要点(见 e2e/README.md):
- Setup:Ubuntu runner,安装 Node.js 与 Docker;
- Build Assets:构建 server/admin 资产与 public app UMD bundles;
- Build E2E Image:
pnpm --filter @tryghost/e2e build:docker(把 public apps 分层打进/content/files); - Prepare E2E Runtime:并行拉取 Playwright/gateway 镜像、启动 infra、同步 Tinybird 状态(
pnpm --filter @tryghost/e2e preflight:build); - Test Execution:在官方 Playwright 容器内运行 E2E 测试;
- Artifacts:失败时上传 Playwright traces 与报告。
需要在本地做一次 CI 似的预演(拉取 Playwright + gateway 镜像并启动 infra),运行 pnpm --filter @tryghost/e2e preflight:build。
十三、小结:一次标准改动如何闭环
综合上述所有规范,一次标准的 E2E 测试改动应这样闭环:
- 阅读权威文档(docs/contributing/e2e-testing.md、e2e/README.md、e2e/data-factory/README.md),不另建工具专属规范;
- 为行为创建
<行为>.test.ts(kebab-case),在文件根部视需要调用usePerTestIsolation(); - 复用或新建单一职责的 Page Object(继承
BasePage/AdminPage,模态框用普通类),按定位器优先级选择稳定定位器,等待 UI 信号而非固定超时; - 通过
createXxxFactory(page.request)准备测试数据; - 运行定向测试 +
pnpm lint+pnpm test:types(改工厂还需pnpm build)验证; - 若共享约定发生变化,回头更新人类权威指南——保持整套体系以仓库内单一事实来源持续演进。
仓库相关参考文件:Writing Browser E2E Tests 权威指南、e2e workspace README、data factory README、e2e/tsconfig.json、e2e/playwright.config.mjs、e2e/eslint.config.js、e2e/helpers/playwright/isolation.ts、e2e/helpers/environment/environment-manager.ts、e2e/helpers/pages/base-page.ts。
atomcodeClaude Code 的开源替代方案。连接任意大模型,编辑代码,运行命令,自动验证 — 全自动执行。用 Rust 构建,极致性能。 | An open-source alternative to Claude Code. Connect any LLM, edit code, run commands, and verify changes — autonomously. Built in Rust for speed. Get StartedRust0629
MiniCPM5-2BMiniCPM5-2B 是一款面向端侧、本地部署和资源受限场景的 2B 稠密 Transformer,能够达到同尺寸开源模型 SOTA 水平。Markdown00
GLM-5.3GLM-5.3 与 GLM-5.2 使用相同的基座模型——所有提升均来自后训练。与 GLM-5.2 相比,它在复杂编程和长程任务上的表现显著提升。Jinja00
HivisionIDPhotos⚡️HivisionIDPhotos: a lightweight and efficient AI ID photos tools. 一个轻量级的AI证件照制作算法。Python07
DragonOSDragonOS is an operating system developed from scratch using Rust, with Linux compatibility. It is designed for **Serverless** scenarios. 使用Rust从0自研内核,具有Linux兼容性的操作系统,面向云计算Serverless场景而设计。Rust00
Spark-X2.5-1.7BSpark-X2.5-1.7B 旨在让强大的 AI 更加实用、高效且易于获取。这些模型在广泛的日常任务中表现出色,涵盖对话、写作、翻译、推理、编程、工具调用和智能体工作流,并在同等规模的开源模型中取得领先结果。Spark-X2.5 将面向效率的架构与最高 1M tokens 的原生上下文窗口相结合,并支持 200 多种语言。Python00