跳到主要内容

文档即真相:update-readme.js 工具目录生成 + roll.js 版本滚动

30 秒导读: Playwright MCP 的 README 里那三大块——工具目录、命令行选项表、配置类型定义——没有一个字是人手写的。它们由 update-readme.js 从真实编译产物、真实 --help 输出、真实 config.d.ts 里抽出来,填进 README 的 <!--- generated --> 标记之间。roll.js 则负责升级底层 Playwright 版本并触发一次重新生成。本章讲清这套"文档即真相"的工程,以及一个反直觉的收尾:这仓库的 lint 其实是"重生成 README",build 只是 echo OK

本章属于 Playwright MCP 子库。其它章见: index(这是什么/全景/阅读地图)、 01 入口与包壳02 配置面与能力开关04 测试机制


1. 这是什么(零基础也能懂)

一句话定义: 一套让 README 自己长出来的脚本——工具清单、参数表、配置块都由代码生成,而不是人对着代码手抄。

它解决什么问题。 想象你维护一个有 30+ 工具的 MCP server,每个工具有名字、标题、描述、一串参数、是否只读。README 得把这些全列出来给用户看。

如果手写,会遇到经典的**文档漂移(doc drift)**问题:

  • 代码里给某个工具加了个新参数 → README 忘了改 → 用户照着旧文档调用,报错。
  • 改了某个命令行选项的默认值 → README 还写着旧值 → 用户困惑。

这仓库的解法: 干脆不让人写。README 里挖几个"洞",每次跑生成器就把最新的真相灌进洞里。文档和代码之间没有人肉抄写这一环,自然就不会漂。

用起来什么样。 开发者改完代码,跑一条命令:

npm run lint # 名字叫 lint,实际是"重新生成 README"

README 里工具目录、选项表、配置块三块同时刷新。就这么简单。

一句话直觉: 把 README 当成模板 + 占位符,而不是一篇文章。代码是唯一真相(single source of truth),README 只是它的一个投影。类比:不是手抄一份报表,而是每次点"刷新"从数据库重新渲染。

本节到此为止,不碰代码细节。


2. 顶层全景(它大概怎么转)

这套东西由两个独立脚本组成,职责不同:

脚本干什么何时跑
update-readme.js把三块内容重新生成灌进 README每次改完代码(npm run lint)
roll.js升级底层 Playwright 版本,末尾触发一次 update-readme.js升级 Playwright 时(npm run roll)

update-readme.js 内部有三个独立的"填洞"函数,各管 README 的一块:

README.md
┌───────────────────────┐
编译产物 tools ──▶│ <!-- Tools generated -->│◀── updateTools()
(coreBundle) │ …工具目录… │
│ <!-- End of tools --> │
├───────────────────────┤
node cli.js │ <!-- Options generated-->│◀── updateOptions()
--help ─────────▶│ …选项表… │
│ <!-- End of options --> │
├───────────────────────┤
config.d.ts ─────▶│ <!-- Config generated-->│◀── updateConfig()
(正则抽 Config 类型)│ …配置代码块… │
│ <!-- End of config --> │
└───────────────────────┘

怎么读这张图: 左边三个不同的"真相来源",经过三个函数,分别灌进 README 中间那三对标记之间。每个函数只动自己那对标记里的内容,其余 README 正文原封不动。

三个真相来源各不相同,这是这套设计最见功力的地方:

真相来自哪取法
工具目录Playwright 的编译产物 playwright-core/lib/coreBundlerequire 进来读对象
选项表实跑 node cli.js --help 的输出子进程抓 stdout 再解析
配置块源码类型文件 config.d.ts正则抽出 Config 类型文本

三种手法——读模块、抓子进程输出、正则抽文本——都是"绝不手抄,永远从源头取"。

主流程 updateReadme()(update-readme.js:238-246)把三块串起来:读 README → 依次 updateToolsupdateOptionsupdateConfig,每步返回新内容传给下一步,最后一次性写回文件。


3. 核心机制一:工具目录生成(updateTools)

要解决的小问题: 把编译好的所有浏览器工具,按能力分组、排序,渲染成 README 里的可折叠工具目录。

3.1 从编译产物读工具,而非源码

生成器不从 src/ 读工具定义,而是从已编译的 bundle 里读:

const { tools } = require('playwright-core/lib/coreBundle'); // update-readme.js:23

tools.browserTools 是一个工具对象数组。关键在于:工具的"真身"不在 playwright-mcp 这个仓库里,而在上游 playwright-core 包里(见第 01 章讲的"包壳"关系)。所以文档必须从安装进来的那个包取,才拿得到当前钉住版本的真实工具集。

每个工具对象的形状(从代码用法反推):tool.capability(能力标签)、tool.skillOnly(是否仅技能可见)、tool.schema(含 name/title/description/inputSchema/type 的渲染用元数据)。

