Playwright webServer 没等服务就开跑:url、port、wait

2026-08-18

手动开着 dev server 跑测试全绿,一把服务交给 webServer 托管,第一批用例就红一片:page.goto 连接被拒,或者拿到一个还没渲染完的骨架页;重跑一次又过了。这种「首批挂、重试就好」的症状,通常不是页面的毛病,是 Playwright 判定「服务已就绪」的那个时间点,比你以为的早。

早多少、早在哪,取决于你在 webServer 里写的是 urlport 还是 wait。这三者在仓库里是三种完全不同的探活语义。

一、先看它到底在等什么

实现在 packages/playwright/src/plugins/webServerPlugin.tsclass WebServerPluginsetup() 顺序是:装探活回调 → _startProcess()_waitForProcess()

第一步就是分水岭:

if (this._options.url)
  this._isAvailableCallback = getIsAvailableFunction(this._options.url, this._checkPortOnly, !!this._options.ignoreHTTPSErrors, this._reporter.onStdErr?.bind(this._reporter));

探活回调只在有 url 的时候才装。而 _waitForProcess() 的开头是:

if (!this._isAvailableCallback && !this._waitForStdioPromise) {
  this._processExitedPromise.catch(() => {});
  return;
}

既没有探活回调、也没有 wait,这个方法直接 return。也就是说:webServer 里只写了 command,Playwright 在把进程 spawn 出去之后就立刻开跑测试,一秒都不等。 这是「没等服务」最彻底的一种,而且它不报任何错——因为在代码语义上它没做错什么。

porturl 的分工发生在 webServerPluginsForConfig 里:两个都写会直接抛 Either 'port' or 'url' should be specified in config.webServer.;只写 port 时,url 被合成成 http://localhost:${port},同时给插件构造函数传 checkPortOnly = true

