跳到主要内容

数据截至 (上游 commit 7e0457a7cbf8)

测试与发布:黑盒集成测试机制与四条发布线

这章讲什么: 一个「实现不在自己仓库里」的包,怎么验证它确实能用;以及验证通过之后,它怎么被发到四个不同的分发渠道。前半是测试机制(可直接搬去测你自己的 MCP server),后半是发布工程。


1. 测试策略:一行 mock 都没有

1.1 为什么必须是黑盒

本仓库不含工具实现(01 章)。单元测试无从谈起——没有单元可测。

于是策略只剩一种:把整个包当黑盒,从 MCP 协议的外面验证它。 这反而带来一个好处:测的就是用户真实经历的路径,包括 npm 包结构、CLI 参数、协议握手、浏览器启动全链路。

1.2 测试拓扑

怎么读:三个真实进程 + 一个真实浏览器,全部由 fixture 拉起和拆掉。

Playwright Test 进程

├── MCP SDK Client(真的 Client 对象)
│ │ StdioClientTransport
│ ▼
│ node cli.js ←── 真的 server 子进程
│ │
│ ▼
│ Chrome / Chromium(真的浏览器)
│ │
└── TestServer(真的本地 http + https 服务器)◄──┘

对应代码:client 与 transport 在 tests/fixtures.ts:95-115,子进程在 tests/fixtures.ts:202-215,本地服务器在 tests/fixtures.ts:158-171

1.3 有意思的一点:用 Playwright 测 Playwright

测试运行器是 @playwright/test(playwright.config.ts:17-21),被测对象是 Playwright MCP,而 fixture 里还直接用 chromium.launchServer()chromium.launchPersistentContext() 造测试用的浏览器(tests/fixtures.ts:121-148)。

CONTRIBUTING.md:81 对此有一句自觉的说明:因为 Playwright 的测试本身就在用 Playwright,所以官方文档里关于运行和调试测试的一切都适用。


2. startClient:一个 fixture 拉起全套

2.1 它做的六件事

tests/fixtures.ts:77-119startClient 是整个测试机制的核心。展开看它按顺序做了什么:

步骤代码位置说明
① 组装 CLI 参数tests/fixtures.ts:82-88依次拼 mcpArgs--headless--browser=、测试自带的 args
② 落盘配置文件tests/fixtures.ts:89-93测试传了 config 对象就写成 config.json,再追加 --config=<相对路径>
③ 建 client 并声明 rootstests/fixtures.ts:95-104需要时声明 capabilities: { roots: {} } 并注册回调
④ 建 transporttests/fixtures.ts:105createTransport,见 §2.3
⑤ 抓 stderrtests/fixtures.ts:106-111全量缓存,PWMCP_DEBUG 时同时透传到本进程 stderr
⑥ 连接并 pingtests/fixtures.ts:113-114connect 之后立刻 ping(),确认握手真的完成

第 ② 步值得单说: 配置文件用的是相对路径(path.relative(cwd, configFile),tests/fixtures.ts:92),而不是绝对路径。因为 Docker 模式下宿主机路径在容器里不存在——统一用相对路径 + 设好 cwd,同一段代码两种模式都能跑。

第 ⑥ 步的 ping() 是廉价而关键的一步:connect() 返回不代表 server 真的活着,一次 ping 才能确认协议层双向可用。

2.2 清理

tests/fixtures.ts:118use 之后统一关闭所有创建过的 client。因为 startClient 可以在一个测试里被调多次(比如同时验证不同 --caps 组合),clients 数组把它们都记下来一起收。

2.3 两种 transport,同一套断言

createTransport(tests/fixtures.ts:184-220)按 mcpMode 分叉:

本地模式(tests/fixtures.ts:202-215):

new StdioClientTransport({
command: 'node',
args: [path.join(__dirname, '../cli.js'), ...args],
cwd, stderr: 'pipe',
env: { …process.env,
DEBUG: process.env.PWMCP_DEBUG ? 'pw:mcp*' : 'pw:mcp:test',
DEBUG_COLORS: '0', DEBUG_HIDE_DATE: '1',
PWMCP_PROFILES_DIR_FOR_TEST: profilesDir, …env },
});

三个环境变量值得注意:

  • DEBUG 默认设成 pw:mcp:test —— 说明上游有一个专供测试消费的 debug 命名空间,server 通过 stderr 往外吐结构化事件。PWMCP_DEBUG 时放宽到 pw:mcp* 全开。
  • DEBUG_COLORS=0 + DEBUG_HIDE_DATE=1 —— 关掉 ANSI 颜色和时间戳,让 stderr 可被逐字断言。时间戳会让输出不确定。
  • PWMCP_PROFILES_DIR_FOR_TEST —— 把浏览器 profile 目录重定向到测试输出目录,避免污染开发者真实的 ~/.cache/ms-playwright,也让并行测试互不干扰。

