reporter.ts 2.5 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263
  1. import { expect, type TestInfo } from "@playwright/test"
  2. import { writeFile } from "node:fs/promises"
  3. import { analyzeVisualObservations, analyzeVisualTraceByMarker } from "./analyzer"
  4. import type { VisualPlan } from "./invariant"
  5. import type { VisualProbeResult } from "./model"
  6. export async function reportVisualStability<RegionName extends string>(
  7. testInfo: TestInfo,
  8. name: string,
  9. result: VisualProbeResult<RegionName>,
  10. plan: VisualPlan<RegionName>,
  11. ) {
  12. const trace = { markers: result.markers, samples: result.samples }
  13. const issues = plan.perMarker
  14. ? analyzeVisualTraceByMarker(trace, plan)
  15. : analyzeVisualObservations(result.samples, plan)
  16. const tracePath = testInfo.outputPath(`${name}-visual-trace.json`)
  17. const issuesPath = testInfo.outputPath(`${name}-visual-issues.json`)
  18. await writeFile(tracePath, JSON.stringify(trace, null, 2))
  19. await writeFile(
  20. issuesPath,
  21. JSON.stringify({ issues, markers: result.markers, capturedFrameCount: result.frames.length }, null, 2),
  22. )
  23. await testInfo.attach(`${name}-visual-trace`, { path: tracePath, contentType: "application/json" })
  24. await testInfo.attach(`${name}-visual-issues`, { path: issuesPath, contentType: "application/json" })
  25. if (issues.length) await attachViolationFrames(testInfo, name, result, issues)
  26. expect(issues, `${name}: ${issues.join("\n")}`).toEqual([])
  27. }
  28. async function attachViolationFrames<RegionName extends string>(
  29. testInfo: TestInfo,
  30. name: string,
  31. result: VisualProbeResult<RegionName>,
  32. issues: string[],
  33. ) {
  34. if (result.frames.length === 0) return
  35. const targets = [
  36. ...new Set(
  37. issues.flatMap((issue) => {
  38. const match = issue.match(/ at (\d+)ms/)
  39. if (match) return [Number(match[1])]
  40. const marker = result.markers.find((item) => issue.startsWith(`${item.label}:`))
  41. return marker ? [marker.at] : []
  42. }),
  43. ),
  44. ].slice(0, 6)
  45. for (const [violation, target] of targets.entries()) {
  46. const nearest = result.frames.reduce(
  47. (best, frame, index) => (Math.abs(frame.at - target) < Math.abs(result.frames[best]!.at - target) ? index : best),
  48. 0,
  49. )
  50. for (const [label, index] of [
  51. ["before", Math.max(0, nearest - 1)],
  52. ["violation", nearest],
  53. ["after", Math.min(result.frames.length - 1, nearest + 1)],
  54. ] as const) {
  55. await testInfo.attach(`${name}-${violation + 1}-${label}-${Math.round(result.frames[index]!.at)}ms`, {
  56. body: Buffer.from(result.frames[index]!.data, "base64"),
  57. contentType: "image/jpeg",
  58. })
  59. }
  60. }
  61. }