Trace、Video、Screenshot 与 Report:从失败现场到可删除证据链
CI 在“提交订单”步骤红了,日志只剩一句 expect(received).toContain(expected)。开发者在本机重跑却是绿色;把超时从 5 秒改成 30 秒后仍无法复现。此时最缺的不是另一条重试命令,而是失败发生前后的页面状态、浏览器 console、网络请求、执行步骤、录屏、运行环境和重试关系。没有这些证据,团队只能在代码、环境和数据之间猜。
另一种极端同样危险:为了“以后好排查”,所有用例始终开启 trace 和视频,完整 HTML 报告永久公开。几轮 CI 后对象存储快速增长,截图里出现客户姓名,trace 的 network 面板保存了 Authorization header 与响应 body,离职成员仍能通过旧链接访问。证据链解决的是可定位性,也会制造新的敏感数据副本、访问路径和删除责任。
Playwright 的四类对象各自回答不同问题。screenshot 保存某一瞬间的像素;video 保存页面随时间变化的画面;trace 把动作、DOM snapshot、网络、console、源码位置与元数据组织成时间线;report 把 test、project、retry、状态与 attachments 汇成入口。真正稳定的做法,是围绕一次 test attempt 采集最少但足够的证据,再用受控 artifact store 管理查看、保留和删除。
先把四类证据放回各自的时间点
screenshot 可以由测试代码随时主动调用,也可以由 Playwright Test 在失败后自动截取。主动截图最适合记录某个业务检查点,例如提交前的订单摘要;only-on-failure 适合保存最终失败页。它只能说明截图那一刻看到了什么,无法证明前一个点击是否命中、页面是否发生过短暂跳转,也无法单独说明网络请求为何失败。官方截图指南提供整页、元素和 buffer 等入口;整页截图会捕获滚动区域,文件更大,也更可能纳入原本不需要的敏感内容。
video 记录 context 内 page 的画面演进,适合判断动作顺序、遮挡、重定向、动画和用户可见卡顿。它通常没有 DOM、网络 body 和精确断言对象,文字较小或瞬间变化时也难以判断。更关键的是,视频只有在 browser context 关闭后才完成保存;官方视频说明明确要求手动创建 context 时 await context.close(),否则作业退出时可能找不到完整文件。
trace 是诊断密度最高的证据。Trace Viewer 能按 action 查看 before/after DOM snapshot、源码、console、network、错误与 metadata。官方Trace Viewer 指南说明 network 面板可以展示请求/响应 header、请求 body 与响应 body;这也是 trace 比截图更有定位能力、同时更敏感的原因。通过 Playwright Test 配置采集的 trace 还会包含断言信息,而直接使用 context.tracing 主要记录 browser operation 与 network,不记录 expect 断言,因此测试项目优先使用 runner 配置。
report 不是另一份录屏。line、dot、GitHub reporter 主要服务终端与 CI 注解;HTML reporter 生成可浏览目录;JSON/JUnit 服务机器消费;blob reporter 保存完整运行信息和附件,便于 shard 合并。报告负责关联,不应被误认为天然脱敏或天然受权。只要附件被复制到 report 的 data 目录或外部存储,报告访问者就可能继续取得敏感副本。
安装后先建立低成本采集基线
运行时与平台要求先按 Playwright 安装入口核对。Node 支持线、操作系统和 browser revision 会变化,仓库应通过 lockfile、npx playwright --version 与浏览器清单形成实际基线。CI 镜像若预装 browser,镜像 tag 要与项目 Playwright 版本匹配;否则 trace 还没来得及产生,browser 就会在启动阶段失败。
node --version
npm ci
npx playwright --version
npx playwright install --with-deps chromium
npx playwright install --list
# 完成下文配置和样例后,先跑一个无凭证、无真实数据的最小用例
npx playwright test e2e/evidence.spec.ts --project=chromium -g "正常路径"第一版配置应让成功用例保持便宜,让失败和第一次重试留下可对照证据。screenshot: 'only-on-failure' 会在失败 attempt 保存截图;trace: 'on-first-retry' 与 video: 'on-first-retry' 只在第一次重试采集。官方use 配置参考列出了 off、on、retain-on-failure、retain-on-first-failure、on-first-retry 等模式;模式名表达的是“哪个 run 被录制”和“什么结果被保留”,不能脱离 retries 单独理解。
import { defineConfig } from '@playwright/test'
export default defineConfig({
testDir: './e2e',
outputDir: './test-results',
preserveOutput: 'failures-only',
retries: process.env.CI ? 1 : 0,
projects: [
{ name: 'chromium', use: { browserName: 'chromium' } },
],
reporter: process.env.CI
? [
['line'],
['blob', { outputDir: 'blob-report' }],
]
: [['html', { outputFolder: 'playwright-report', open: 'never' }]],
use: {
screenshot: 'only-on-failure',
trace: 'on-first-retry',
video: 'on-first-retry',
},
})outputDir 默认是 test-results,每次测试运行开始时会清理;Playwright 为并行测试创建唯一子目录,避免文件名冲突。preserveOutput: 'failures-only' 控制测试输出目录的保留,而 reporter 目录有自己的生命周期。把二者都指向同一个目录,会让清理与报告写入互相覆盖;把输出放进源码目录,又会让附件误入 Git。
本地没有 retries 时,on-first-retry 永远不会触发。开发者需要立即抓 trace,可以临时执行 npx playwright test --trace on;没有重试但需要保留失败 run 时用 retain-on-failure。若只关心首轮偶发故障,当前 API 还提供 retain-on-first-failure,它只录制首轮并在首轮失败时保留,比录制每个 run 更省。选择后先用“首轮失败、重试通过”和“每次都失败”两个受控样例确认附件落在哪个 attempt,问题解决后恢复团队基线。长期全量 trace: 'on' 会增加执行时间和磁盘,官方也不建议把它作为常规 CI 策略。
正反实验揭示采集策略的真实分支
下面的正向用例只证明页面结果成立,并附加一份小型环境 JSON。失败用例故意让页面停在 pending,却断言它会变成 confirmed,用来观察首轮与重试的附件差异。实验不访问公网、不需要账号,也不依赖浏览器特有 UA,可以稳定验证证据链。
import { expect, test } from '@playwright/test'
test('正常路径只保留结构化环境摘要', async ({ page }, testInfo) => {
await page.setContent('<main><h1>Checkout</h1><button>Submit order</button></main>')
const environment = await page.evaluate(() => ({
timezone: Intl.DateTimeFormat().resolvedOptions().timeZone,
viewport: [innerWidth, innerHeight],
}))
await testInfo.attach('environment', {
body: Buffer.from(JSON.stringify(environment, null, 2)),
contentType: 'application/json',
})
await expect(page.getByRole('button', { name: 'Submit order' })).toBeEnabled()
})
test('错误的状态契约产生失败证据', async ({ page }) => {
await page.setContent('<p role="status">pending</p>')
await expect(page.getByRole('status')).toHaveText('confirmed', {
timeout: 1_000,
})
})用 npx playwright test e2e/evidence.spec.ts --project=chromium --retries=1 执行时,正向用例应通过,反向用例会在首轮和第一次重试都因 pending 不等于 confirmed 而失败。首轮失败 attempt 应有自动截图,第一次重试除失败截图外还应有 video.webm 与 trace.zip;实际目录名和辅助附件会随 runner 版本与 reporter 改变,应以报告里的 attempt 和 attachments 列表为准。这个结果验证的是配置分支:截图观察所有失败 attempt,trace/video 只观察第一次重试。
首轮与重试若都在同一状态断言失败,DOM snapshot 也始终是 pending,证据就支持“预期或产品状态机错误”,不支持“等待不够”。若首轮失败、重试通过,则要比较两个 attempt 的网络、DOM 与数据状态;重试后绿色只能说明现象具有时序或环境变化,不能自动把首次失败降为无效。验证完成后应修正或删除这个故意失败用例,再运行一次确认流水线恢复,而不是把它标成长期 expected failure。
容量测量应分别记录 test-results、blob、HTML report 与外部 attachments 的字节数。极小样例中报告壳、脚本与索引可能比 trace/video 本身更大;真实页面包含图片、长路径、网络 body 和多个 project 后,附件通常成为主要增量。容量模型必须依据自己的成功率、失败率、重试分布和 P50/P95 附件大小测量,不能用“一个 trace 大概几 MB”作为永久预算。
用 test attempt 和附件元数据完成关联
证据关联的核心不是目录名好看,而是能从报告中的一个失败 attempt 找到同一 attempt 的所有附件。Playwright 已经提供 test id、project、retry、worker/parallel index、状态和附件列表;自定义附件应通过 testInfo.attach() 加入,不要把文件随手写到共享目录。官方TestInfo API说明 attach() 会把文件复制到 reporter 可访问的位置,等待复制完成后源临时文件可以删除;outputPath() 则保证路径位于当前测试独占输出目录,避免并行冲突。
import { test } from '@playwright/test'
test.afterEach(async ({ page }, testInfo) => {
const safeLabel = (value: string) =>
value.replace(/[^a-zA-Z0-9_.-]/g, '_').slice(0, 80)
const runId = safeLabel(process.env.E2E_RUN_ID ?? `local-${process.pid}`)
const shard = safeLabel(process.env.E2E_SHARD ?? '1-of-1')
const testId = testInfo.testId.replace(/[^a-zA-Z0-9_.-]/g, '_')
const requestedEnvironment = process.env.E2E_ENVIRONMENT ?? 'test'
const environment = ['test', 'staging'].includes(requestedEnvironment)
? requestedEnvironment
: 'unknown'
const requestedCommit = process.env.E2E_COMMIT ?? ''
const commit = /^[0-9a-f]{7,64}$/i.test(requestedCommit)
? requestedCommit.slice(0, 12)
: 'local'
const attemptKey = [
runId,
shard,
safeLabel(testInfo.project.name),
testId,
`retry-${testInfo.retry}`,
].join(':')
const summary = {
attemptKey,
runId,
shard,
testId,
project: testInfo.project.name,
retry: testInfo.retry,
parallelIndex: testInfo.parallelIndex,
status: testInfo.status,
expectedStatus: testInfo.expectedStatus,
durationMs: testInfo.duration,
environment,
commit,
}
await testInfo.attach('run-summary', {
body: Buffer.from(JSON.stringify(summary, null, 2)),
contentType: 'application/json',
})
if (testInfo.status !== testInfo.expectedStatus && !page.isClosed()) {
const body = await page.screenshot({
mask: [page.locator('[data-sensitive]')],
maskColor: '#000000',
}).catch(() => undefined)
if (body) {
await testInfo.attach('failure-checkpoint', {
body,
contentType: 'image/png',
})
}
}
})attemptKey 以平台 run、shard、Playwright testId、project 和 retry 组成;parallelIndex 只用于诊断 worker 分配,不能当主键,因为 worker 崩溃后会重启。关联字段仍要避免敏感信息:commit 可以用公开提交哈希或受控短标识,账号只记录角色和合成 ID,不记录邮箱、手机号与 session。环境写 test、staging 这类批准枚举,不放内网域名。报告标题可以包含 pipeline 与 shard 标识,但不要拼接 secret、分支中未经清洗的用户输入或完整请求 URL。
artifact 上传成功后还要记录平台返回的 artifact ID、内容 digest、预期 shard 数和生成 job。artifact 名称只是人类入口,不是稳定主键;同名覆盖、手工重传和跨 run 下载都可能让名称指向另一份内容。下载或合并前校验 digest 与 shard manifest,报告发布后把最终 report ID 反向写回运行摘要,才能从一个失败条目追到不可混淆的原始附件。
多个 shard 合并时,blob report 包含测试结果和附件。官方分片与报告合并指南说明每个 shard 生成独立 blob,汇总作业下载后用 npx playwright merge-reports 生成一个 HTML report。blob 文件名含由 project、过滤器、tag 和 shard 计算的信息,仍应在 artifact 名称里显式加入环境与 shard,防止不同流水线的文件被错误混合。
查看顺序要从报告索引走到 trace 时间线
本地打开 HTML report 使用 npx playwright show-report,自定义目录则把路径作为参数。官方Reporter 指南说明 HTML reporter 生成一个可作为网页服务的目录,默认输出到 playwright-report;也支持打开顶层含 index.html 的 zip。不要直接双击复杂报告里的 index.html 并据此判断附件坏了,浏览器的 file:// 限制、CSP 与相对路径可能使资源加载行为不同。
# 打开本地 HTML 报告
npx playwright show-report playwright-report
# 从失败条目进入 trace,或直接打开文件
npx playwright show-trace test-results/<test-attempt>/trace.zip
# 分片汇总
npx playwright merge-reports --reporter=html ./all-blob-reports排障顺序建议从 report 的 test、project、retry 和错误行开始,确认是 expected、unexpected、flaky 还是 skipped;随后优先进入 trace,对齐 action、DOM snapshot、console 和 network,因为官方也把 trace 作为 CI 失败的首选诊断入口。screenshot 用来确认某个时点的可见状态,video 用来补充动作顺序、瞬时遮挡和重定向,它们不能替代 trace 的结构化信息。遇到 HTTP 失败时选中对应 action,再看请求是否发出、状态码、响应 body 与页面错误;遇到元素超时时,比较 action 前后的 DOM,而不是只盯最终截图。
trace.playwright.dev 是静态托管的 Trace Viewer。官方所说的“完全在浏览器中加载且不向外传输”针对用户从本机选择或拖入的 trace 文件,不代表远程 URL 模式也没有数据传输。浏览器仍会读取本地文件中的敏感数据,受管终端、屏幕录制、浏览器扩展和本地下载目录都属于数据边界。通过 URL 打开远程 trace 时,Viewer 必须从该 URL 取回文件,因而同时受对象授权、URL 有效期和 CORS 约束;把签名 URL 拼进可转发的 Viewer 链接,还会让凭据进入聊天记录、浏览器历史、CI 日志或工单。需要远程查看时,应由受控报告服务在鉴权后签发短期只读 URL,并确认过期后旧链接无法继续取回对象。
HTML report 的 attachmentsBaseURL 可以让附件放到外部存储。它解决的是路径解析,不提供认证、授权或签名刷新。报告与附件分离后,两个存储都要执行相同的 tenant、环境、保留和删除策略;只删 report 首页,外部 data 对象仍然存在。
采集前脱敏比生成后擦除可靠
截图最直观,却只覆盖像素。CSS 遮罩可以挡住账号输入框,页面其他位置仍可能出现姓名、订单号和地址;浏览器自动填充、toast、错误栈和下载预览也可能泄漏。trace 更难事后清洗,因为 DOM snapshot、network header/body、console、附件和 source snippet 可能重复保存同一值。zip 内部替换字符串还可能破坏索引或漏掉压缩、编码后的副本。
可靠顺序是先控制数据,再控制采集。测试环境使用合成账号、合成订单和专用域名;API fixture 在生成数据时就避免真实个人信息;前端对 secret、token、验证码和完整卡号从不渲染;测试代码不把环境变量、Authorization header 与 storageState 打进 console。对不需要验证的第三方请求可在 context route 中返回无敏感内容的稳定 fixture,同时保留“请求发生且参数满足契约”的断言。
import { test } from '@playwright/test'
test.beforeEach(async ({ context }) => {
await context.route('**/api/customer-profile', async route => {
const request = route.request()
if (request.method() !== 'GET') return route.fallback()
await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({
id: 'customer-example',
name: 'Example User',
email: 'user@example.test',
}),
})
})
})主动截图可以对已知 locator 使用截图 API 的 mask 能力,或先在页面内替换敏感文本再截图;这只能降低该张 screenshot 的暴露,不能清洗自动 trace 与 video。认证状态文件尤其敏感,Playwright 的认证指南警告其中可能包含可冒用账号的 cookie 与 header,必须加入 .gitignore,短期生成并按环境隔离。不要把 storageState 作为普通 attachment 上传。
对源码或自定义附件敏感的仓库,可以用官方TestOptions.trace 配置减少 trace 内的不必要副本,例如关闭 source 与 attachment 嵌入;attachments: false 不会从 HTML/blob report 删除同一附件。这也不是网络脱敏开关,DOM snapshot、console、请求/响应 header 和 body 仍可能含敏感值。关闭 snapshots 会显著降低定位能力,应先在固定失败样例上比较诊断损失,再决定是否采用。
import { defineConfig } from '@playwright/test'
export default defineConfig({
use: {
trace: {
mode: 'on-first-retry',
sources: false,
attachments: false,
},
},
})需要生产相似数据才能复现时,先在受控数据管道完成字段级匿名化与不可逆 tokenization,保留授权记录和删除策略,再让测试消费。直接把生产 HAR、trace 或页面录像复制到开发 artifact store,会把原系统的访问控制降级成 CI 查看权限。诊断价值不足以自动获得数据豁免。
RBAC 要控制生成、上传、查看和删除四个动作
Playwright 不负责组织级 RBAC;权限由 CI、仓库、对象存储和报告服务共同决定。最小角色可以分成:测试作业只能写入当前 run 前缀,普通开发者只能读取自己有仓库访问权的非敏感环境证据,质量或安全 owner 能批准访问受限附件,artifact 管理服务能按策略删除,审计角色只能读取元数据与操作日志。一个永不过期的共享下载链接会把这些角色全部绕开。
GitHub Actions 中,仓库读取者可以读取 artifact;通过 API 下载需要仓库的 Actions read 权限,删除则需要 Actions write 权限,见官方Actions Artifacts API。这意味着把敏感 trace 放进普通仓库 artifact,往往已经把读取面扩大到所有仓库读者。删除返回成功后无法从该 artifact 恢复,但组织保留、审计或外部复制是否另有副本要单独核对。GitLab 可用 artifacts:access 限制下载者,并用 expire_in 设过期;同时官方Job artifacts 文档说明“每个 ref 最近一次成功流水线”的 artifact 默认可能不受 expire_in 删除,容量策略必须同时检查该项目设置。选择哪种平台不改变原则:能读源码不一定就该读含客户数据的 trace,能重跑测试也不一定该获得删除法律保留证据的权限。
permissions:
contents: read
steps:
- name: Run browser tests
run: npx playwright test
env:
E2E_BASE_URL: ${{ vars.E2E_BASE_URL }}
E2E_TEST_TOKEN: ${{ secrets.E2E_TEST_TOKEN }}
E2E_RUN_ID: ${{ github.run_id }}-${{ github.run_attempt }}
E2E_SHARD: 1-of-1
E2E_COMMIT: ${{ github.sha }}
- name: Upload failure evidence
id: upload-evidence
if: ${{ !cancelled() }}
uses: actions/upload-artifact@v7
with:
name: playwright-evidence-${{ github.run_id }}-${{ github.run_attempt }}
path: blob-report/
if-no-files-found: warn
retention-days: 7这里的 permissions: contents: read 只是在收紧当前 job 的 GITHUB_TOKEN,不会替 artifact 创建独立读者名单。上传步骤应保留 artifact-id、artifact-digest 输出供后续 manifest 使用;真正需要删除的管理 job 再单独授予 actions: write,测试 job 不应持有删除权限。GitHub 官方 upload-artifact 还会默认排除点文件;不要为了“附件完整”直接打开 include-hidden-files,认证状态和环境文件正是最容易被意外上传的对象。
fork PR、外部贡献者和可修改 workflow 的分支要格外小心。攻击者可以让页面显示 secret、把环境变量写进附件,或上传任意本地文件。高权限 secret 只在受信任代码和受控环境运行;来自 fork 的测试使用无敏感数据的隔离环境。workflow action 应锁定受信任版本或完整 commit SHA,上传路径使用固定 allowlist,不能让用户输入决定 path。
对象存储直链使用短期签名 URL,下载动作进入审计;报告服务验证用户身份后再换取附件链接。静态报告若必须发布,放在需要认证的私有站点,设置不被搜索引擎索引和严格缓存策略。robots.txt、难猜 URL 与压缩包密码都不能替代 RBAC。
保留期要由失败类型和数据等级共同决定
把所有证据都保留相同天数很省配置,却会浪费容量并放大泄漏窗口。可以先按结果分层:成功用例只保留小型机器摘要或不保留附件;首次失败与重试通过的 flaky 证据短期保留,用于归因;稳定失败保留到问题关闭后再加一个缓冲周期;发布阻断、合规事件或安全事件进入受控 case,由事件保留策略接管。任何延长都要有 owner 和到期时间,不能靠手工点击“永久保留”。
数据等级还要覆盖结果分层。完全合成数据的 trace 可以走普通工程保留;含内部域名、源码片段或测试账号标识的证据至少放在内部受控存储;疑似个人信息、token、生产响应或安全缺陷的证据应立即限制访问、吊销凭证并进入安全处置。先限制扩散再讨论调试便利。
容量估算不难,难在使用真实分布。对本文基线,令 A_f 为每日失败 attempt 数,A_r 为每日实际发生的第一次重试数;S 是每个失败 attempt 的平均截图大小,V、T 是第一次重试的视频和 trace 平均大小,H 是报告、结构化摘要和索引开销,D 是保留天数,副本系数为 C。近似存储量为:
daily = A_f × S + A_r × (V + T) + H
retained = daily × D × C如果采用 retain-on-failure 或 trace: 'on',必须按各模式实际录制和保留的 run 重算,不能继续套用这个式子。还要加上传流量、下载流量、跨区域复制、对象请求、解压临时空间和 shard 合并峰值。失败率上升会同时增加重试计算和证据存储,是一个放大器。建议把 artifact 总量、每类附件 P50/P95、每日新增、下载次数、过期删除量和永久保留例外数作为趋势指标;单次超大 trace 设置告警并关联测试 owner。
降低成本的顺序应是修 flaky、缩短失败路径、减少不必要网络 body、按失败策略采集、调整视频尺寸与保留,再考虑压缩和冷存储。直接把 trace 全关掉会让失败修复时间上升;直接把所有视频永久保留则会让存储吞掉测试预算。
分片报告要先汇总再发布
并行 shard 各自产生 report 时,开发者需要在多个 artifact 里查同一提交,附件名称也容易碰撞。blob reporter 是 Playwright 为汇总设计的中间格式,包含运行详情与附件;汇总 job 下载所有 blob 到一个目录,再生成 HTML、JSON 或其他 reporter。原始 blob 和最终 HTML 不必保留同样时长:汇总成功后可短期删除 blob,只保留受控 HTML;若需要重新生成不同格式,则保留 blob 到重生成窗口结束。
jobs:
test:
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
shard: [1, 2, 3, 4]
steps:
- uses: actions/checkout@v6
- uses: actions/setup-node@v6
with:
node-version: "22"
cache: npm
- run: npm ci
- run: npx playwright install --with-deps chromium
- run: npx playwright test --project=chromium --shard=${{ matrix.shard }}/4 --reporter=blob
env:
E2E_RUN_ID: ${{ github.run_id }}-${{ github.run_attempt }}
E2E_SHARD: ${{ matrix.shard }}-of-4
E2E_COMMIT: ${{ github.sha }}
- uses: actions/upload-artifact@v7
if: ${{ !cancelled() }}
with:
name: blob-${{ github.run_id }}-${{ github.run_attempt }}-${{ matrix.shard }}
path: blob-report/
if-no-files-found: error
retention-days: 2
merge:
runs-on: ubuntu-latest
if: ${{ !cancelled() }}
needs: test
steps:
- uses: actions/checkout@v6
- uses: actions/setup-node@v6
with:
node-version: "22"
cache: npm
- run: npm ci
- uses: actions/download-artifact@v8
with:
pattern: blob-${{ github.run_id }}-${{ github.run_attempt }}-*
path: all-blob-reports
merge-multiple: true
- name: Verify shard manifest
shell: bash
run: test "$(find all-blob-reports -maxdepth 1 -name 'report-*.zip' | wc -l)" -eq 4
- run: npx playwright merge-reports --reporter=html ./all-blob-reports
- uses: actions/upload-artifact@v7
with:
name: playwright-report-${{ github.run_id }}-${{ github.run_attempt }}
path: playwright-report/
if-no-files-found: error
retention-days: 7合并环境必须与生成报告时的测试根路径兼容;跨操作系统合并时,官方文档建议提供显式 merge config 来消除 testDir 歧义。合并 job 不需要测试账号 secret,只需要读取 blob 和写最终 report。把测试凭证继续传给它扩大了不必要权限。
发布前做完整性检查:每个计划 shard 都有 blob;报告中 project、shard 与总测试数符合预期;失败条目能打开附件;index.html 与 data 目录引用完整;随机抽一个 trace 能在 Viewer 打开。合并命令退出 0 但少了一个 shard,只能得到“格式合法的不完整报告”,因此 shard 清单是独立门禁。
缺附件时沿生命周期逐点排障
“没有 video”先看模式和 attempt。on-first-retry 只在 retry #1 录制;没有启用 retries、测试首轮通过、或查看首轮失败目录,都不会有视频。手动 context 未关闭时,视频尚未完成写入。进程被强制终止、节点磁盘满或 job 被取消也可能让视频不完整。修复后用一个故意失败且允许一次重试的无敏感样例确认文件出现。
“没有 trace”同样先看 trace 模式。retain-on-failure 从首轮开始记录并在成功时删除,on-first-retry 只记录第一次重试;二者观察的 attempt 不同。直接调用 context.tracing 时看不到 Playwright Test 断言,不是 Viewer 丢数据。trace.zip 存在但打不开时,检查文件大小、上传是否截断、解压工具是否重写、Playwright Viewer 版本和 artifact 下载是否返回了 HTML 登录页而非 zip。
“截图与错误不一致”通常是截图时机晚于失败点,页面在 teardown 中继续导航,或者失败后错误提示被重试逻辑覆盖。用 test.step 标记关键业务步骤,在失败点主动截局部元素,并与 trace action 的 before/after snapshot 对照。截图通过不代表断言通过,截图失败也可能只是字体或动画差异;它是证据之一。
“HTML report 打开但附件 404”要检查 report 与 data 是否成套上传、attachmentsBaseURL 是否指向同一版本、静态服务器 base path、CORS、签名 URL 是否过期,以及报告服务是否把未知路径回退成首页。先用浏览器 Network 确认实际附件 URL 与状态,再查存储对象;不要反复重新生成报告覆盖原证据。
“运行结束后目录为空”要检查 outputDir 是否在运行开头被清理、preserveOutput 是否为 never、后续 job 是否执行了 workspace clean、上传步骤是否在失败时被跳过,以及路径是否相对另一个工作目录。GitHub Actions 的 if: !cancelled() 能覆盖正常成功和失败,但作业被取消或 runner 被强杀时不保证上传部分文件;需要取消现场时,要给测试进程和独立 finalizer 留出优雅退出时间,仍不能把它当成强保证。空路径必须 warning 或失败,不能静默成功。
删除与回滚要处理副本、引用和凭证
本地清理先关闭 report/trace viewer 进程,再删除当前项目输出。删除前确认路径解析在工作区内,避免变量为空时递归清理错误目录。CI artifact 删除后通常不可恢复,先判断是否存在事故调查、审计或法律保留;受保留约束的证据应转入隔离存储并冻结普通删除,而不是靠给所有开发者开放永久 artifact。
$root = (Get-Location).Path
$targets = @('.\test-results', '.\playwright-report', '.\blob-report')
foreach ($item in $targets) {
$target = [IO.Path]::GetFullPath((Join-Path $root $item))
if (-not $target.StartsWith($root + [IO.Path]::DirectorySeparatorChar)) {
throw "拒绝清理工作区外路径: $target"
}
if (Test-Path -LiteralPath $target) {
Remove-Item -LiteralPath $target -Recurse -Force
}
if (Test-Path -LiteralPath $target) {
throw "清理未完成: $target"
}
}删除清单要包含原始 test-results、blob、合并后的 HTML、外部 attachments、下载副本、临时解压目录、CDN cache 和搜索索引。报告引用外部附件时,只删 HTML 会留下孤儿对象;只删附件会留下能暴露测试名、源码片段和内部结构的报告壳。对象存储启用版本化或跨区域复制后,普通 DELETE 可能只是创建删除标记,真正物理回收还要由 lifecycle 处理。
如果 artifact 暴露了 token 或 cookie,删除文件不是回滚完成。应先吊销 token、使 session 失效、轮换受影响 secret,检查下载审计,再删除各副本并验证旧链接返回无权或不存在。缓存与浏览器下载目录也可能保存报告 zip;受管 runner 应在作业后销毁工作目录,长期 runner 需要受控清扫。
采集策略升级失败时,回滚配置要与 reporter 和存储规则一起恢复。例如从 on-first-retry 改成 retain-on-failure 后容量暴涨,回滚不仅改 trace 字段,还要恢复 retention、上传路径和容量告警;已经生成的超量附件按策略删除。更换报告平台时保留旧查看入口到迁移验证完成,确认新平台能关联 project/retry、打开 trace、执行 RBAC 和删除,再关闭旧入口。
运行模型解释了为什么证据会重复或丢失
一次测试包含一个或多个 attempt。首轮失败后,runner 记录失败状态、结束 fixture、关闭 context,并可能重启 worker;retry #1 是新的 attempt,有新的 context、页面和输出目录。on-first-retry 的 trace/video 观察的是第二次执行,不是对首轮失败现场的倒带。若错误只在首轮出现,重试 trace 可能完全绿色或呈现不同原因,所以关键高风险用例可选择 retain-on-first-failure 只抓首轮失败;需要保留每个失败 attempt 时再使用 retain-on-failure,同时重新评估性能与容量。
screenshot 自动采集发生在 runner 处理失败阶段;手工截图发生在测试代码调用点。video 的编码与落盘依赖 context closure。trace 在 attempt 生命周期内收集事件,停止后形成 zip。reporter 订阅 runner 事件,最终把状态和 testInfo.attachments 序列化。自定义 reporter 若在附件复制完成前读取源文件,或在 onEnd 前被进程强杀,就可能得到不完整报告。
worker 并行意味着共享文件名必然冲突。testInfo.outputPath() 把路径限制在当前 attempt 的独占目录;直接写 screenshots/failure.png 会被多个 project 和 retry 覆盖。分片进一步把并发扩展到多台机器,只有 test id、project、retry、shard 与 run id 的组合才能可靠关联。不要用 wall-clock 时间戳作为唯一键,时钟漂移和同毫秒并发都会造成碰撞。
报告显示的 expected、unexpected 与 flaky 也值得区分。一个被 test.fail() 标记的预期失败可能在终端不阻断,但附件仍包含真实页面数据;重试后通过的 flaky 在最终退出码上可能是绿色,却需要治理。artifact 上传条件不能只写 if: failure(),否则 flaky 证据会丢失;if: !cancelled() 覆盖成功与失败,但取消和进程强杀仍可能来不及上传。上传后再由报告状态和策略决定保留。
证据架构选型从故障恢复时间倒推
小团队可以采用“失败截图 + 第一次重试 trace/video + CI 私有 artifact + 短保留”,维护成本低,足以解决多数页面时序问题。关键交易系统更适合“retain-on-first-failure 首轮 trace + 关键步骤主动截图 + 结构化业务关联 + 受控 report 服务”,因为只记录重试可能错过一次性故障;若还要分析每次失败 retry,再升级为 retain-on-failure。大规模分片则需要 blob 中间层、合并 job、对象存储 lifecycle 和容量指标。
第三方测试报告平台能提供趋势、历史、检索和团队协作,也会扩大供应链与数据处理边界。选型时检查数据存储区域、加密、SSO、SCIM、细粒度角色、审计、API 删除、批量导出、保留上限、外部链接策略和退出迁移。不能只比较仪表盘;平台若不能证明附件物理删除或无法导出 test-attempt 关系,长期锁定风险很高。
证据等级可以和恢复目标绑定。普通 UI 回归希望在一个开发周期内定位,短保留足够;偶发支付或权限缺陷可能需要跨多次发布对照,保留更长但访问更严;安全与合规事件由事件响应系统接管。每提升一个等级,都要明确额外采集什么、谁能看、保留多久、成本谁承担、何时降级。
不需要 trace 的信号也应清楚。纯 API 契约失败优先保存请求摘要与响应 schema,不必录页面;编译失败没有 browser context,不会产生有效视频;视觉差异已有 expected/actual/diff 时,视频可能没有增量价值。证据应服务故障假设,不能把“附件越多越专业”当目标。
长期治理关注首次失败,不只关注最终退出码
治理指标至少包括首次失败率、重试后通过率、稳定失败平均定位时间、无附件失败数、附件打开失败数、每种附件 P95 大小、每日新增存储、过期删除成功率、永久保留例外、敏感信息事件和旧链接访问。最终失败率下降而首次失败率上升,通常说明重试在掩盖 flaky;附件量突然下降,可能是采集优化,也可能是上传步骤坏了。
配置、reporter、CI action 和存储策略都需要 owner。升级 Playwright 时跑固定失败样例,确认首轮截图、重试 trace/video、HTML/JSON/blob、merge、查看和删除仍成立;升级 CI artifact action 时验证权限、隐藏文件、同名 artifact、压缩与 retention 行为;更换对象存储 lifecycle 时做一个可审计的过期删除演练。
团队模板应集中维护默认采集策略,但允许项目按风险上调。任何 trace: 'on'、永久保留、外部分享、生产相似数据或关闭删除的例外都需要原因、owner 和到期检查。项目下线时吊销测试账号与上传凭证,删除 report/attachment/index/cache 副本,关闭定时任务,并保存不含敏感附件的最小审计摘要。
当一个失败能从 report 精确进入对应 project 与 retry,再由 screenshot、video 和 trace 回答“看到了什么、之前发生了什么、网络与 DOM 为什么走到这里”,同时证据又有明确访问者、保留期、容量 owner 和可验证删除路径,浏览器自动化才真正具备可运营的诊断能力。红灯不再只是一个结果,附件也不再是一堆无人负责的文件。