于是 getIsAvailableFunction 分成两条路:

  • checkPortOnly 为真 → 走 isPortUsed(+port),用 net.connect 同时打 127.0.0.1::1任意一边 connect 成功就算可用
  • 否则 → 走 isURLAvailable(在 packages/utils/network.ts),发一个带 Accept: */* 的 HTTP GET,判定条件是 statusCode >= 200 && statusCode < 404;如果拿到 404 且 URL 的 pathname 恰好是 /,会再试一次 /index.html

这就是本篇的落点:port 只要求 TCP 握手得通,url 要求你回一个状态码。 很多 dev server 是先 listen 端口、再去编译首屏(这是前端工具链的通用现象,不是仓库里的说法),port 语义就会在编译完成之前放行。指南页 docs/src/test-webserver-js.md 的属性表里 port 已标注为 Deprecated,写着用 url 代替;顺带一提,API 属性页 docs/src/test-api/class-testconfig.md 里的 port 条目没有带这个标记,两处说法不一致,按指南页的方向走更稳妥。

二、怎么确认就是这个问题

不要靠猜配置,把插件的调试日志打开。docs/src/test-api/class-testconfig.md 明写了这个变量:要看 stdout,设置 DEBUG=pw:webserver。代码里对应的是 const debugWebServer = debug('pw:webserver')

Linux / macOS:

DEBUG=pw:webserver npx playwright test

Windows 命令提示符(形式照 docs/src/debug.mdDEBUG=pw:api 的 batch 写法,只换了 debug 命名空间):

set DEBUG=pw:webserver
npx playwright test

Windows PowerShell:

$env:DEBUG="pw:webserver"
npx playwright test

然后照着代码里的日志字面量对号入座,这几条都能在 webServerPlugin.ts 里 grep 到:

  • Starting WebServer process ... → 准备执行 command
  • Process started
  • Waiting for availability...进入等待阶段了
  • HTTP GET: <url> / HTTP Status: <code> → 走的是 URL 探活(这两条在 httpStatusCode 里)
  • Waiting <N>ms → 一轮探活没通过,退避后重试
  • WebServer available → 放行,开始跑测试
  • WebServer is already available → 命中了已有服务

顺带解释一下 Waiting <N>ms 为什么间隔会变:探活轮询在 waitFor 里,退避序列写死成 const logScale = [100, 250, 500],用完之后固定退避 1000 毫秒(这是仓库当前代码里的值,随版本可能变动)。所以日志里间隔越拉越大是正常退避,不是卡死;真正卡死的表现是这几行一直刷到超时报错为止。

判定规则很直接:

  1. 日志里根本没有 Waiting for availability... → 你既没配 url/port,也没配 wait,命中第一节那个 early return;
  2. Waiting <N>ms没有 HTTP GET: → 走的是端口探活,服务只是 listen 上了;
  3. 一上来就是 WebServer is already available → 你的 command 压根没执行,跑的是别的进程提供的服务。

三、按判定结果处置

只写了 command:补一个 url。仓库里给的最小形态是这样(http://localhost:3000 是仓库示例里的值):

import { defineConfig } from '@playwright/test';

export default defineConfig({
  webServer: {
    command: 'npm run start',
    url: 'http://localhost:3000',
    reuseExistingServer: !process.env.CI,
    stdout: 'ignore',
    stderr: 'pipe',
  },
});

用的是 port:换成 url,但要留意一个连带后果。webServerPluginsForConfig 里只有「给了 port 而没给 url」时才会写 process.env.PLAYWRIGHT_TEST_BASE_URL,代码注释自述这是一个 legacy 模式;文档也写明 port(而不是 url)会被传给 baseURL。换成 url 之后 baseURL 不再自动来了,测试里的相对路径 page.goto('./login') 会跟着一起崩。所以改 port 的同时要显式补 use: { baseURL: ... }。数组形式的 webServer 也一样——packages/playwright/src/common/config.ts 在数组模式下把 config.webServer 置为 null,文档因此写明数组形式必须自己配 baseURL,哪怕数组里只有一项。

URL 太早就返回 2xx(比如网关先起来、真正的应用还在后面):用 wait 去抓启动日志。下面是 class-testconfig.md 里那个示例的 webServer 部分,原样照抄(文档里它外面还包着 defineConfig):

webServer: {
  command: 'npm run start',
  wait: {
    stdout: /Listening on port (?<my_server_port>\d+)/
  },
},

代码里 wait 的行为是:把 stdout/stderr 累积起来(先过一遍 stripVTControlCharacters 剥掉颜色控制符),命中正则后把 named capture group 逐个写进 process.env键名会被转成大写key.toUpperCase()),所以上面示例对应的是 process.env['MY_SERVER_PORT']

这里有个容易踩的语义:urlwait 同时给的时候,_waitForProcess 用的是 Promise.race,文档也写明「至少满足一个条件即认为已启动」。它不是「两个都满足」,想要「与」的语义,我们在仓库里没有找到对应开关。如果你日志打得比服务可用早,加了 wait 反而会让放行更早。

命中了已有服务reuseExistingServertrue 时,探活一通过就 return,命令不执行。文档建议的写法是 reuseExistingServer: !process.env.CI——本地复用手边开着的 dev server,CI 上不复用。本地反复出现这条日志,多半是上一轮的进程没退干净,端口被旧代码占着。

服务起来了但行为跟手动启动不一样:先怀疑环境变量。_startProcess 传给子进程的 env 是三层合并,顺序是 DEFAULT_ENVIRONMENT_VARIABLESprocess.env → 你写的 env。第一层在代码里是 BROWSER: 'none'FORCE_COLOR: '1'DEBUG_COLORS: '1' 三项,其中 BROWSER: 'none' 那行带着注释,自述是为了避免 create-react-app 自动开浏览器。注意合并顺序意味着 process.env 会盖掉这层默认——你 shell 里如果本来就导出过同名变量,默认值不会生效。另外文档表格里说 env 默认继承 process.env 并附加 PLAYWRIGHT_TEST=1,这一项不在插件的那三个默认值里,它来自 packages/utils/env.tssetPlaywrightTestProcessEnv(),设在 runner 进程自己身上,再随 process.env 一起被子进程继承,函数上方的注释自述就是用来标记「本进程及其子进程运行在 Playwright Test 下」。判断服务是不是被这个变量改了行为,直接在你的启动脚本里打一下它就知道。

以上为按仓库代码中的接口语义组合的示例,未经实测,以仓库最新代码为准。

四、Windows 这一侧要多看一眼

gracefulShutdown 在 Windows 上是不生效的。文档表格写明 Windows 不支持 SIGTERMSIGINT,这个选项在 Windows 上被忽略;代码里对应的是 attemptToGracefullyClose 开头那句 if (process.platform === 'win32') throw new Error('Graceful shutdown is not supported on Windows'),未指定时进程组会被强制 SIGKILL

把这条和上一节接起来就是 Windows 用户的高频剧本:上一轮 dev server 的子进程没被干净带走,端口还占着,下一轮 reuseExistingServer: true 一探活就通过,于是你以为在测新代码,其实连的是上一轮的旧服务。判据就是日志第一行的 WebServer is already available。另外 launchProcess 传的是 shell: truecommand 是交给 shell 执行的,Windows 上的 shell 与 bash 语法差异也归你自己处理。

五、怎么验证改对了

再开一次 DEBUG=pw:webserver,看这三件事:

  1. Waiting for availability... 出现了;
  2. 中间有 HTTP GET:HTTP Status:,且最后那个状态码在放行区间内(>= 200 && < 404);
  3. WebServer available 出现在第一条测试输出之前。

想顺带看服务自己的启动日志,把 stdout 从默认的 "ignore" 改成 "pipe"stderr 的默认值是 "pipe";这两个是仓库当前文档里的默认值,随版本可能变动)。name 会作为日志行前缀,代码里 prefixOutputLines 的默认前缀是 WebServer,多服务并行时给每个起个名字才分得清谁在说话。

一个反直觉的点:DEBUG 打开时 stdout 是无条件被转发的(代码条件是 debugWebServer.enabled || this._options.stdout === 'pipe')。所以关掉 DEBUG 之后日志消失,不代表你的 stdout: 'pipe' 没生效——很可能是你压根没改那一项。

六、什么情况说明不是这个原因

以下几种,方向都不在「没等」上,别顺着探活语义继续改:

  • Timed out waiting <N>ms from config.webServer.:这是等了但没等到timeout 的默认值文档写的是 60000 毫秒(仓库当前文档里的默认值,随版本可能变动),要放宽照 timeout: 120 * 1000 这个仓库示例写法即可。真正该查的是服务为什么没起来、URL 是不是指错了。
  • Process from config.webServer was not able to start. Exit code: <code>Process from config.webServer exited early.:进程自己退了,去看命令本身和 cwd——cwd 默认是配置文件所在目录,代码里用 path.resolve(configDir, cwd) 解析,写相对路径时基准是它不是你的终端目录。
  • ... is already used, make sure that nothing is running on the port/url or set reuseExistingServer:true in config.webServer.reuseExistingServerfalse 且端口被占,是「不许复用」的保护,不是时序问题。
  • stderr 里出现 [WebServer] Self-signed certificate detected. Try adding ignoreHTTPSErrors: true to config.webServer.:这是 TLS 校验挡住了探活请求,对应的开关是 ignoreHTTPSErrors(文档写明默认 false,同样随版本可能变动)。
  • 日志显示 WebServer available 明确在测试之前、且带着 2xx 的 HTTP Status,用例仍然挂:探活已经按 URL 语义完成了,问题在应用侧的异步渲染,该用断言与定位器自带的等待去解,不是 webServer 的事。
  • url 指向的不是根路径且一直 404:isURLAvailable 里回退 /index.html 的分支只在 pathname 为 /才走,其余路径的 404 会一路不可用直到超时。这属于 URL 写错,不属于等待不足。

最后提醒一句:webServer 会在你本机用 shell 执行一条真实命令、拉起真实进程并访问真实端口,它没有沙箱。CI 与本地的 reuseExistingServer 取值要分开对待。安全与合规相关做法请结合自身环境评估,本文不构成安全方案建议。


本文依据 github.com/microsoft/playwright 仓库于 2026-08-18 的公开内容整理, 事实来自仓库内的文档与源码。我们没有对文中涉及的功能做过实测, 因此不涉及运行速度、稳定性与实际表现的任何描述。 该项目迭代频繁,文中涉及的 API 签名、配置项与默认值随版本变动,请以仓库最新内容为准。 本文不涉及浏览器版本矩阵与版本清单,相关信息请以官方发布说明为准。

想系统学会用 AI?报名体系课或加入会员,照着学、照着用。