analyzer.ts 10 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209
  1. import type { VisualInvariant, VisualPlan } from "./invariant"
  2. import type { VisualObservation, VisualStabilityTrace } from "./model"
  3. export function analyzeVisualObservations<RegionName extends string>(
  4. observations: readonly VisualObservation<RegionName>[],
  5. plan: VisualPlan<RegionName>,
  6. ) {
  7. const issues: string[] = []
  8. const invariants = plan.invariants
  9. const names = [...new Set(observations.flatMap((sample) => Object.keys(sample.regions) as RegionName[]))]
  10. const required = regions(invariants, "required")
  11. const continuousAny = invariants.filter(
  12. (invariant): invariant is Extract<VisualInvariant<RegionName>, { type: "continuous-any" }> =>
  13. invariant.type === "continuous-any",
  14. )
  15. const unique = new Set(regions(invariants, "unique"))
  16. const stable = new Set(regions(invariants, "stable"))
  17. const fixed = invariants.filter(
  18. (invariant): invariant is Extract<VisualInvariant<RegionName>, { type: "fixed" }> => invariant.type === "fixed",
  19. )
  20. const opacity = invariants.filter(
  21. (invariant): invariant is Extract<VisualInvariant<RegionName>, { type: "opacity" }> => invariant.type === "opacity",
  22. )
  23. const continuity = invariants.filter(
  24. (invariant): invariant is Extract<VisualInvariant<RegionName>, { type: "continuity" }> =>
  25. invariant.type === "continuity",
  26. )
  27. const motion = invariants.filter(
  28. (invariant): invariant is Extract<VisualInvariant<RegionName>, { type: "motion" }> => invariant.type === "motion",
  29. )
  30. const labelStability = invariants.filter(
  31. (invariant): invariant is Extract<VisualInvariant<RegionName>, { type: "label-stability" }> =>
  32. invariant.type === "label-stability",
  33. )
  34. for (const name of new Set(required)) {
  35. if (!observations.some((sample) => sample.regions[name]?.visible)) issues.push(`${name} never rendered`)
  36. }
  37. for (const invariant of continuousAny) {
  38. if (!invariant.regions.some((name) => observations.some((sample) => sample.regions[name]?.visible)))
  39. issues.push(`${invariant.regions.join(" | ")} never rendered`)
  40. }
  41. for (const name of names) {
  42. const samples = observations.flatMap((observation) => {
  43. const region = observation.regions[name]
  44. if (!region) return []
  45. const clipped =
  46. observation.viewport && (region.bottom <= observation.viewport.top || region.top >= observation.viewport.bottom)
  47. return [{ at: observation.at, ...region, visible: region.visible && !clipped }]
  48. })
  49. const visible = samples.filter((sample) => sample.visible)
  50. if (visible.length === 0) continue
  51. if (unique.has(name)) {
  52. const duplicate = samples.find((sample) => sample.count > 1)
  53. if (duplicate) issues.push(`${name} appeared ${duplicate.count} times at ${Math.round(duplicate.at)}ms`)
  54. }
  55. if (stable.has(name)) {
  56. const identities = [...new Set(visible.map((sample) => sample.node).filter((node) => node > 0))]
  57. if (identities.length > 1) issues.push(`${name} remounted ${identities.length - 1} times`)
  58. }
  59. for (const invariant of fixed.filter((invariant) => includes(invariant.regions, name))) {
  60. const origin = visible[0]
  61. const movement = origin ? Math.max(0, ...visible.map((sample) => Math.abs(sample.top - origin.top))) : 0
  62. if (movement > (invariant.tolerance ?? 1))
  63. issues.push(`${name} moved ${Math.round(movement * 10) / 10}px in the viewport`)
  64. }
  65. for (const invariant of opacity.filter((invariant) => includes(invariant.regions, name))) {
  66. for (const sample of visible) {
  67. if (sample.opacity < (invariant.floor ?? 0.65))
  68. issues.push(`${name} opacity fell to ${sample.opacity} at ${Math.round(sample.at)}ms`)
  69. }
  70. }
  71. if (continuity.some((invariant) => includes(invariant.regions, name))) {
  72. const firstPresent = samples.findIndex((sample) => sample.present)
  73. const lastPresent = samples.findLastIndex((sample) => sample.present)
  74. if (samples.slice(firstPresent, lastPresent + 1).some((sample) => !sample.present))
  75. issues.push(`${name} disappeared between present frames`)
  76. const firstVisible = samples.findIndex((sample) => sample.visible)
  77. const lastVisible = samples.findLastIndex((sample) => sample.visible)
  78. if (
  79. firstVisible >= 0 &&
  80. samples.slice(firstVisible, lastVisible + 1).some((sample) => !sample.visible && sample.inViewport)
  81. )
  82. issues.push(`${name} blanked between visible frames`)
  83. }
  84. for (const invariant of motion.filter((invariant) => includes(invariant.regions, name))) {
  85. for (const metric of ["top", "bottom", "width", "height"] as const) {
  86. const directions = visible
  87. .slice(1)
  88. .map((sample, index) => sample[metric] - visible[index]![metric])
  89. .filter((delta) => Math.abs(delta) > (invariant.tolerance ?? 1))
  90. .map(Math.sign)
  91. const reversals = directions.slice(1).filter((direction, index) => direction !== directions[index]).length
  92. const allowed =
  93. metric === "top" || metric === "bottom"
  94. ? (invariant.maxPositionReversals ?? invariant.maxReversals ?? 1)
  95. : (invariant.maxReversals ?? 1)
  96. if (reversals > allowed) issues.push(`${name} ${metric} reversed ${reversals} times`)
  97. }
  98. }
  99. if (labelStability.some((invariant) => includes(invariant.regions, name))) {
  100. const labels = samples
  101. .map((sample) => sample.label)
  102. .filter((label) => label.length > 0)
  103. .filter((label, index, all) => label !== all[index - 1])
  104. if (labels.some((label, index) => labels.indexOf(label) !== index))
  105. issues.push(`${name} label reverted: ${labels.join(" -> ")}`)
  106. }
  107. }
  108. if (invariants.some((invariant) => invariant.type === "preserve-bottom-anchor")) {
  109. const viewports = observations.flatMap((sample) => (sample.viewport ? [sample.viewport] : []))
  110. if (viewports[0] && viewports[0].distanceFromBottom <= 4) {
  111. const lost = viewports.find((viewport) => viewport.distanceFromBottom > 4)
  112. if (lost) issues.push(`bottom anchor moved to ${lost.distanceFromBottom}px`)
  113. }
  114. }
  115. if (invariants.some((invariant) => invariant.type === "acquire-bottom-anchor")) {
  116. const final = observations.findLast((sample) => sample.viewport)?.viewport
  117. if (!final || final.distanceFromBottom > 4)
  118. issues.push(`did not acquire bottom anchor${final ? ` (${final.distanceFromBottom}px away)` : ""}`)
  119. }
  120. for (const invariant of continuousAny) {
  121. const active = observations.map((sample) => invariant.regions.some((name) => sample.regions[name]?.visible))
  122. const first = active.indexOf(true)
  123. const last = active.lastIndexOf(true)
  124. if (first >= 0 && active.slice(first, last + 1).some((value) => !value))
  125. issues.push(`${invariant.regions.join(" | ")} blanked between visible frames`)
  126. }
  127. for (const invariant of invariants.filter(
  128. (item): item is Extract<VisualInvariant<RegionName>, { type: "flow" }> => item.type === "flow",
  129. )) {
  130. for (const [before, after] of invariant.regions
  131. .slice(1)
  132. .map((after, index) => [invariant.regions[index]!, after])) {
  133. let maximum: { overlap: number; at: number } | undefined
  134. let inverted: { at: number } | undefined
  135. for (const sample of observations) {
  136. const first = sample.regions[before]
  137. const second = sample.regions[after]
  138. if (!first?.visible || !second?.visible) continue
  139. if (
  140. sample.viewport &&
  141. (first.bottom <= sample.viewport.top ||
  142. first.top >= sample.viewport.bottom ||
  143. second.bottom <= sample.viewport.top ||
  144. second.top >= sample.viewport.bottom)
  145. )
  146. continue
  147. const overlap = first.bottom - second.top
  148. if (first.top > second.top && !inverted) inverted = { at: sample.at }
  149. if (overlap > (invariant.overlapTolerance ?? 0.5) && (!maximum || overlap > maximum.overlap))
  150. maximum = { overlap, at: sample.at }
  151. }
  152. if (inverted) issues.push(`${before} rendered after ${after} at ${Math.round(inverted.at)}ms`)
  153. if (maximum)
  154. issues.push(
  155. `${before} overlapped ${after} by ${Math.round(maximum.overlap * 10) / 10}px at ${Math.round(maximum.at)}ms`,
  156. )
  157. }
  158. }
  159. return [...new Set(issues)]
  160. }
  161. export function analyzeVisualTraceByMarker<RegionName extends string>(
  162. trace: VisualStabilityTrace<RegionName>,
  163. plan: VisualPlan<RegionName>,
  164. ) {
  165. if (trace.markers.length === 0) return analyzeVisualObservations(trace.samples, plan)
  166. const required = [...new Set(plan.markerRequired ?? regions(plan.invariants, "required"))].flatMap((name) =>
  167. trace.samples.some((sample) => sample.regions[name]?.visible) ? [] : [`${name} never rendered`],
  168. )
  169. const withoutRequired = plan.invariants.filter((invariant) => invariant.type !== "required")
  170. const windows = trace.markers.flatMap((marker, index) => {
  171. const end = trace.markers[index + 1]?.at ?? Infinity
  172. const before = trace.samples.findLast((sample) => sample.at < marker.at)
  173. const samples = [
  174. ...(before ? [before] : []),
  175. ...trace.samples.filter((sample) => sample.at >= marker.at && sample.at < end),
  176. ]
  177. if (samples.length < 2) return []
  178. return analyzeVisualObservations(samples, { ...plan, perMarker: false, invariants: withoutRequired }).map(
  179. (issue) => `${marker.label}: ${issue}`,
  180. )
  181. })
  182. const aggregateMotion =
  183. plan.aggregateMotion === false
  184. ? []
  185. : analyzeVisualObservations(trace.samples, {
  186. invariants: plan.invariants.filter((invariant) => invariant.type === "motion"),
  187. }).filter((issue) => / (?:top|bottom|width|height) reversed \d+ times$/.test(issue))
  188. return [...new Set([...required, ...aggregateMotion, ...windows])]
  189. }
  190. function regions<RegionName extends string, Type extends VisualInvariant<RegionName>["type"]>(
  191. invariants: readonly VisualInvariant<RegionName>[],
  192. type: Type,
  193. ) {
  194. return invariants.flatMap((invariant) =>
  195. invariant.type === type && "regions" in invariant && invariant.regions !== "all" ? [...invariant.regions] : [],
  196. ) as RegionName[]
  197. }
  198. function includes<RegionName extends string>(regions: readonly RegionName[] | "all", name: RegionName) {
  199. return regions === "all" || regions.includes(name)
  200. }