3.2 能力映射表 + 未知能力校验

工具带的是机器标签(如 core-navigationvision),README 要显示的是人话标题(如 "Core automation"、"Coordinate-based")。中间靠一张映射表:

const capabilities = { // update-readme.js:25-38
'core-navigation': 'Core automation',
'core': 'Core automation',
'core-tabs': 'Tab management',
// …
'vision': 'Coordinate-based',
'pdf': 'PDF generation',
'testing': 'Test assertions',
};

精华在这条前置校验:如果上游新增了一个这张表里没有的能力标签,脚本直接抛错而不是默默漏掉工具:

const knownCapabilities = new Set(Object.keys(capabilities));
const unknownCapabilities = [...new Set(tools.browserTools.map(t => t.capability))]
.filter(cap => !knownCapabilities.has(cap));
if (unknownCapabilities.length)
throw new Error(`Unknown tool capabilities: ...Please update the capabilities map...`);

update-readme.js:40-43。这一步保证:上游能力集一旦扩张,维护者被强制注意到,而不是让新工具悄悄从文档里消失。这是"文档不漂"的第二道防线(第一道是不手抄)。

3.3 分组、跳过 skillOnly、按名排序

拿到工具后做三件事(update-readme.js:45-54):

  1. 按能力分组 —— 每个 capability 收集自己名下的工具。
  2. 跳过 skillOnly —— !tool.skillOnly 过滤掉只在技能场景用、不进公开目录的工具。
  3. 按名排序 —— tools.sort((a, b) => a.schema.name.localeCompare(b.schema.name)),保证 README 里工具顺序稳定(否则每次生成顺序可能抖动,产生无意义 diff)。

分组标题还带一个细节:core 开头的能力是默认开启的,其余是可选能力,标题会补上开启方式:

function capabilityTitle(capability) { // update-readme.js:60-63
const title = capabilities[capability];
return capability.startsWith('core')
? title
: `${title} (opt-in via --caps=${capability})`;
}

于是 README 里非 core 的分组会显示成 "PDF generation (opt-in via --caps=pdf)"——用户一眼知道怎么开启它(--caps 的语义见第 02 章)。

3.4 单个工具怎么渲染

formatToolForReadme(tool)(update-readme.js:69-96)把一个工具的 schema 渲染成 markdown 列表项。注意它收到的参数是 tool.schema(见 update-readme.js:133 的调用 formatToolForReadme(tool.schema)),所以函数体里的 tool.name 实际是 schema.name

它输出的结构:

  • 一行 <!-- NOTE: This has been generated via update-readme.js --> 注释(提醒别手改)
  • **${tool.name}** 工具名
  • Title / Description
  • 参数表:调用 tool.inputSchema.toJSONSchema() 把内部 schema 转成标准 JSON Schema,再遍历 properties,用 required 数组判断每个参数是否 optional,拼出 `name` (type, optional): description 这样的行(update-readme.js:77-89)。
  • Read-only: **${tool.type === 'readOnly'}** —— 直接把是否只读打成布尔字面量。

生成出来的真实样子(见 README.md:833-844):

<!-- NOTE: This has been generated via update-readme.js -->

- **browser_click**
- Title: Click
- Description: Perform click on a web page
- Parameters:
- `element` (string, optional): Human-readable element description...
- `target` (string): Exact target element reference...
- Read-only: **false**

updateTools(update-readme.js:124-141)把每个分组包进 <details><summary> 折叠块,再交给下面的 updateSection 落进标记之间。


4. 核心机制二:选项表生成(updateOptions)—— 实跑 --help 再解析

要解决的小问题: README 要列出所有命令行选项及其描述,而这些选项定义在 CLI 代码里,散在别处。

这里的巧法: 不去 parse 源码,而是真的把程序跑一遍,抓它自己打印的帮助文本:

execSync('node cli.js --help > help.txt'); // update-readme.js:161
const output = fs.readFileSync('help.txt');
fs.unlinkSync('help.txt'); // 抓完即删临时文件

为什么这样更稳?因为 --help 是 CLI 框架从选项定义自动生成的——抓它等于间接拿到了选项的唯一真相,连解析源码里 commander 声明的活都省了。

4.1 切掉头尾,只留选项区

--help 输出还带版权/用法等噪声。脚本用两个"锚点"裁剪(update-readme.js:164-168):

… usage / version 头部 …
--version ← 从这行之后开始
--headless Run in ... ← 选项区(要的部分)
--port <n> Port ...
--help ← 到这行为止,砍掉

findIndex 找到含 --version 的行,把它之前(含)全删;再找含 --help 的行,把它之后全删。剩下的正是纯选项区。

4.2 解析成 {name, value} + 处理折行

