跳到主要内容

数据截至 (上游 commit 7e0457a7cbf8)

文档即产物:README 三段生成器、版本滚动与 CI 钉死

这章讲什么: 这个仓库里工程含量最高的一支代码,不是浏览器相关的,而是 update-readme.jsroll.js 这两个脚本。它们解的问题是:当核心实现在另一个仓库、而且几乎每天都在变时,怎么保证本仓库的文档永远是真的。 这套做法可以原样搬到任何「文档描述了一堆运行时对象」的项目上。


1. 问题:文档为什么必然会烂

先把痛点摆清楚。这个仓库的 README 有 1604 行,其中要描述:

  • 69 个工具的名字、标题、描述、每个参数的类型和说明、是否只读;
  • 46 个 CLI 选项及其对应环境变量;
  • 一份完整的 Config TypeScript 类型

而这三样东西的定义全在另一个仓库(src/README.md:1-3),并且随 Playwright 每日 alpha 滚动。人工同步的结局是确定的:文档腐烂。

解法一句话: 别写文档,生成文档;再用 CI 保证生成结果和仓库里的一致。


2. 三段生成,一个替换器

2.1 总流程

npm run lint ──► node update-readme.js


读入 README.md 全文

┌────────────────────┼────────────────────┐
▼ ▼ ▼
updateTools() updateOptions() updateConfig()
require 编译产物 execSync 跑 --help 正则抠 config.d.ts
拿真实工具对象 解析输出成选项表 的 Config 类型体
│ │ │
└────────────────────┼────────────────────┘

三次 updateSection 换段


写回 README.md

入口在 update-readme.js:238-246updateReadme,三段是依次串联的——每一段的输入是上一段的输出,最后一次性写盘。

2.2 替换器:靠一对注释标记定位

三段共用同一个 updateSection(update-readme.js:105-118)。它的做法很朴素:

const startMarkerIndex = content.indexOf(startMarker);
const endMarkerIndex = content.indexOf(endMarker);
if (startMarkerIndex === -1 || endMarkerIndex === -1)
throw new Error('Markers for generated section not found in README');

找到起止标记,保留标记本身,把中间整段换掉。README 里对应的三对标记是:

起标记位置
选项表<!--- Options generated by update-readme.js -->README.md:405-456
配置 schema<!--- Config generated by update-readme.js -->README.md:550-773
工具目录<!--- Tools generated by update-readme.js -->README.md:862-1604

注意标记名是拼出来的: `<!--- Tools generated by ${path.basename(__filename)} -->`(update-readme.js:138)。脚本改名,标记名跟着变——虽然会一次性打断,但保证了「文档里写的生成者」永远是真的那个文件。

为什么用标记而不是整文件生成: README 里手写的部分(定位说明、各家客户端安装指引、Docker 用法、安全声明)占了一大半,这些内容脚本生成不了。标记法让「手写区」和「生成区」在同一个文件里共存,人改人的、机器改机器的。


3. 三段各自怎么取到真相

3.1 工具目录:直接 require 编译产物

这是最关键的一处设计决策。update-readme.js:23 一行:

const { tools } = require('playwright-core/lib/coreBundle');

它不解析源码,它把真实注册的工具对象 require 进来。 于是 tools.browserTools 就是运行时那份工具表,每个元素有 capabilityskillOnlyschema 等字段。

渲染在 formatToolForReadme(update-readme.js:69-96),值得注意的一行是参数 schema 的取法(update-readme.js:77):

const inputSchema = tool.inputSchema ? tool.inputSchema.toJSONSchema() : {};

调用工具自己的 toJSONSchema()——文档里的参数类型,和 MCP client 拿到的参数类型,是同一个对象的两次渲染。不可能不一致。

分组逻辑在 update-readme.js:45-54:按 capabilities 映射表的 key 顺序遍历,过滤出该 capability 且非 skillOnly 的工具,同标题的多个 capability 会被 concat 合并(这就是 corecore-navigationcore-input 三个 key 合成一个「Core automation」组的原因),最后按工具名字典序排。

排序这一步别小看: 它保证了同一份输入永远产出同一份输出,git diff 才有意义。

3.2 一道会让构建停下来的守卫

update-readme.js:40-43 是全脚本最值得学的五行:

const knownCapabilities = new Set(Object.keys(capabilities));
const unknownCapabilities = [...new Set(tools.browserTools.map(tool => tool.capability))]
.filter(cap => !knownCapabilities.has(cap));
if (unknownCapabilities.length)
throw new Error(`Unknown tool capabilities: ${unknownCapabilities.join(', ')}. …`);

它在干嘛: 如果上游新增了一个本地映射表里没有的 capability,脚本直接抛异常,而不是悄悄跳过。

