返回知识中心
入门

Selenium 无头 Chrome:ChromeOptions 与页面差异

Selenium 无头 Chrome 与有头会话在页面可读字段上并不相同。真正起作用的 ChromeOptions、默认窗口尺寸,以及为何无头模式更容易被区分。

BotBrowser Team

文档中心

想直接进入 入门 文档吗?

这篇文章属于知识中心的指南库。若你要步骤化配置、参考说明和持续更新,请直接进入对应 docs 分区。

用 Selenium 跑无头模式,只是 ChromeOptions 里的一个参数,但底层换成了另一个浏览器;多数困惑就出在这两件事之间的落差里。BotBrowser 提供已声明的配置文件,让页面可读的各项表面在每次启动时彼此保持一致,它不改变 Selenium 向 Chrome 提出的要求;你传入的选项决定模式,配置文件决定该模式对外报告什么。一次本地运行的三组会话,能看出模式切换时哪些字段会动、哪些不会。

摘要

  • --headless=new 是当前 Selenium 文档在 Chrome 示例中给出的写法,它运行完整浏览器,只是没有窗口。
  • 旧实现不是该参数的另一个取值,而是另一个可执行文件 chrome-headless-shell。
  • 模式写在 ChromeOptions 里:同一个 --headless=new 字符串通过各绑定自己的参数方法传入,如下方代码块所示。
  • 在本次记录的运行中,无头与有头在视口、设备像素比和逻辑核心数上一致。
  • 二者的差异在用户代理 token、shell 的插件数量,以及 shell 的 chrome 对象上。
  • 当这些字段互相矛盾时,无头会话比有头会话更容易被单独识别出来。
  • 每次运行都记录二进制文件、token 和插件数量;单看参数无法说明实际运行了哪个浏览器。

目录

两个面板。左侧显示 Selenium 中启用无头模式的 ChromeOptions 调用,以及改变行为的三个参数。右侧列出页面在无头与有头会话中读取的字段,并标出一次记录运行中哪些一致、哪些不同。

模式在哪里设置

Selenium 自己从不选择无头模式,它把参数交给 Chrome。当前 Selenium 文档在 Chrome 示例中给出 --headless=new,同一个字符串出现在每种语言绑定里,因为参数是原样透传的。

Java        options.addArguments("--headless=new");
Python      options.add_argument("--headless=new")
C#          options.AddArgument("--headless=new");
JS          options.addArguments('--headless=new')

旧实现不是该参数的另一个取值。它是另一个可执行文件 chrome-headless-shell,由 Chrome for Testing 单独发布;Selenium 是通过把浏览器二进制指向那个路径来使用它,而不是修改参数。这一区别解释了一类报告:两个团队传入完全相同的选项,却观察到不同的浏览器。

两种实现的完整对比,包括各自对插件和 chrome 对象的报告,在 Chrome headless 新模式与旧模式 里;与 Selenium 相关的部分只是二进制如何被选中。

same options + chrome binary            -> unified headless
same options + chrome-headless-shell    -> old implementation

Selenium 4 要求 ChromeDriver 的主版本与 Chrome 主版本一致,文档写得很直接。版本不一致会在创建会话时失败,这是好的失败;坏的情况是驱动匹配而二进制不匹配,因为此时会话照常启动,并报告那个二进制所报告的一切。

二进制及其配置文件的来源是另一个决定,有自己的一套检查清单,Selenium 浏览器配置文件集成 讨论的是无头参数触及不到的会话归属问题。

改变行为的 ChromeOptions

除模式本身之外,还有三个选项会改变页面看到的内容,每一个都对应一种可避免的失败。

--headless=new                 full browser, no window
--window-size=1920,1080        without it, the window is 800x600 or the driver default
--disable-gpu                  older advice; check whether the target needs GPU paths

窗口尺寸最常用来解释本地运行与 CI 之间的视觉差异。没有显式尺寸的无头会话会从一个很小的默认值开始,而在断点以下会重新排版的页面,会渲染出另一份文档,而不是同一份文档放进更小的画框。显式设定尺寸,比较才有意义。