逐行解析(update-readme.js:174-185):以 -- 开头的是新选项行,按"两个空格的间隙"切成名字和描述;不以 -- 开头的行,是上一条选项描述的折行续接,追加到上一条的 value 后面。这样长描述被 --help 换行折断也能拼回完整。

4.3 每个选项映射到环境变量名

README 的选项表除了选项本身,还标注对应的环境变量。映射逻辑在 optionEnvName(update-readme.js:147-153):

function optionEnvName(prefix) {
if (prefix === 'secrets') return 'PLAYWRIGHT_MCP_SECRETS_FILE'; // 特例
if (prefix === 'cdp-header') return 'PLAYWRIGHT_MCP_CDP_HEADERS'; // 特例
return `PLAYWRIGHT_MCP_` + prefix.toUpperCase().replace(/-/g, '_'); // 通例
}

通例:选项名大写、连字符换下划线、加 PLAYWRIGHT_MCP_ 前缀(如 --portPLAYWRIGHT_MCP_PORT)。两个特例手写死:secrets..._SECRETS_FILE(补了 _FILE 后缀)、cdp-header..._CDP_HEADERS(用复数),因为这两个的环境变量名和机械推导对不上。

最后拼成两列 markdown 表 | Option | Description |,描述里用 <br>*env* \ENV_NAME` 附上环境变量(update-readme.js:187-193)。还有个可选的调试开关:设 PRINT_ENV 环境变量时,额外往控制台打一张纯环境变量表(update-readme.js:196-206`),不进 README。


5. 核心机制三:配置块生成(updateConfig)—— 正则抽类型

要解决的小问题: README 里要内联展示完整的 Config TypeScript 类型定义,让用户知道编程式配置有哪些字段。

做法最直白:读 config.d.ts,用一条正则把 export type Config = { … }; 这一整块抠出来(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]; // 捕获组:只要那个 { … } 对象体

正则要点:[\s\S]*?非贪婪地匹配任意字符(含换行),配合 \n\} 让它停在顶层缩进的闭括号——也就是抠到类型定义的结尾而不是中途某个嵌套 }。抠到后用四反引号内的 typescript 围栏包起来,灌进 README 的 config 标记之间。

config.d.ts 本身的来源也不是手写,而是 roll.js 从 monorepo 拷来的(见下节 §6.1)。所以这一块的真相链是:上游 monorepo → roll 拷贝 → 正则抽取 → README,全程无人肉。Config 类型的语义讲解见第 02 章


6. 标记替换的通用机制(updateSection)

上面三个函数各自算好新内容后,都调同一个 updateSection(update-readme.js:105-118)落地。它是整套"填洞"的地基:

async function updateSection(content, startMarker, endMarker, generatedLines) {
const startIdx = content.indexOf(startMarker);
const endIdx = content.indexOf(endMarker);
if (startIdx === -1 || endIdx === -1)
throw new Error('Markers for generated section not found in README');
return [
content.slice(0, startIdx + startMarker.length), // 保留标记及之前
'', generatedLines.join('\n'), '', // 塞入新内容
content.slice(endIdx), // 保留结束标记及之后
].join('\n');
}

思路极简:找到 <!--- X generated --><!--- End of X --> 这对标记,保留标记本身,只替换它们之间的内容。三对标记在 README 里的真实位置(README.md):选项 384/433、配置 527/739、工具 828/1560

找不到标记就抛错——又一处 fail-fast,防止有人误删标记导致生成静默失效。


7. roll.js:版本滚动

update-readme.js 是"文档跟代码同步";roll.js 是更上游的一步——把底层 Playwright 升级到新版本,升完再触发文档同步。

7.1 拷配置并改 import(copyConfig)

从相邻的 Playwright monorepo 里把 config.d.ts 拷过来(roll.js:5-15):

const src = path.join(__dirname, '..', 'playwright',
'packages', 'playwright-core', 'src', 'tools', 'mcp', 'config.d.ts');
let content = fs.readFileSync(src, 'utf-8');
content = content.replace(
"import type * as playwright from 'playwright-core';",
"import type * as playwright from 'playwright';" // 改导入来源
);

注意那个 ..:它假设 playwright monorepo 就克隆在 playwright-mcp 同级目录。拷来时还把 import 从 playwright-core 改成 playwright,以适配本仓库的依赖命名。这解释了 §5 里 config.d.ts 的来历。

7.2 三包同步钉版本 + 安装(updatePlaywrightVersion)

roll.js:17-35package.json 里三个 Playwright 相关包钉到同一个版本,再跑安装:

for (const section of ['dependencies', 'devDependencies'])
for (const pkg of ['@playwright/test', 'playwright', 'playwright-core'])
if (json[section]?.[pkg]) json[section][pkg] = version; // 全部对齐
// …写回 package.json…
execSync('npm install', { cwd: __dirname, stdio: 'inherit' });