配套的 formatOutput(tests/fixtures.ts:246-248)进一步归一化:剥掉 pw:mcp:test 前缀、把 user data dir.* 整段替换成固定的 user data dir(路径含随机临时目录,必须抹掉)、去空行去首尾空白。

Docker 模式(tests/fixtures.ts:188-199):

const relCwd = path.relative(test.info().project.outputDir, cwd);
const dockerCwd = path.posix.join('/app/test-results', relCwd.split(path.sep).join('/'));
const dockerArgs = ['run', '--rm', '-i', '--network=host',
'-v', `${test.info().project.outputDir}:/app/test-results`, '-w', dockerCwd];

三处细节:把测试输出目录挂进容器让产物能被读回、--network=host 让容器能访问宿主机上的 TestServer、把 Windows 的 \ 路径分隔符转成 posix 的 /(relCwd.split(path.sep).join('/'))。

两种模式返回同一个 Transport 接口,所以测试代码一个字都不用改。


3. 自定义匹配器:把一段 markdown 变成可断言的对象

3.1 问题

MCP 工具的返回是一段大文本(见 02 章)。直接对整段做字符串断言会脆得没法维护:多一个小节、换个顺序,全挂。

3.2 解法:先解析成对象,再用 objectContaining

toHaveResponse(tests/fixtures.ts:224-244)先把响应喂给 parseResponse,再做:

expect(parsed).toEqual(expect.objectContaining(object));

objectContaining 意味着测试只需要声明它关心的字段。于是测试读起来是这样的(tests/click.spec.ts:39-48):

expect(await client.callTool({
name: 'browser_click',
arguments: { element: 'Submit button', target: 'e2' },
})).toHaveResponse({
code: `await page.getByRole('button', { name: 'Submit' }).click();`,
snapshot: expect.stringContaining(`button "Submit" [active] [ref=e2]`),
});

只断言两件事:回执代码逐字相等,快照包含目标片段。响应里的 ### Open tabs### Page state 等其他小节存在与否都不影响。

匹配器还正确处理了 isNot(tests/fixtures.ts:227-243),所以 .not.toHaveResponse(...) 也能用。

3.3 解析器做的三件规整

parseResponse(tests/fixtures.ts:250-293)不只是切分:

  1. 剥围栏 —— code 字段去掉 ```js 前后缀(tests/fixtures.ts:263),snapshot 去掉 ```yaml(tests/fixtures.ts:276)。测试里写断言时不用带围栏噪声。
  2. 跟随落盘链接 —— 快照小节如果是 [Snapshot](路径) 形式,就去把那个文件读进来(tests/fixtures.ts:269-274);读不到就静默留空。于是「快照内联」和「快照落盘」两种情况对测试是透明的。
  3. 分离附件 —— attachments = response.content.slice(1)(tests/fixtures.ts:265),第 0 项文本之外的都是附件。