--disable-gpu 是无头模式默认使用软件光栅化那个年代遗留的建议。在有可用 GPU 的机器上,它会改变实际执行的渲染路径,所以不做检查就把它复制进新套件,是在引入差异而不是消除差异。

option                     what it changes                     check before adopting
--headless=new             window visibility, not the engine   yes, it is the mode
--window-size              layout breakpoints                  set it to the product viewport
--disable-gpu              rendering path                      only if the host has no GPU

还有两个选项几乎出现在每段被复制的代码里,而它们都不是无头模式所必需的。--no-sandbox 关闭一道安全边界,属于容器环境的变通做法,而不是无头设置;--disable-dev-shm-usage 用来绕过某些容器里过小的 /dev/shm。两者都改变页面所处的环境,都应放在主机配置里,而不是放在一个本地也要运行的测试文件里。

第四个选项值得点名,因为它根本不是 Chrome 参数:配置文件目录。Selenium 像传其他参数一样传 --user-data-dir,一旦缺失,驱动会创建一个临时配置文件,并在会话结束时丢弃。对从干净状态开始的测试这没问题,对期望登录状态或扩展能保留的测试就不对;而且失败是静默的,因为新配置文件看起来就像正常浏览器,直到第一条依赖已保存状态的断言。

options.addArguments("--user-data-dir=/tmp/profile-42")   -> state survives the session
no --user-data-dir                                        -> temporary profile, discarded

参数顺序对 Chrome 不重要,但配置顺序对阅读者重要。先定二进制,再定模式,再定窗口,再定配置文件,最后是主机层面的变通。用别的顺序混在一起的代码片段并不算错,只是当会话行为异常时会难审阅得多。

本地与 CI 运行同一个测试

最常见的 Selenium 无头问题其实不是无头问题,而是写测试的机器与跑测试的机器之间的差异。有三项主机属性会改变页面渲染出的文档,而它们都不由 ChromeOptions 设定。

property        local value              CI value                 effect
fonts           full system set          minimal or none          glyphs render as boxes
GPU             hardware path            software rasteriser      subtle anti-aliasing change
display         real screen              none, or a virtual one   window metrics differ

当无头运行产出的图像看起来几乎正确时,第一项要查的是字体。没有 CJK 字体的容器会把这些字符渲染成 notdef 方块,而在同一个容器上用有头模式失败方式完全相同,这就是线索:如果有头也以同样方式失败,变量就不是模式。

第二项是 GPU 路径。在没有可用 GPU 的机器上,无头会话会落到软件光栅化,合成输出与同一台机器上的有头会话可能相差几个亚像素值。因此,在开发者笔记本上调好的比较容差,会报告来自主机而非被测代码的差异。

第三项是显示。在服务器上,Selenium 用户最先撞上的就是它。有头 Chrome 需要显示服务器,无头不需要。这个不对称正是 CI 采用无头模式的全部理由,也意味着在无头下通过、在有头下失败的套件,通常是显示问题而不是渲染问题。

第四项主机属性是时间。容器启动时带着宿主机的时钟与时区,格式化日期的页面在 UTC 时区会话与开发者时区会话里会渲染出不同字符串。这种差异在视觉对比里表现为一块变化的文本区域,直到读字符串之前都像是字体问题。

host property     how it shows up in a diff
fonts             boxes, wrong glyph shapes, changed text width
GPU               subpixel differences across the whole image
display           window metrics, outerWidth
time zone         a different date or time string in one region

时区值得在运行报告里写一句,因为排除它很便宜:在两个会话里分别打印 Intl.DateTimeFormat().resolvedOptions().timeZone 和 navigator.language,不一致本身就能解释自己。

passes headless, fails headed  -> check the display server first
fails both the same way        -> host property (fonts, GPU, libraries)
fails only headless            -> mode or binary