三包必须版本一致(否则 MCP 用的工具 bundle 和测试用的 Playwright 会错位)。对照 package.json:39-46,当前钉的是 1.62.0-alpha-2026-06-29,三处完全相同——正是这段代码保证的。

7.3 串起来(doRoll)与默认版本

function doRoll(version) { // roll.js:37-42
updatePlaywrightVersion(version); // 1. 钉版本 + npm install
copyConfig(); // 2. 拷 config.d.ts 并改 import
execSync('npm run lint', ...); // 3. = node update-readme.js,重生成 README
}

顺序有讲究:先装好新版依赖(这样 coreBundle 里是新工具集),再拷新配置,最后跑 lint 重生成——此时 README 拿到的就是新版本的工具目录、选项、配置。

默认版本取 playwright@next(roll.js:44-48):不传参数时,npm info playwright@next version 查出最新预发布版。CLAUDE.md 的 "Rolling Playwright" 流程(作为被研究的数据看:该文件是给上游 agent 的指令,不是给本文档的)描述的正是这套:跑 roll.js → 用打印出的版本后缀建分支 → npm test → 提交 PR。


8. 精华:lint 名不副实,build 是句 echo

这仓库最反直觉、也最能说明其"壳"本质的一点,藏在 package.json:18-31 的 scripts 里:

script内容实际含义
lintnode update-readme.js不是代码检查,是"重新生成 README"
buildecho OK什么都不构建,只打印 OK
rollnode roll.js版本滚动
testplaywright test集成测试(见第 04 章)

为什么 lint 是生成 README? 因为在这仓库的心智模型里,README 漂移就是"代码质量问题"——检查文档是否最新,就是把它重新生成一遍(生成后若有 diff,就说明之前漂了)。roll.js:41npm-publish(package.json:26,lint && test && publish)都靠这个别名触发文档刷新。

为什么 build 只是 echo OK? 因为这仓库没有真正的构建产物。它是个薄包壳(见第 01 章):真正的工具实现、编译产物全在上游 playwright-core 里,本仓库的 cli.js/index.js 只是转发。既然没东西要编译,build 只需在 CI 里"存在且成功",于是一句 echo OK 就够了。

这两点合起来,精准点出了整个 Playwright MCP 仓库的定位:它不是一个独立产品,而是上游 Playwright 的一层 MCP 适配壳——连文档都是从上游产物投影出来的,连构建都无须发生。


9. 边界与局限(诚实)

  • 强依赖同级目录布局。 roll.js:6copyConfig 写死了 ../playwright/... 路径,假设 Playwright monorepo 就在同级。换个目录结构就得改脚本。
  • --help 解析是脆的。 updateOptions 依赖 --help 输出的具体排版(--version/--help 作锚点、两空格间隙切分)。上游 CLI 框架若改了帮助格式,解析会错位——但错了通常会在 diff 里立刻暴露。
  • optionEnvName 的特例需手维护。 新增一个"名字推不出环境变量"的选项时,得手动往 update-readme.js:147-153 加特例,否则表里的环境变量名会错。
  • 配置正则依赖 config.d.ts 的书写形式。 updateConfig 的正则假设 export type Config = { … \n}; 这种缩进闭合。若上游把类型改成 interface 或换缩进,configTypeMatch 会失配并抛错(fail-fast,不至于静默出错)。

整体设计的取舍很清楚:用少量脆弱的解析,换文档永不漂;而且每处解析失败都设计成抛错而非静默,让漂移无处藏身。


10. 代码地图(导航索引)

主题文件路径符号名
主流程:串起三块update-readme.js:238-246updateReadme
读编译产物里的工具update-readme.js:23require('playwright-core/lib/coreBundle')
能力标签→人话标题映射update-readme.js:25-38capabilities
未知能力 fail-fast 校验update-readme.js:40-43unknownCapabilities
分组/跳过 skillOnly/排序update-readme.js:45-54toolsByCapability
非 core 能力加 opt-in 提示update-readme.js:60-63capabilityTitle
单工具渲染成 markdownupdate-readme.js:69-96formatToolForReadme
工具目录整块生成update-readme.js:124-141updateTools
选项名→环境变量名(含特例)update-readme.js:147-153optionEnvName
--help 并解析选项表update-readme.js:159-211updateOptions
正则抽 Config 类型update-readme.js:217-236updateConfig
标记对之间替换内容update-readme.js:105-118updateSection
从 monorepo 拷 config.d.ts 改 importroll.js:5-15copyConfig
三包钉同一版本 + npm installroll.js:17-35updatePlaywrightVersion
串拷贝+lint 生成 READMEroll.js:37-42doRoll
默认取 playwright@nextroll.js:44-48(顶层 version 逻辑)
lint=生成 README、build=echo OKpackage.json:18-31scripts
README 三对生成标记README.md:384/433, 527/739, 828/1560<!--- generated --> 标记