触发下载后拿不到文件:Playwright 的 download 事件与保存路径

2026-08-18
站内工具 AI 编程工具报错分诊器 → 把报错原文贴进去,先分清是网络、额度、配置还是上游故障,再决定往哪个方向查。

下载这件事在自动化脚本里翻车的方式很集中:点击动作执行了,页面上看着也确实在下载,但脚本这边要么等不到对象,要么等到了对象、调 path() 报错,要么文件保存下来了、用例跑完却发现目录里空空如也。这三种表现背后其实是三件不同的事,而且仓库文档里都写得很直白,只是分散在两个文件里,容易只读一半。

下面按仓库里能查到的语义逐条对,涉及的文件是 docs/src/downloads.mddocs/src/api/class-download.md,配置项的定义在 docs/src/api/params.md。该项目持续更新,文中涉及的 API 签名、选项与默认值随版本变动,请以仓库最新内容为准。

现象长什么样

最常见的三种:

第一种,点击之后脚本直接往下走,等待下载的那一步超时,或者干脆永远挂在那里不动。

第二种,拿到了 Download 对象,但调用取路径的方法时抛错——文档对这个方法写明了一种会抛错的情形,见后文第五步。

第三种,saveAs 也调了、日志也打了,用例结束后去看目录,文件不在。或者更迷惑:文件在,但文件名是一串看不懂的字符。

第一步:确认你是不是等晚了

docs/src/api/class-download.md 在类说明里写了一句关键的话:download 事件在下载开始时触发,而下载路径要等下载完成之后才可用。这两个时间点是分开的,很多写法的问题就出在把它们当成一个。

由此派生出仓库示例里那个看着别扭的写法——先开始等,再点击。JS 侧 docs/src/downloads.md 给的原样是:

// Start waiting for download before clicking. Note no await.
const downloadPromise = page.waitForEvent('download');
await page.getByText('Download file').click();
const download = await downloadPromise;

// Wait for the download process to complete and save the downloaded file somewhere.
await download.saveAs('/path/to/save/at/' + download.suggestedFilename());

注释里那句 Note no await 是重点:第一行不能 await,否则点击永远不会发生。如果你把顺序写成「先点击、再 await page.waitForEvent('download')」,事件已经在点击那一刻发过了,后面这个等待就只能等下一次下载。

可执行的判定动作:把你代码里点击那一行和等待那一行的先后顺序拉出来看。等待的 promise(或 context manager)必须在触发动作之前建立。这一条不用跑任何东西就能判定。

第二步:确认你用的是本语言绑定里存在的那个方法

这一步是跨语言迁移代码时最容易踩的。docs/src/api/class-page.mdPage.waitForDownload 标的是 * langs: java, python, csharp——这个列表里没有 js。JS 侧没有这个专用方法,用的是通用的 page.waitForEvent('download')。反过来,Python 侧的别名标注是 alias-python: expect_download,C# 的别名标注是 alias-csharp: RunAndWaitForDownload

仓库里四种绑定各自的原样写法如下。Python 同步模式:

# Start waiting for the download
with page.expect_download() as download_info:
    # Perform the action that initiates download
    page.get_by_text("Download file").click()
download = download_info.value

# Wait for the download process to complete and save the downloaded file somewhere
download.save_as("/path/to/save/at/" + download.suggested_filename)

Java:

// Wait for the download to start
Download download = page.waitForDownload(() -> {
    // Perform the action that initiates download
    page.getByText("Download file").click();
});

// Wait for the download process to complete and save the downloaded file somewhere
download.saveAs(Paths.get("/path/to/save/at/", download.suggestedFilename()));

C#:

// Start the task of waiting for the download before clicking
var waitForDownloadTask = page.WaitForDownloadAsync();
await page.GetByText("Download file").ClickAsync();
var download = await waitForDownloadTask;

// Wait for the download process to complete and save the downloaded file somewhere
await download.SaveAsAsync("/path/to/save/at/" + download.SuggestedFilename);

