一套验收流程:AI 写完代码后,自己开浏览器实操、验证页面效果、交出证据
从读改动、启动应用,到操作、保存、刷新、修复复测和交付证据,把一次完整验收交给 Agent。再用项目规则、Skill 和可选工具把它复用起来。
这是一套让 AI 编程智能体(Coding Agent)在写完前端代码后,按真实用户的操作路径打开浏览器,点击、输入、保存和刷新,检查功能与页面效果,并留下可复查证据的验收流程。
开发者 Timothy Ogbemudia 先把这些要求写进项目规则和可复用的 Skill,后来又开发了开源工具 katalepsis,用来运行检查、整理报告。前面的验收流程也可以独立使用。
用 Claude Code、Codex 或 Cursor 改网页,你可能遇过这种情况:Agent 汇报“已实现,测试全部通过”,打开页面却发现筛选后仍是旧数据、设置刷新后恢复原值,或弹窗里的保存按钮被挤出了手机屏幕。
接下来又得你操作一遍、截图、复制报错、等它修改,再打开页面核对。代码写得快了,验收这一段却还需要人不断传话。
Ogbemudia 想补上的就是这段反馈:Agent 改完代码,先跑已有检查,再启动应用、使用刚改的功能;发现问题就修,修后再用一遍。已有测试检查它们覆盖的场景,浏览器验收则从本次改动出发,检查实际运行中的结果。两者配合,才更容易发现“测试全绿,界面仍然不对”的问题。
先走完一次验收:修改设置、保存,再刷新
下面用一个原创教学场景串起流程:设置页新增“显示名称”和“周报通知”的保存功能。路由、字段和后面的报告都为讲解而设,不是本次实际运行的项目。
1. 先读需求和改动,确定要验什么
Agent 先看 git status --short,记录已有修改和未跟踪文件;再读需求与本次实际代码差异,包括任务涉及的提交、未提交修改和新增文件,不能把一条 git diff HEAD 当成所有任务的完整改动。
假设改动涉及设置页面和保存接口,验收计划就应写到具体结果:
- 打开
/settings/profile,记录名称和开关当前状态。 - 改成与初始状态不同的新值,保存后检查结果。
- 刷新页面,等数据加载完,再读名称和开关的当前值。
- 检查保存中的禁用状态是否解除,失败时是否有错误提示;若改动涉及空数据或相邻路径,也纳入检查。
- 在相关桌面、窄屏视口检查布局,核对保存请求与本次操作引入的新报错。
进入浏览器前,先运行项目已有的相关测试,以及本次需要的构建或类型检查,记录结果。浏览器检查要补上这些检查没有回答的问题。
2. 按项目已有方式启动,记录自己创建的资源
先查 package.json 的 dev 脚本,再看 README 的本地启动说明;确实需要容器时,使用项目已有的 Docker 配置。命令、端口、依赖都以该项目为准。
启动时保存日志,记录自己启动的进程 PID、容器和临时目录;等待服务就绪,并为等待设定时限。启动失败就读日志、报告缺少的条件,不能只因进程已创建就宣布应用可用。
验收的是准备交付的真实应用。不能为方便登录而关掉认证、修改 .env、另造假服务,或增加绕过真实流程的临时路由。按文档测试账号或种子数据仍无法登录时,报告受阻及原因。
3. 修改、保存、刷新,核对最终状态
浏览器通道接好后——具体接法在后文——Agent 打开目标路由。在这个例子里,假定初始名称是“原名称”,周报开关关闭:
- 确认初始值,再把名称改为“小互”、开关设为开启。
- 点击保存,检查成功提示、按钮状态,以及相关请求是否成功。
- 刷新页面,等待设置重新加载。
- 读取输入框的当前值,检查是否仍是“小互”;读取开关状态,检查是否仍为开启。
这里要读运行中的控件状态,不能只读输入框初始 HTML 的 value 属性。DOM 是浏览器里的页面元素结构;通过它或无障碍快照,可以找到控件并读取状态。写成 Playwright 测试时,名称可用 toHaveValue("小互") 检查,真正的开关/复选框可用 toBeChecked();使用通用浏览器工具,也应读取新页面里的对应状态。
这组检查能验证本次改值、保存、刷新后的结果。“按钮点过了”或“成功提示出现过”,都不能代替最后一步。Playwright 的网页断言会在超时前重试检查条件,适合等待界面异步更新;断言仍需对应实际用户要求。
4. 功能看状态,布局看画面,接口看响应
不同证据回答不同问题:
- 功能状态:读 DOM 或无障碍快照,核对名称、选中状态、按钮是否禁用、弹窗是否打开。
- 页面效果:例如在 375×812、1440×900 两种视口截图,查看按钮遮挡、弹窗裁切、文字重叠。它们是浏览器视口检查,不等于实体手机实测。
- 接口与新错误:保存依赖接口时,检查对应请求的响应,以及本次操作产生的相关控制台错误。页面显示成功,接口却返回 HTTP 500,仍然需要报告。
截图能显示遮挡,却不能单独证明刷新后的字段值正确;DOM 中有弹窗,也不代表它在屏幕上能正常使用。检查范围围绕本次改动:旧警告或无关请求不应自动扩成另一项修复任务,纯前端切换也不必额外做全面网络审计。
同样的思路也用于筛选和重置。Ogbemudia 在 freeCodeCamp 文章中给的筛选计划是:记录初始行数,选 Active,再叠加 Owner,清空后检查原始结果恢复;再重新设置非默认筛选,检查 URL 与刷新后的条件、结果是否保留。不能在一开始就没有筛选时点 Reset,然后声称“重置有效”。
5. 发现问题,修复后重走流程
假如刷新后恢复旧名称,就保留当时的请求、状态和错误记录,回代码中查原因。完成修复后,重跑受影响的测试,再重复“修改→保存→刷新→读当前值”的流程。不能在改完代码后只说“现在应该好了”。
缺少项目要求的测试登录条件、迁移或可用预览环境,可能使验收受阻(blocked);已经执行的业务流程因真实依赖接口故障而失败,则应保留实际失败和原因,不能仅因问题不在当前源码里就改写成受阻。
6. 保留交付证据,只清理自己创建的资源
成功、失败或中途受阻,都要关闭本次创建的浏览器,停止自己的服务和容器,删除不需交付的临时材料。依据启动时记录的归属清理,避免用 pkill node、git clean -fd 等全局命令波及其他工作。
再次查看 git status --short:原有修改和未跟踪文件应保留,本次修复应能解释,临时 QA 产物不要混入代码提交;报告要交付的截图和轨迹另行保留,并注明位置。修复本身可能改变源码,工作区不必与开始时完全一致。验收流程本身不授权额外提交或推送。
7. 把做过什么、看到什么交回来
最后交付路由、操作、预期与实际结果、证据位置、相关测试、修复和复验,以及受阻或未测项。开发者应能据此复查,而不必再问一遍“你到底测了哪里”。
一份能拿来复查的交付报告
下面是与上述场景对应的教学示意,不是本次生成的测试结果或证据文件:
### 设置页验收报告(教学示意)
- 环境:本地开发应用;路由 /settings/profile。
- 初始状态:显示名称“原名称”;周报关闭。
- 前置检查:已运行项目现有设置页相关测试,结果通过。
操作与结果:
1. 名称改为“小互”,周报开启,点击保存。
预期:显示成功提示,保存按钮恢复可用。
实际:符合预期,保存请求成功。
2. 刷新并等待数据加载。
预期:当前名称仍为“小互”,周报仍开启。
实际:读取输入框当前值与开关状态,符合预期。
3. 检查桌面与窄屏视口。
预期:按钮完整可见,标签无重叠。
实际:符合预期;本次操作无新增相关控制台错误。
发现与复验:
- 初测发现保存后按钮一直禁用。
- 修复提交状态恢复逻辑,重跑相关测试;再完整操作、保存、刷新,结果通过。
证据(示意路径):
- evidence/settings-desktop.png:桌面状态截图。
- evidence/settings-mobile.png:窄屏布局截图。
- evidence/settings-actions.log:操作、字段状态、请求和错误记录。
未测/受阻:未测实体手机;本次无受阻项。
清理:已关闭自己创建的浏览器、服务及临时材料;保留上述证据。
工作区:原有修改和未跟踪文件保留;本次修复已说明,无额外 QA 产物混入提交。
同一处改动,哪些证据还不够?
以下是教学示例。切换任务,比较现有测试、页面加载与结果检查各自能说明什么。
选中的筛选条件写进网址参数,刷新后仍然生效。
已打开列表页,状态码 200,没有报错,验证通过。
现有单元测试
只检查状态函数,未必能发现网址参数与页面同步的问题。
只打开页面
还要检查的结果
选择筛选条件,检查结果列表;刷新带参数的网址,再确认筛选状态与结果都保留。
把流程留在项目里:简短规则、按需 Skill、独立审查
每次都靠人重发一长段提示词,很容易漏掉步骤。Ogbemudia 的做法是把完成标准留在项目规则里,把具体操作放进可复用技能。
常驻规则说明“何时算完成”,Skill 说明“怎样检查”
在支持相应约定的客户端中,AGENTS.md(例如 Codex 使用)或 CLAUDE.md(Claude Code 使用)会按客户端和目录范围加载为工作指令。把长篇浏览器手册全部放进去,会让不需要浏览器的任务也携带这些内容。
Skill 是包含 SKILL.md 的目录,文件以 name、description 等元数据开头。支持技能的客户端通常先展示简短介绍,任务匹配或明确调用时,再读取详细步骤。因此可以拆成:
- 项目规则:用户可见行为要在运行的应用里验证,具体遵循哪个 Skill。
- browser-review Skill:从读改动到启动、操作、取证、修复和清理的完整步骤。
这些文件是 Agent 的工作指令,实际加载方式取决于客户端支持。原作者同时用 Codex 和 Claude Code,通过 Claude 的 @ 导入语法共享规则;这是一种配置方式,不是所有客户端都会自动共用。
支持 AGENTS.md 时,可加入下面这段自拟规则;使用 Claude Code 则放入 CLAUDE.md 或按其支持的方式导入:
## 界面验证规范
用户可见行为的改动,在宣布完成前,要在实际运行的应用中验证受影响的流程。
具体遵循 `.agents/skills/browser-review/SKILL.md`。
构建、类型检查及已有测试结果,与本次运行页面的检查一并报告。
无法完成的步骤说明受阻原因;没有做的检查列为未测,不当作通过。
只清理自己创建的资源,保留交付证据,不把临时 QA 产物混入提交。
让另一位 Agent 自己决定该检查哪里
实现者知道自己打算改什么,也可能沿着同一套假设设计自测。原作者因此要求:重要改动交给一个没有继承实现会话的审查 Agent,只给原始需求、实际 diff 和验收 Skill,让它独立判断影响范围,不依赖实现者的截图、QA 笔记或绿灯结论。
原文的一个例子很具体:需求是让端到端测试通过 E2E_BASE_URL 配置应用地址,实现者在默认的 8080 端口测试通过。但 e2e/global-setup.ts 里种子图片的 URL 仍写死为 http://localhost:8080/test-media/...。
独立审查者改用 8081 启动应用,看到了 3 个发往旧端口的失败图片请求,以及页面上 3 张回退卡片。只检查“卡片存在”仍会通过,因为回退卡片也是卡片。修法是从配置的基础地址生成图片 URL,或采用合适的相对地址。
这个例子的价值,是展示另一套验收范围怎样发现默认路径掩盖的问题;它不能保证第二位 Agent 会找到所有缺陷。
可复用的验收技能:中英双语 browser-review Skill
以下是两份内容完全等价的标准化流程文件,分别提供中文版与英文版。用户无需双语并用,只需根据团队工作语言选一种使用即可。
使用时,在项目根目录下创建 .agents/skills/browser-review/SKILL.md,并将对应内容完整存入(保留开头的 YAML 元数据)。
中文版(本指南自拟)
---
name: browser-review
description: 检查本次用户可见改动在真实应用中的行为,并提交可复查证据。
---
请对当前任务的用户可见改动执行浏览器验收:
1. 先读原始需求和实际 git diff(包含本次未提交改动与新增文件),记录 git status --short 中已有的工作。列出受影响路由、相关加载/空/错误/选中/禁用状态、相邻路径、操作、预期结果及检查方式。先运行已有的相关测试及必要的构建或类型检查,记录结果。
2. 按项目 dev 脚本、README 或既有 Docker 配置启动应用;限时等待服务就绪后再打开浏览器,启动失败保留日志并报告。记录自己启动的进程、容器、浏览器与临时文件。不为方便检查而关闭认证、修改 .env、造假服务或新增绕过真实流程的路由。
3. 走完受影响的用户流程,检查最终结果。保存要先记录初始值、改成不同目标值,再检查刷新后的当前值;重置要先改变条件,再检查输入和结果是否都恢复。不能只以页面加载或点击没有报错当成功。
4. 功能状态读取 DOM/无障碍快照;布局问题检查相关桌面、窄屏视口并截图。涉及接口时检查相关响应和新报错;旧警告、无关网络请求不扩展本次范围。
5. 发现缺陷,修最小原因,重跑相关测试,再走受影响流程。缺文档规定的凭据、预览服务本身不可达等环境障碍写 blocked;已执行业务因真实依赖接口故障而失败,保留失败与原因。没做的检查列为未测,说明原因。
6. 无论成功失败,只清理自己启动或创建的进程、容器、浏览器和临时材料,保留需要交付的证据。再看 git status --short,保住原有修改与未跟踪文件。不执行 git add、commit、push 或全局清理命令。
7. 报告列出:路由、操作、预期与实际结果、对应证据、相关测试结果、修复、受阻/未测项及清理情况。不要把没有证据的检查写成通过。
English version(本指南自拟)
---
name: browser-review
description: Verify this task's user-visible changes in the running app and report reviewable evidence.
---
Perform browser verification for this task's user-visible changes:
1. Read the original request and actual git diff, including this task's uncommitted changes and new files. Record pre-existing work with git status --short. Plan affected routes, relevant loading/empty/error/selected/disabled states, nearby flows, actions, expected outcomes and how to check them. Run existing relevant tests and any necessary build or type checks first, and record results.
2. Start the real app using its dev script, README or existing Docker configuration. Wait for readiness with a time limit; retain logs and report startup failure. Track only the processes, containers, browsers and temporary files you create. Do not disable authentication, edit .env, invent a fake service or add a bypass route to make verification easier.
3. Exercise each affected user flow and check the final result. Before saving, record initial values and set distinct target values; reload and read current values to check persistence. Before resetting, change the state, then check both inputs and results. Page loading or a click without an error is insufficient.
4. Use DOM/accessibility state for functional checks and relevant desktop/narrow viewport screenshots for layout. Inspect relevant responses and new errors when the flow uses an API. Do not expand scope to old warnings or unrelated requests.
5. Fix the smallest cause of a defect, rerun relevant tests and repeat the affected flow. Report missing documented credentials or an unreachable preview server as blocked. If an executed product flow fails because a real dependency API fails, keep the failure and its cause. List checks not performed and why.
6. Clean up your own processes, containers, browsers and temporary files on success or failure; preserve evidence needed for hand-off. Check git status --short again and preserve pre-existing edits and untracked files. Do not run git add, commit, push or global cleanup commands.
7. Report routes, actions, expected versus actual outcomes, supporting evidence, test results, fixes, blocked/untested items and cleanup. Do not claim a pass without evidence.
给 Agent 接上浏览器:Playwright MCP 或 CLI + Skill
上面的流程需要一条实际可操作的浏览器通道。Playwright 能自动控制浏览器、定位元素和检查状态;MCP 是让 Agent 调用外部工具的协议。Playwright MCP 将这些浏览器操作提供给 Agent,不是普通浏览器插件。
微软的仓库还列出 Playwright CLI + Skill 路线:用命令行与按需说明控制浏览器,适合编码任务中关注上下文开销的场景;MCP 则提供持续的工具接口和浏览器上下文。接上任何一路,都仍需要前面的验收规则。
原文使用 Node.js 20+;所核对的 Playwright MCP 官方 README 最低要求是 Node.js 18+。若还要安装后面的 katalepsis,则需 Node.js 22.12+。
按客户端选择注册命令。Claude Code:
claude mcp add playwright npx @playwright/mcp@latest
Codex:
codex mcp add playwright npx "@playwright/mcp@latest"
重载客户端连接,确认能看到浏览器工具。然后让 Agent 打开 TodoMVC 演示页,新增“Browser QA”待办并确认列表中出现。这个小测试验证通道可用,不能代替项目本身的验收。
Agent 怎样找到要点的控件?
Playwright MCP 可以返回无障碍快照:把页面整理成带角色、名称和状态的结构,例如 button "保存"、textbox "邮箱",并提供元素引用编号 ref。Agent 根据当前快照寻找控件,不必凭空猜 CSS 选择器。页面变化后要重新读取,不能照抄文章中的编号去点击。
语义定位有助于围绕用户看到的行为检查。每个测试也应隔离自己的数据和状态,避免依赖上一项检查留下的结果。但快照有按钮不代表按钮没有被遮挡,布局仍需查看截图。
可选工具:用 katalepsis 整理检查与证据
前面的流程不需要 katalepsis。原作者先用 Skill 让 Agent 真正操作应用,后来仍希望拿到便于 PR 审查的报告,于是开发了 MIT 开源的 katalepsis。browser-review 是流程 Skill,katalepsis 是执行检查和整理报告的命令行工具。
它把结果写入 artifacts/katalepsis/<run-id>/:HTML 报告供浏览器查看,Markdown 摘要可贴入 PR,manifest.json 保存结构化结果与证据文件的 SHA-256 哈希。截图、视频和 Playwright trace 按执行路径、配置及结果收集,不能假定每项检查都有全套证据。
哈希可用于检查文件一致性,不保证文件不可篡改,也不证明测试目标选对。trace 则能逐步查看动作、当时的 DOM 快照及网络信息,帮助还原失败过程,参见 Trace Viewer 文档。
两条执行路径:已有测试用 run,自然语言检查用 verify
katalepsis run |
katalepsis verify |
|
|---|---|---|
| 输入 | 项目已有 Playwright 测试 | 写明操作意图和成功条件的 checks.json |
| 模型 | 不需要模型密钥 | 自备 Anthropic、OpenAI 或 OpenRouter 密钥 |
| 执行 | 项目自己的 Playwright 运行器 | 模型调用内部 Playwright 浏览器工具,不依赖 Playwright MCP |
| 判定 | 保留 Playwright 的原始断言结果,再分类整理 | 模型提交结论与断言编号,程序核对证据门槛 |
verify 的断言范围比普通 Playwright 测试窄,不能直接覆盖前面示例中的全部字段、持久化和布局检查;具体边界在后文说明。
安装与第一次使用
katalepsis 尚未发布到 npm。安装器从仓库获取源码并构建,需要 Node.js 22.12+、Git、pnpm 10 或可用的 corepack。以下为官方安装方式,本文没有在本机代为安装或运行它:
curl -fsSL https://www.katalepsis.glamboyosa.xyz/install.sh | sh
安装器将 katalepsis、katalepsis-mcp 放到 ~/.local/bin,需确保该目录在终端 PATH 中。
已有 Playwright 测试时,先将下列字段合并进项目已有 playwright.config.ts 的 use 配置,保留其他设置:
use: {
baseURL: process.env.KATALEPSIS_BASE_URL ?? "http://localhost:3000",
screenshot: "only-on-failure",
video: "retain-on-failure",
trace: "retain-on-failure",
},
项目须有 @playwright/test 和实际测试用例。启动应用后,在其根目录运行;将示例地址替换为真实运行的本地或预览地址:
katalepsis run --cwd . --url http://127.0.0.1:3000
浏览器无法启动且提示版本缺失时,可在同一命令加 --install-browsers,让工具安装与项目 Playwright 匹配的浏览器。verify 则会在首次运行时下载它使用的 Chromium。
地址接线要核对实际用法。 配置没有引用 KATALEPSIS_BASE_URL 时,工具会警告;出现变量名也不保证每条测试都使用它。测试中若写死 page.goto("http://localhost:8080/login"),仍可能跑错服务器。原作者在自己的两个项目中遇到过这种情况。
没有现成测试时,可在项目根目录保存 checks.json。下面是自拟的空表单检查,/signup、按钮描述和成功条件要按真实应用修改:
{
"checks": [
{
"id": "empty-signup-validation",
"name": "空表单提交检查",
"startPath": "/signup",
"intent": "不填写输入项,点击提交注册按钮。",
"passCondition": "必填字段错误提示可见,且注册成功确认没有显示。"
}
]
}
同时写明应出现和不应出现的结果,能把验收目标说清;复查报告时,仍需检查模型拿出的断言是否真在回答这个目标。
数据边界也要在使用前知道:verify 将页面结构和文字发送给模型提供商,截图不发送给该模型,而是留作报告证据。文本报告会脱敏 URL 查询值,但截图、视频、trace 不会自动脱敏。应使用适合外发与共享的测试数据;原作者在真实工作分支上,先换成合成种子数据并遵守自己的许可规则,再做模型检查。
以 OpenRouter 为例,先保存密钥、选择提供商并查询真实可用的模型 ID:
katalepsis auth set openrouter
katalepsis config provider openrouter
katalepsis models openrouter
katalepsis config model '替换为上方列出的模型ID'
katalepsis verify --cwd . --url http://127.0.0.1:3000 --checks checks.json
这里同样要先启动应用、替换地址。auth set 将密钥存入系统钥匙串,也可按工具支持的环境变量配置;不要把密钥放进命令行参数或 MCP 工具参数。
让 Coding Agent 调用检查,并读回报告
用 katalepsis 的 MCP 服务,可以让编码 Agent 在改完后自行运行检查、读取报告。以 Codex 为例,将 --root 后的占位符替换为项目的实际绝对路径:
codex mcp add katalepsis -- katalepsis-mcp --root /path/to/your-app
重载客户端连接,确认出现三个工具:verification_run_tests(已有测试)、verification_check_browser(自然语言检查)、verification_get_report(读取报告)。服务的项目目录边界由 --root 指定,模型认证先用前面的 auth 配好。
浏览器检查可能比一般工具调用耗时更长。Codex 可在 ~/.codex/config.toml 已有的 [mcp_servers.katalepsis] 配置节内加入 tool_timeout_sec = 600;不要放成全局配置,也不要覆盖该节原有启动参数。其他客户端参见 官方配置说明。
报告里的四种情况要分清
- passed:满足该执行路径的通过规则。已有 Playwright 测试若重试后才通过,仍归 passed,同时保留
flaky标记和每次尝试。 - failed:检查实际失败。真实第三方 API 故障导致业务失败,也保留 failed;它说明这个环境中的流程失败,不自动证明根因在当前改动。
- blocked:无法形成有效结论。例如预览探活发生网络错误、返回 502/503/504,浏览器无法启动,或模型检查遇到证据不足、工具错误、步骤/token/时间预算耗尽。
- not tested:没有运行,例如被过滤或跳过。工具将其另列为
notTested并注明原因,不能算进通过数量。
预览本身不可达时,工具会在浏览器测试前阻断所选检查,避免把环境离线误报成多个功能缺陷。已经运行后发现的业务失败,则需按实际证据判断。
模型说“通过”,程序怎样检查它的证据?
在 verify 中,模型可以调用 navigate、snapshot、click、fill、press、assert 和 finish。assert 由工具在真实浏览器中执行,记录结果、截图和编号,例如 a1;模型最后用 finish 声明结论,并引用断言编号。
模型的文字判断不能自行盖章。程序函数 decideOutcome 核对声明和执行记录:
- 引用必须对应实际执行过的断言。没有结束结论、没有引用或引用未知编号,都不能通过。
- 声称通过时,引用的断言必须全部通过,并至少有一条可见性断言;只证明 URL 变化或某元素隐藏还不够。
- 如果引用之后又出现未被引用的断言失败,不能忽略较新的矛盾证据继续报通过。
- 声称失败也需要引用真正失败的断言;没有失败证据的失败声明同样会受阻。
这些不符合证据要求的声明会归为 blocked: no-evidence。它约束的是模型提交的证据,不是在自动审核全部业务需求。
页面本来就有的标签,不能独自证明操作成功
原作者曾遇到一个模型用“邮箱输入框的标签可见”证明表单有效。但标签在点击前就有,断言即使为真,也没说明提交产生了什么结果。
因此,每项检查会使用分开的浏览器上下文:模型操作一份页面,另一份加载同样地址但不执行这些交互,用作未操作对照。模型通过 navigate 直接打开的新 URL,也会建立相应对照副本。
执行可见性断言时,工具检查同一元素是否也在这些未操作副本中成立,并记录 trueAtStart。对于通常的交互检查,至少要有一条通过的可见断言,在这些对照里原先不成立;如果引用的可见内容全都原先存在,就不能凭此通过。
检查“页面初始状态”的用例可在 checks.json 对应检查对象中加 "observeOnly": true,跳过上述新变化要求,但仍需真实执行并通过的可见性断言。“初始状态”不等于只检查首屏。
模型说通过,证据规则会接受吗?
改变声明与断言条件,观察 passed、failed、blocked 如何分流。这演示证据门槛,不代替对用户要求的判断。
对照页会再次请求网址,浏览器上下文隔离不等于后端数据隔离。一次性激活链接、访问就改变服务端状态的页面,可能因第二次加载而不同;对照加载失败也可能使检查受阻。
断言为真,也可能没有回答需求
假设要求是“提交空表单后显示错误”。模型没有检查错误,而是点击普通链接去了“关于我们”,再证明新标题可见。这个标题是实际出现的新内容,可能满足可见性和变化门槛,却与表单校验无关。
所以,人复查报告时仍要把成功条件、实际动作、所引断言放在一起看。真实执行的断言能约束无证据的声明,不能自动保证断言与需求相关。
verify 当前只提供 visible、hidden、URL 包含三类模型断言。前面保存案例的输入框当前值、开关状态、刷新后的持久化,以及控制台、网络和多视口布局,应由普通浏览器工具或 Playwright 测试补足;这是该工具适配层的范围,不是 Playwright 本身做不到。
固定的是证据判定规则,模型选择的操作仍会随模型和运行变化。快速消失的提示可能在断言前消失,因此失败时还要回看截图、动作和 trace,区分产品失败与检查没有捕捉到状态。仓库的实现状态文档自报:外部开发者独立安装验收和跨平台覆盖仍未完整;原作者的试用结果不能当作普遍可靠性保证。
测对地址后,还要确认预览是哪次代码
代码已改,预览可能尚未更新。旧预览的绿灯不能证明本次改动。katalepsis 会按顺序读取预览的提交标识,再与当前检出代码的 HEAD 比较:
- 响应头
x-commit-sha; - HTML 的
<head>前 512KB 中的<meta name="commit-sha">; - 调用方传入的
--preview-commit <sha>。
第三种只应传已知实际部署的 SHA,不能拿期望部署的 pushed SHA 或 GITHUB_SHA 冒充。结果分三种:
- confirmed:与 checkout HEAD 匹配,也允许至少 7 位的有效短 SHA 前缀。这仍不能证明工作区未提交的修改已进入预览。
- unknown:无法确认版本;报告注明未知,检查仍可运行。
- mismatched:明确不匹配。检查仍执行,本来会通过的运行改为 blocked,已发生的 failed 保留。
动作按序执行,并限制每项检查的消耗
作者在试用中见过两个并行 fill 争用焦点、把姓名填进邮箱字段的问题。工具因此让浏览器动作通过串行队列,上一项结束才执行下一项;这不表示页面所有网络请求都串行。
每项模型检查使用独立浏览器上下文并开启 trace,默认最多 15 步、200,000 token、120 秒;触及配置预算后报告 blocked 和原因。顶层导航只允许预览 origin 及显式 --allow-origin 列出的 origin(协议、主机和端口)。外部子资源仍可加载,这不是全网络沙箱。
在宣布完成之前,交出对应的运行结果
这套流程用于用户可见的运行行为:表单、筛选、弹窗、导航、响应式布局和刷新后的状态等。修文档错字、不影响页面的后端改动,不必为此启动浏览器;按改动选择合适的检查。
已有测试、浏览器验收、代码审查和重要改动的人工查看各有作用。回到最初的设置页:Agent 交付的应当包括它改了什么值、怎样保存和刷新、最终读到了什么、遇到了什么问题,以及你能在哪里复查证据。这样的交接,才有机会减少人反复截图、传话和催它重试的来回。
让 Coding Agent 先用一遍,再交付
Agent 说测试全绿,设置却一刷新就丢?让它按用户路径操作真实应用,带着最终状态和证据回来。
一条验收闭环
- 01读需求与实际改动,列出路由、状态和预期;先跑相关测试。
- 02按项目文档启动真实应用,限时等就绪;记录自己创建的进程。
- 03走用户路径,查最终结果设置页示例:记初值 → 改成不同值 → 保存 → 刷新并等加载 → 读当前值与开关状态。
- 04发现问题 → 修复 → 复测,重跑受影响的测试,再走一次操作路径。
- 05交回结果与证据,列出预期/实际、修复复验、未测项;只清理自己创建的资源。
按问题选证据,别用一种代替全部
规则管完成标准,工具负责执行
重要改动可交给未继承实现会话的审查 Agent,只给需求、实际 diff 和验收 Skill,独立决定检查范围。另一套假设能发现盲区,也不保证查出所有缺陷。
有执行证据,仍要核对是否回答了需求
门槛不保证需求满足:要求“空表单报错”,却只证明跳转后的新标题可见,仍可能答非所问。人要把成功条件、动作、所引断言放在一起看。
How to Make Your Coding Agent Check Its Work in the Browser