把这三项属性记进运行报告,上表事后才用得上。一张没有字体清单、没有 GPU 描述的截图,只是一台未知机器的照片。

HTML 本身不同的时候

无头会话返回与有头不同的标记,与返回不同像素是两类问题,诊断也短得多。页面要么执行了不同的 JavaScript,要么收到了不同的值,两者都能在 DOM 里看到。

1. dump document.documentElement.outerHTML in both modes
2. diff the two files, ignoring timestamps and generated ids
3. for each difference, check whether a page script branches on a value you record

第三步是大多数情况的终点。依据 navigator.userAgent 分支的页面会产出不同标记,因为 token 不同,这是预期而非缺陷。依据 navigator.plugins.length 分支的页面只在二进制变化时产出不同标记,那是另一个结论,也有另一种修法。

branch on userAgent       -> differs between headless and headed, expected
branch on plugins.length  -> differs only when the binary is the shell
branch on innerWidth      -> differs only when the window option is missing

这张映射把对比变成决定。它也解释了为什么本地通过、CI 失败的套件会败在没人记录过的值上:分支存在于页面里,而记录的字段告诉你它走了哪一支。

值得点名的失败模式

有四种失败解释了大多数最终与无头无关的 Selenium 无头报告,每一种在调试之前都有可见特征。

symptom                                  actual cause
element not visible, headed works        window size below the breakpoint
session creation fails immediately       driver and browser major mismatch
blank screenshot, text renders as boxes  missing fonts in the container
stale element after navigation           the element handle was captured before the load

第一种是前面说过的窗口尺寸情形:元素存在,页面把它排到可见区域之外,而无头会话的小默认窗口就把它报告为不可见。第二种是版本不一致,与模式无关,但它出现在同样的搜索里,因为两种失败都在新环境第一次运行时暴露。

第三种是字体情形,它产出的截图看起来是坏的,而不是空的。第四种是等待问题而非渲染问题,Selenium 等待与稳定测试 说明了稳定等待的条件。

check before debugging the page:
  1. window size at or above the product viewport
  2. driver major equals browser major
  3. container font set matches the developer machine
  4. the element handle was obtained after the navigation settled

按顺序走完这张清单只要几分钟,就能排除最常见的四种解释。四条都排除后仍然存在的失败,才值得在页面层面调试;此时前几节记录的字段会说明是哪个模式和哪个二进制造成的。

页面读到的差异

一次本地运行在同一台主机上记录了三组会话的相同字段,使用标准 Chromium 构建,每组会话一个一次性配置文件。这些值是那次运行打印出来的结果,不是对每个构建的断言。

unified headless  HeadlessChrome/131.0.0.0      plugins=5  chrome=object
headless shell    HeadlessChrome/131.0.6778.33  plugins=0  chrome=undefined
headed            Chrome/131.0.0.0              plugins=5  chrome=object

webdriver=true in all three (Selenium sets it)

token 是页面最先读取的字段,也是最明显区分模式的一个:两种无头变体都报告 HeadlessChrome,有头报告 Chrome。这是有意且写进文档的信号,不是意外,所以断言 token 的套件必须先决定自己在断言什么。

navigator.userAgent    HeadlessChrome in headless, Chrome in headed
navigator.plugins      0 on the shell, 5 on unified and headed
window.chrome          undefined on the shell, object on the others

插件数量和 chrome 对象这两个字段区分的是 shell 与另外两者,而不是无头与有头。这对调试本地与 CI 差异的人很关键:如果插件数量变了,变的是二进制,不是模式。

默认窗口与屏幕值

让这次记录运行意外的是那些没有变化的测量值。视口、屏幕尺寸和设备像素比在三组会话里完全相同,只有 outerWidth 不同,有头会话因窗口边框多了两个像素。

field              unified   shell   headed
innerWidth         1280      1280    1280
innerHeight        720       720     720
screen.width       1280      1280    1280
outerWidth         1280      1280    1282
devicePixelRatio   1         1       1