顺便留意一个命名细节:Python 侧建议文件名是属性 suggested_filename,不带括号;JS 侧 suggestedFilename() 是方法。从 JS 抄过去忘了去括号,报错信息会指向一个和下载毫无关系的方向。

还有一处只在文档里对得出来的差异:Download.createReadStream 标注的是 * langs: java, js, csharpPython 不在其中。所以按 JS 思路在 Python 里找流式读取的接口是找不到的,文档里的建议是下载完成后直接从磁盘读文件。

第三步:确认是不是永远等不到

如果顺序没错、方法也对,那就要看等待本身的超时语义了,这里各绑定的默认值不一样。

docs/src/api/params.mdwait-for-event-timeout 这段标的是 * langs: csharp, java, python,写明默认 30000(30 秒),传 0 可以禁用超时;而 JS 侧 Page.waitForEventoptionsOrPredicate 参数里,timeout 写的是默认 0——不超时,文档同时写明这个默认值可以通过配置里的 actionTimeout 选项修改,也可以用 BrowserContext.setDefaultTimeoutPage.setDefaultTimeout 改。以上均为仓库当前文档里的默认值,随版本可能变动。

把这两处放在一起就能解释一个现象:同一段逻辑,Python 侧会在半分钟后抛超时告诉你等不到,JS 侧如果没配 actionTimeout,表现就是「卡着不动」。卡住不报错本身不是 bug,是这个默认值的直接后果。

再往下有两个判定点值得先查,因为它们比调代码便宜:

一是事件级别。docs/src/api/class-browsercontext.md 里有 BrowserContext.download 事件,说明是「该 context 下任意页面开始下载附件时触发」,并明确提示了另有 Page.download 用于接收特定页面的事件。如果你的下载是点击后弹出新窗口触发的,挂在原 page 上的监听自然接不到。把监听换到 context 级别就能判定是不是这个原因。

二是配置里有没有把下载关掉。docs/src/api/params.mdacceptDownloads 的说明是「是否自动下载所有附件」,默认为 true,即所有下载都被接受(这是仓库当前文档里的默认值,随版本可能变动)。Test Runner 侧对应 TestOptions.acceptDownloads* since: v1.10),文档给的用法示例是:

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

export default defineConfig({
  use: {
    acceptDownloads: false,
  },
});

这是仓库示例里的取值。如果你的项目配置或者某个 test.use 里有这一行,排查方向就完全不同了。

第四步:确认文件到底被放到了哪里,以及什么时候被删

这是第三种现象的正解,也是最反直觉的一段。三处说明要连起来读:

  • docs/src/downloads.md:所有附件都下载到一个临时文件夹里;该文件里的提示框写明「产生这些下载的 browser context 关闭时,下载的文件会被删除」。
  • docs/src/api/class-download.md:属于该 browser context 的所有已下载文件,在 context 关闭时被删除。
  • docs/src/api/params.mddownloadsPath 的说明:指定了它,被接受的下载会下到这个目录;不指定则创建临时目录,浏览器关闭时删除。紧接着还有一句——不论哪种情况,下载文件都会在其所属的 browser context 关闭时被删除。

第三条那句「不论哪种情况」是很多人踩坑的地方:以为设了 downloadsPath 文件就归自己管了,实际上按文档语义它照样会随 context 关闭而清掉。要把文件留下来,用的是 Download.saveAs——docs/src/api/class-download.md 写明它「把下载复制到用户指定的路径」,并且「在下载仍在进行中调用也是安全的,必要时会等待下载完成」。复制这个词是关键,saveAs 的目标路径不在上面那套清理逻辑里。

downloadsPath 出现在 params.mdshared-browser-options-list-v1.8 中,也就是 BrowserType.launchBrowserType.launchPersistentContextBrowserType.launchServer 都能传。

至于「文件名是一串看不懂的字符」:Download.path 的说明里写明,下载文件的名字是一个随机 GUID,要拿建议文件名得用 Download.suggestedFilename。而 suggestedFilename 自己那段又写明,它通常由浏览器根据 Content-Disposition 响应头或 download 属性计算得出,不同浏览器可能用不同的逻辑。这句话的含义是:不要把建议文件名当成跨浏览器稳定的断言依据。

