跳到主要内容

自动等待与输入动作:让每个操作可靠落地

30 秒导读: agent 点一个按钮后,页面可能开始跳转、可能弹窗、可能异步刷新一大片 DOM。 如果这时候立刻去读快照,读到的是「动作还没生效」的半成品。本章讲 chrome-devtools-mcp 怎么把每一个输入动作都包进一层「等结果」的编排里:先埋好导航探针和弹窗处理,再执行动作, 然后等页面真正安静下来,才把控制权还给 agent。这就是 README 三大卖点里的 Reliable automation

本章是 snapshot/uid 定位模型的下一步:上一章讲 agent 怎么看见并点中一个元素, 本章讲点下去之后怎么可靠地等到结果。至于结果最终怎么被渲染成一段带快照的文本回给 agent, 那是下一章 05 响应组装的事,这里只到「把结果 attach 上去」为止。


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

先看一个会「翻车」的朴素实现

假设你自己写一个「点击」工具,最直觉的写法是这样:

// 示意,非源码 —— 朴素但不可靠的点击
async function click(uid) {
const el = await findElement(uid); // 找到元素
await el.click(); // 点下去
return await takeSnapshot(); // 立刻读页面
}

问题出在最后一行。点击往往会触发一连串异步后果:

  • 触发一次页面跳转(navigation),新页面还没加载完;
  • 弹出一个 confirm / alert 对话框,挡住后续所有操作;
  • 触发 JS 异步渲染,一大片 DOM 正在陆续插入。

在这些后果还没尘埃落定时就 takeSnapshot(),agent 看到的是一张「动作还没生效」的旧照片—— 它会据此做出错误的下一步判断。这就是浏览器自动化「flaky(时好时坏)」的根源。

这一层做的事:把动作包进「等结果」

chrome-devtools-mcp 的解法,是给每个输入动作套一层统一的编排器 waitForEventsAfterAction。它把上面那三种后果都接管掉:

  • 点之前就挂好弹窗处理器、架好导航探针;
  • 点之后,如果探到跳转就等跳转完成;
  • 然后注入一个 DOM 变化观察器,等页面安静 100ms 才算稳定;
  • 全部就绪,才把「有没有跳转 / 有没有处理弹窗」的结果交回。

一句话直觉:「点一下,然后耐心等到画面不再动,再拍照。」 这就是 auto-wait。

两层等待,各管一段(关键区分)

这里有个容易混淆的点:其实有两层等待,分工不同。

谁负责等什么在哪
动作可交互性等待Puppeteer 的 Locator等元素可见、可点、稳定才真正下手handle.asLocator().click()
动作后果等待本章的 WaitForHelper等动作引发的页面变化尘埃落定waitForEventsAfterAction(...)
  • 第一层是 Puppeteer Locator 自带的「actionability」:它在点击前会自动等元素进入可交互状态, 超时了就报错(见 handleActionError)。这层 chrome-devtools-mcp 直接复用,不用自己写。
  • 第二层才是本章的主角:动作已经发生之后,页面开始动,这一层负责等它停。

理解了这个分工,后面所有代码就都好读了:输入工具的 handler 永远是 「用 Locator 做动作(第一层)」外面再套一层「等后果(第二层)」。


2. 顶层全景(一次点击怎么转)

以最典型的 click 为例,走一遍从工具被调用到结果 attach 的全过程。

agent 调用 click(uid)


┌─────────────────────────────────────────────┐
│ input.ts: click handler │
│ 1. getElementByUid(uid) ── 拿 ElementHandle │ ← 第03章的 uid→句柄
│ 2. 包进 waitForEventsAfterAction(action): │
│ action = handle.asLocator().click() │ ← 第一层:Locator 等可交互
└───────────────────┬─────────────────────────┘