I got tired of coding agents telling me the frontend work was done when nobody had opened the frontend.
You've probably seen the routine. The agent changes some code, runs the formatter, the type checker, and the tests, everything comes back green, and you get some version of "Implemented and verified."
But verified how, exactly? A passing test suite is useful evidence, but it isn't the same as opening the app and using the thing that just changed.
I've had plenty of changes where the code made sense and the tests passed, and the UI still had the kind of problem a person spots in about three seconds. A control overlapping something else. A selected state that exists in the store but isn't highlighted on screen. Reset working for one path and not another. A filter that updates state but leaves the old data on the page. Save looking fine until you refresh. A map layer drawn in the wrong order. A modal that technically opens but doesn't fit on the screen.
The agent had tested its code, but nobody had tested the application. So I started making browser review part of the definition of done for anything user-facing, and a few weeks in, that habit turned into a tool. I'll get to that near the end.
In this article, you'll learn how to give a coding agent a real browser with Playwright MCP, and how to write the instructions that make it test the change it just made instead of confirming that a page loads.
You'll see how to have the agent start your app the way your project already starts it, how to split those instructions between AGENTS.md and a reusable skill, why a separate reviewer agent catches bugs the author misses, and how to make the agent clean up only what it started.
In the last part, I'll walk through katalepsis, the open source tool this turned into, including how it decides whether a check really passed.
Table of Contents
- Prerequisites
- The Loop Agents Are Missing
- Why a Browser on Its Own Falls Short
- How to Start the Real App
- Where the Instructions Live
- Why the Reviewer Should Work Independently
- Clean Up After Yourself and Only Yourself
- When a Skill Stopped Being Enough
- How to Try katalepsis
- How katalepsis Works Under the Hood
- What Happened When I Used It
- Where I Landed
Prerequisites
This isn't a beginner tutorial, so it helps to have:
- Some experience with a coding agent that supports MCP (Model Context Protocol), such as Claude Code, Codex, or Cursor, and an idea of how you give it instructions.
- A web app you can run locally with a dev script, written in JavaScript or TypeScript. The examples use a Node project with
pnpm, but nothing depends on a specific framework. - Basic Playwright knowledge: what a test, a locator, and an assertion look like. You don't need an existing test suite to follow the first half.
- Node.js 20 or newer for Playwright MCP, and Node.js 22.12 or newer plus Git if you want to install katalepsis.
- Comfort with the terminal and Git, since the agent will be starting processes and checking
git status.
The Loop Agents Are Missing
Coding agents got useful quickly in large part because they already have a decent feedback loop for code. They read, edit, and run the formatter and tests, read the failure, and try again. Nobody has to get it right in one heroic attempt.
For frontend work, the clear missing input is the frontend itself. The loop I want goes a bit further: edit the code, run the tests, start the app, use the changed feature, look at what happened, fix whatever's broken, and then use it again.
That last step matters more than you might think. Agents are very comfortable telling you a fix "should now work", and I'd rather they just open the page again and find out.
Playwright MCP is a good way to give them that. I already used Playwright for E2E tests, but the MCP server is useful for a different reason: it hands the agent a browser directly. It can open localhost, read the page, find controls, click and type, check what changed, and take screenshots.
That isn't the same as asking the agent to run pnpm playwright test. The test suite tells you whether the scenarios you already wrote down still pass. Browser review asks what this particular change could have affected and goes to look.
If an agent refactors some shared map-layer utilities, the tests might cover a few helpers. But from the diff, it can also work out that the code runs during initial render, selection, filtering, and reset, and try each of those. That's a lot closer to how I review my own frontend work.
Setup is small. You need Node 20 or newer and an MCP-capable client:
{
"mcpServers": {
"playwright": {
"command": "npx",
"args": ["@playwright/mcp@latest"]
}
}
}A quick sanity check is to ask the agent to open the Playwright TodoMVC demo, add a todo called "Browser QA," and confirm it shows up. If it manages that, it has a browser. What it does with one is the harder part.
Why a Browser on Its Own Falls Short
Giving an agent a browser only gives it the ability to look. It doesn't tell the agent what to look at, how far to go, or what counts as proof, and without that it falls back to the cheapest thing that technically involves a browser. An agent with Playwright can still do hilariously weak QA. I've had reports like this:
Opened /route.
Page loaded successfully.
Browser verification complete.That's a report about opening a page, not about testing a change. Nothing in it would have caught any of the bugs from the start of this article.
So the browser needed instructions to go with it. What follows are the rules I wrote for how an agent should review its own frontend work: what to test, what to check, and what evidence to bring back. They started as a few lines I kept repeating in prompts, and they later became the browser-review skill you'll see in the next sections.
The first rule I settled on is that the review has to come from the change. If it changed a filter, use the filter. If it changed reset logic, change some state first and then reset it. If it touched persistence, refresh the page. If it changed a modal, open and close the modal, and if it changed responsive layout, resize the viewport.
That all sounds obvious when a person says it, but it isn't obvious at all if your agent instructions just say "verify that the app works".
In practice, this means the agent reads the diff and turns it into a short test plan before it touches the browser. For a change to a filter bar, the plan I'd expect looks something like this:
Changed: src/features/projects/FilterBar.tsx, src/features/projects/useFilters.ts
Plan:
1. Open /projects and note how many rows are showing.
2. Filter by Status: Active. Every row should say Active.
3. Add Owner: me on top. The list should narrow again.
4. Clear filters. The original row count should come back.
5. Apply a filter and refresh. The filter lives in the URL, so it should survive.Then it carries out that plan through the browser tools Playwright MCP exposes. A trimmed version of the calls for the first few steps looks like this:
browser_navigate { "url": "http://localhost:5173/projects" }
browser_snapshot {} → table with 40 rows
browser_click { "element": "Status filter", "ref": "e14" }
browser_click { "element": "Active option", "ref": "e31" }
browser_snapshot {} → table with 12 rows, all "Active"
browser_click { "element": "Clear filters", "ref": "e15" }
browser_snapshot {} → table with 40 rowsThe ref values come from the accessibility snapshot, which is how the agent finds a control without guessing at CSS selectors.
This paid off on a PR that fixed a cursor getting stuck in its hover state on an interactive map. The PR's new unit tests passed against the old code too, so they didn't prove the fix did anything. What did prove it was the agent opening the app, moving on and off the interactive layers, and watching the cursor change and reset exactly when it should.
The second rule is to check the end result rather than the interaction. "I clicked Reset and nothing threw" doesn't tell you much. What you actually care about is that reset puts things back the way they were. Same with saving: the real claim is that you can change a setting, save, refresh, and it's still there.
Written as a Playwright test, that claim looks like this. The important line is the last one, which runs after the reload:
import { test, expect } from "@playwright/test";
test("weekly digest setting survives a reload", async ({ page }) => {
await page.goto("/settings/notifications");
await page.getByRole("switch", { name: "Weekly digest" }).click();
await page.getByRole("button", { name: "Save" }).click();
await page.reload();
await expect(page.getByRole("switch", { name: "Weekly digest" })).toBeChecked();
});The agent doesn't need to write this test to follow the rule. Through MCP it does the same thing by hand: click, save, browser_navigate to the same URL, and read the switch's state from a fresh snapshot.
I also ask for different evidence depending on the question. Whether the URL changed, a button is disabled, or a dialog opened is a question for the DOM and browser state, not a screenshot. Whether that dialog is clipped, two things overlap, or text wraps badly on a phone is a visual question, and that's where screenshots are useful. Sometimes you need both, since a modal can sit happily in the DOM while being unusable on screen.
For a layout change, that means resizing and capturing what a person would actually see:
browser_resize { "width": 375, "height": 812 }
browser_click { "element": "Export button", "ref": "e22" }
browser_take_screenshot { "filename": "export-dialog-375.png" }
browser_resize { "width": 1440, "height": 900 }
browser_take_screenshot { "filename": "export-dialog-1440.png" }While it's in there, the agent may as well look at the console and, when the feature depends on an API call, the network. I only care about errors the change introduced, though. A warning that's been on main for six months shouldn't derail every review, and a client-only transition doesn't need a network audit. If a save that "worked" actually got a 500, I want to hear about it.
browser_console_messages and browser_network_requests return what the page logged and requested, so a failed save shows up as a line like this even when the UI looked fine:
[PUT] http://localhost:5173/api/settings => [500] Internal Server ErrorPut together, the rules turn the weak report from the start of this section into something I can actually check:
Route: /settings/notifications
Did: turned on "Weekly digest", clicked Save, reloaded the page.
Checked: switch still on after reload (aria-checked="true");
PUT /api/settings returned 200; no new console errors.
Screenshots: settings-375.png, settings-1440.png (label wraps cleanly on mobile)
Tests: pnpm test settings (14 passed)
Found and fixed: Save stayed disabled after the first save. Fixed in
SettingsForm.tsx, then went through the flow again.How to Start the Real App
The agent can't test anything until the app is running, and this is where it's most tempting for it to take shortcuts.
I didn't want to write "run pnpm dev" into every repository, so the procedure tells the agent to work out how the project starts. It checks the dev script in package.json first, then whatever the README documents, and only falls back to an existing Docker setup if the app can't reasonably run otherwise.
The procedure here is the "Start the application" section of the browser-review skill, which you'll see in full in the next section. In a typical Vite or Next.js project, following it looks something like this:
# 1. How does this project start?
jq .scripts package.json
# {
# "dev": "vite",
# "build": "vite build",
# "test": "vitest run",
# "e2e": "playwright test"
# }
# 2. Start the dev server in the background, in a temp folder the agent owns,
# and record the PID so it can stop exactly this process later.
QA_DIR=$(mktemp -d)
pnpm dev --port 5173 > "$QA_DIR/dev.log" 2>&1 &
echo $! > "$QA_DIR/dev.pid"
# 3. Wait until the server answers before opening a browser.
until curl -sf http://localhost:5173 > /dev/null; do sleep 1; doneIf there's no dev script, the README is next, and if the README says the app needs Postgres and Redis from docker compose up, the agent uses that compose file and records the containers it started. It doesn't write a new one.
What I really don't want is the agent changing the app to make it easier to test. No temporary auth bypass, no fake server, no edits to .env, and no throwaway route that exists only because it couldn't work out the real setup. The point is to exercise the software I'm actually shipping.
This is the kind of change I mean, and agents really do reach for it when a login page is in the way:
- if (!session) redirect("/login");
+ if (!session && process.env.NODE_ENV !== "development") redirect("/login");If the agent can't get past authentication with the project's documented test account or seed data, the right move is to stop and report that browser review is blocked and why. A blocked review is honest. A review of an app with its auth switched off isn't a review of my app.
Where the Instructions Live
My first instinct was to keep adding to AGENTS.md. It already held the rules every agent in the repo should know: structure, conventions, testing philosophy, keep changes surgical, and no pointless comments.
I was about to add another hundred lines about starting the app, browser setup, what to click, screenshots, console errors and teardown...but at some point that file stops being instructions and turns into a small religious text.
It helps to know how each of these files is actually used. AGENTS.md is a plain Markdown file that coding agents such as Codex read automatically at the start of every session in that repository, and Claude Code does the same with CLAUDE.md. Everything in it lands in the agent's context for every task, whether the task is a CSS fix or a database migration. So it costs context on every run, and it applies everywhere.
A skill works differently. It's a folder with a SKILL.md file that starts with a short name and description. At the start of a session the agent only sees that name and description, a line or two per skill. When a task matches the description, or another instruction points at the skill, the agent reads the rest of the file and follows it. So you can have a long, detailed procedure that costs almost nothing until it's needed.
What I landed on is treating AGENTS.md as the constitution and skills as the playbooks. A constitution is short, applies to everything, and says what must be true without explaining how to do it. A playbook is a step-by-step procedure for one kind of situation, and you only pull it out when you're in that situation.
In this setup, AGENTS.md says that user-visible work isn't done until it's been checked in a browser, and the browser-review skill says how to do the checking:
/
├── AGENTS.md
└── .agents/
└── skills/
├── browser-review/
│ └── SKILL.md
├── pr-review/
│ └── SKILL.md
└── grill-me/
└── SKILL.mdThe rule in AGENTS.md stays short and always visible:
## Browser Verification
User-visible work is not complete until the affected behavior has been
verified in the running application.
For user-visible changes, follow `.agents/skills/browser-review/SKILL.md`.
A successful build, type check, unit test suite, or E2E test suite does
not replace browser verification.
Do not commit browser-verification artifacts. Stop anything you started
for verification and clean up before hand-off.The skill holds the procedure and only gets loaded when it's needed. I use Codex and Claude Code, so there's one global AGENTS.md that Claude's CLAUDE.md imports, and one shared skills folder both of them read. Whichever agent I'm in that day, it follows the same playbook.
The import is a single line, because Claude Code lets CLAUDE.md pull in another file with @:
@~/.codex/AGENTS.mdHere's a shortened version of the one I first wrote:
---
name: browser-review
description: Verify user-visible changes in the running application with Playwright MCP.
---
# Browser Review
## Before verification
Run `git status --short` and remember what was already there. Don't touch
pre-existing work.
Read the task and the diff. Work out the affected routes, the changed flow,
the loading, empty, error, selected and disabled states that matter,
whether anything needs to survive a refresh, and what nearby behavior
could regress.
## Start the application
Use the repository's dev script, then its documented local setup, then its
existing Docker setup. Don't invent a new startup config and don't change
app behavior to make QA easier. Record every process and container you start.
## Exercise the change
Don't stop once the page loads. Go through the whole changed flow and check
the final result. Use browser or DOM state for functional claims and
screenshots for visual ones. Report new relevant console errors and failed
requests, not old warnings.
If something is broken, fix the smallest cause, rerun the affected tests
and go through the flow again. Don't assume the fix works.
## Cleanup
Teardown is required even when verification fails. Close browsers, stop
servers and containers, and delete screenshots, traces and other artifacts,
but only the ones you created. Run `git status --short` again before
hand-off. Never run `git add`, `git commit` or `git push`.
## Report
Say which routes you tested, what you did, which states you checked, what
tests you ran and what you found and fixed. If you couldn't finish, say why.None of that is clever. The value is that it's the default expectation, so I'm not relying on the agent to decide by itself that it should open a browser.
Why the Reviewer Should Work Independently
The clear weakness is that the agent deciding whether the work is done is the same one that wrote it. That's still worth something, since I test my own code too, but it starts with a theory of how its implementation works.
By theory I mean the author's mental model of its own change: which code it touched, what it believes that code affects, and, just as important, what it believes it didn't affect. Its test plan comes straight from that model. So it tests the paths it meant to change, they work, and it never looks at the path it didn't realize was involved. That's the same blind spot people have when they test their own code, and it's exactly where the bugs that survive review tend to live.
So when a second agent reviews a branch, I tell it to work out the test scope for itself. My global rules now require a fresh reviewer with no conversation history: it gets the original request, the diff, and the review skill, and nothing about why the author thinks it works. It decides what the code could affect and tests that, without leaning on the author's screenshots, QA notes, or green test run.
The prompt for that reviewer is short on purpose:
You are reviewing a branch you did not write.
Original request:
Make the E2E app URL configurable through E2E_BASE_URL so the
tests can run against any port.
Run `git diff main...HEAD` to see the change. Follow the browser-review
skill. Work out for yourself what this change could affect, test that,
and report what you checked with evidence.The best catch so far came from a PR that made an end-to-end test setup's app URL configurable. The reviewer noticed the seeded test data still hardcoded image URLs to the old port, and running the app on a different port confirmed it. Three image requests went to the wrong port and failed, and the page quietly rendered fallback cards where the images should have been. Nothing threw.
Simplified, the global setup looked like this after the change:
// e2e/global-setup.ts
const baseURL = process.env.E2E_BASE_URL ?? "http://localhost:8080";
await seedDatabase({
media: [
{ id: "m1", url: "http://localhost:8080/test-media/cover-1.jpg" },
{ id: "m2", url: "http://localhost:8080/test-media/cover-2.jpg" },
{ id: "m3", url: "http://localhost:8080/test-media/cover-3.jpg" },
],
});The author's theory was "the app URL now comes from E2E_BASE_URL", and every test it ran used the default port, where the hardcoded URLs happened to be right. The reviewer had no such theory. It read the whole setup file the diff touched, ran the app on port 8081, and came back with this:
Found: seeded media URLs in e2e/global-setup.ts still point at :8080.
Ran the app with E2E_BASE_URL=http://localhost:8081.
Network: 3 requests to http://localhost:8080/test-media/... failed
(net::ERR_CONNECTION_REFUSED).
Screen: the gallery rendered 3 fallback cards instead of images
(gallery-8081.png).
Suggested fix: build the seeded URLs from E2E_BASE_URL, or make them
relative.The fix was one line per URL:
{ id: "m1", url: new URL("/test-media/cover-1.jpg", baseURL).href },A test that checks the cards exist passes either way, because a fallback card is still a card. You only see it by looking at the page and the network tab, which is the whole argument of this article in one bug.
Clean Up After Yourself and Only Yourself
Once agents can start servers and browsers, cleanup is part of the design. Otherwise every session leaves a layer behind: a stray node process, a Chromium window, screenshots, a Playwright trace, a temporary auth file, or a Docker container.
"Clean everything" is the wrong fix, though. pkill node isn't cleanup, it's an agent going on a small rampage, and it'll happily kill the dev server you started in another terminal. git clean -fd will delete an untracked file that has nothing to do with the agent.
The rule is to clean up what you created and leave everything else alone, and the git status before and after is there so browser QA doesn't quietly end up in my next commit.
This is why the startup step records a PID in a temp folder. Cleanup can then be precise:
# Not cleanup
pkill node
git clean -fd
# Cleanup
kill "$(cat "$QA_DIR/dev.pid")"
rm -rf "$QA_DIR"And this is roughly what I expect to see at the end of a review:
Cleanup
- Stopped the dev server I started (pid 48213, pnpm dev --port 5173).
- Closed the Playwright MCP browser.
- Deleted export-dialog-375.png and export-dialog-1440.png.
- Left running: pid 39102 (pnpm dev on :3000). It was already running
before I started, so it isn't mine.
git status --short before review:
M src/features/export/ExportDialog.tsx
?? notes/todo.md
git status --short after cleanup:
M src/features/export/ExportDialog.tsx
?? notes/todo.mdThe two git status blocks match, so nothing from the review leaked into the working tree, and the untracked notes file that was already there is still there.
I didn't make this rule up in the abstract, either. While building the tool I'm about to describe, the agent testing it ran a cleanup command that closed every Playwright browser on my machine, not just its own. To be fair, it owned up to it in its summary.
When a Skill Stopped Being Enough
The skill worked, in the sense that agents started actually opening the app. But what I got back was still prose. The agent would tell me it opened the page, clicked through the flow and everything looked right, and I had to take its word for it.
It's the same problem I started with, just one level up. I didn't want a nicer sentence about the browser. I wanted the evidence: what was checked, what passed, what failed, and the screenshots and traces to back it up, in a form I could drop into a PR for whoever reviews it.
So I wrote a build plan and spent a Saturday building it, pairing with Claude Code through milestones and independent review rounds. It's called katalepsis, the Stoic word for grasping something so clearly you can't be wrong about it, which felt like a fair bar for a pull request.
You point it at a preview URL and it writes a report: HTML, Markdown you can paste into the PR, and a JSON manifest.
It does two things. katalepsis run runs your existing Playwright tests against the preview, and Playwright's result is final. katalepsis sorts, redacts and bundles it, and never reinterprets an assertion.
katalepsis verify takes plain-English checks for behavior nobody has written a test for, and a model drives a real browser with your own Anthropic, OpenAI, or OpenRouter key. There's also an MCP server, so the agent that just made a change can run either one itself and read the report back.
How to Try katalepsis
katalepsis isn't on npm yet, so the installer clones the repository and builds it. It needs Node 22.12 or newer, Git, and pnpm 10 (or corepack, which ships with Node):
curl -fsSL https://www.katalepsis.glamboyosa.xyz/install.sh | shThat puts two launchers, katalepsis and katalepsis-mcp, in ~/.local/bin.
To run your existing tests against a preview, your Playwright config has to use the URL katalepsis passes in. It's also worth turning on failure evidence so the report has something to show:
// playwright.config.ts
use: {
baseURL: process.env.KATALEPSIS_BASE_URL ?? "http://localhost:3000",
screenshot: "only-on-failure",
video: "retain-on-failure",
trace: "retain-on-failure",
},The repository includes a small demo app, a signup form with a deliberate bug: submitting the empty form does nothing instead of showing an error. It has three Playwright tests. Here's a real run against it:
$ katalepsis run --cwd apps/demo-form --url http://127.0.0.1:4173
katalepsis: FAILED
2 passed, 1 failed, 0 blocked, 0 not tested
Preview commit: not reported, so unconfirmed
Report: artifacts/katalepsis/2026-10-19T08-21-25-288Z-3dba5a/report.html
Markdown: artifacts/katalepsis/2026-10-19T08-21-25-288Z-3dba5a/report.mdThe Markdown report is meant to be pasted into a PR. The top of it looks like this:
# katalepsis report: Failed
**2 passed, 1 failed, 0 blocked** out of 3 checks run.
- Preview: http://127.0.0.1:4173/
- Preview commit: not reported, so unconfirmed
- Runner: katalepsis 0.1.0, Playwright 1.63.0, Node 22.22.0
## Checks
| Status | Check | Project | Location | Duration |
| --- | --- | --- | --- | --- |
| Failed | signup form › empty submission shows a required-field error | chromium | signup.spec.ts:8 | 3.2 s |
| Passed | signup form › valid submission shows a confirmation | chromium | signup.spec.ts:13 | 418 ms |
| Passed | signup form › malformed email shows an email error and does not submit | chromium | signup.spec.ts:20 | 449 ms |
### Failed: signup form › empty submission shows a required-field error
- Failure type: assertion
- Evidence: screenshot, video, log, traceBelow that, it includes Playwright's own failure message (getByRole('alert') never appeared) and a list of limitations, such as the preview not reporting which commit it was built from.
For behavior without a test, you write checks in plain English. Each one has an intent, which is what to do, and a pass condition, which is what must be true at the end:
{
"checks": [
{
"id": "empty-form",
"name": "Empty form validation",
"intent": "Submit the signup form without filling it",
"passCondition": "A required-field error is visible"
},
{
"id": "invalid-email",
"name": "Invalid email",
"intent": "Fill the name, enter an invalid email address and submit",
"passCondition": "An email validation error is visible and the success confirmation is not shown"
}
]
}Then you give katalepsis a key and run them. The key is stored in your OS keychain or read from an environment variable, and it's never passed on the command line:
$ katalepsis auth set openrouter
$ katalepsis verify --cwd apps/demo-form --url http://127.0.0.1:4173 --checks checks.json \
--provider openrouter --model anthropic/claude-haiku-5.5
katalepsis: FAILED (openrouter/anthropic/claude-haiku-5.5)
2 passed, 1 failed, 0 blocked
Preview commit: not reported, so unconfirmed
Report: artifacts/katalepsis/2026-10-10T12-45-05-343Z-b51a0a/report.htmlThe demo's full checks.json has three checks, and the agent checks find the same empty-form bug the Playwright test did, without anyone having written that test.
Finally, so the coding agent can do all of this itself, you register the MCP server. With Codex, that's:
codex mcp add katalepsis -- katalepsis-mcp --root /path/to/your-appThe server exposes three tools, verification_run_tests, verification_check_browser, and verification_get_report, and only works inside the folder you give it. In practice the agent's instruction becomes something like "after changing the signup form, run verification_check_browser with a check for the change and paste the Markdown report into your summary."
How katalepsis Works Under the Hood
A few of the decisions came straight out of the problems in this article. Here's how they work in the code.
katalepsis is a TypeScript monorepo with five packages:
core(the result schema, the Playwright runner, and the report renderers)agent(the browser tools and evidence rules forverify)credentials(keys and preferences)mcp(the MCP server)cli
It uses Effect for the parts where errors, cancellation, and dependencies matter, such as running Playwright and probing the preview, and Effect Schema for the report format. The model loop in verify uses the Vercel AI SDK, so the same code works with Anthropic, OpenAI, and OpenRouter.
The Report is a Schema, Not a Log
Every run, whether it came from Playwright or from a model, decodes into the same VerificationRun structure before anything is written. A trimmed version of a single check looks like this:
export const CheckStatus = Schema.Literals(["passed", "failed", "blocked"]);
export const VerificationCheck = Schema.Struct({
id: Schema.String,
source: Schema.Literals(["playwright-test", "agent"]),
title: Schema.String,
status: CheckStatus,
flaky: Schema.Boolean,
failure: Schema.NullOr(Schema.Struct({ code: FailureCode, message: Schema.String })),
attempts: Schema.Array(Attempt),
evidence: Schema.Array(BundlePath),
});Tests that never ran go into a separate notTested list with a reason, which is how you end up with four outcomes: passed, failed, blocked, or not tested. Every evidence file is stored with its SHA-256 hash, and BundlePath rejects absolute paths and .., so a report can't point outside its own folder.
Blocked isn't Failed
If the preview is down, you get one blocked run, not three failed tests that send you hunting for bugs that don't exist. Before starting Playwright, katalepsis requests the preview URL once. This is the core of that probe:
const UNAVAILABLE_STATUSES = new Set([502, 503, 504]);
const response = await fetch(url, {
redirect: "manual",
signal: AbortSignal.any([signal, AbortSignal.timeout(30_000)]),
});
if (UNAVAILABLE_STATUSES.has(response.status)) {
return { reachable: false, reason: `the preview answered with HTTP ${response.status}` };
}A network error or a gateway status blocks every selected test, and Playwright never starts. Pointing the demo at a port with nothing on it gives you this:
$ katalepsis run --cwd apps/demo-form --url http://127.0.0.1:4999
katalepsis: BLOCKED
0 passed, 0 failed, 3 blocked, 0 not tested
preview-unavailable: http://127.0.0.1:4999/ did not respond (connect ECONNREFUSED 127.0.0.1:4999). Playwright was not run.Playwright's Verdict is Final
run starts the project's own Playwright with --reporter=json and reads the result file, rather than reimplementing anything. Playwright already marks a test that passed on retry as flaky, so katalepsis keeps that flag and lists every attempt.
What it does add is classification. A failure whose message names the preview's own origin as unreachable becomes blocked, but a dead third-party API stays failed, because that's a real problem with your app. A browser that couldn't launch is blocked too, which is exactly what I got the first time I ran the demo for this article on a machine with the wrong browser build installed.
The Model Doesn't Get to Call Something a Pass
The model in verify can click and type and read the page, but it doesn't get to call something a pass. A check only passes if the model points to a browser assertion that katalepsis ran, that passed, and that shows something that wasn't on the page before the model acted. Otherwise it comes back blocked.
Here's how that works. The model only gets a small set of tools: navigate, snapshot, click, fill, press, assert, and finish. assert doesn't return the model's opinion. katalepsis runs the check in the browser itself, takes a screenshot, and records the result with an ID like a3. When the model calls finish, it has to cite those IDs:
finish: tool({
description: "Report the verdict once, citing assertion IDs such as a1.",
inputSchema: schema(
Schema.Struct({
verdict: Schema.Literals(["passed", "failed", "blocked"]),
summary: Schema.String,
assertionIds: Schema.Array(Schema.String),
}),
),
execute: (verdict) => {
onFinish(verdict);
return "Recorded.";
},
}),Then a plain function, decideOutcome, compares the claim with the evidence. A trimmed version of the pass rule:
if (!cited.every((assertion) => assertion.passed)) {
return "The agent reported a pass, but a cited assertion failed.";
}
const shown = cited.filter((assertion) => assertion.expectation === "visible");
if (shown.length === 0) {
return "The agent reported a pass citing only hidden or URL assertions.";
}
if (!observeOnly && shown.every((assertion) => assertion.trueAtStart === true)) {
return "The agent reported a pass, but everything it cited was already on the page before it acted.";
}Any of those messages turns the check into blocked with the code no-evidence. Citing an assertion ID that never ran is blocked too. The function also blocks a pass if an uncited assertion failed after the last cited one, because the latest evidence disagrees with the verdict.
That trueAtStart condition exists because one of the models I tested tried to prove a form worked by citing the label of the email field, which had been there all along. T
o catch it, each check opens two copies of the start page in separate browser contexts. The model works in one, and the other stays untouched. When the model asserts that something is visible, katalepsis looks for the same element on the untouched copy, and if it's there too, the assertion gets trueAtStart: true and can't carry a pass on its own.
One Action at a Time
The first live runs also caught parallel tool calls stepping on each other, and a name ending up typed into the email field. Models like to send several tool calls at once, and two fill calls racing each other type into whichever field has focus. The fix is a promise queue that every browser action goes through:
private exclusive<A>(action: () => Promise<A>): Promise<A> {
const next = this.queue.then(action, action);
this.queue = next.catch(() => undefined);
return next;
}Each action waits for the previous one to settle, whether it succeeded or failed, and a failure doesn't poison the queue for the next action.
Budgets and Boundaries
Each agent check runs in its own browser context with tracing on, so the report always has a trace to replay. Navigation is limited to the preview's origin plus anything you allow with --allow-origin. The model has a step limit (--max-steps, 15 by default), a token limit (--max-tokens, 200,000), and a time limit (--check-timeout, 120 seconds), and running out of any of them is reported as blocked with the reason, not as a failure. Query strings are redacted from URLs in the report.
How it Was Built
Before writing code, I wrote a build plan split into milestones. M0 was a review of how two of my earlier projects handled credentials and providers, plus the first architecture decision record and the monorepo tooling. M1 was the Playwright runner and the report, M2 was agent checks with your own key, and M3 was the MCP server. Each milestone ended with a fresh agent with no history reviewing the work against the plan, the same independent-reviewer rule from earlier in this article.
Those reviews shaped the tool more than the plan did. The first one came back with 15 findings, including the rule that only an unreachable preview counts as blocked while other hosts stay failed. The M2 review found that a model could get a pass by citing an assertion that proved nothing, which is where the stricter pass rule and the untouched copy of the page came from. The M3 review found that a client quitting mid-run left Playwright and its browsers running, and that a crafted symlink could let the report tool read files outside the project. All of those are fixed and covered by tests now.
What Happened When I Used It
Before telling anyone about it, I ran it against two of my own projects. Conclave's suite came back with 95 passed. A larger app came back with 137 passed and one failure, an assertion on a zoom level that was slightly off.
That was a test I already had and hadn't noticed was failing, so katalepsis didn't find the bug so much as make it impossible to miss. Both projects also hardcoded their baseURL, which meant their tests never touched the URL I passed in. katalepsis now warns about that, because a green run against the wrong server is worse than no run.
After I posted it on LinkedIn, someone in the comments pointed out that the commit you have checked out isn't necessarily the commit the preview was built from. They were right, so the report now records both and marks them confirmed, mismatched, or unknown, and a mismatch blocks a run that would otherwise pass, since a green run against a stale preview tells you nothing about your diff.
The preview reports its commit with an x-commit-sha response header or a <meta name="commit-sha"> tag, which the same probe that checks availability reads. If your host can't do either, you can pass --preview-commit yourself.
I'm dogfooding it in the same instructions this article describes. The browser-review skill now tells my agents to run katalepsis first, and to fall back to plain browser automation for what it can't check yet: field values, persistence, console and network, and layout across viewports.
The first Codex session to pick that up went looking for a "katalepsis skill" because of how I'd worded the line in AGENTS.md, didn't find one, and fell back to its old browser review. Wrong for a silly reason, but the fallback did exactly what it was supposed to, and the fix was saying plainly that katalepsis is a CLI and browser-review is the skill.
A few days later I used it on a real work branch, a change that made GeoJSON the main download button and moved CSV and KML into a dropdown. The agent stopped before running anything, because verify sends page content to the model provider and my rules say it needs my OK before doing that for an app. That's the behavior I want
With seeded synthetic data and an explicit OK, the check passed: one visible primary button, and the other formats showing up once the dropdown opened, with the screenshots and trace in the report. The skill still keeps pages with real data on the fallback path.
Where I Landed
Not every task needs any of this. I don't need Playwright to confirm a Markdown typo got fixed, and a backend refactor that can't change what renders doesn't need Chromium. I do want it for components, forms, navigation, dialogs, client state, maps, filtering, responsive layout, persistence, and any user-visible bug fix.
My rule of thumb is that if you're making a claim about how the UI behaves at runtime, the runtime UI should be part of the evidence.
None of this replaces good tests, code review, or me looking at changes that matter. It just adds the feedback loop that was missing. The agent already has the repository and the shell, and it knows what it changed. With a browser it can start answering whether the thing it built actually works, and with a report it can show me instead of telling me. If it can write the button, it can click it and show me what happened before it says it's done.
katalepsis is MIT licensed and on GitHub. If an agent check ever gives you a result you don't believe, I'd like to hear about it.
Learn to code for free. freeCodeCamp's open source curriculum has helped more than 40,000 people get jobs as developers. Get started
