Playwright webServer 没等服务就开跑:url、port、wait
手动开着 dev server 跑测试全绿,一把服务交给 webServer 托管,第一批用例就红一片:page.goto 连接被拒,或者拿到一个还没渲染完的骨架页;重跑一次又过了。这种「首批挂、重试就好」的症状,通常不是页面的毛病,是 Playwright 判定「服务已就绪」的那个时间点,比你以为的早。
早多少、早在哪,取决于你在 webServer 里写的是 url、port 还是 wait。这三者在仓库里是三种完全不同的探活语义。
一、先看它到底在等什么
实现在 packages/playwright/src/plugins/webServerPlugin.ts,class WebServerPlugin 的 setup() 顺序是:装探活回调 → _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 出去之后就立刻开跑测试,一秒都不等。 这是「没等服务」最彻底的一种,而且它不报任何错——因为在代码语义上它没做错什么。
port 和 url 的分工发生在 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.md 里 DEBUG=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 ...→ 准备执行commandProcess startedWaiting 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 毫秒(这是仓库当前代码里的值,随版本可能变动)。所以日志里间隔越拉越大是正常退避,不是卡死;真正卡死的表现是这几行一直刷到超时报错为止。
判定规则很直接:
- 日志里根本没有
Waiting for availability...→ 你既没配url/port,也没配wait,命中第一节那个 early return; - 有
Waiting <N>ms但没有HTTP GET:→ 走的是端口探活,服务只是 listen 上了; - 一上来就是
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']。
这里有个容易踩的语义:url 和 wait 同时给的时候,_waitForProcess 用的是 Promise.race,文档也写明「至少满足一个条件即认为已启动」。它不是「两个都满足」,想要「与」的语义,我们在仓库里没有找到对应开关。如果你日志打得比服务可用早,加了 wait 反而会让放行更早。
命中了已有服务:reuseExistingServer 为 true 时,探活一通过就 return,命令不执行。文档建议的写法是 reuseExistingServer: !process.env.CI——本地复用手边开着的 dev server,CI 上不复用。本地反复出现这条日志,多半是上一轮的进程没退干净,端口被旧代码占着。
服务起来了但行为跟手动启动不一样:先怀疑环境变量。_startProcess 传给子进程的 env 是三层合并,顺序是 DEFAULT_ENVIRONMENT_VARIABLES → process.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.ts 的 setPlaywrightTestProcessEnv(),设在 runner 进程自己身上,再随 process.env 一起被子进程继承,函数上方的注释自述就是用来标记「本进程及其子进程运行在 Playwright Test 下」。判断服务是不是被这个变量改了行为,直接在你的启动脚本里打一下它就知道。
以上为按仓库代码中的接口语义组合的示例,未经实测,以仓库最新代码为准。
四、Windows 这一侧要多看一眼
gracefulShutdown 在 Windows 上是不生效的。文档表格写明 Windows 不支持 SIGTERM 与 SIGINT,这个选项在 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: true,command 是交给 shell 执行的,Windows 上的 shell 与 bash 语法差异也归你自己处理。
五、怎么验证改对了
再开一次 DEBUG=pw:webserver,看这三件事:
Waiting for availability...出现了;- 中间有
HTTP GET:与HTTP Status:,且最后那个状态码在放行区间内(>= 200 && < 404); 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.:reuseExistingServer为false且端口被占,是「不许复用」的保护,不是时序问题。 - 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 签名、配置项与默认值随版本变动,请以仓库最新内容为准。
本文不涉及浏览器版本矩阵与版本清单,相关信息请以官方发布说明为准。