QA Wolf 如何用 WebRTC 直播 Web、Android 和 iOS 端到端测试运行
Live streaming Web, Android, and iOS E2E test runs over WebRTC
QA Wolf 基于 WebRTC 实现了 Web、Android 和 iOS 端到端测试运行的实时直播,浏览器与 runner 之间直连延迟低于 200ms。
端到端测试天生就很脆弱——它们运行在真实的浏览器、真实的设备和真实的网络上,因此同一个测试可能因为与你的代码毫无关系的原因而通过、卡住或失败。QA 中慢的部分不是编写测试;而是当一个测试出问题时弄清楚发生了什么——而过去这意味着等待:读日志,或者在运行结束后再看录制的视频。
能够实时直播正在进行的运行,对任何测试人员来说都是巨大的体验提升——而且对于让 AI 在测试期间介入以清除不稳定因素至关重要。人类或代理可以终止一个正走向超时的挂起流程,或者实时确认一个不稳定问题的修复。而这是只有 QA Wolf 才提供的能力。
为了实现它,我们必须成为像 Google Meet、Zoom 或 Twitch 那样的低延迟流媒体公司。事实上,我们的解决方案构建在它们所使用的同一种流媒体协议 WebRTC 之上。
但我们必须在这些公司从未面对过的约束下做到这一点:
- 编码费用由我们承担,而且是车队规模。在视频通话中,每个人有一个流,由用户的设备进行编码。我们并行运行数千个运行,每个流都在我们的基础设施上编码——因此对 Zoom 来说不可见的单流成本,在每月数万次运行中被成倍放大。
- 三个不共享代码的无头来源。这里没有摄像头,也没有人。我们的“发送端”是一个 Linux 容器的显示、一个 Android 虚拟机,或一台真实的 iPhone——三种完全不同的采集机制,它们都必须对同一个客户端看起来完全一致。
- 没有人去点击“重新连接”。发送端是一个无人值守的运行器。恢复、重连和优雅失败都必须自动完成,因为另一端没有人来重试。
- 它必须同时进行流式传输和录制。视频通话是短暂的——你观看,然后它就消失了。我们直播的每一次运行也都必须被捕获为持久录像,以便之后回看,而且两者都来自同一个连接,而不是两套独立的采集系统。
以下是我们如何构建它,以及在此过程中遇到的问题。
让一个流感觉“实时”——以及由此引发的连锁反应
从核心来看,每一个直播运行器——Web、Android 或 iOS——都有相同的概念形态:一个服务器端(在运行器或设备内部)负责采集屏幕并施加输入,以及一个客户端(在你的浏览器中)负责渲染视频并捕获你的点击和触摸。WebRTC 连接两者,通过一个信令步骤来介绍对等端,并通过媒体 + 数据通道将视频单向传输、将控制输入反向传输。
问题在于,每个平台对“服务器”端的实现完全不同。采集 Linux 容器的屏幕、Android 虚拟机的屏幕和真实 iPhone 的屏幕是三个互不相关的问题。统一发生在客户端:一个单一的 React 组件 @qawolf/webrtc-control(WebRtcScreenControl)被设计为渲染任何流来源——而且它们都必须感觉是实时的。

