Ghost E2E 测试工作区协作规范:AGENTS.md 中的工作流、校验闭环与 Playwright MCP 定位器发现
Ghost 在仓库根目录维护着多层文档:面向开发者的 docs/contributing/e2e-testing.md 是"写浏览器端到端测试的规范本体",而 e2e/AGENTS.md 则是面向在 e2e/ 工作区里"动手的人与 AI 代理"的操作入口——它回答三个问题:改代码前读什么、改完必须跑什么、以及如何借助 Playwright MCP 高效且稳定地发现 UI 定位器。本文以该文件为骨架,结合其背后引用的三份"权威文档"(写作指南、E2E 工作区 README、数据工厂 README)及工作区内的真实配置与源码,把这条"进入 E2E 工作区的正确路径"讲透:读完你将掌握 Ghost E2E 工作区的分层文档体系、改动前后必须执行的校验命令链,以及一套可复现的、从"运行测试拿到实例 URL"到"确认稳定定位器"的完整实操流程。
为什么需要一份 AGENTS.md:在进入工作区前先读权威文档
e2e/AGENTS.md 全文极短,但它传达了一个明确工程原则:任何 E2E 改动都应建立在阅读"人工维护的规范文档"之上,而不是靠工具自身的提示或零散记忆。文件开篇就是硬性要求:
Read the canonical human documentation before changing this workspace
也就是说,这份文件是一份"门禁索引",它把散落在仓库中的规范收敛到三个唯一事实来源(Single Source of Truth):
| 想要了解的内容 | 权威文档(相对仓库根目录) | 覆盖范围 |
|---|---|---|
| 测试结构、Page Object、定位器优先级、等待与断言方式 | docs/contributing/e2e-testing.md | 写测试本身的约定,被称为 canonical writing guide |
| 基础设施模式、fixtures、隔离策略、命令与排障 | e2e/README.md | 本地工作区、Docker 基础设施、运行与调试 |
| 测试数据工厂与持久化适配器 | e2e/data-factory/README.md | 测试数据构造 helpers 的用法与扩展方式 |
这种分层有一个刻意为之的目的:文档的"章节职责"被拆开,且 e2e/README.md 与写作指南之间通过链接互相引用(例如 README 中写明"测试结构与约定见写作指南,本 README 只覆盖本地工作区、基础设施、fixtures 与命令")。AGENTS.md 则充当两者的总入口,避免协作者(尤其是 AI Agent)在错误的位置寻找信息。
此外,e2e/AGENTS.md 所在目录结构恰好对应了这些文档的实体位置,便于对照阅读:
e2e/
├── AGENTS.md # 本文件:工作区协作入口与工作流要求
├── README.md # 运行模式、隔离、命令、排障
├── data-factory/README.md # 数据工厂说明
├── docs(仓库根)/contributing/e2e-testing.md # 写测试的规范本体
├── tests/ # 用例(public / admin / portal 等)
├── helpers/ # playwright / pages / environment / services / utils
├── playwright.config.mjs
├── eslint.config.js # 含文件名 kebab-case 等强约束
├── package.json
└── tsconfig.json # @/ 路径别名定义处
硬性工作流:pnpm、@/ 别名与改动后的必跑校验
e2e/AGENTS.md 的第二部分 "Required workflow" 规定了三条任何人都不能绕过的规则,它们都在仓库配置中可被验证。
1. 只用 pnpm,绝不使用 npm 或 Yarn
Ghost 是一个 pnpm workspace 仓库(见根目录 pnpm-workspace.yaml),e2e 自身是名为 @tryghost/e2e 的私有包(见 e2e/package.json)。在 E2E 工作区中运行任意命令都应从 e2e/ 目录发起,或通过 pnpm --filter @tryghost/e2e 定位到该包。
2. 通过 @/ 别名导入共享测试 helper
写作指南与 lint 配置共同强制了这一约定。@/ 别名定义在 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/*"]
}
对应的导入形态(写作指南原文示例):
import {expect, test} from '@/helpers/playwright';
import {LoginPage, PostsPage} from '@/admin-pages';
import {createPostFactory} from '@/data-factory';
import {usePerTestIsolation} from '@/helpers/playwright/isolation';
这些别名有双重保障:一方面 e2e/eslint.config.js 通过 no-relative-import-paths/no-relative-import-paths 规则(允许同目录相对导入、其余统一使用 @ 前缀)在风格上阻止散乱相对路径;另一方面 no-restricted-imports 又限制 @/helpers/pages/* 这类深层导入只出现在页面对象内部,从而保证 Page Object 的封装不被破坏。
3. 改动后必跑的校验闭环
e2e/AGENTS.md 明确给出改动后的动作组合,这也是被 package.json scripts 支撑的最小回归集合:
# 从 e2e/ 工作区运行
pnpm test tests/admin/signin.test.ts # 运行被改动影响的聚焦用例
pnpm lint # eslint . --cache
pnpm test:types # tsc --noEmit && tsc -p tsconfig.scripts.json
- 改完测试:跑 focused test +
pnpm lint+pnpm test:types; - 改完数据工厂:除了上面三项,还必须追加
pnpm build(在 package.json 中它被定义为pnpm test:types,见 e2e/package.json)。
为什么 lint 与类型检查如此重要?因为写作指南里的很多约定——而不是全部——是"可执行"的约定。例如文件名 kebab-case 规则 ghost/filenames/match-regex 的正则为 ^[a-z0-9.-]+$ 且为 error 级别(见 e2e/eslint.config.js),FeaturePage.ts 这类 PascalCase 文件名会被直接判错,从而保证"测试文件按行为命名、页面对象按 <feature>-page.ts 命名"的规范有机器兜底。测试目录(tests/**/*.ts)还被额外约束:禁止使用 page.locator()(必须用 Page Object 或更高层方法)、禁止 test.describe.parallel()/serial()(前者被要求改用 usePerTestIsolation())、并启用本地自研规则 local/no-unsafe-reset-environment 来保护隔离逃生舱的正确用法(详见 e2e/eslint.config.js)。
4. 规范变更要回流到文档,而不是制造"工具专属副本"
最后一条工作流规则颇具 Agent 时代的前瞻性:
Update the canonical human guide when a shared E2E convention changes. Do not create or rely on tool-specific copies of the guidance.
它要求:一旦共享 E2E 约定发生变化,改动应回到唯一规范文档(canonical human guide)里,而不允许创建"某个 AI 工具专属的规则副本"并在后续依赖它。这正是 AGENTS.md 自身保持极短、只做索引与指针的原因——规范本体始终只有一份,避免多个副本漂移失配。
发现定位器的标准姿势:Playwright MCP 工作流
e2e/AGENTS.md 用近半篇幅描述了"何时以及如何用 Playwright MCP 发现定位器、构建 Page Object"。这是对 AI 代理最实用的一段,完整流程可拆成四个步骤。
前置条件与适用场景
When discovering selectors or building a Page Object, use Playwright MCP when it is available.
MCP(Model Context Protocol)服务器在这里扮演"浏览器控制接口":当代理需要为一段陌生 UI 挑选稳定定位器时,与其凭空猜测 DOM,不如让 MCP 直接连上真实运行中的测试实例。
步骤一:用 PRESERVE_ENV=true 保留环境并拿到实例 URL
在 e2e/ 目录下以保留环境的方式运行一个聚焦测试:
cd e2e
PRESERVE_ENV=true pnpm test
正常情况下每次测试结束后环境会被回收(global teardown 会清理 e2e 容器与测试数据库),而 PRESERVE_ENV=true 会保留容器与数据库,测试运行器随后会打印出该 Ghost 实例的地址——写作指南给出的典型值是 http://localhost:2369(见 docs/contributing/e2e-testing.md "Preserve the test environment")。基础设施(MySQL、Redis、Mailpit、Tinybird)必须已在运行,可使用 pnpm dev 或 pnpm --filter @tryghost/e2e infra:up 先行启动(见 e2e/README.md)。
步骤二:导航到实例并拍摄"无障碍快照"
打开打印出的实例 URL,在交互前先取 accessibility snapshot。这一步非常关键:它迫使你基于可访问性语义而非视觉像素来选择定位器,与项目"优先语义定位器"的定位器优先级天然对齐(详见下文)。
步骤三:实际演练交互,验证定位器并截图
对目标 UI 执行真实交互(点击、输入等)以验证候选定位器确实命中了期望元素;当渲染状态对后续判断有参考价值时再截图留存。换句话说,定位器必须以"真实交互通过"为证据,而不是只在静态 DOM 里存在。
步骤四:按定位器优先级重写,不照抄生成结果
这是 MCP 工作流中最容易被忽略、却写在 AGENTS.md 里的一句话:
Follow the locator priority in the E2E writing guide; do not copy generated selectors without checking that they are stable.
生成的选择器可能是脆弱的结构表达式,必须经过稳定性审查并按写作指南的优先级重写,自高向低为:
- ARIA 角色 + 可访问名称:
page.getByRole('button', {name: 'Save'}) - 表单标签:
page.getByLabel('Name') - 唯一可见文本:
page.getByText('Saved') - 稳定测试 ID:仅在无语义定位器可用时使用
- Ember Admin 常用
data-test-*,React Admin apps 用data-testid
- Ember Admin 常用
- 稳定的结构选择器:仅在万不得已时使用
这条优先级的完整示例代码与解释在 docs/contributing/e2e-testing.md,页面对象中推荐按固定次序为元素声明只读 Locator,例如:
// e2e/helpers/pages/admin/feature-page.ts(示意结构)
export class FeaturePage extends AdminPage {
readonly saveButton = page.getByRole('button', {name: 'Save'});
readonly nameInput = page.getByLabel('Name');
readonly statusMessage = page.getByText('Saved');
}
如果 UI 上确实没有可靠的语义锚点,正确做法是回到产品代码里加一个稳定的测试 ID,而不是让测试去耦合样式或 DOM 位置——这正是 Ghost 工作区中测试与产品代码相互演进的典型形态。
备选方案:Playwright Inspector 与浏览器开发者工具
AGENTS.md 明确留了退路:
If Playwright MCP is unavailable, use Playwright Inspector or browser developer tools as described in the writing guide.
两种途径的目标一致:打开被保留的实例、观察可访问性树与相关属性、在优先级指导下选定定位器并验证交互。区别仅在于"控制浏览器的接口"不同,规范本身不随之分叉。
工作流背后的规范底座:写作指南核心约定
e2e/AGENTS.md 反复指向的 docs/contributing/e2e-testing.md 是所有这些工作流的"语法层"。其中与定位器发现最直接相关的约定包括:
- 等待靠状态而非计时:用
await element.waitFor({state: 'visible'})与 web assertion(await expect(...).toContainText(...));禁止page.waitForTimeout(5000)与page.waitForLoadState('networkidle')。 - 异步操作等"用户会看到的 UI 信号":例如保存后等待状态消息出现,而不是固定延时。
- iframe 用
frameLocator():它像其他定位器一样自动重试,例如page.frameLocator('[data-testid="portal-popup-frame"]')。 - 页面对象内方法返回 locator 或值、但不在其中断言:断言留在测试用例里;Modal 被建模为普通类而非页面子类,把定位器作用域收敛到
getByRole('dialog')上。 - Arrange–Act–Assert 作为可读性启发:搭建场景 → 执行被测行为 → 验证结果,用命名与结构让三阶段自明。
这些约定直接支撑了 MCP 工作流中"取无障碍快照、按优先级选 locator、做交互验证"三步的产出质量。
从用例到数据工厂:改动边界的两种情形
e2e/AGENTS.md 把"改测试"与"改数据工厂"作为两条不同的校验路径对待,是因为它们处于不同的抽象层:
- 测试用例通过 Page Object 与
@/别名使用 helpers,通常只需要"聚焦用例 + lint + 类型检查"即可完成回归。 - 数据工厂(e2e/data-factory)是测试数据的构造与持久化层,其代码会以
@tryghost/e2e的构建产物被引用,因此改动后必须追加pnpm build。
数据工厂本身采用"工厂类 + 持久化适配器"的分层:实体形状与随机默认值归属 @tryghost/test-data 包,工厂在 build() 中把"响应形状"转换为"写入载荷"(扁平化关联、丢弃仅响应字段),entityType 属性驱动持久化(见 e2e/data-factory/README.md)。在测试中的典型用法:
import {createPostFactory} from '@/data-factory';
test('...', async ({page}) => {
const postFactory = createPostFactory(page.request);
const publishedPost = await postFactory.create({
title: 'My Published Post',
status: 'published'
});
});
隔离模型与运行模式:理解 PRESERVE_ENV 之前必知的环境语义
MCP 工作流第一步 PRESERVE_ENV=true 之所以能"保留一个可检查的实例",背后是一套精心设计的隔离模型。理解它有助于避免在错误的环境身份上做定位器验证:
- 默认按文件隔离(per-file):每个文件一次 Ghost 环境周期。global setup 先创建基础数据库、启动 Ghost、等待健康并快照数据库;文件边界处从快照克隆新库并重启 Ghost 复用。
- 按测试隔离(per-test):
usePerTestIsolation()(定义于 e2e/helpers/playwright/isolation.ts)用两个标准 Playwright 调用完成——test.describe.configure({mode: 'parallel'})加test.use({isolation: 'per-test'}),为每个测试分配独立 Ghost 实例。fullyParallel: true会强制按测试隔离。 - 环境身份(identity)参与因素:fixture option 中
config与labs参与 per-file 复用身份,二者任一变化都会在文件内触发环境回收重建;而stripeEnabled不参与复用,总是强制 per-test 隔离(因为 Ghost 必须针对每个测试的 fake Stripe 服务器启动)。 - 逃生舱
resetEnvironment()只能在beforeEach钩子中、且必须在解析baseURL、page、pageWithAuthenticatedUser、ghostAccountOwner等有状态 fixture 之前调用;ESLint 自研规则会拦截明显误用,fixture 内的运行时守卫是最终硬校验。
运行模式方面,若未显式设置 GHOST_E2E_MODE,脚本会自动选择:本机 admin dev server 在 http://127.0.0.1:5174 可达则走 dev 模式(Ghost 挂载源码并把资源代理给宿主 dev server),否则走 build 模式(使用预构建镜像,资源从 /content/files 提供);也可用 GHOST_E2E_MODE=dev / GHOST_E2E_MODE=build 强制指定。这些语义在 e2e/README.md 中有完整展开——PRESERVE_ENV=true 之后你要检查的,正是某个处于确定隔离身份与运行模式下的真实 Ghost 实例。
结语:一份让"人与 AI"共用同一套共识的入口文档
e2e/AGENTS.md 的价值不在篇幅,而在刻意保持精简的指针式设计:它把所有规范收敛到三份人工权威文档,把工作流压缩为"先读文档 → 只用 pnpm/@/ 别名 → 按边界跑全校验 → 约定变更回流文档",再把最容易让 AI 代理出错的选择器发现环节固化成"保留环境 → 无障碍快照 → 交互验证 → 按优先级重写"四步。配合 eslint.config.js 中可执行的命名、导入与隔离规则,Ghost 得以让编辑器、CI 与 AI 代理在同一套约定下协作:规范只有一份,谁来执行都一样。若你的改动触及任何共享约定,请记住最后那条回写规则——把变化送回 canonical guide,而不要在别处另起炉灶。
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 StartedRust0627
Hy4-previewHy4 preview 是由腾讯混元团队研发的新一代混合专家(MoE)旗舰模型。模型总参数量 770B,每个 token 激活 49B,主干共包含78层,第一层采用标准 FFN,其余 77 层均为 MoE 结构,每层包含 256 个路由专家与 1 个共享专家,每个 token 激活 top-8 路由专家及共享专家。主干之外原生内置 1 层 MTP(总参数量 10B,激活 0.7B)以支持投机解码。Python00
GLM-5.3GLM-5.3 与 GLM-5.2 使用相同的基座模型——所有提升均来自后训练。与 GLM-5.2 相比,它在复杂编程和长程任务上的表现显著提升。Jinja00
GLM-5.3-FlashGLM-5.3-Flash (320B-A18B),是GLM-5系列的首个原生多模态模型。320B总参数,能力超过GLM-5.2Jinja00
Spark-X2.5-4BSpark-X2.5-4B 旨在让强大的 AI 更实用、更高效、更易获得。在广泛日常任务中表现强劲,涵盖对话、写作、翻译、推理、编码、工具调用以及智能体工作流,并在同等规模的开源模型中取得领先成绩。Spark-X2.5 将面向效率的架构与最高 1M tokens 的原生上下文窗口相结合,并支持 200 多种语言。Python00
Spark-X2.5-1.7BSpark-X2.5-1.7B 旨在让强大的 AI 更加实用、高效且易于获取。这些模型在广泛的日常任务中表现出色,涵盖对话、写作、翻译、推理、编程、工具调用和智能体工作流,并在同等规模的开源模型中取得领先结果。Spark-X2.5 将面向效率的架构与最高 1M tokens 的原生上下文窗口相结合,并支持 200 多种语言。Python00