这是「生成式文档」最容易出的静默失败——上游加了新东西,生成器不认识,于是这部分内容在文档里凭空消失,没人发现。这五行把静默失败变成了显式失败,而且错误信息里直接告诉你去哪个文件加哪一项。

3.3 选项表:跑一次 --help,再解析文本

updateOptions(update-readme.js:159-211)的做法看起来很土,但很聪明:

execSync('node cli.js --help > help.txt');
const output = fs.readFileSync('help.txt');
fs.unlinkSync('help.txt');

为什么不 require 选项定义? 因为选项定义在上游的 decorateMCPCommand 里,是通过副作用挂到 commander program 上的,没有一个干净的对象可以取。而 --help 的输出是这套定义唯一稳定的对外投影

解析靠三步定位(update-readme.js:164-185):

  1. 从含 --version 的那行之后开始切;
  2. 到含 --help 的那行为止;
  3. 中间以 -- 开头的行是新选项,不以它开头的行是上一条选项的续行,+= ' ' + value 接回去。

第 3 步的续行处理是必需的——commander 会把长描述自动折行,不接回去表格就散了。

还有一个隐藏功能:设了 PRINT_ENV 环境变量时,额外往 stdout 打印一张纯环境变量表(update-readme.js:196-206),但不写进 README。这是给维护者临时查阅用的旁路输出。

3.4 配置 schema:正则从 .d.ts 抠类型体

updateConfig(update-readme.js:217-236)最简单:

const configTypeMatch = configContent.match(/export type Config = (\{[\s\S]*?\n\});/);
if (!configTypeMatch)
throw new Error('Config type not found in config.d.ts');
const configType = configTypeMatch[1]; // 只要捕获组,即那个对象字面量

它把 config.d.tsConfig 类型的对象体(连同全部注释)原样搬进 README 的 TypeScript 代码块。所以 README 里那 200 多行配置文档,和类型定义是同一份文本——不存在「注释更新了但文档没更」。

非贪婪匹配 [\s\S]*? 配合 \n\}); 这个锚点,依赖的是 .d.ts 里类型结束的那行恰好是行首的 };。这是一个对格式有隐含要求的正则,但因为源文件本身也是生成/复制来的(见 §4),格式稳定。

找不到就抛异常——和 §3.2 同一个思路。


4. roll.js:把上游版本拉过来

4.1 三步

roll.js:37-42doRoll 就三行:

function doRoll(version) {
updatePlaywrightVersion(version); // ① 改 package.json 里三个包的版本 + npm install
copyConfig(); // ② 从相邻的 playwright 仓复制 config.d.ts
execSync('npm run lint',); // ③ 重新生成 README
}

版本参数可以不给——不给就自动去查 playwright@next 的版本(roll.js:44-48):

version = execSync('npm info playwright@next version', { encoding: 'utf-8' }).trim();

updatePlaywrightVersion(roll.js:17-35)遍历 dependenciesdevDependencies 两个 section、@playwright/test / playwright / playwright-core 三个包,只改已存在的项(if (json[section]?.[pkg])),写回后立刻 npm install

4.2 一个隐含的目录约定

copyConfig(roll.js:5-15)的源路径是:

const src = path.join(__dirname, '..', 'playwright', 'packages', 'playwright-core', 'src', 'tools', 'mcp', 'config.d.ts');

它假设 Playwright 主仓被 clone 在本仓库的同级目录、且叫 playwright 这个约定没有写在任何检查里——路径不对就是一个 ENOENT.claude/skills/release.md:8 里提到的路径 ~/playwright/packages/playwright-core/src/tools/ 印证了维护者的实际布局。

4.3 一处观察到的替换失效

copyConfig 复制时还想改一行 import(roll.js:9-12):

content = content.replace(
"import type * as playwright from 'playwright-core';",
"import type * as playwright from 'playwright';"
);

意图很清楚: 上游文件里 playwright-core 是可解析的,但在发行包里应该指向 playwright

但当前仓库里的实际内容是(config.d.ts:17):

import type * as playwright from '../../..';

这既不是替换前的字符串,也不是替换后的——它是一个只在 Playwright 主仓目录结构下成立的相对路径。也就是说,上游把这行改成相对路径之后,roll.jsreplace 就再也匹配不上了,而 replace 匹配不上时不报错、静默返回原串

对使用方的影响 (inferred):index.d.ts:18./config 引入 Config,而 config.d.ts 顶部这行相对导入在发行包里会指到包目录之外,Config 里所有 playwright.LaunchOptions 之类的引用因此拿不到正确类型。

这一处是本仓库里最值得记的反面教材: 静默的 String.replace 是一种没有失败信号的耦合。同一个脚本里,update-readme.js 的两处 throw(§3.2、§3.4)恰好演示了正确做法——关键假设必须有断言。如果这里写成「替换后校验结果确实变了,否则 throw」,上游改动的当天就会暴露。