┌─────────────────────────────────────────────┐
│ WaitForHelper.waitForEventsAfterAction │ 第二层:等后果
│ │
│ [a] 若 handleDialog → 挂 dialog 处理器 │
│ [b] 并行架导航探针 navigationFinished: │
│ waitForNavigationStarted() │
│ └→ 若真跳转 → waitForNavigation() │
│ [c] 执行 action() ← 动作真正发生 │
│ [d] await navigationFinished (若在跳转就等它)│
│ [e] 若开过弹窗 → 直接返回,不等 DOM │
│ 否则 → waitForStableDom() 等 DOM 静默 │
│ [f] finally: abortController.abort() 清理 │
│ [g] 返回 { navigatedToUrl?, dialogHandled? } │
└───────────────────┬─────────────────────────┘

┌─────────────────────────────────────────────┐
│ 回到 handler: │
│ response.attachWaitForResult(result) │ → 交给第05章渲染
│ 可选 response.includeSnapshot() │
└─────────────────────────────────────────────┘

部件一句话职责:

部件干什么文件
输入工具 handler定义每种动作(click/fill/…),把动作包进等待层src/tools/input.ts
McpPage.waitForEventsAfterAction薄封装:按当前 CPU/网络倍率造一个 helper 再委托src/McpPage.ts:260
WaitForHelper核心编排:导航探测 + DOM 静默 + 弹窗 + 清理src/WaitForHelper.ts
Locator(Puppeteer)第一层:动作前等元素可交互handle.asLocator() 得到
snapshot.tstake_snapshot / wait_for,只是复用上面的能力src/tools/snapshot.ts

3. 核心原理(逐个机制,由浅入深)

3.1 编排器:waitForEventsAfterAction 的顺序为什么这么排

waitForEventsAfterAction 是整层的心脏(src/WaitForHelper.ts:133-210)。它的顺序不是随便排的, 每一步都在防一种时序竞态。

关键点一:导航探针必须在动作之前架好。 如果先执行动作、再去「监听有没有跳转」,那跳转事件可能在你挂监听器之前就已经发出了——错过就永远等不到。 所以代码里 navigationFinished 的 promise 是在 await action() 之前就创建的(WaitForHelper.ts:173), 只是它内部的 await 被推迟到动作之后:

// 示意,非源码 —— 编排的骨架,重点看「先架探针,后执行动作」
const navigationFinished = this.waitForNavigationStarted() // ① 先架探针(不 await)
.then(started => started
? this.#page.waitForNavigation({ timeout, signal }) // 真跳转才等它加载完
: undefined);

await action(); // ② 现在才执行动作(点击/输入)

await navigationFinished; // ③ 回收探针:如果在跳转,这里才真正等