实际后果是,跨这些模式做截图比较,测的并不是窗口尺寸。如果同一页面的两次无头运行产出不同图像,差异来自动画、懒加载或字体,换模式不会修好它。

same mode, two runs differ  -> animation, timing or fonts
different modes, same size  -> mode or binary, not the viewport

这个区分值得写进测试报告,因为没有原因说明的视觉差异会引来错误的修法。把四个视口字段记在图像旁边,就把「截图变了」变成「截图变了,而视口没变」。

无头模式为何更容易被区分

无头会话本身并不可疑,但当它的字段互相矛盾时,更容易被单独识别出来。记录的那次运行是干净的例子:token 说是无头,浏览器其余部分看起来普通。被改过的会话可能相反,token 说一套,插件列表或 chrome 对象说另一套。

coherent headless    -> token says headless, everything else consistent
patched headless     -> token rewritten, plugin list or chrome object not

所以有用的比较是在字段之间进行,而不是对照单一期望值。只读 navigator.userAgent 的页面,一次改写就能满足;同时读 token、插件数量和 chrome 对象的页面,检查的是浏览器内部是否自洽。浏览器内核与注入覆盖的差别 从另一个方向说了同一件事。

Selenium 自己还带来一个信号:navigator.webdriver 为 true,因为驱动会设置它,有头模式同样如此。因此它不是无头的证据,把它当成证据的测试得到的是关于驱动的结论,而不是关于模式的结论。

同样的逻辑适用于自动化提示条。某些版本的 Chrome 在被驱动启动时会显示「自动化测试软件」信息条,那条信息条是界面元素而不是页面可见值,所以无头会话不显示,有头会话可能显示。因此包含浏览器外框的截图比较,会因与页面无关的原因而在两种模式间不同。

page-level fields     token, plugins, chrome object, viewport, time zone
window-level chrome   infobar, window frame, tab strip

截取页面而不是窗口,就能把第二行从比较里去掉。Selenium 自带的截图 API 截取视口,正因如此它才是合适的默认做法;而从桌面截整窗、不走驱动,会把问题重新引入。

对要在多个浏览器构建上跑同一套件的人,还有一个字段值得记录。HeadlessChrome token 带有版本,该 token 里的版本就是产出这次会话的构建。在两次运行之间升级 Chrome 却只记录「无头」的套件,丢掉的正是能解释渲染变化的字段,修法是报告里多一行,而不是改测试本身。

recorded token:  HeadlessChrome/131.0.0.0    -> build 131, headless
recorded token:  HeadlessChrome/131.0.6778.33 -> shell build, patch differs

这一行也是回答最初那个问题的最快方式。相同选项而 token 不同的两次运行,跑的是不同二进制,再怎么调选项也不会让它们一致。

token 相同而行为不同的两次运行是更难的情况,几乎总是主机差异而不是模式差异。token 说明构建;字体、GPU 路径和时区说明机器;上面四项检查用来区分这两类。

实践结论

用 --headless=new 设定模式,把窗口尺寸设成产品视口,并把二进制路径和用户代理 token 一起记录。这三项事实把 shell、统一无头和会话有头区分开,而且是在页面一侧完成,测试不必读启动代码就能断言。

当无头运行与有头不同时,按这个顺序检查字段:token、插件数量、chrome 对象、视口、设备像素比。前三项是模式与二进制属性,后两项属于主机和选项。第一组里的差异是启动决定,第二组里的差异是配置问题。

需要避免的说法是无头什么都不留下可读。这不成立,记录的那次运行恰好显示了模式在哪里可见。成立的是更窄也更实用的一点:自洽的无头会话就是没有窗口的完整浏览器,描述它的字段可以像其他字段一样读取和记录。

来源

#Selenium#无头 Chrome#ChromeOptions#Chromedriver#自动化

本文属于指南: 浏览器自动化入门:首次运行地图

在 BotBrowser 里跑一遍同样的检查

加载 profile,让刚读到的这些信号在浏览器内部保持一致,并在正式上线前先在自己的机器上确认结果。