分节靠 parseSections(tests/fixtures.ts:295-309),用 text.split(/^### /m) 切、.slice(1) 丢掉首段,每段第一个换行前是标题、之后是内容。

这套「解析 + objectContaining」的组合,是测试任何返回富文本的 MCP server 的通用解法。


4. 密闭的测试服务器

CONTRIBUTING.md:88 定了一条硬规矩:测试必须是 hermetic(密闭) 的,不依赖外部服务,并且要在 macOS、Linux、Windows 三个平台都能跑。

tests/testserver/index.tsTestServer 就是为此存在的。它的几个设计点:

4.1 端口按 worker 索引分配

const port = 8907 + workerInfo.workerIndex * 4;

tests/fixtures.ts:158-163:每个 worker 分到 4 个端口位,http 用 port、https 用 port + 1。CDP fixture 另有一套 3200 + parallelIndex(tests/fixtures.ts:131)。并行测试永不撞端口。

4.2 worker 级创建,test 级 reset

服务器实例是 worker fixture(tests/fixtures.ts:158-171,{ scope: 'worker' }),启一次用一整个 worker;而每个测试拿到它之前先 reset()(tests/fixtures.ts:173-181)。

reset(tests/testserver/index.ts:119-137)清路由、清 CSP、清额外头、关掉所有连接、把等待中的订阅者全部 reject,然后重新装上三条默认路由:/favicon.ico(空,避免噪声请求 404)、/(空 HTML)、/hello-world(带 title 和 body 的最小页面)。

启动成本只付一次,隔离性每个测试都有。

4.3 同源与跨源前缀

构造函数里同时算出两个前缀(tests/testserver/index.ts:63-69):

this.PREFIX = `${protocol}://localhost:${port}/`;
this.CROSS_PROCESS_PREFIX = `${protocol}://127.0.0.1:${port}/`;

同一个服务器,两个不同 host 名 → 浏览器眼里是两个 origin。测跨源行为不需要第二台服务器。

4.4 HTTPS 用仓库内自签证书

createHTTPS(tests/testserver/index.ts:45-53)读同目录的 key.pem / cert.pem,passphrase 硬编码为 'aaaa'。证书生成参数在 tests/testserver/san.cnf--ignore-https-errors 这类选项不需要联网。

4.5 面向测试的便利 API

方法用途位置
setContent(path, content, mime)起一个静态页面;HTML 自动补 <!DOCTYPE html>tests/testserver/index.ts:89-94
route(path, handler)任意自定义处理tests/testserver/index.ts:85-87
redirect(from, to)302 重定向tests/testserver/index.ts:96-102
setCSP / setExtraHeaders注入响应头tests/testserver/index.ts:72-78
waitForRequest(path)等某个请求到达,返回 Promisetests/testserver/index.ts:104-117

waitForRequest 用一对 Symbol(fulfilSymbol / rejectSymbol,tests/testserver/index.ts:24-25)把 resolve/reject 挂在 Promise 对象上,reset 时能统一 reject 掉所有悬挂的等待——避免测试之间泄漏 pending promise。

tests/click.spec.ts:20-29 演示了典型用法:先 setContent 造一个只有一个按钮的页面,页面里还塞了一段 script,注释解释得很实在——「不手动 focus 的话 webkit 会 focus 到 body」(tests/click.spec.ts:26)。跨浏览器测试的现实细节。


5. 测试项目与运行入口

5.1 两个 project

playwright.config.ts:27-37:

project什么时候有跑什么
chrome总是全部测试
chromium-dockerMCP_IN_DOCKER=1`grep: /browser_navigate

Docker 项目故意只跑两个用例。理由很实际:容器化验证要的是「镜像能起来、浏览器能跑、协议能通」,不需要把整套功能重跑一遍。

其余配置:fullyParallel: true、CI 上 forbidOnly + workers: 2(playwright.config.ts:23-26)。

5.2 npm scripts

package.json:19-23 提供了按浏览器切分的快捷入口:

脚本命令
testplaywright test
ctest / ftest / wtest分别锁 chrome / firefox / webkit
dtestMCP_IN_DOCKER=1 playwright test --project=chromium-docker

mcpBrowser 默认是 'chrome'(tests/fixtures.ts:154),而 CDP 相关的 fixture 会对非 Chromium 系浏览器直接 skip(tests/fixtures.ts:128)。


6. CI 的三个 job

.github/workflows/ci.yml,push 到 main 和对 main 的 PR 都触发:

job干什么位置
lint跑生成器 + git diff --exit-code(见 04 章)ci.yml:10-22
test三平台矩阵 ubuntu / macos-15 / windows,fail-fast: falseci.yml:24-44
test_mcp_docker构建镜像(GHA cache)后跑 docker projectci.yml:46-75

test_mcp_docker 里有一行注释解释的小技巧(ci.yml:71-72):跑测试前 umask 0000,「用于 Docker 测试与容器共享 test-results 目录」。容器里的进程 UID 和 runner 不同,不放宽权限就写不进挂载卷。

Docker 构建用 cache-from: type=gha / cache-to: type=gha,mode=max(ci.yml:65-66),配合 01 章 里那个独立的浏览器下载层,省下大部分构建时间。


7. 四条发布线

7.1 全景

每天 08:00 UTC / 手动 ──► publish-mcp-canary-npm ──► npm publish --tag next

GitHub Release published
├──► publish-mcp-release-npm ─────► npm publish
│ │
│ ▼(成功或被跳过)
├──► publish-mcp-release-registry ► 校验版本 → mcp-publisher publish

└──► publish-mcp-release-docker ──► ACR:mcp:<tag> 和 :latest

手动(Azure Pipelines)──► ESRP ──► npm dist-tag: test

7.2 逐条说明

① canary(.github/workflows/publish.yml:10-44) —— cron 0 8 * * * 每天跑。版本号拼成 <version>-alpha-<YYYY-MM-DD>,发到 next tag。发布前必跑 npm run lintnpm run ctest(publish.yml:40-41)。

② release npm(publish.yml:46-62) —— GitHub Release 触发,同样先 lint + ctest 再 npm publish

两条线都用 OIDC 而非长期 token,permissions: id-token: write 加注释「Required for OIDC npm publishing」(publish.yml:14-15)。

③ MCP Registry(publish.yml:64-109) —— 三步:先用内联 Node 脚本校验 server.jsonpackage.json 的三处版本一致(见 01 章),再下载 mcp-publisher 二进制,然后 login github-oidc + publish

它的 if 条件写得很讲究(publish.yml:68-73),注释解释了动机:允许通过手动 workflow_dispatch 追补发布某个版本——这种情况下 npm 那个 job 会被跳过,靠 always() 加上「结果不是 failure 也不是 cancelled」的判断,让 registry job 仍然能跑。

④ Docker(publish.yml:111-154) —— QEMU + Buildx 做 linux/amd64,linux/arm64 双架构,Azure OIDC 登录后推到 ACR,同时打版本 tag 和 latest

最后一段用 oras attach 给每个 tag 附一个 EOL 生命周期清单(publish.yml:142-154),注释里还附了内部事故单链接作为依据。这是镜像仓库的合规要求。

⑤ ESRP alpha(.azure-pipelines/publish.yml) —— 走微软内部的 1ES 模板和 ESRP 签名发布,trigger: none + pr: none 纯手动。

第一步就是硬检查分支必须是 refs/heads/main,否则 exit 1(.azure-pipelines/publish.yml:38-49)。版本号是 <base>-alpha-<unix秒>000(.azure-pipelines/publish.yml:80-82),发布的 npm dist-tag 是 test(.azure-pipelines/publish.yml:116)。

7.3 汇总表

线路触发产物认证方式
canary npm每日 cron / 手动@playwright/mcp@nextGitHub OIDC
release npmGitHub Release@playwright/mcp@latestGitHub OIDC
MCP RegistryRelease / 手动追补registry 条目GitHub OIDC
DockerReleaseACR 双架构镜像Azure OIDC
ESRP alpha手动@playwright/mcp@test托管标识 + KeyVault

8. 这一章的可借鉴点

  1. 测 MCP server 就用真 SDK client 起真进程。 mock 掉协议层等于什么都没测。
  2. connect() 之后补一次 ping() 一行代码,把「连上了」和「能用」区分开。
  3. 把 debug 输出归一化到可断言。 关颜色、关时间戳、抹随机路径——DEBUG_COLORS=0DEBUG_HIDE_DATE=1formatOutput 的正则。
  4. 富文本响应先解析成对象再断言。 parseSections + objectContaining,测试只声明关心的字段。
  5. 端口按 worker 索引算,不要靠随机或固定值。
  6. 服务器 worker 级创建、测试级 reset。 启动成本摊薄,隔离性不打折。
  7. 容器化验证只跑冒烟子集。grep 限定,别把全套重跑一遍。
  8. 发布全面 OIDC,不留长期 token。
  9. 多处版本号靠 CI 脚本校验,不靠 checklist。

9. 代码地图

主题文件路径符号名 / 锚点
client 启动全流程tests/fixtures.tsstartClient
transport 双模式tests/fixtures.tscreateTransport
测试专用环境变量tests/fixtures.tsDEBUG=pw:mcp:testPWMCP_PROFILES_DIR_FOR_TEST
stderr 归一化tests/fixtures.tsformatOutput
自定义断言tests/fixtures.tstoHaveResponse
响应解析tests/fixtures.tsparseResponseparseSections
worker 级服务器与端口分配tests/fixtures.ts_workerServers
CDP fixture 与 skiptests/fixtures.tscdpServer
roots 回调与延迟注入tests/fixtures.tsListRootsRequestSchemarootsResponseDelay
密闭测试服务器tests/testserver/index.tsTestServercreatecreateHTTPS
每测试重置tests/testserver/index.tsreset
同源/跨源前缀tests/testserver/index.tsPREFIXCROSS_PROCESS_PREFIXHELLO_WORLD
请求等待tests/testserver/index.tswaitForRequestfulfillSymbol
测试项目定义playwright.config.tsprojectschromium-dockergrep
测试脚本入口package.jsonscripts.testctestdtest
CI 三 job.github/workflows/ci.ymllinttesttest_mcp_docker
发布四线.github/workflows/publish.ymlpublish-mcp-canary-npmpublish-mcp-release-npmpublish-mcp-release-registrypublish-mcp-release-docker
版本一致性校验.github/workflows/publish.ymlValidate server.json version matches package.json
ESRP 手动发布.azure-pipelines/publish.ymlEsrpRelease@11、分支检查步骤
贡献规则(密闭性/三平台)CONTRIBUTING.md## Add a test
开发容器.devcontainer/devcontainer.jsonmcr.microsoft.com/playwright 镜像、noVNC 6080