skillhub-147-playwright-skill
762 行
26 KiB
Markdown
762 行
26 KiB
Markdown
# 文件操作
|
||
|
||
> **使用时机**:测试文件上传、下载、拖放文件交互、文件类型验证及下载验证。
|
||
> **前置要求**:[core/locators.md](locators.md)、[core/assertions-and-waiting.md](assertions-and-waiting.md)
|
||
|
||
## 快速参考
|
||
|
||
```typescript
|
||
// 上传——单个文件
|
||
await page.getByLabel("Upload").setInputFiles("fixtures/resume.pdf")
|
||
|
||
// 上传——多个文件
|
||
await page.getByLabel("Upload").setInputFiles(["fixtures/a.png", "fixtures/b.png"])
|
||
|
||
// 上传——清除选择
|
||
await page.getByLabel("Upload").setInputFiles([])
|
||
|
||
// 下载——等待并保存
|
||
const download = await page.waitForEvent("download")
|
||
await page.getByRole("button", { name: "Export CSV" }).click()
|
||
const path = await download.path() // 临时路径
|
||
await download.saveAs("test-results/export.csv") // 永久路径
|
||
|
||
// 文件选择对话框——非 input 元素上传
|
||
const fileChooser = await page.waitForEvent("filechooser")
|
||
await page.getByRole("button", { name: "Choose file" }).click()
|
||
await fileChooser.setFiles("fixtures/photo.jpg")
|
||
```
|
||
|
||
## 模式
|
||
|
||
### 单个文件上传
|
||
|
||
**适用场景**:表单包含标准的 `<input type="file">` 元素。
|
||
**避免场景**:上传使用拖放区域,且底层没有文件输入元素。
|
||
|
||
**TypeScript**
|
||
|
||
```typescript
|
||
import { test, expect } from "@playwright/test"
|
||
import path from "path"
|
||
|
||
test("upload a single document", async ({ page }) => {
|
||
await page.goto("/settings/profile")
|
||
|
||
const filePath = path.join(__dirname, "../fixtures/avatar.png")
|
||
await page.getByLabel("Profile picture").setInputFiles(filePath)
|
||
|
||
// 验证文件名出现在 UI 中
|
||
await expect(page.getByText("avatar.png")).toBeVisible()
|
||
|
||
await page.getByRole("button", { name: "Save" }).click()
|
||
await expect(page.getByText("Profile updated")).toBeVisible()
|
||
})
|
||
```
|
||
|
||
**JavaScript**
|
||
|
||
```javascript
|
||
const { test, expect } = require("@playwright/test")
|
||
const path = require("path")
|
||
|
||
test("upload a single document", async ({ page }) => {
|
||
await page.goto("/settings/profile")
|
||
|
||
const filePath = path.join(__dirname, "../fixtures/avatar.png")
|
||
await page.getByLabel("Profile picture").setInputFiles(filePath)
|
||
|
||
await expect(page.getByText("avatar.png")).toBeVisible()
|
||
|
||
await page.getByRole("button", { name: "Save" }).click()
|
||
await expect(page.getByText("Profile updated")).toBeVisible()
|
||
})
|
||
```
|
||
|
||
### 多个文件上传
|
||
|
||
**适用场景**:文件输入接受 `multiple` 属性,且需要一次附加多个文件。
|
||
**避免场景**:UI 只允许上传一个文件。请使用单个文件上传。
|
||
|
||
**TypeScript**
|
||
|
||
```typescript
|
||
import { test, expect } from "@playwright/test"
|
||
import path from "path"
|
||
|
||
test("upload multiple attachments", async ({ page }) => {
|
||
await page.goto("/tickets/new")
|
||
|
||
const fixtures = ["doc1.pdf", "doc2.pdf", "screenshot.png"].map((f) =>
|
||
path.join(__dirname, "../fixtures", f)
|
||
)
|
||
|
||
await page.getByLabel("Attachments").setInputFiles(fixtures)
|
||
|
||
// 验证所有文件均已列出
|
||
await expect(page.getByTestId("file-list").getByRole("listitem")).toHaveCount(3)
|
||
|
||
// 通过清除并重新设置来移除一个文件
|
||
await page.getByLabel("Attachments").setInputFiles(fixtures.slice(0, 2))
|
||
await expect(page.getByTestId("file-list").getByRole("listitem")).toHaveCount(2)
|
||
})
|
||
```
|
||
|
||
**JavaScript**
|
||
|
||
```javascript
|
||
const { test, expect } = require("@playwright/test")
|
||
const path = require("path")
|
||
|
||
test("upload multiple attachments", async ({ page }) => {
|
||
await page.goto("/tickets/new")
|
||
|
||
const fixtures = ["doc1.pdf", "doc2.pdf", "screenshot.png"].map((f) =>
|
||
path.join(__dirname, "../fixtures", f)
|
||
)
|
||
|
||
await page.getByLabel("Attachments").setInputFiles(fixtures)
|
||
await expect(page.getByTestId("file-list").getByRole("listitem")).toHaveCount(3)
|
||
|
||
await page.getByLabel("Attachments").setInputFiles(fixtures.slice(0, 2))
|
||
await expect(page.getByTestId("file-list").getByRole("listitem")).toHaveCount(2)
|
||
})
|
||
```
|
||
|
||
### 文件选择对话框
|
||
|
||
**适用场景**:上传由点击按钮触发,该按钮打开系统原生文件选择器,而非由可见的 `<input type="file">` 触发。常见于拖放库和自定义上传组件。
|
||
**避免场景**:存在可见的 `<input type="file">` —— 应直接使用 `setInputFiles`。
|
||
|
||
**TypeScript**
|
||
|
||
```typescript
|
||
import { test, expect } from "@playwright/test"
|
||
|
||
test("upload via file chooser dialog", async ({ page }) => {
|
||
await page.goto("/documents")
|
||
|
||
// 在点击打开对话框的按钮之前注册监听器
|
||
const fileChooserPromise = page.waitForEvent("filechooser")
|
||
await page.getByRole("button", { name: "Upload document" }).click()
|
||
const fileChooser = await fileChooserPromise
|
||
|
||
// 验证对话框属性
|
||
expect(fileChooser.isMultiple()).toBe(false)
|
||
|
||
await fileChooser.setFiles("fixtures/report.pdf")
|
||
await expect(page.getByText("report.pdf")).toBeVisible()
|
||
})
|
||
|
||
test("upload multiple via file chooser", async ({ page }) => {
|
||
await page.goto("/gallery")
|
||
|
||
const fileChooserPromise = page.waitForEvent("filechooser")
|
||
await page.getByRole("button", { name: "Add photos" }).click()
|
||
const fileChooser = await fileChooserPromise
|
||
|
||
expect(fileChooser.isMultiple()).toBe(true)
|
||
await fileChooser.setFiles(["fixtures/photo1.jpg", "fixtures/photo2.jpg"])
|
||
|
||
await expect(page.getByRole("img")).toHaveCount(2)
|
||
})
|
||
```
|
||
|
||
**JavaScript**
|
||
|
||
```javascript
|
||
const { test, expect } = require("@playwright/test")
|
||
|
||
test("upload via file chooser dialog", async ({ page }) => {
|
||
await page.goto("/documents")
|
||
|
||
const fileChooserPromise = page.waitForEvent("filechooser")
|
||
await page.getByRole("button", { name: "Upload document" }).click()
|
||
const fileChooser = await fileChooserPromise
|
||
|
||
expect(fileChooser.isMultiple()).toBe(false)
|
||
|
||
await fileChooser.setFiles("fixtures/report.pdf")
|
||
await expect(page.getByText("report.pdf")).toBeVisible()
|
||
})
|
||
|
||
test("upload multiple via file chooser", async ({ page }) => {
|
||
await page.goto("/gallery")
|
||
|
||
const fileChooserPromise = page.waitForEvent("filechooser")
|
||
await page.getByRole("button", { name: "Add photos" }).click()
|
||
const fileChooser = await fileChooserPromise
|
||
|
||
expect(fileChooser.isMultiple()).toBe(true)
|
||
await fileChooser.setFiles(["fixtures/photo1.jpg", "fixtures/photo2.jpg"])
|
||
|
||
await expect(page.getByRole("img")).toHaveCount(2)
|
||
})
|
||
```
|
||
|
||
### 拖放文件上传
|
||
|
||
**适用场景**:UI 包含一个拖放区域,通过 HTML5 拖放 API 接受文件,且没有 `<input type="file">` 回退机制。
|
||
**避免场景**:存在文件输入元素 —— 即使是隐藏的输入元素也可以用 `setInputFiles` 处理。
|
||
|
||
**TypeScript**
|
||
|
||
```typescript
|
||
import { test, expect } from "@playwright/test"
|
||
import fs from "fs"
|
||
import path from "path"
|
||
|
||
test("drop file onto upload zone", async ({ page }) => {
|
||
await page.goto("/upload")
|
||
|
||
// 将文件读入缓冲区
|
||
const filePath = path.join(__dirname, "../fixtures/data.csv")
|
||
const buffer = fs.readFileSync(filePath)
|
||
|
||
// 创建包含文件的 DataTransfer 并派发 drop 事件
|
||
const dropZone = page.getByTestId("drop-zone")
|
||
|
||
await dropZone.dispatchEvent("drop", {
|
||
dataTransfer: {
|
||
files: [{ name: "data.csv", mimeType: "text/csv", buffer }],
|
||
},
|
||
})
|
||
|
||
await expect(page.getByText("data.csv")).toBeVisible()
|
||
await expect(page.getByText("Upload complete")).toBeVisible()
|
||
})
|
||
|
||
test("drag-and-drop with hidden input fallback", async ({ page }) => {
|
||
await page.goto("/upload")
|
||
|
||
// 许多拖放库仍然使用隐藏的 <input type="file">
|
||
// 先检查是否存在——这比模拟 DnD 事件更可靠
|
||
const hiddenInput = page.locator('input[type="file"]')
|
||
|
||
if ((await hiddenInput.count()) > 0) {
|
||
await hiddenInput.setInputFiles("fixtures/data.csv")
|
||
}
|
||
|
||
await expect(page.getByText("data.csv")).toBeVisible()
|
||
})
|
||
```
|
||
|
||
**JavaScript**
|
||
|
||
```javascript
|
||
const { test, expect } = require("@playwright/test")
|
||
const fs = require("fs")
|
||
const path = require("path")
|
||
|
||
test("drop file onto upload zone", async ({ page }) => {
|
||
await page.goto("/upload")
|
||
|
||
const filePath = path.join(__dirname, "../fixtures/data.csv")
|
||
const buffer = fs.readFileSync(filePath)
|
||
|
||
const dropZone = page.getByTestId("drop-zone")
|
||
|
||
await dropZone.dispatchEvent("drop", {
|
||
dataTransfer: {
|
||
files: [{ name: "data.csv", mimeType: "text/csv", buffer }],
|
||
},
|
||
})
|
||
|
||
await expect(page.getByText("data.csv")).toBeVisible()
|
||
await expect(page.getByText("Upload complete")).toBeVisible()
|
||
})
|
||
|
||
test("drag-and-drop with hidden input fallback", async ({ page }) => {
|
||
await page.goto("/upload")
|
||
|
||
const hiddenInput = page.locator('input[type="file"]')
|
||
|
||
if ((await hiddenInput.count()) > 0) {
|
||
await hiddenInput.setInputFiles("fixtures/data.csv")
|
||
}
|
||
|
||
await expect(page.getByText("data.csv")).toBeVisible()
|
||
})
|
||
```
|
||
|
||
### 文件下载——等待并验证
|
||
|
||
**适用场景**:测试导出按钮、报告生成,或任何触发浏览器下载的操作。
|
||
**避免场景**:文件以页面导航方式提供(在新标签页中打开)。请改用多标签页模式。
|
||
|
||
**TypeScript**
|
||
|
||
```typescript
|
||
import { test, expect } from "@playwright/test"
|
||
import fs from "fs"
|
||
|
||
test("download and verify file content", async ({ page }) => {
|
||
await page.goto("/reports")
|
||
|
||
// 在触发下载的点击之前注册
|
||
const downloadPromise = page.waitForEvent("download")
|
||
await page.getByRole("button", { name: "Export CSV" }).click()
|
||
const download = await downloadPromise
|
||
|
||
// 验证下载元数据
|
||
expect(download.suggestedFilename()).toBe("report-2025.csv")
|
||
|
||
// 保存到已知位置
|
||
const savePath = "test-results/report.csv"
|
||
await download.saveAs(savePath)
|
||
|
||
// 读取并验证内容
|
||
const content = fs.readFileSync(savePath, "utf-8")
|
||
expect(content).toContain("Name,Email,Status")
|
||
expect(content).toContain("Jane Doe,jane@example.com,Active")
|
||
|
||
// 验证文件大小合理
|
||
const stats = fs.statSync(savePath)
|
||
expect(stats.size).toBeGreaterThan(100)
|
||
})
|
||
|
||
test("download triggered by a link", async ({ page }) => {
|
||
await page.goto("/files")
|
||
|
||
const downloadPromise = page.waitForEvent("download")
|
||
await page.getByRole("link", { name: "Download invoice" }).click()
|
||
const download = await downloadPromise
|
||
|
||
expect(download.suggestedFilename()).toMatch(/invoice-\d+\.pdf/)
|
||
|
||
// 使用 download.path() 获取临时文件路径
|
||
const tempPath = await download.path()
|
||
expect(tempPath).toBeTruthy()
|
||
})
|
||
```
|
||
|
||
**JavaScript**
|
||
|
||
```javascript
|
||
const { test, expect } = require("@playwright/test")
|
||
const fs = require("fs")
|
||
|
||
test("download and verify file content", async ({ page }) => {
|
||
await page.goto("/reports")
|
||
|
||
const downloadPromise = page.waitForEvent("download")
|
||
await page.getByRole("button", { name: "Export CSV" }).click()
|
||
const download = await downloadPromise
|
||
|
||
expect(download.suggestedFilename()).toBe("report-2025.csv")
|
||
|
||
const savePath = "test-results/report.csv"
|
||
await download.saveAs(savePath)
|
||
|
||
const content = fs.readFileSync(savePath, "utf-8")
|
||
expect(content).toContain("Name,Email,Status")
|
||
expect(content).toContain("Jane Doe,jane@example.com,Active")
|
||
|
||
const stats = fs.statSync(savePath)
|
||
expect(stats.size).toBeGreaterThan(100)
|
||
})
|
||
|
||
test("download triggered by a link", async ({ page }) => {
|
||
await page.goto("/files")
|
||
|
||
const downloadPromise = page.waitForEvent("download")
|
||
await page.getByRole("link", { name: "Download invoice" }).click()
|
||
const download = await downloadPromise
|
||
|
||
expect(download.suggestedFilename()).toMatch(/invoice-\d+\.pdf/)
|
||
|
||
const tempPath = await download.path()
|
||
expect(tempPath).toBeTruthy()
|
||
})
|
||
```
|
||
|
||
### 配置下载路径
|
||
|
||
**适用场景**:需要将下载文件保存到特定目录,或需要禁用下载对话框提示。
|
||
**避免场景**:通过 `download.path()` 或 `download.saveAs()` 使用默认临时路径即可满足需求。
|
||
|
||
**TypeScript**
|
||
|
||
```typescript
|
||
// playwright.config.ts —— 全局下载行为
|
||
import { defineConfig } from "@playwright/test"
|
||
|
||
export default defineConfig({
|
||
use: {
|
||
// 接受所有下载,无需提示
|
||
acceptDownloads: true, // 默认为 true
|
||
},
|
||
})
|
||
```
|
||
|
||
```typescript
|
||
import { test, expect } from "@playwright/test"
|
||
import fs from "fs"
|
||
import path from "path"
|
||
|
||
// 通过自定义 fixture 实现每个测试独立的下载目录
|
||
const downloadTest = test.extend<{ downloadDir: string }>({
|
||
downloadDir: async ({}, use, testInfo) => {
|
||
const dir = path.join("test-results", "downloads", testInfo.title.replace(/\s+/g, "-"))
|
||
fs.mkdirSync(dir, { recursive: true })
|
||
await use(dir)
|
||
},
|
||
})
|
||
|
||
downloadTest("save downloads to organized directories", async ({ page, downloadDir }) => {
|
||
await page.goto("/exports")
|
||
|
||
const downloadPromise = page.waitForEvent("download")
|
||
await page.getByRole("button", { name: "Export" }).click()
|
||
const download = await downloadPromise
|
||
|
||
const savePath = path.join(downloadDir, download.suggestedFilename())
|
||
await download.saveAs(savePath)
|
||
|
||
expect(fs.existsSync(savePath)).toBe(true)
|
||
})
|
||
```
|
||
|
||
**JavaScript**
|
||
|
||
```javascript
|
||
// playwright.config.js
|
||
const { defineConfig } = require("@playwright/test")
|
||
|
||
module.exports = defineConfig({
|
||
use: {
|
||
acceptDownloads: true,
|
||
},
|
||
})
|
||
```
|
||
|
||
```javascript
|
||
const { test, expect } = require("@playwright/test")
|
||
const fs = require("fs")
|
||
const path = require("path")
|
||
|
||
const downloadTest = test.extend({
|
||
downloadDir: async ({}, use, testInfo) => {
|
||
const dir = path.join("test-results", "downloads", testInfo.title.replace(/\s+/g, "-"))
|
||
fs.mkdirSync(dir, { recursive: true })
|
||
await use(dir)
|
||
},
|
||
})
|
||
|
||
downloadTest("save downloads to organized directories", async ({ page, downloadDir }) => {
|
||
await page.goto("/exports")
|
||
|
||
const downloadPromise = page.waitForEvent("download")
|
||
await page.getByRole("button", { name: "Export" }).click()
|
||
const download = await downloadPromise
|
||
|
||
const savePath = path.join(downloadDir, download.suggestedFilename())
|
||
await download.saveAs(savePath)
|
||
|
||
expect(fs.existsSync(savePath)).toBe(true)
|
||
})
|
||
```
|
||
|
||
### 文件类型验证
|
||
|
||
**适用场景**:测试应用程序拒绝无效文件类型并接受有效文件类型。
|
||
**避免场景**:应用程序不做任何客户端验证,完全依赖服务端检查(改为通过 API 测试)。
|
||
|
||
**TypeScript**
|
||
|
||
```typescript
|
||
import { test, expect } from "@playwright/test"
|
||
|
||
test("rejects unsupported file types", async ({ page }) => {
|
||
await page.goto("/upload")
|
||
|
||
// 上传无效文件类型
|
||
await page.getByLabel("Upload image").setInputFiles({
|
||
name: "malware.exe",
|
||
mimeType: "application/octet-stream",
|
||
buffer: Buffer.from("fake-exe-content"),
|
||
})
|
||
|
||
await expect(page.getByText("Only JPG, PNG, and GIF files are allowed")).toBeVisible()
|
||
await expect(page.getByRole("button", { name: "Submit" })).toBeDisabled()
|
||
})
|
||
|
||
test("accepts valid file types", async ({ page }) => {
|
||
await page.goto("/upload")
|
||
|
||
await page.getByLabel("Upload image").setInputFiles({
|
||
name: "photo.jpg",
|
||
mimeType: "image/jpeg",
|
||
buffer: Buffer.from("fake-jpg-content"),
|
||
})
|
||
|
||
await expect(page.getByText("photo.jpg")).toBeVisible()
|
||
await expect(page.getByRole("button", { name: "Submit" })).toBeEnabled()
|
||
})
|
||
|
||
test("validates file size limits", async ({ page }) => {
|
||
await page.goto("/upload")
|
||
|
||
// 创建一个超过 5MB 限制的缓冲区
|
||
const largeBuffer = Buffer.alloc(6 * 1024 * 1024, "x")
|
||
|
||
await page.getByLabel("Upload document").setInputFiles({
|
||
name: "huge-file.pdf",
|
||
mimeType: "application/pdf",
|
||
buffer: largeBuffer,
|
||
})
|
||
|
||
await expect(page.getByText("File size must be under 5MB")).toBeVisible()
|
||
})
|
||
```
|
||
|
||
**JavaScript**
|
||
|
||
```javascript
|
||
const { test, expect } = require("@playwright/test")
|
||
|
||
test("rejects unsupported file types", async ({ page }) => {
|
||
await page.goto("/upload")
|
||
|
||
await page.getByLabel("Upload image").setInputFiles({
|
||
name: "malware.exe",
|
||
mimeType: "application/octet-stream",
|
||
buffer: Buffer.from("fake-exe-content"),
|
||
})
|
||
|
||
await expect(page.getByText("Only JPG, PNG, and GIF files are allowed")).toBeVisible()
|
||
await expect(page.getByRole("button", { name: "Submit" })).toBeDisabled()
|
||
})
|
||
|
||
test("accepts valid file types", async ({ page }) => {
|
||
await page.goto("/upload")
|
||
|
||
await page.getByLabel("Upload image").setInputFiles({
|
||
name: "photo.jpg",
|
||
mimeType: "image/jpeg",
|
||
buffer: Buffer.from("fake-jpg-content"),
|
||
})
|
||
|
||
await expect(page.getByText("photo.jpg")).toBeVisible()
|
||
await expect(page.getByRole("button", { name: "Submit" })).toBeEnabled()
|
||
})
|
||
|
||
test("validates file size limits", async ({ page }) => {
|
||
await page.goto("/upload")
|
||
|
||
const largeBuffer = Buffer.alloc(6 * 1024 * 1024, "x")
|
||
|
||
await page.getByLabel("Upload document").setInputFiles({
|
||
name: "huge-file.pdf",
|
||
mimeType: "application/pdf",
|
||
buffer: largeBuffer,
|
||
})
|
||
|
||
await expect(page.getByText("File size must be under 5MB")).toBeVisible()
|
||
})
|
||
```
|
||
|
||
### 大文件处理
|
||
|
||
**适用场景**:测试大文件的上传或下载,此时超时和进度指示器至关重要。
|
||
**避免场景**:每个测试都用大文件。大文件测试速度较慢。请放入单独的测试套件或标记为仅夜间运行。
|
||
|
||
**TypeScript**
|
||
|
||
```typescript
|
||
import { test, expect } from "@playwright/test"
|
||
import fs from "fs"
|
||
import path from "path"
|
||
|
||
test.describe("large file operations", () => {
|
||
// 增加整个 describe 块的超时时间
|
||
test.slow() // 将默认超时时间延长三倍
|
||
|
||
test("upload large file with progress tracking", async ({ page }) => {
|
||
await page.goto("/upload")
|
||
|
||
// 在磁盘上创建测试文件(避免对超大文件使用 Buffer.alloc)
|
||
const largePath = path.join("test-results", "large-test-file.bin")
|
||
const stream = fs.createWriteStream(largePath)
|
||
for (let i = 0; i < 100; i++) {
|
||
stream.write(Buffer.alloc(1024 * 1024, "a")) // 共计 100MB
|
||
}
|
||
stream.end()
|
||
await new Promise((resolve) => stream.on("finish", resolve))
|
||
|
||
await page.getByLabel("Upload file").setInputFiles(largePath)
|
||
|
||
// 等待进度指示器出现
|
||
await expect(page.getByRole("progressbar")).toBeVisible()
|
||
|
||
// 等待完成——延长超时时间
|
||
await expect(page.getByText("Upload complete")).toBeVisible({ timeout: 120_000 })
|
||
|
||
// 清理
|
||
fs.unlinkSync(largePath)
|
||
})
|
||
|
||
test("download large file", async ({ page }) => {
|
||
await page.goto("/exports")
|
||
|
||
const downloadPromise = page.waitForEvent("download")
|
||
await page.getByRole("button", { name: "Export full dataset" }).click()
|
||
const download = await downloadPromise
|
||
|
||
const savePath = "test-results/large-export.zip"
|
||
await download.saveAs(savePath)
|
||
|
||
// 验证文件大小合理(至少 10MB)
|
||
const stats = fs.statSync(savePath)
|
||
expect(stats.size).toBeGreaterThan(10 * 1024 * 1024)
|
||
|
||
fs.unlinkSync(savePath)
|
||
})
|
||
})
|
||
```
|
||
|
||
**JavaScript**
|
||
|
||
```javascript
|
||
const { test, expect } = require("@playwright/test")
|
||
const fs = require("fs")
|
||
const path = require("path")
|
||
|
||
test.describe("large file operations", () => {
|
||
test.slow()
|
||
|
||
test("upload large file with progress tracking", async ({ page }) => {
|
||
await page.goto("/upload")
|
||
|
||
const largePath = path.join("test-results", "large-test-file.bin")
|
||
const stream = fs.createWriteStream(largePath)
|
||
for (let i = 0; i < 100; i++) {
|
||
stream.write(Buffer.alloc(1024 * 1024, "a"))
|
||
}
|
||
stream.end()
|
||
await new Promise((resolve) => stream.on("finish", resolve))
|
||
|
||
await page.getByLabel("Upload file").setInputFiles(largePath)
|
||
|
||
await expect(page.getByRole("progressbar")).toBeVisible()
|
||
await expect(page.getByText("Upload complete")).toBeVisible({ timeout: 120_000 })
|
||
|
||
fs.unlinkSync(largePath)
|
||
})
|
||
|
||
test("download large file", async ({ page }) => {
|
||
await page.goto("/exports")
|
||
|
||
const downloadPromise = page.waitForEvent("download")
|
||
await page.getByRole("button", { name: "Export full dataset" }).click()
|
||
const download = await downloadPromise
|
||
|
||
const savePath = "test-results/large-export.zip"
|
||
await download.saveAs(savePath)
|
||
|
||
const stats = fs.statSync(savePath)
|
||
expect(stats.size).toBeGreaterThan(10 * 1024 * 1024)
|
||
|
||
fs.unlinkSync(savePath)
|
||
})
|
||
})
|
||
```
|
||
|
||
## 决策指南
|
||
|
||
| 场景 | 方法 | 关键 API |
|
||
| ------------------------------- | ------------------------------------------------- | ---------------------------------------------------- |
|
||
| 标准 `<input type="file">` | 在定位器上使用 `setInputFiles()` | `locator.setInputFiles(path)` |
|
||
| 隐藏的文件输入 | 找到输入元素(即使隐藏),调用 `setInputFiles()` | `page.locator('input[type="file"]').setInputFiles()` |
|
||
| 自定义按钮打开文件选择器 | 在点击前监听 `filechooser` 事件 | `page.waitForEvent('filechooser')` |
|
||
| 无输入元素的拖放区域 | 使用 `DataTransfer` 派发 `drop` 事件 | `locator.dispatchEvent('drop', ...)` |
|
||
| 带隐藏输入回退的拖放区域 | 优先对隐藏输入使用 `setInputFiles()` | 先检查 `input[type="file"]` 的数量 |
|
||
| 一次上传多个文件 | 向 `setInputFiles()` 传递数组 | `setInputFiles([path1, path2])` |
|
||
| 内存中测试文件(无需磁盘) | 传入包含 `name`、`mimeType`、`buffer` 的对象 | `setInputFiles({ name, mimeType, buffer })` |
|
||
| 下载——验证文件名 | `download.suggestedFilename()` | `page.waitForEvent('download')` |
|
||
| 下载——验证内容 | `download.saveAs()` 然后使用 `fs` 读取 | `fs.readFileSync()` |
|
||
| 下载——仅临时文件 | `download.path()` 返回临时位置 | 测试结束后自动删除 |
|
||
| 大文件上传/下载 | 使用 `test.slow()`,增加断言超时时间 | `{ timeout: 120_000 }` |
|
||
|
||
## 反模式
|
||
|
||
| 不要这样做 | 问题 | 应改为 |
|
||
| ------------------------------------------------------------- | ------------------------------------------------------- | -------------------------------------------------------------- |
|
||
| 上传后使用 `await page.waitForTimeout(3000)` | 任意延迟;不稳定 | `await expect(page.getByText('Upload complete')).toBeVisible()` |
|
||
| 使用 CSS 选择器 `await page.setInputFiles('#file', path)` | 定位器脆弱;ID 变更后失效 | `await page.getByLabel('Upload').setInputFiles(path)` |
|
||
| 点击下载后立即读取文件 | 竞态条件;文件可能尚未写入 | 使用 `page.waitForEvent('download')` 再 `download.saveAs()` |
|
||
| 在 `beforeAll` 中创建大型测试文件且从不清理 | CI 中磁盘被占满,拖慢后续运行 | 在 `afterAll` 中清理或使用 fixture 的拆解逻辑 |
|
||
| 每次上传都使用 `page.on('filechooser')` | 当 `<input type="file">` 存在时带来不必要的复杂性 | 直接使用 `setInputFiles()` |
|
||
| 硬编码绝对文件路径 | 在不同机器和 CI 中失效 | 使用 `path.join(__dirname, ...)` 或相对于项目根目录的路径 |
|
||
| 使用空缓冲区测试文件上传 | 未测试真实的验证行为 | 使用真实的文件内容或最小有效文件大小 |
|
||
| 使用 `download.path()` 进行永久存储 | 临时文件在测试上下文关闭后被清理 | 使用 `download.saveAs()` 保存到永久路径 |
|
||
| 每次测试运行都上传真实的 100MB 文件 | 拖慢整个测试套件,浪费 CI 资源 | 将大文件测试单独标记;按计划运行,而非每次 PR |
|
||
|
||
## 故障排除
|
||
|
||
### "FileChooser event was not emitted"
|
||
|
||
**原因**:点击操作并未打开系统原生文件选择器对话框。上传组件可能使用了不同的机制。
|
||
|
||
```typescript
|
||
// 调试:检查是否存在可以直接定位的隐藏 <input type="file">
|
||
const fileInputCount = await page.locator('input[type="file"]').count()
|
||
console.log(`Found ${fileInputCount} file inputs`)
|
||
|
||
// 如果输入元素存在,则完全跳过 file chooser 方式
|
||
if (fileInputCount > 0) {
|
||
await page.locator('input[type="file"]').setInputFiles("fixtures/file.pdf")
|
||
}
|
||
```
|
||
|
||
### "Download event was not emitted"
|
||
|
||
**原因**:链接在新标签页中打开或导航到文件 URL,而不是触发下载。
|
||
|
||
```typescript
|
||
// 修复 1:确保配置中 acceptDownloads 为 true
|
||
// 修复 2:检查链接是否打开新标签页——将其作为新页面处理
|
||
const pagePromise = page.context().waitForEvent("page")
|
||
await page.getByRole("link", { name: "Download" }).click()
|
||
const newPage = await pagePromise
|
||
// 然后在新页面上等待下载事件
|
||
const download = await newPage.waitForEvent("download")
|
||
```
|
||
|
||
### 上传在本地正常,但在 CI 中失败
|
||
|
||
**原因**:CI 中的文件路径错误,或 fixture 文件未包含在仓库/构建中。
|
||
|
||
```typescript
|
||
// 修复:始终相对于测试文件解析路径
|
||
import path from "path"
|
||
const fixturePath = path.join(__dirname, "..", "fixtures", "test-file.pdf")
|
||
|
||
// 在上传前验证文件是否存在
|
||
import fs from "fs"
|
||
if (!fs.existsSync(fixturePath)) {
|
||
throw new Error(`Fixture file missing: ${fixturePath}`)
|
||
}
|
||
```
|
||
|
||
### `setInputFiles` 无效果——未出现文件
|
||
|
||
**原因**:输入元素已分离、位于 Shadow DOM 内部,或位于 iframe 中。
|
||
|
||
```typescript
|
||
// 检查 iframe
|
||
const frame = page.frameLocator('iframe[title="Upload"]')
|
||
await frame.locator('input[type="file"]').setInputFiles("fixtures/file.pdf")
|
||
|
||
// 检查 Shadow DOM——Playwright 会自动穿透开放 shadow root
|
||
// 但输入元素可能位于封闭的 shadow root 中(极少见)。改用 file chooser 方式。
|
||
```
|
||
|
||
## 相关文档
|
||
|
||
- [core/locators.md](locators.md) —— 查找上传/下载元素的定位器策略
|
||
- [core/assertions-and-waiting.md](assertions-and-waiting.md) —— 验证上传/下载结果的断言模式
|
||
- [core/fixtures-and-hooks.md](fixtures-and-hooks.md) —— 创建可复用的下载目录 fixture
|
||
- [core/network-mocking.md](network-mocking.md) —— 模拟上传端点以加速测试
|
||
- [core/error-and-edge-cases.md](error-and-edge-cases.md) —— 测试上传失败状态和错误处理
|