关键点二:开了弹窗就短路,不等 DOM。 如果动作触发了一个 confirm 且被自动处理(#dialogOpened 为 true), 代码会直接返回,跳过 waitForStableDom(WaitForHelper.ts:196-198)。 因为弹窗期间页面 JS 是阻塞的,再去等 DOM 静默没有意义,只会白白耗到超时。

关键点三:动作抛错要立刻清理。 await action() 用 try 包着;一旦动作本身失败,马上 abortController.abort() 把导航探针、 DOM 观察器等挂起的 promise 全部取消,再把错误抛出(WaitForHelper.ts:185-191),不留悬空监听器。

关键点四:无论如何都 abort 收尾。 正常路径走完 waitForStableDom 后,finally 里也会 abort()(WaitForHelper.ts:205-207)—— AbortController 是这一层统一的「清理总开关」,所有子等待(导航、DOM 观察、timeout)都注册了 abort 回调, 一拉闸全部释放。

最后 #getResult() 组装返回值(WaitForHelper.ts:212-220):对比动作前后的 URL,变了就带上 navigatedToUrl,再附上 dialogHandled 标记。注意 helper 是一次性的——开头就检查 signal.aborted,复用会直接抛「Can't re-use a WaitForHelper」(WaitForHelper.ts:141-143)。

3.2 导航探测:怎么区分「真跳转」和「假跳转」

不是所有导航都值得等。点一个 #anchor 锚点、或 SPA 用 history.pushState 改 URL, 这些是同文档导航,页面并不会重新加载,等 waitForNavigation 只会白等到超时。

waitForNavigationStarted 靠 CDP 的 Page.frameStartedNavigating 事件来分辨 (WaitForHelper.ts:91-121)。它检查事件的 navigationType:

// 示意,非源码 —— 只有「跨文档」导航才值得等
const listener = (event) => {
if (['historySameDocument', 'historyDifferentDocument', 'sameDocument']
.includes(event.navigationType)) {
resolve(false); // 同文档 / history 导航 → 不算「要等的跳转」
return;
}
resolve(true); // 真正的跨文档导航 → 要等它加载完
};

而且这个探测本身也有超时:waitForNavigationStarted 和一个 expectNavigationIn(默认 100ms×CPU 倍率) 的定时器 Promise.race(WaitForHelper.ts:117-120)。含义是:动作发生后 ~100ms 内没探到跳转, 就认定这次动作不导航,直接进入等 DOM 阶段。这个 100ms 就是「等跳转开始」的耐心上限。

3.3 DOM 静默:怎么判断「页面终于不动了」

没跳转的动作(比如 fill 一个输入框、点一个展开菜单),后果是局部 DOM 异步变化。 怎么知道它变完了?waitForStableDom 用一个经典技巧:MutationObserver + 静默计时器 (WaitForHelper.ts:42-89)。

思路很直观:「只要 DOM 还在变,就重置计时器;连续安静 100ms,就认定稳定了。」

// 示意,非源码 —— 注入页面里的「DOM 静默」观察器
let timer;
function onQuietWindowElapsed() { // 100ms 内没有任何变化才会跑到这
resolver.resolve();
observer.disconnect();
}
function onAnyMutation() { // 每次 DOM 变化都重置计时
clearTimeout(timer);
timer = setTimeout(onQuietWindowElapsed, stableDomFor); // stableDomFor 默认 100ms
}
onAnyMutation(); // 先启动一次:万一 DOM 根本不变
observer.observe(document.body, { childList: true, subtree: true, attributes: true });

几个关键细节:

  • 先手动调一次 callback()(WaitForHelper.ts:58):如果动作根本没引起 DOM 变化, observer 永远不触发,那就靠这个初始计时器在 100ms 后 resolve,不会卡死。
  • 观察范围很宽:childList + subtree + attributes(WaitForHelper.ts:60-64), 子节点增删、深层后代、属性变化全都算「页面在动」。
  • 总超时兜底:整个等待和 stableDomTimeout(默认 3000ms×CPU 倍率)Promise.race (WaitForHelper.ts:81-88)。就算页面里有个永不停歇的动画/轮询,最多等 3 秒也会放行, 不会永久挂起。
  • abort 时优雅退出:注册了 abort 回调,断连 observer 并 resolve(WaitForHelper.ts:69-79), 确保注入页面的 handle 被 dispose,不泄漏。

3.4 节流倍率:CPU/网络越慢,超时越宽

上面几个超时数字(3000 / 100 / 3000ms)都不是死的,而是乘上了倍率。 这是为了配合第02章提到的 CPU/网络节流仿真:一旦你把页面 CPU 调慢 4 倍、 网络设成 Slow 3G,原来的超时就该按比例放宽,否则慢机器上处处误判超时。

倍率来自两处:

倍率来源影响的超时
CPU 倍率McpPage.cpuThrottlingRate(节流仿真设的 rate)stableDomTimeoutstableDomForexpectNavigationIn
网络倍率getNetworkMultiplierFromString(networkConditions)navigationTimeout

网络倍率是一张简单的档位表(WaitForHelper.ts:235-252):

网络档位倍率
Fast 4G1
Slow 4G2.5
Fast 3G5
Slow 3G10

McpPage.waitForEventsAfterAction(McpPage.ts:260-273)每次都用当前的两个倍率新建一个 helper—— 所以节流设置一改,下一个动作的超时就自动跟着变。同样地,updateTimeouts(McpPage.ts:659-673) 把这两个倍率也应用到 Puppeteer 自己的 setDefaultTimeout / setDefaultNavigationTimeout 上, 让第一层的 Locator 等待也一起变宽。两层的超时口径因此始终一致。


4. 输入工具:统一的 handler 模式

src/tools/input.ts 里九个工具(click / click_at / hover / fill / fill_form / type_text / drag / upload_file / press_key)长得高度一致,因为它们都遵循同一个套路。

4.1 通用模式

// 示意,非源码 —— 输入工具的通用骨架
handler: async (request, response) => {
const handle = await request.page.getElementByUid(uid); // ① uid → ElementHandle(第03章)
try {
const result = await request.page.waitForEventsAfterAction( // ② 包进等待层(第二层)
async () => {
await handle.asLocator().click(); // ③ Locator 做动作(第一层)
},
);
response.appendResponseLine('Successfully clicked ...'); // ④ 写人类可读结果
response.attachWaitForResult(result); // ⑤ 附上 navigatedToUrl/dialogHandled
if (request.params.includeSnapshot) response.includeSnapshot(); // ⑥ 可选:附快照
} catch (error) {
handleActionError(error, uid); // ⑦ 统一错误话术
} finally {
void handle.dispose(); // ⑧ 释放句柄
}
}

真实的 click 就是这个样子(input.ts:107-140)。五个要点:

  1. getElementByUid 把 agent 给的 uid 解析成活的 ElementHandle(第03章的定位模型)。
  2. waitForEventsAfterAction(action) 是本章第二层等待的入口。
  3. asLocator().click() 是第一层——Puppeteer 在此自动等元素可交互。
  4. attachWaitForResult(result) 把「有没有跳转 / 有没有弹窗」挂到 response 上(渲染是第05章)。
  5. includeSnapshot() 是可选的:输入工具默认带快照(省 token),agent 想要才传 includeSnapshot: true。而 take_snapshot / wait_for 是专门用来拿快照的。

出错时 handleActionError(input.ts:36-44)统一抛一句「元素没在超时内变得可交互」—— 把第一层 Locator 的超时失败翻译成对 agent 友好的话。

click_at(input.ts:143-176)是个变体:它按坐标点,不走 uid,用 mouse.click(x, y), 但一样包在 waitForEventsAfterAction 里,享受同样的 auto-wait。

4.2 fill:一个工具,四种元素

fill 表面是「填个值」,实际要伺候四类完全不同的表单元素。核心逻辑在 fillFormElement(input.ts:256-299),它先看元素类型再分派:

元素类型判断依据怎么填
combobox(带 option 子节点)role==='combobox' 且有 option 子节点selectOption:按文本找到 option,读它真实 value 再 fill
checkbox / radio / switchinput.type 或 role 命中只接受 "true"/"false",fill(true/false)
<option>(在原生 <select> 里)click 时 AXNode role==='option'selectNativeSelectOption:找到父 <select> 再 fill 值
普通 input / textarea以上都不是fill(value),并按字符数加长超时

几个有意思的设计:

  • combobox 要「反查真实值」(selectOption,input.ts:219-250):a11y 树里的 option 只有显示文本, 没有底层 value。所以代码遍历子 option、按文本匹配,再用元素句柄读出真正的 value 来填。
  • 原生 <select> 走 click 而非 fill:当 agent 对一个 <option>click 时,click handler 会识别出 role==='option' 并改调 selectNativeSelectOption(input.ts:46-87111-119), 找到祖先 <select> 用它的值来选中——即「点 option = 选中它」。
  • 长文本自动放宽超时(input.ts:287-291):填很长的字符串时,按 10ms/字符 追加超时, 避免长输入被误判超时。

4.3 type_text / press_key:键盘级动作

type_text(input.ts:341-369)不针对某个元素,而是往当前已聚焦的输入里敲字符 (keyboard.type),可选敲一个 submitKey(如 Enter)收尾——同样包在等待层里, 敲完 Enter 若触发提交跳转,会被自动等到。

press_key(input.ts:506-554)处理组合键(如 Control+A)。parseKey (src/utils/keyboard.ts:280-305)把字符串拆成「主键 + 修饰键」,主键在前。handler 的写法藏着一个 修饰键泄漏修复(issue #2309):

// 示意,非源码 —— 按下修饰键 → 敲主键 → 无论如何都松开修饰键
const heldModifiers = [];
try {
for (const mod of modifiers) { // 依次按住 Control、Shift …
await keyboard.down(mod);
heldModifiers.push(mod);
}
await keyboard.press(key); // 敲主键(可能抛错)
} finally {
for (const mod of heldModifiers.toReversed()) { // 逆序松开,连出错也要松
await keyboard.up(mod);
}
}

要点看 finally(input.ts:536-543):如果主键 press 抛了错,不做这层保护的话, Control逻辑上一直被按住,污染后面所有输入。这里用「已按下的才松开、逆序松开」保证复原。

4.4 fill_form:批量优先

fill_form(input.ts:408-452)一次填多个字段。它的工具描述里明确写着「永远优先用这个, 不要连开多个 fill/click」——因为一次调用比多次往返快得多、也更可靠。

实现上它循环对每个元素调 waitForEventsAfterAction(fillFormElement(...)), 只保留最后一次WaitForEventsResult 来 attach(input.ts:435-447)—— 批量动作里最后那次的导航/弹窗状态才是 agent 关心的终态。

4.5 drag / upload_file

  • drag(input.ts:371-406):取两个 uid 的句柄,fromHandle.drag(toHandle) → 停 50ms → toHandle.drop(...),整段包在等待层里。
  • upload_file(input.ts:454-504):先尝试直接对 <input type=file> 上传;失败则退回 「点击元素触发 waitForFileChooser,再 accept 文件」——兼容那种用代理元素触发文件框的站点。 注意它没有waitForEventsAfterAction(上传本身不产生需要等的页面后果)。

5. snapshot.ts:等待能力的「纯消费者」

看完输入工具,回头看 src/tools/snapshot.ts 会发现它出奇地薄——因为它只是复用前面的能力。

  • take_snapshot(snapshot.ts:12-44):它的 handler 只有一句 response.includeSnapshot({ verbose, filePath })。它不自己拍照,只是声明「这次响应要带快照」, 真正的采集与渲染交给第05章的 response 流程。
  • wait_for(snapshot.ts:46-74):handler 调 page.waitForTextOnPage(text, timeout) 等某段文本出现,然后 includeSnapshot()

waitForTextOnPage(McpPage.ts:675-692)自己也是薄封装:对所有 frame、对每个候选文本, 同时架 aria/<text>text/<text> 两种 Locator,用 Locator.race 抢答——任一命中即返回。 超时同样受节流倍率放大(经 updateTimeouts 设的默认超时)。

这印证了本章的架构:「等」的逻辑集中在 WaitForHelper / McpPage 一层, 上层工具(输入类、快照类)都只是不同姿势地消费它。 这也是为什么加一个新输入工具很便宜—— 套上 waitForEventsAfterAction 就自动获得了全套 auto-wait。


6. 巧妙之处(可带走的技术)

  • 导航探针「先架后收」破解时序竞态。 promise 在动作前创建、await 推迟到动作后 (WaitForHelper.ts:173-194),既不会错过瞬发的跳转事件,又能在「其实没跳转」时快速放行。
  • navigationType 过滤假跳转。 同文档 / history 导航不触发重载,直接跳过等待 (WaitForHelper.ts:96-108),省掉一次必然超时的空等。
  • MutationObserver + 静默窗口 + 双重兜底。 初始先跑一次计时器(防「DOM 根本不变」), 外层再套总超时(防「DOM 永远在动」),两头都不会挂死(WaitForHelper.ts:5881-88)。
  • AbortController 当统一清理总闸。 导航、DOM 观察、每个 timeout 都注册 abort 回调, 正常结束、动作报错、外部取消三条路都能一键释放(WaitForHelper.ts:69111126206)。
  • 超时随节流倍率联动。 慢 CPU / 慢网络下,两层等待的超时按同一套倍率放宽,避免慢环境处处误超时 (WaitForHelper.ts:29-32McpPage.ts:659-673)。
  • 修饰键 finally 逆序释放(#2309)。 组合键失败也不残留「按住」状态(input.ts:536-543)。

7. 边界与局限(诚实)

  • DOM 静默是启发式,不是保证。 「安静 100ms = 稳定」只是经验值。若页面在 100ms 后才开始异步渲染, 可能被提前放行;若有持续动画,则一定等满 3 秒总超时(WaitForHelper.ts:81-88)。
  • 只等 DOM 与导航,不等网络空闲。 这一层不等「所有 XHR 完成」;纯数据请求若不改 DOM,不会被等到。 需要等具体结果时,agent 应显式用 wait_for 等目标文本。
  • 弹窗一开就不等 DOM。 自动处理弹窗后直接返回(WaitForHelper.ts:196-198),弹窗关闭后引发的 后续 DOM 变化不在本次等待范围内。
  • helper 一次性。 每个动作新建一个 WaitForHelper,复用会抛错(WaitForHelper.ts:141-143)。
  • 网络倍率表是固定档位。 只认 Fast/Slow 3G/4G 四档,其它一律按 1 倍 (WaitForHelper.ts:235-252)。

8. 横向对比(同 shelf 兄弟)

浏览器类 agent 大多要解决「动作后何时读页面」这个共性问题,取舍不同:

  • Playwright / Puppeteer 的 auto-waiting 主要在动作前等元素可交互(即本章第一层); chrome-devtools-mcp 在此之上额外加了第二层——动作的导航 + DOM 静默等待,更贴合 「LLM 需要一张稳定快照」的场景。
  • 与依赖固定 sleep 或让模型自己「猜要不要再等」的朴素 MCP 相比,这里把等待下沉成基础设施, 每个工具白拿,减少 flaky 与无谓往返。

同组其它章:index · 01 工具模型 · 02 浏览器接入与上下文 · 03 快照与 uid · 05 响应组装与采集 · 06 复用 DevTools 引擎


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

主题文件符号
等待编排主入口src/WaitForHelper.tsWaitForHelper.waitForEventsAfterAction
DOM 静默检测src/WaitForHelper.tsWaitForHelper.waitForStableDom
导航开始探测 / 假跳转过滤src/WaitForHelper.tsWaitForHelper.waitForNavigationStarted
统一取消总闸src/WaitForHelper.tsWaitForHelper.#abortController / timeout
返回值(navigatedToUrl/dialogHandled)src/WaitForHelper.tsWaitForHelper.#getResult / WaitForEventsResult
网络节流倍率表src/WaitForHelper.tsgetNetworkMultiplierFromString
按当前倍率造 helpersrc/McpPage.tsMcpPage.createWaitForHelper / waitForEventsAfterAction
两层超时联动src/McpPage.tsMcpPage.updateTimeouts
文本等待(race aria/text)src/McpPage.tsMcpPage.waitForTextOnPage
点击工具(通用模式样板)src/tools/input.tsclick
坐标点击src/tools/input.tsclickAt
填表分派(四类元素)src/tools/input.tsfillFormElement
combobox 反查真实值src/tools/input.tsselectOption
原生 select 选项src/tools/input.tsselectNativeSelectOption
批量填表src/tools/input.tsfillForm
组合键 + 修饰键释放修复src/tools/input.tspressKey
键解析(主键+修饰键)src/utils/keyboard.tsparseKey
错误话术统一src/tools/input.tshandleActionError
快照 / 等文本(纯消费者)src/tools/snapshot.tstakeSnapshot / waitFor