第 1 步:解决延迟问题
对我们来说,实时观看并不新鲜——我们一直允许用户在 playground 中观看浏览器。最初的实现使用 VNC:在每个 runner 容器中运行 TigerVNC 加上 websockify,浏览器通过 noVNC 库连接(一个 RFB——Remote Frame Buffer——客户端,将帧渲染到 DOM 元素中)。
VNC 能用,但它的所有天花板都源于同一个根源:它是为可靠的远程桌面而构建的,而不是为实时远程桌面。
- 延迟。VNC 运行在 WebSocket/TCP 之上,而 TCP 的可靠性保证——重传、按序交付、队头阻塞——对于不完美网络上的实时屏幕来说恰恰是错误的取舍。对于实时视频,你宁愿丢帧也不愿等待。
- 带宽。VNC 传输原始像素数据,随着分辨率和帧率的攀升,其扩展性很差。
- 移动端。VNC 流式传输整个桌面,设备只是其中的一部分,这破坏了检查器的坐标计算,而且感觉很笨拙。
- 无自动重连。一个 noVNC
RFB实例只包装一个会话,无法自动重连;恢复意味着将其拆除并重新创建——在没有人来操作的情况下,这是不可行的。
替代方案 WebRTC 是 Google Meet 背后的同一种技术,它是为相反的优先级而专门构建的:基于 UDP 的低延迟点对点媒体、高效视频编码(VP8/H.264)而非原始像素,以及用于控制的双向数据通道。其回报是你的浏览器与 runner 之间直接的 低于 200 毫秒连接,没有中间人转发每一帧——所以你看到的就是实时发生的——再加上更好的高动态性能、对移动端的原生适配,以及控制输入的快速路径。
但这种直接的点对点连接承担了大量工作——也正是它引出了下一个问题。
第 2 步:解决发现问题(信令、STUN 和 TURN)
WebRTC 的点对点特性对延迟很有利,但引入了可发现性问题:两个对等方必须找到彼此并打开一条直接路径。这有多难完全取决于另一个对等方所在的位置。当它是一个本地进程时——比如录制器通过 localhost 消费一次运行的流——这很简单:同一台机器,没有 NAT,无需穿越任何东西。但一旦有人观看,远端对等方就是他们的浏览器,位于公共互联网上、ISP 的 NAT 之后——无论他们是在 playground 会话中,还是在观察一次实时 run。这才是需要真正下功夫的情况。
让它们连接起来分两步。首先通过 信令 进行介绍——一个服务器(桌面/iOS 上用 WebSocket,Android 上用 gRPC),对等方通过它协商如何连接。然后它们必须真正跨越 NAT 相互到达:
- STUN 告诉对等方它自己的公网 IP 和 NAT 类型,以便两者可以通过 打洞 尝试直接连接。我们使用 Google 的公共 STUN 服务器以及我们自己的服务器。
- TURN 是当无法建立直接连接时(例如对称 NAT)的备选方案:一个具有公网可达 IP 的中继,当打洞失败时流量通过它流动。
我们在专用云虚拟机上运行 coturn(开源 STUN/TURN),配有静态 IP;每个对等端读取 iceServers 配置来建立连接。它部署在虚拟机上而非 Kubernetes 上有两个具体原因:TURN 需要大范围的 UDP 端口(例如 49152–65535),而 Kubernetes 负载均衡器不支持;并且会话中的两个对等端必须落在同一个 coturn 实例上。
第 3 步:解决成本问题
高效视频消耗 CPU,而在我们的规模下,CPU 就是一切。编码视频而不是传输原始像素,正是让 WebRTC 在带宽上可负担的原因——但这把成本转移到了 CPU 上,而我们的规模正是在这里咬人。一个会议应用在用户自己的设备上编码一路流。我们在自己的集群上编码数千路并发流,因此编码器的每路流 CPU 成本乘以我们的并行度,编解码器的选择就变成了容量问题,而非细节问题。
- VP8 是起点,但它很吃 CPU。
- H.264 作为更高效的替代方案被加入:它支持硬件加速和软件优化模式,自动检测硬件加速并回退到软件,即使在软件模式下,它通常也比 VP8 更省 CPU。
问题在于:我们必须解码自己的流。 在视频通话中,H.264 只需在一个地方解码——观看者的浏览器,它本来就会。我们的接收端是一串自动化消费者——录制器、截图处理器、AI 代理——每一个都必须自己解码视频,没有浏览器可以依靠。大家所依赖的 WebRTC 库都假设远端是浏览器,所以开箱即用时它们根本无法解析 H.264。要抓住效率优势,意味着分叉我们所依赖的 WebRTC 库,并编译进我们自己的 H.264 支持,以便那些无头消费者能读取流。这是编码账单问题的镜像:我们在编码时节省的每一个字节,我们这边仍然有东西必须解码——而没有浏览器替我们完成这项工作。
三台机器:OneControl 统御一切
QA Wolf 支持在 Linux 容器中测试 Web 应用、在虚拟化设备中测试 Android 应用,以及在真实 iPhone 和 iPad 上测试 iOS 应用——三台完全不同的机器,三种完全不同的从屏幕获取像素、把输入送回屏幕的方式,而且它们不共享代码。然而从你在浏览器中的位置来看,观看 Web 运行和观看 iPhone 运行应该感觉像同一个产品:相同的播放器、相同的控件、相同的低于 200 毫秒延迟。
所以这里的难点不在于证明这三个来源不同——它们当然不同。难点在于让它们对同一个客户端看起来一模一样,同时不让三个分叉的前端在并行中逐渐漂移。首先,每个来源与其他两个有何不同:
- Web / 桌面端——我们完全自主构建的那一个。与移动端来源不同,这里没有现成的流媒体服务器可以依赖——而且我们捕获的不是“浏览器窗口”,而是Linux 容器的 X11 显示(这也是为什么这些在内部被称为“桌面”运行器)。我们用 Go 从零编写:一个 WebSocket 信令服务,加上一个“服务器”对等端,通过 X11 协议同时完成屏幕捕获和输入注入——鼠标、滚动、按键,甚至双向剪贴板——基于
pion/webrtc构建以覆盖其功能。这是唯一一个我们拥有每一层的来源,因此它承担了最艰难的“自建还是采购”抉择(我们评估了 Selkies-GStreamer、neko、LiveKit、Guacamole 和 RDP 技术栈;构建一个专注的方案与改造其中任何一个成本相当,同时让我们可以自由优化)。 - Android——我们主要采用的那一个。Android 作为 Cuttlefish(Google 的 KVM 加速 Android 发行版)下的虚拟化设备运行,它已经自带一个任何客户端都可以连接的内置 WebRTC 服务器。因此,与 Web 和 iOS 不同,这里的胜利不是构建我们自己的流媒体——而是弃用旧的 VNC 服务器和
websockify,让 Cuttlefish 流式传输真正的全屏(元素检查器依赖于此),延迟更低、质量更好。它与其他两个来源的一个别扭差异是:Cuttlefish 通过 gRPC 而非 WebSocket 进行协商——这是阻碍我们实现完全统一客户端的最大障碍。 - iOS——运行在真实硬件上的那一个,也是我们的第一个。iOS 是唯一一个运行在真实设备而非虚拟机或容器上的来源,而且它实际上是我们第一个自定义 WebRTC 构建——因此它奠定了桌面端后来效仿的架构模式。它作为设备上的广播扩展运行,使用 ReplayKit 进行捕获,一个 WebSocket 信令服务器,一个用于触摸和键盘的控制通道,以及一个单独的设备上代理,将屏幕馈入 WebRTC。在真实硬件上运行使其成为该问题最困难的版本,这也正是它成为参考设计的原因:屏幕捕获、WebRTC 处理和输入控制之间的清晰分离,其他技术栈都可以向此靠拢。
我们如何统一它们:OneControl
如果放任不管,这三个技术栈不仅在服务器端不同——客户端也会漂移,而客户端才是用户实际接触的部分。Android 依赖 Google 的 android-emulator-webrtc 包(仅限 React,已被归档,且无法在无头自动化所需的 Node.js 环境中运行);桌面端和 iOS 使用 WebSocket,而 Android 使用 gRPC。三个来源将三个前端拉向三个方向,这就是你最终维护三个产品而不是一个产品的原因。
OneControl 就是我们将其收敛回一个的方式。解决方案归结为三个刻意的举措:
- 一个客户端,无论来源如何。一个单一的流媒体/控制组件(
@qawolf/webrtc-control、WebRtcScreenControl)加上一个共享的控制逻辑核心,可以渲染这三个来源中的任何一个——因此其上层的一切(播放器、元素检查器、仅查看模式)只需编写一次,永远不必知道底层是什么。 - 收敛异常值,而不是重建一切。我们选择 iOS WebSocket 模式作为参考,并从零重新实现桌面端以与之匹配,将三个来源中的两个放到共同的信令路径上,而不是让每个来源各自为政。
- 在已经最优的地方直接采用。我们明确将重建 Android 的 WebRTC 范围缩小,因为 Cuttlefish 的内置流式传输已接近最优,且真实 Android 设备不在近期路线图中。统一并不意味着强制每个来源完全相同——而是将仍然存在的差异(尤其是 Android 的 gRPC 信令)隐藏在共享客户端之后,让调用方永远看不到它们。
一条流水线,两个消费者:流式传输与录制
增加对运行进行直播的能力,意味着之前仅用于录制运行的同一流水线需要承担第二个角色。显而易见的构建方式是两套系统——一套用于流式传输,一套用于捕获到磁盘。但那样会……
- 使编码的 CPU 成本翻倍,却没有任何附加价值。
- 使需要捕获的视频流数量翻倍。
- 导致流与录制之间出现偏差。
所以我们只构建了一套——而 WebRTC 正是让这成为简单路径而非困难路径的关键。诀窍在于之前被视为负担的同一个点对点特性:WebRTC 发送方既不知道也不关心接收端是谁。这种漠不关心迫使我们做了上面讨论的所有 NAT 穿透工作,但在这里它却成了礼物。
因为源只一次捕获并编码单条轨道,然后简单地将其发出,接收端的任何东西都只是已有字节的另一个消费者——所以没有第二次编码,没有第二条捕获路径,也不会有任何东西失去同步(#3),因为根本不存在第二条流水线。同样的特性在另一个方向上也带来好处:因为每个额外消费者的成本几乎只是增加一点带宽,一条编码流可以同时分发给多个观看者——整个团队,加上我们的 AI 代理——都在同一次运行上。实现这一点需要我们这边做一些追赶:WebRTC 原生处理许多并发连接,但我们的运行器套接字层假设只有一个客户端,因此我们添加了显式的多客户端支持以及仅查看模式,以便观察者可以在不发送输入的情况下观看。(观察成为共享基础之上的独立工作流——运行器连接、认证和套接字服务器——支撑着若干相关功能。)
所以录制不是单独的捕获栈——它是连接了不同对等端的实时流水线。当你在观看运行时,对等端是你的浏览器;当没有人在观看时,对等端是一个本地进程,其唯一职责是消费同一条流并将其写入磁盘——相同的帧、相同的编码、相同的连接,不同的消费者。在 Web 上已经是这样工作的:录制器消费运行器正在流式传输的同一个 X11 显示,而在 iOS 上,一个基于 Go 的自定义录制器从 WebRTC 对等端做同样的事。Android 是仍在向此收敛的一个来源——目前它使用 adb screenrecord 单独录制,而直播作为自己的进程运行,当我们迁移到真实 Android 设备时,两者将合并。在录制实际上就是流加上另一个对等端的情况下,实时查看和保存的视频永远不会分叉成两条可能漂移的代码路径——改进流水线,两者同时改进。
E2E 测试从设计上就是敌对环境
让数据流从一台机器干净地传到另一台机器已经够难了,但我们还得让整个操作在会议应用会视为病态的条件下保持运行:
运行器无人值守,可能在流传输中途挂掉
在我们的系统里,两端都没有人坐着点击重连、重启卡死的进程,甚至注意到出问题了。
这成为问题的一种情况是:如果一次运行因内存不足错误而失败。运行器没有优雅地关闭——所以视频产物虽然存在,但已损坏(从未被正确关闭),而 UI 却一直显示进度转圈,仿佛它还在运行。
修复方案结合了一个短期 UI 指示器(用来区分内存不足失败和卡死)与长期工作:调整 ffmpeg 以输出部分可播放的视频,并让运行器优雅地处理 SIGTERM,从而让产物干净地关闭。
被测软件预期会失败
我们流式传输的不是通话中的人——而是我们预期会失败的软件,因为捕获这种失败正是全部意义所在。所以流水线必须忠实传递会议应用永远不会产生的状态:冻结的 UI、空白屏幕、流程中途崩溃。
这些恰恰是值得关注的时刻,这意味着不能让卡住的应用看起来像死掉的流——区分“应用卡死”和“运行器挂了”与上面处理陈旧转圈的问题是同一个问题。反过来也一样:一个沉重或病态的应用可能拖垮正在捕获和编码它的那台机器,浏览器在数据密集的页面上卡死,录制器在 iframe 密集的页面上吃力。这与其说是一次性修复,不如说是一种持续的纪律——让流如实反映应用真正在做什么,即使它什么都没做。
流将在不完美的网络上运行
观看流的人可能网络缓慢或不稳定,而 WebRTC 默认的 UDP 传输在这种环境下很吃力。在“慢速 4G”(约 5 Mbps)用户报告流完全无法连接后,我们添加了 TCP 回退——TCP/TLS ICE 候选——这样流仍能连接,并在低至约 3G 的速度下保持可用。一旦建立了慢速连接,它往往能维持住。从流传输中途连接质量下降中恢复,在实时演练路径上有效,但在 iOS 录制路径上仍是一个粗糙的边缘。
结论
这些都不是用户会看到的东西。他们打开一次运行并观看它发生——而整个成就就在于,WebRTC 握手、TURN 中继、读取我们自己 H.264 的分叉解码器、三个互不相关的捕获栈假装成一个客户端,以及悄悄搭在同一连接上的录制器,全都保持在视线之外。“添加一种观看运行的方式”最终意味着把一个实时流媒体栈藏在一个播放按钮后面。
它之所以难,从来不是视频本身——而是我们运行视频的条件。发送方无人值守,被测软件生来就会失败,观看者在我们无法控制的网络上,而这一切都乘以机群规模,在那里千分之一的故障不是边缘情况,而是此时此刻正在某个地方、某次运行上发生的事,且没有人在那里捕捉它。这项工作从来不是让流播放一次;而是让它每次都能自行播放。
我们还没做完。Android 仍然在自己的独立路径上录制,iOS 上的流中途恢复仍是个粗糙的边缘,而让流如实反映一个行为异常的应用究竟在做什么,与其说是一个已解决的问题,不如说是一项持续不断的功课。但真正重要的部分是实实在在的:运行不再是你只能干等的黑箱。你可以看着它展开,在卡住的流程烧到超时之前介入,并捕捉到某样东西出问题的确切时刻——而对于一家全部工作就是捕捉问题发生时刻的公司来说,这被证明值得它底下的每一层。
来源:Hacker News · qawolf.com