5. CI 怎么把这一切钉死

5.1 lint job = 生成 + 比对

.github/workflows/ci.yml:10-22 的 lint job 只有四步,最后两步是核心:

- run: npm ci
- run: npm run lint # 实际是 node update-readme.js
- name: Ensure no changes
run: git diff --exit-code

package.json:18"lint": "node update-readme.js" ——这个仓库的「lint」不检查代码风格,它检查文档是否是最新生成结果

git diff --exit-code 在有差异时返回非零,构建红。于是:

  • 你手改了生成区 → 红;
  • 依赖里的工具定义变了但没重跑生成器 → 红;
  • 生成器逻辑改了但没重跑 → 红。

5.2 发布前也跑一遍

npm run lint 不只在 CI 的 lint job 里跑,发布路径上也跑:

场景依据
canary 每日发布前.github/workflows/publish.yml:40
release 正式发布前.github/workflows/publish.yml:60
本地 npm run npm-publishpackage.json:24(npm run lint && npm run test && npm publish)

所以不可能发出一个文档和实现不符的版本——除非生成器本身出错(如 §4.3 那种静默失败)。

5.3 完整同步链路

上游 playwright 加了新工具 / 改了参数


node roll.js ──► 钉新版本 + copy config.d.ts + npm run lint


README 三段被重新生成,git 有 diff


提交 "chore: roll Playwright to <version>" → PR


CI:lint(生成+diff)、三平台 test、docker test


合入 main → 发版时再跑一次 lint + ctest

CLAUDE.md:31-36 把这套流程写成了给维护者(和 agent)的操作手册:跑 node roll.js、以脚本打印出的版本后缀命名分支 roll-pw-<suffix>npm test 全绿才继续、提交信息固定为 chore: roll Playwright to <version>


6. 发布说明也被流程化了

.claude/skills/release.md 是一份把「写 release notes」这件事变成可执行步骤的文档。它里面有一条技术上很硬的经验值得单独摘出来(.claude/skills/release.md:24-39):

别用日期窗口切上游 commit 范围,用每个已发布 alpha 包的 gitHead

理由写得很具体:日期窗口会重复计入边界日的 commit——一个 commit 的日期落在上次发布的构建日,但实际并不在那个构建里。正确做法是用 npm view playwright-core@<alpha> gitHead 把两个 alpha 版本各自解析成精确的上游 commit,取 <baseline>..<new> 这个区间。

还有一条配套的校验(.claude/skills/release.md:56):对边界 commit 用 git merge-base --is-ancestor <sha> <gitHead> 验证归属,已经在上次 release notes 里announce 过的不要重复。

为什么这条值得记: 任何「跨仓库聚合变更」的场景(monorepo 拆分、依赖上游、多仓发布)都会遇到同一个问题。用构建产物自带的 commit 指针而不是时间戳来切范围,是通用解法。


7. 这一章的可借鉴点

  1. 从运行时对象生成文档,而不是解析源码。 require 编译产物 + 调 toJSONSchema(),文档和实际契约同源。
  2. 拿不到对象时,解析该组件唯一稳定的对外投影。 选项定义取不到,就跑 --help 解析——土,但正确。
  3. 关键假设必须有断言。 未知 capability 抛异常、正则匹配不到抛异常,是对的;静默 replace 是错的(§4.3 亲自演示了后果)。
  4. 生成 + git diff --exit-code = 零成本的一致性守卫。 不需要额外的对比工具或测试。
  5. 标记法让手写区与生成区共存一文件。 比「整个文件生成」实用得多。
  6. 生成结果必须确定性排序。 否则 git diff 每次都有噪声,守卫失效。
  7. 跨仓库切变更范围,用 gitHead 不用日期。

8. 代码地图

主题文件路径符号名 / 锚点
生成总入口update-readme.jsupdateReadme
通用段落替换update-readme.jsupdateSection
capability 映射表update-readme.jscapabilities
未知 capability 守卫update-readme.jsknownCapabilitiesunknownCapabilities
分组与排序update-readme.jstoolsByCapabilitycapabilityTitle
单个工具渲染update-readme.jsformatToolForReadmetoJSONSchema()
工具目录段update-readme.jsupdateTools
选项表段(跑 --help)update-readme.jsupdateOptions
环境变量命名update-readme.jsoptionEnvName
配置 schema 段(正则)update-readme.jsupdateConfig
版本滚动主流程roll.jsdoRoll
改依赖版本roll.jsupdatePlaywrightVersion
复制并改写 config.d.tsroll.jscopyConfig
lint 脚本定义package.jsonscripts.lintscripts.npm-publish
文档一致性守卫.github/workflows/ci.ymlEnsure no changes / git diff --exit-code
滚动操作手册CLAUDE.md## Rolling Playwright
release notes 流程.claude/skills/release.md## 3. Find the exact commit window