Windows 与 Linux/macOS 的差别:仓库示例里的 /path/to/save/at/ 是 POSIX 风格的占位路径,本来就只是占位,Windows 上不要照抄,换成你自己那台机器上的实际路径。另外 JS/Python/C# 三份示例用的都是字符串直接拼接,只有 Java 那份用的是 Paths.get(...) 两段式;在 Windows 上做拼接时,分隔符的处理交给各语言的路径 API 更省事——这一条是通用工程做法,不是 Playwright 官方内容。至于不指定 downloadsPath 时临时目录具体落在哪个位置,仓库文档里没有找到相关说明。

第五步:远程连接场景要单独看

Download.path 的说明末尾有一句独立的话:该方法在远程连接时会抛错。所以如果你的 CI 是连到远端浏览器服务跑的,任何依赖 path() 的代码都会在这一步失败,处置方式是改用 saveAs(复制到本地路径)或 createReadStream(那三种绑定里可用)。

另外 docs/src/api/class-browsertype.md 里,BrowserType.connectOverCDP 有个 artifactsDir 选项(* since: v1.61),说明是「浏览器产物(例如 trace 与下载)保存到该目录」;同一处还有 noDefaults 选项,写明置为 true 时 Playwright 不会对已有的默认 browser context 应用自己的覆盖,具体点名了 acceptDownloads 会保持浏览器自身的设置。连的是别人的浏览器又开了 noDefaults,下载行为就不再由你的配置决定了。

处置后怎么验证

不用跑完整用例,按下面几处对一遍即可:

  1. 等待对象建立在触发动作之前,且第一行没有 await(JS/C#)或用的是 context manager / 回调形式(Python/Java)。
  2. 落盘用的是 saveAs 到你自己的目录,而不是依赖 path() 返回的临时文件。
  3. 断言文件存在的代码,位置在 saveAs 之后、context 关闭之前。
  4. 需要确认下载本身是否失败时,用 Download.failure——它的说明是「返回下载错误(如果有),必要时会等待下载完成」。配套的是 Download.cancel* since: v1.13),文档写明取消成功后 download.failure() 会 resolve 成 'canceled',这个字符串可以直接当判定依据。
  5. 想确认事件确实来了但内容不对,可以先打 Download.url(返回下载的 url)看看是不是打到了预期资源。

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

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

排除掉下面几种,就别在 download 事件的时序上继续耗了:

  • 事件能收到、failure() 返回了非空错误字符串:那是下载过程本身失败,问题在服务端响应或网络,不在等待写法。
  • url() 拿到的地址跟预期不符:点击命中的不是你以为的那个元素,属于定位问题。
  • 页面根本没有发起下载,而是在新标签页里直接渲染了文件(比如 PDF 被浏览器内置查看器接管):这时不会有附件下载,docs/src/downloads.md 描述的事件是「每个被页面下载的附件」都会触发,没有附件就没有事件;这种情况要换思路,仓库文档在这一节里没有给对应处置。
  • 你要做的是上传而不是下载docs/src/downloads.md 末尾的提示框直接把上传指向了 input.md 的 upload files 一节,两套 API 完全不同。
  • 文件确实保存成功、只是名字对不上:那是 suggestedFilename 的浏览器差异,不是下载失败。

最后提一句 docs/src/downloads.md 里那段容易被跳过的告诫。文档给了一种「不知道什么动作会触发下载」时的写法:

page.on('download', download => download.path().then(console.log));

紧接着的原文说明是:这样处理事件会分叉控制流,让脚本更难跟踪;由于主控制流并没有等待这个操作完成,你的场景可能在文件还在下载时就结束了。这句话解释了一类特别难查的间歇性失败——不是下载没成功,是用例先跑完了。能用「先等待、再触发」的成对写法就别用裸监听。


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

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