fix(app): stabilize session timeline layout continuity (#34533)
This commit is contained in:
parent
f266e829cf
commit
3cf71808c4
57 changed files with 6159 additions and 142 deletions
209
packages/app/e2e/utils/visual-stability/analyzer.ts
Normal file
209
packages/app/e2e/utils/visual-stability/analyzer.ts
Normal file
|
|
@ -0,0 +1,209 @@
|
|||
import type { VisualInvariant, VisualPlan } from "./invariant"
|
||||
import type { VisualObservation, VisualStabilityTrace } from "./model"
|
||||
|
||||
export function analyzeVisualObservations<RegionName extends string>(
|
||||
observations: readonly VisualObservation<RegionName>[],
|
||||
plan: VisualPlan<RegionName>,
|
||||
) {
|
||||
const issues: string[] = []
|
||||
const invariants = plan.invariants
|
||||
const names = [...new Set(observations.flatMap((sample) => Object.keys(sample.regions) as RegionName[]))]
|
||||
const required = regions(invariants, "required")
|
||||
const continuousAny = invariants.filter(
|
||||
(invariant): invariant is Extract<VisualInvariant<RegionName>, { type: "continuous-any" }> =>
|
||||
invariant.type === "continuous-any",
|
||||
)
|
||||
const unique = new Set(regions(invariants, "unique"))
|
||||
const stable = new Set(regions(invariants, "stable"))
|
||||
const fixed = invariants.filter(
|
||||
(invariant): invariant is Extract<VisualInvariant<RegionName>, { type: "fixed" }> => invariant.type === "fixed",
|
||||
)
|
||||
const opacity = invariants.filter(
|
||||
(invariant): invariant is Extract<VisualInvariant<RegionName>, { type: "opacity" }> => invariant.type === "opacity",
|
||||
)
|
||||
const continuity = invariants.filter(
|
||||
(invariant): invariant is Extract<VisualInvariant<RegionName>, { type: "continuity" }> =>
|
||||
invariant.type === "continuity",
|
||||
)
|
||||
const motion = invariants.filter(
|
||||
(invariant): invariant is Extract<VisualInvariant<RegionName>, { type: "motion" }> => invariant.type === "motion",
|
||||
)
|
||||
const labelStability = invariants.filter(
|
||||
(invariant): invariant is Extract<VisualInvariant<RegionName>, { type: "label-stability" }> =>
|
||||
invariant.type === "label-stability",
|
||||
)
|
||||
|
||||
for (const name of new Set(required)) {
|
||||
if (!observations.some((sample) => sample.regions[name]?.visible)) issues.push(`${name} never rendered`)
|
||||
}
|
||||
for (const invariant of continuousAny) {
|
||||
if (!invariant.regions.some((name) => observations.some((sample) => sample.regions[name]?.visible)))
|
||||
issues.push(`${invariant.regions.join(" | ")} never rendered`)
|
||||
}
|
||||
|
||||
for (const name of names) {
|
||||
const samples = observations.flatMap((observation) => {
|
||||
const region = observation.regions[name]
|
||||
if (!region) return []
|
||||
const clipped =
|
||||
observation.viewport && (region.bottom <= observation.viewport.top || region.top >= observation.viewport.bottom)
|
||||
return [{ at: observation.at, ...region, visible: region.visible && !clipped }]
|
||||
})
|
||||
const visible = samples.filter((sample) => sample.visible)
|
||||
if (visible.length === 0) continue
|
||||
if (unique.has(name)) {
|
||||
const duplicate = samples.find((sample) => sample.count > 1)
|
||||
if (duplicate) issues.push(`${name} appeared ${duplicate.count} times at ${Math.round(duplicate.at)}ms`)
|
||||
}
|
||||
if (stable.has(name)) {
|
||||
const identities = [...new Set(visible.map((sample) => sample.node).filter((node) => node > 0))]
|
||||
if (identities.length > 1) issues.push(`${name} remounted ${identities.length - 1} times`)
|
||||
}
|
||||
for (const invariant of fixed.filter((invariant) => includes(invariant.regions, name))) {
|
||||
const origin = visible[0]
|
||||
const movement = origin ? Math.max(0, ...visible.map((sample) => Math.abs(sample.top - origin.top))) : 0
|
||||
if (movement > (invariant.tolerance ?? 1))
|
||||
issues.push(`${name} moved ${Math.round(movement * 10) / 10}px in the viewport`)
|
||||
}
|
||||
for (const invariant of opacity.filter((invariant) => includes(invariant.regions, name))) {
|
||||
for (const sample of visible) {
|
||||
if (sample.opacity < (invariant.floor ?? 0.65))
|
||||
issues.push(`${name} opacity fell to ${sample.opacity} at ${Math.round(sample.at)}ms`)
|
||||
}
|
||||
}
|
||||
if (continuity.some((invariant) => includes(invariant.regions, name))) {
|
||||
const firstPresent = samples.findIndex((sample) => sample.present)
|
||||
const lastPresent = samples.findLastIndex((sample) => sample.present)
|
||||
if (samples.slice(firstPresent, lastPresent + 1).some((sample) => !sample.present))
|
||||
issues.push(`${name} disappeared between present frames`)
|
||||
const firstVisible = samples.findIndex((sample) => sample.visible)
|
||||
const lastVisible = samples.findLastIndex((sample) => sample.visible)
|
||||
if (
|
||||
firstVisible >= 0 &&
|
||||
samples.slice(firstVisible, lastVisible + 1).some((sample) => !sample.visible && sample.inViewport)
|
||||
)
|
||||
issues.push(`${name} blanked between visible frames`)
|
||||
}
|
||||
for (const invariant of motion.filter((invariant) => includes(invariant.regions, name))) {
|
||||
for (const metric of ["top", "bottom", "width", "height"] as const) {
|
||||
const directions = visible
|
||||
.slice(1)
|
||||
.map((sample, index) => sample[metric] - visible[index]![metric])
|
||||
.filter((delta) => Math.abs(delta) > (invariant.tolerance ?? 1))
|
||||
.map(Math.sign)
|
||||
const reversals = directions.slice(1).filter((direction, index) => direction !== directions[index]).length
|
||||
const allowed =
|
||||
metric === "top" || metric === "bottom"
|
||||
? (invariant.maxPositionReversals ?? invariant.maxReversals ?? 1)
|
||||
: (invariant.maxReversals ?? 1)
|
||||
if (reversals > allowed) issues.push(`${name} ${metric} reversed ${reversals} times`)
|
||||
}
|
||||
}
|
||||
if (labelStability.some((invariant) => includes(invariant.regions, name))) {
|
||||
const labels = samples
|
||||
.map((sample) => sample.label)
|
||||
.filter((label) => label.length > 0)
|
||||
.filter((label, index, all) => label !== all[index - 1])
|
||||
if (labels.some((label, index) => labels.indexOf(label) !== index))
|
||||
issues.push(`${name} label reverted: ${labels.join(" -> ")}`)
|
||||
}
|
||||
}
|
||||
|
||||
if (invariants.some((invariant) => invariant.type === "preserve-bottom-anchor")) {
|
||||
const viewports = observations.flatMap((sample) => (sample.viewport ? [sample.viewport] : []))
|
||||
if (viewports[0] && viewports[0].distanceFromBottom <= 4) {
|
||||
const lost = viewports.find((viewport) => viewport.distanceFromBottom > 4)
|
||||
if (lost) issues.push(`bottom anchor moved to ${lost.distanceFromBottom}px`)
|
||||
}
|
||||
}
|
||||
if (invariants.some((invariant) => invariant.type === "acquire-bottom-anchor")) {
|
||||
const final = observations.findLast((sample) => sample.viewport)?.viewport
|
||||
if (!final || final.distanceFromBottom > 4)
|
||||
issues.push(`did not acquire bottom anchor${final ? ` (${final.distanceFromBottom}px away)` : ""}`)
|
||||
}
|
||||
|
||||
for (const invariant of continuousAny) {
|
||||
const active = observations.map((sample) => invariant.regions.some((name) => sample.regions[name]?.visible))
|
||||
const first = active.indexOf(true)
|
||||
const last = active.lastIndexOf(true)
|
||||
if (first >= 0 && active.slice(first, last + 1).some((value) => !value))
|
||||
issues.push(`${invariant.regions.join(" | ")} blanked between visible frames`)
|
||||
}
|
||||
|
||||
for (const invariant of invariants.filter(
|
||||
(item): item is Extract<VisualInvariant<RegionName>, { type: "flow" }> => item.type === "flow",
|
||||
)) {
|
||||
for (const [before, after] of invariant.regions
|
||||
.slice(1)
|
||||
.map((after, index) => [invariant.regions[index]!, after])) {
|
||||
let maximum: { overlap: number; at: number } | undefined
|
||||
let inverted: { at: number } | undefined
|
||||
for (const sample of observations) {
|
||||
const first = sample.regions[before]
|
||||
const second = sample.regions[after]
|
||||
if (!first?.visible || !second?.visible) continue
|
||||
if (
|
||||
sample.viewport &&
|
||||
(first.bottom <= sample.viewport.top ||
|
||||
first.top >= sample.viewport.bottom ||
|
||||
second.bottom <= sample.viewport.top ||
|
||||
second.top >= sample.viewport.bottom)
|
||||
)
|
||||
continue
|
||||
const overlap = first.bottom - second.top
|
||||
if (first.top > second.top && !inverted) inverted = { at: sample.at }
|
||||
if (overlap > (invariant.overlapTolerance ?? 0.5) && (!maximum || overlap > maximum.overlap))
|
||||
maximum = { overlap, at: sample.at }
|
||||
}
|
||||
if (inverted) issues.push(`${before} rendered after ${after} at ${Math.round(inverted.at)}ms`)
|
||||
if (maximum)
|
||||
issues.push(
|
||||
`${before} overlapped ${after} by ${Math.round(maximum.overlap * 10) / 10}px at ${Math.round(maximum.at)}ms`,
|
||||
)
|
||||
}
|
||||
}
|
||||
return [...new Set(issues)]
|
||||
}
|
||||
|
||||
export function analyzeVisualTraceByMarker<RegionName extends string>(
|
||||
trace: VisualStabilityTrace<RegionName>,
|
||||
plan: VisualPlan<RegionName>,
|
||||
) {
|
||||
if (trace.markers.length === 0) return analyzeVisualObservations(trace.samples, plan)
|
||||
const required = [...new Set(plan.markerRequired ?? regions(plan.invariants, "required"))].flatMap((name) =>
|
||||
trace.samples.some((sample) => sample.regions[name]?.visible) ? [] : [`${name} never rendered`],
|
||||
)
|
||||
const withoutRequired = plan.invariants.filter((invariant) => invariant.type !== "required")
|
||||
const windows = trace.markers.flatMap((marker, index) => {
|
||||
const end = trace.markers[index + 1]?.at ?? Infinity
|
||||
const before = trace.samples.findLast((sample) => sample.at < marker.at)
|
||||
const samples = [
|
||||
...(before ? [before] : []),
|
||||
...trace.samples.filter((sample) => sample.at >= marker.at && sample.at < end),
|
||||
]
|
||||
if (samples.length < 2) return []
|
||||
return analyzeVisualObservations(samples, { ...plan, perMarker: false, invariants: withoutRequired }).map(
|
||||
(issue) => `${marker.label}: ${issue}`,
|
||||
)
|
||||
})
|
||||
const aggregateMotion =
|
||||
plan.aggregateMotion === false
|
||||
? []
|
||||
: analyzeVisualObservations(trace.samples, {
|
||||
invariants: plan.invariants.filter((invariant) => invariant.type === "motion"),
|
||||
}).filter((issue) => / (?:top|bottom|width|height) reversed \d+ times$/.test(issue))
|
||||
return [...new Set([...required, ...aggregateMotion, ...windows])]
|
||||
}
|
||||
|
||||
function regions<RegionName extends string, Type extends VisualInvariant<RegionName>["type"]>(
|
||||
invariants: readonly VisualInvariant<RegionName>[],
|
||||
type: Type,
|
||||
) {
|
||||
return invariants.flatMap((invariant) =>
|
||||
invariant.type === type && "regions" in invariant && invariant.regions !== "all" ? [...invariant.regions] : [],
|
||||
) as RegionName[]
|
||||
}
|
||||
|
||||
function includes<RegionName extends string>(regions: readonly RegionName[] | "all", name: RegionName) {
|
||||
return regions === "all" || regions.includes(name)
|
||||
}
|
||||
51
packages/app/e2e/utils/visual-stability/capture.ts
Normal file
51
packages/app/e2e/utils/visual-stability/capture.ts
Normal file
|
|
@ -0,0 +1,51 @@
|
|||
import type { CDPSession, Page } from "@playwright/test"
|
||||
import type { CapturedFrame } from "./model"
|
||||
|
||||
export type VisualCapture = {
|
||||
session: CDPSession
|
||||
frames: CapturedFrame[]
|
||||
startedAtEpoch: number
|
||||
running: boolean
|
||||
capture: Promise<void>
|
||||
}
|
||||
|
||||
export async function startVisualCapture(page: Page, startedAtEpoch: number) {
|
||||
if (process.env.OPENCODE_STABILITY_CAPTURE !== "1") return
|
||||
const session = await page.context().newCDPSession(page)
|
||||
await session.send("Page.enable")
|
||||
const recording: VisualCapture = {
|
||||
session,
|
||||
frames: [],
|
||||
startedAtEpoch,
|
||||
running: true,
|
||||
capture: Promise.resolve(),
|
||||
}
|
||||
recording.capture = (async () => {
|
||||
try {
|
||||
while (recording.running && recording.frames.length < 900) {
|
||||
const frame = await session.send("Page.captureScreenshot", {
|
||||
format: "jpeg",
|
||||
quality: 80,
|
||||
captureBeyondViewport: false,
|
||||
optimizeForSpeed: true,
|
||||
})
|
||||
recording.frames.push({ at: Date.now() - recording.startedAtEpoch, data: frame.data })
|
||||
await new Promise((resolve) => setTimeout(resolve, 50))
|
||||
}
|
||||
} catch {
|
||||
recording.running = false
|
||||
}
|
||||
})()
|
||||
return recording
|
||||
}
|
||||
|
||||
export async function stopVisualCapture(recording: VisualCapture | undefined) {
|
||||
if (!recording) return []
|
||||
recording.running = false
|
||||
try {
|
||||
await recording.capture
|
||||
} finally {
|
||||
await recording.session.detach().catch(() => undefined)
|
||||
}
|
||||
return recording.frames
|
||||
}
|
||||
8
packages/app/e2e/utils/visual-stability/index.ts
Normal file
8
packages/app/e2e/utils/visual-stability/index.ts
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
export * from "./analyzer"
|
||||
export * from "./capture"
|
||||
export * from "./invariant"
|
||||
export * from "./model"
|
||||
export * from "./probe"
|
||||
export * from "./regions"
|
||||
export * from "./reporter"
|
||||
export * from "./scenario"
|
||||
112
packages/app/e2e/utils/visual-stability/invariant.ts
Normal file
112
packages/app/e2e/utils/visual-stability/invariant.ts
Normal file
|
|
@ -0,0 +1,112 @@
|
|||
import type { VisualRegionDefinition } from "./regions"
|
||||
|
||||
type RegionSet<RegionName extends string> = readonly RegionName[] | "all"
|
||||
|
||||
export type VisualInvariant<RegionName extends string = string> =
|
||||
| { type: "required"; regions: readonly RegionName[] }
|
||||
| { type: "continuous-any"; regions: readonly RegionName[] }
|
||||
| { type: "unique"; regions: readonly RegionName[] }
|
||||
| { type: "stable"; regions: readonly RegionName[] }
|
||||
| { type: "fixed"; regions: readonly RegionName[]; tolerance?: number }
|
||||
| { type: "opacity"; regions: RegionSet<RegionName>; floor?: number }
|
||||
| {
|
||||
type: "motion"
|
||||
regions: RegionSet<RegionName>
|
||||
tolerance?: number
|
||||
maxReversals?: number
|
||||
maxPositionReversals?: number
|
||||
}
|
||||
| { type: "continuity"; regions: RegionSet<RegionName> }
|
||||
| { type: "label-stability"; regions: RegionSet<RegionName> }
|
||||
| { type: "flow"; regions: readonly RegionName[]; overlapTolerance?: number }
|
||||
| { type: "preserve-bottom-anchor" }
|
||||
| { type: "acquire-bottom-anchor" }
|
||||
|
||||
export type VisualPlan<RegionName extends string = string> = {
|
||||
regionNames?: readonly RegionName[]
|
||||
invariants: readonly VisualInvariant<RegionName>[]
|
||||
markerRequired?: readonly RegionName[]
|
||||
perMarker?: boolean
|
||||
aggregateMotion?: boolean
|
||||
}
|
||||
|
||||
export type LegacyVisualStabilityOptions<RegionName extends string = string> = {
|
||||
flow?: RegionName[]
|
||||
motionTolerance?: number
|
||||
opacityFloor?: number
|
||||
overlapTolerance?: number
|
||||
maxReversals?: number
|
||||
maxPositionReversals?: number
|
||||
stable?: RegionName[]
|
||||
fixed?: RegionName[]
|
||||
motion?: RegionName[]
|
||||
unique?: RegionName[]
|
||||
preserveBottomAnchor?: boolean
|
||||
acquireBottomAnchor?: boolean
|
||||
perMarker?: boolean
|
||||
continuousAny?: RegionName[][]
|
||||
required?: RegionName[]
|
||||
aggregateMotion?: boolean
|
||||
inferRequired?: boolean
|
||||
}
|
||||
|
||||
export function visualPlan<const Regions extends Record<string, VisualRegionDefinition>>(
|
||||
regions: Regions,
|
||||
invariants: readonly VisualInvariant<Extract<keyof Regions, string>>[],
|
||||
options: Omit<VisualPlan<Extract<keyof Regions, string>>, "regionNames" | "invariants"> = {},
|
||||
): VisualPlan<Extract<keyof Regions, string>> {
|
||||
return { ...options, regionNames: Object.keys(regions) as Extract<keyof Regions, string>[], invariants }
|
||||
}
|
||||
|
||||
export function legacyVisualPlan<RegionName extends string>(
|
||||
options: LegacyVisualStabilityOptions<RegionName> = {},
|
||||
): VisualPlan<RegionName> {
|
||||
const inferred =
|
||||
options.inferRequired === false
|
||||
? []
|
||||
: [
|
||||
...(options.stable ?? []),
|
||||
...(options.fixed ?? []),
|
||||
...(options.unique ?? []),
|
||||
...(options.motion ?? []),
|
||||
...(options.flow ?? []),
|
||||
]
|
||||
return {
|
||||
perMarker: options.perMarker,
|
||||
aggregateMotion: options.aggregateMotion,
|
||||
markerRequired: [
|
||||
...(options.required ?? []),
|
||||
...(options.stable ?? []),
|
||||
...(options.fixed ?? []),
|
||||
...(options.unique ?? []),
|
||||
...(options.motion ?? []),
|
||||
...(options.flow ?? []),
|
||||
],
|
||||
invariants: [
|
||||
{ type: "required", regions: [...(options.required ?? []), ...inferred] },
|
||||
...(options.continuousAny ?? []).map(
|
||||
(regions): VisualInvariant<RegionName> => ({ type: "continuous-any", regions }),
|
||||
),
|
||||
...(options.unique ? [{ type: "unique" as const, regions: options.unique }] : []),
|
||||
...(options.stable ? [{ type: "stable" as const, regions: options.stable }] : []),
|
||||
...(options.fixed
|
||||
? [{ type: "fixed" as const, regions: options.fixed, tolerance: options.motionTolerance }]
|
||||
: []),
|
||||
{ type: "opacity", regions: "all", floor: options.opacityFloor },
|
||||
{ type: "continuity", regions: "all" },
|
||||
{
|
||||
type: "motion",
|
||||
regions: options.motion ?? "all",
|
||||
tolerance: options.motionTolerance,
|
||||
maxReversals: options.maxReversals,
|
||||
maxPositionReversals: options.maxPositionReversals,
|
||||
},
|
||||
{ type: "label-stability", regions: "all" },
|
||||
...(options.preserveBottomAnchor ? [{ type: "preserve-bottom-anchor" as const }] : []),
|
||||
...(options.acquireBottomAnchor ? [{ type: "acquire-bottom-anchor" as const }] : []),
|
||||
...(options.flow
|
||||
? [{ type: "flow" as const, regions: options.flow, overlapTolerance: options.overlapTolerance }]
|
||||
: []),
|
||||
],
|
||||
}
|
||||
}
|
||||
47
packages/app/e2e/utils/visual-stability/model.ts
Normal file
47
packages/app/e2e/utils/visual-stability/model.ts
Normal file
|
|
@ -0,0 +1,47 @@
|
|||
export type VisualRegionSample = {
|
||||
present: boolean
|
||||
visible: boolean
|
||||
inViewport: boolean
|
||||
cssHidden?: boolean
|
||||
top: number
|
||||
bottom: number
|
||||
layoutTop?: number
|
||||
layoutBottom?: number
|
||||
width: number
|
||||
height: number
|
||||
opacity: number
|
||||
count: number
|
||||
node: number
|
||||
label: string
|
||||
text: string
|
||||
}
|
||||
|
||||
export type VisualViewportSample = {
|
||||
top: number
|
||||
bottom: number
|
||||
scrollTop: number
|
||||
scrollHeight: number
|
||||
clientHeight: number
|
||||
distanceFromBottom: number
|
||||
}
|
||||
|
||||
export type VisualObservation<RegionName extends string = string> = {
|
||||
at: number
|
||||
regions: string extends RegionName
|
||||
? Record<string, VisualRegionSample>
|
||||
: Partial<Record<RegionName, VisualRegionSample>>
|
||||
viewport?: VisualViewportSample
|
||||
}
|
||||
|
||||
export type VisualMarker = { at: number; label: string }
|
||||
|
||||
export type VisualStabilityTrace<RegionName extends string = string> = {
|
||||
markers: VisualMarker[]
|
||||
samples: VisualObservation<RegionName>[]
|
||||
}
|
||||
|
||||
export type CapturedFrame = { at: number; data: string }
|
||||
|
||||
export type VisualProbeResult<RegionName extends string = string> = VisualStabilityTrace<RegionName> & {
|
||||
frames: CapturedFrame[]
|
||||
}
|
||||
226
packages/app/e2e/utils/visual-stability/probe.ts
Normal file
226
packages/app/e2e/utils/visual-stability/probe.ts
Normal file
|
|
@ -0,0 +1,226 @@
|
|||
import type { Page } from "@playwright/test"
|
||||
import { startVisualCapture, stopVisualCapture, type VisualCapture } from "./capture"
|
||||
import type { VisualMarker, VisualObservation, VisualProbeResult } from "./model"
|
||||
import type { VisualRegionDefinition } from "./regions"
|
||||
|
||||
type ProbeWindow<RegionName extends string = string> = Window & {
|
||||
__visualStabilityProbe?: {
|
||||
startedAt: number
|
||||
markers: VisualMarker[]
|
||||
samples: VisualObservation<RegionName>[]
|
||||
stop: () => void
|
||||
}
|
||||
}
|
||||
|
||||
const captures = new WeakMap<Page, VisualCapture>()
|
||||
|
||||
export async function startVisualProbe<Regions extends Record<string, VisualRegionDefinition>>(
|
||||
page: Page,
|
||||
regions: Regions,
|
||||
) {
|
||||
await stopCapture(page)
|
||||
await page.evaluate(() => {
|
||||
;(window as ProbeWindow).__visualStabilityProbe?.stop()
|
||||
})
|
||||
const startedAtEpoch = await page.evaluate((regions) => {
|
||||
const samples: VisualObservation[] = []
|
||||
const markers: VisualMarker[] = []
|
||||
const startedAt = performance.now()
|
||||
const nodes = new WeakMap<Node, number>()
|
||||
const lastBounds = new Map<string, { top: number; bottom: number }>()
|
||||
let nextNode = 1
|
||||
let running = true
|
||||
const round = (value: number) => Math.round(value * 10) / 10
|
||||
const opacity = (element: Element) => Number(getComputedStyle(element).opacity)
|
||||
const sample = () => {
|
||||
if (!running) return
|
||||
setTimeout(() => {
|
||||
if (!running) return
|
||||
const viewport = [...document.querySelectorAll<HTMLElement>(".scroll-view__viewport")].find((element) =>
|
||||
element.querySelector("[data-timeline-row]"),
|
||||
)
|
||||
const viewportRect = viewport?.getBoundingClientRect()
|
||||
samples.push({
|
||||
at: performance.now() - startedAt,
|
||||
viewport: viewport
|
||||
? {
|
||||
top: round(viewportRect!.top),
|
||||
bottom: round(viewportRect!.bottom),
|
||||
scrollTop: round(viewport.scrollTop),
|
||||
scrollHeight: round(viewport.scrollHeight),
|
||||
clientHeight: round(viewport.clientHeight),
|
||||
distanceFromBottom: round(viewport.scrollHeight - viewport.clientHeight - viewport.scrollTop),
|
||||
}
|
||||
: undefined,
|
||||
regions: Object.fromEntries(
|
||||
Object.entries(regions).map(([name, config]) => {
|
||||
const found = document.querySelector<HTMLElement>(config.selector)
|
||||
const count = document.querySelectorAll(config.selector).length
|
||||
const element = config.closest ? found?.closest<HTMLElement>(config.closest) : found
|
||||
if (!element)
|
||||
return [
|
||||
name,
|
||||
{
|
||||
present: false,
|
||||
visible: false,
|
||||
inViewport: false,
|
||||
top: 0,
|
||||
bottom: 0,
|
||||
width: 0,
|
||||
height: 0,
|
||||
opacity: 0,
|
||||
count,
|
||||
node: 0,
|
||||
label: "",
|
||||
text: "",
|
||||
},
|
||||
]
|
||||
const rect = element.getBoundingClientRect()
|
||||
const style = getComputedStyle(element)
|
||||
if (rect.height > 0) lastBounds.set(name, { top: rect.top, bottom: rect.bottom })
|
||||
const known = rect.height > 0 ? rect : lastBounds.get(name)
|
||||
const painted = (() => {
|
||||
const result = { top: rect.top, bottom: rect.bottom, left: rect.left, right: rect.right }
|
||||
let parent = element.parentElement
|
||||
while (parent) {
|
||||
const parentStyle = getComputedStyle(parent)
|
||||
if (["hidden", "clip", "scroll", "auto"].includes(parentStyle.overflowY)) {
|
||||
const parentRect = parent.getBoundingClientRect()
|
||||
result.top = Math.max(result.top, parentRect.top)
|
||||
result.bottom = Math.min(result.bottom, parentRect.bottom)
|
||||
}
|
||||
if (["hidden", "clip", "scroll", "auto"].includes(parentStyle.overflowX)) {
|
||||
const parentRect = parent.getBoundingClientRect()
|
||||
result.left = Math.max(result.left, parentRect.left)
|
||||
result.right = Math.min(result.right, parentRect.right)
|
||||
}
|
||||
if (parent === viewport) break
|
||||
parent = parent.parentElement
|
||||
}
|
||||
if (viewportRect) {
|
||||
result.top = Math.max(result.top, viewportRect.top)
|
||||
result.bottom = Math.min(result.bottom, viewportRect.bottom)
|
||||
result.left = Math.max(result.left, viewportRect.left)
|
||||
result.right = Math.min(result.right, viewportRect.right)
|
||||
}
|
||||
return result
|
||||
})()
|
||||
const contentOpacity = config.opacitySelectors?.length
|
||||
? Math.max(
|
||||
0,
|
||||
...config.opacitySelectors.flatMap((selector) =>
|
||||
[...element.querySelectorAll(selector)].map((node) => {
|
||||
let value = 1
|
||||
let current: Element | null = node
|
||||
while (current) {
|
||||
value *= opacity(current)
|
||||
if (current === element) break
|
||||
current = current.parentElement
|
||||
}
|
||||
return value
|
||||
}),
|
||||
),
|
||||
)
|
||||
: opacity(element)
|
||||
let visibleOpacity = contentOpacity
|
||||
let ancestor = element.parentElement
|
||||
let ancestorHidden = false
|
||||
while (ancestor) {
|
||||
const ancestorStyle = getComputedStyle(ancestor)
|
||||
visibleOpacity *= Number(ancestorStyle.opacity)
|
||||
if (ancestorStyle.display === "none" || ancestorStyle.visibility === "hidden") ancestorHidden = true
|
||||
if (ancestor === viewport) break
|
||||
ancestor = ancestor.parentElement
|
||||
}
|
||||
const cssHidden =
|
||||
ancestorHidden || style.display === "none" || style.visibility === "hidden" || visibleOpacity === 0
|
||||
return [
|
||||
name,
|
||||
{
|
||||
present: true,
|
||||
visible:
|
||||
style.display !== "none" &&
|
||||
style.visibility !== "hidden" &&
|
||||
visibleOpacity > 0 &&
|
||||
painted.right > painted.left &&
|
||||
painted.bottom > painted.top,
|
||||
inViewport:
|
||||
!viewportRect || (!!known && known.bottom > viewportRect.top && known.top < viewportRect.bottom),
|
||||
cssHidden,
|
||||
top: round(painted.top),
|
||||
bottom: round(painted.bottom),
|
||||
layoutTop: round(rect.top),
|
||||
layoutBottom: round(rect.bottom),
|
||||
width: round(painted.right - painted.left),
|
||||
height: round(painted.bottom - painted.top),
|
||||
opacity: round(visibleOpacity),
|
||||
count,
|
||||
node: (() => {
|
||||
const current = nodes.get(element)
|
||||
if (current) return current
|
||||
nodes.set(element, nextNode)
|
||||
return nextNode++
|
||||
})(),
|
||||
label: element.getAttribute("aria-label") ?? "",
|
||||
text: (element.textContent ?? "").trim().replace(/\s+/g, " ").slice(0, 500),
|
||||
},
|
||||
]
|
||||
}),
|
||||
),
|
||||
})
|
||||
requestAnimationFrame(sample)
|
||||
}, 0)
|
||||
}
|
||||
;(window as ProbeWindow).__visualStabilityProbe = {
|
||||
startedAt,
|
||||
markers,
|
||||
samples,
|
||||
stop: () => {
|
||||
running = false
|
||||
},
|
||||
}
|
||||
requestAnimationFrame(sample)
|
||||
return new Promise<number>((resolve) => {
|
||||
const ready = () => {
|
||||
if (samples.length > 0) return resolve(performance.timeOrigin + startedAt)
|
||||
requestAnimationFrame(ready)
|
||||
}
|
||||
ready()
|
||||
})
|
||||
}, regions)
|
||||
const capture = await startVisualCapture(page, startedAtEpoch)
|
||||
if (capture) captures.set(page, capture)
|
||||
}
|
||||
|
||||
export async function stopVisualProbe<RegionName extends string = string>(
|
||||
page: Page,
|
||||
): Promise<VisualProbeResult<RegionName>> {
|
||||
return page
|
||||
.evaluate(() => {
|
||||
const probe = (window as ProbeWindow).__visualStabilityProbe
|
||||
if (!probe) throw new Error("Visual stability probe is not running")
|
||||
probe.stop()
|
||||
return { markers: probe.markers, samples: probe.samples }
|
||||
})
|
||||
.then(
|
||||
async (trace) => ({ ...trace, frames: await stopCapture(page) }) as unknown as VisualProbeResult<RegionName>,
|
||||
async (error: unknown) => {
|
||||
await stopCapture(page)
|
||||
throw error
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
export async function markVisualProbe(page: Page, label: string) {
|
||||
await page.evaluate((label) => {
|
||||
const probe = (window as ProbeWindow).__visualStabilityProbe
|
||||
if (!probe) return
|
||||
probe.markers.push({ at: performance.now() - probe.startedAt, label })
|
||||
}, label)
|
||||
}
|
||||
|
||||
async function stopCapture(page: Page) {
|
||||
const capture = captures.get(page)
|
||||
if (capture) captures.delete(page)
|
||||
return stopVisualCapture(capture)
|
||||
}
|
||||
18
packages/app/e2e/utils/visual-stability/regions.ts
Normal file
18
packages/app/e2e/utils/visual-stability/regions.ts
Normal file
|
|
@ -0,0 +1,18 @@
|
|||
export type VisualRegionDefinition = {
|
||||
selector: string
|
||||
closest?: string
|
||||
opacitySelectors?: readonly string[]
|
||||
}
|
||||
|
||||
export function defineVisualRegions<const Regions extends Record<string, VisualRegionDefinition>>(regions: Regions) {
|
||||
return regions
|
||||
}
|
||||
|
||||
export function mapVisualRegions<const Regions extends Record<string, VisualRegionDefinition>, Result>(
|
||||
regions: Regions,
|
||||
map: (region: Regions[keyof Regions], name: keyof Regions) => Result,
|
||||
) {
|
||||
return Object.fromEntries(
|
||||
Object.entries(regions).map(([name, region]) => [name, map(region as Regions[keyof Regions], name)]),
|
||||
) as { [Name in keyof Regions]: Result }
|
||||
}
|
||||
63
packages/app/e2e/utils/visual-stability/reporter.ts
Normal file
63
packages/app/e2e/utils/visual-stability/reporter.ts
Normal file
|
|
@ -0,0 +1,63 @@
|
|||
import { expect, type TestInfo } from "@playwright/test"
|
||||
import { writeFile } from "node:fs/promises"
|
||||
import { analyzeVisualObservations, analyzeVisualTraceByMarker } from "./analyzer"
|
||||
import type { VisualPlan } from "./invariant"
|
||||
import type { VisualProbeResult } from "./model"
|
||||
|
||||
export async function reportVisualStability<RegionName extends string>(
|
||||
testInfo: TestInfo,
|
||||
name: string,
|
||||
result: VisualProbeResult<RegionName>,
|
||||
plan: VisualPlan<RegionName>,
|
||||
) {
|
||||
const trace = { markers: result.markers, samples: result.samples }
|
||||
const issues = plan.perMarker
|
||||
? analyzeVisualTraceByMarker(trace, plan)
|
||||
: analyzeVisualObservations(result.samples, plan)
|
||||
const tracePath = testInfo.outputPath(`${name}-visual-trace.json`)
|
||||
const issuesPath = testInfo.outputPath(`${name}-visual-issues.json`)
|
||||
await writeFile(tracePath, JSON.stringify(trace, null, 2))
|
||||
await writeFile(
|
||||
issuesPath,
|
||||
JSON.stringify({ issues, markers: result.markers, capturedFrameCount: result.frames.length }, null, 2),
|
||||
)
|
||||
await testInfo.attach(`${name}-visual-trace`, { path: tracePath, contentType: "application/json" })
|
||||
await testInfo.attach(`${name}-visual-issues`, { path: issuesPath, contentType: "application/json" })
|
||||
if (issues.length) await attachViolationFrames(testInfo, name, result, issues)
|
||||
expect(issues, `${name}: ${issues.join("\n")}`).toEqual([])
|
||||
}
|
||||
|
||||
async function attachViolationFrames<RegionName extends string>(
|
||||
testInfo: TestInfo,
|
||||
name: string,
|
||||
result: VisualProbeResult<RegionName>,
|
||||
issues: string[],
|
||||
) {
|
||||
if (result.frames.length === 0) return
|
||||
const targets = [
|
||||
...new Set(
|
||||
issues.flatMap((issue) => {
|
||||
const match = issue.match(/ at (\d+)ms/)
|
||||
if (match) return [Number(match[1])]
|
||||
const marker = result.markers.find((item) => issue.startsWith(`${item.label}:`))
|
||||
return marker ? [marker.at] : []
|
||||
}),
|
||||
),
|
||||
].slice(0, 6)
|
||||
for (const [violation, target] of targets.entries()) {
|
||||
const nearest = result.frames.reduce(
|
||||
(best, frame, index) => (Math.abs(frame.at - target) < Math.abs(result.frames[best]!.at - target) ? index : best),
|
||||
0,
|
||||
)
|
||||
for (const [label, index] of [
|
||||
["before", Math.max(0, nearest - 1)],
|
||||
["violation", nearest],
|
||||
["after", Math.min(result.frames.length - 1, nearest + 1)],
|
||||
] as const) {
|
||||
await testInfo.attach(`${name}-${violation + 1}-${label}-${Math.round(result.frames[index]!.at)}ms`, {
|
||||
body: Buffer.from(result.frames[index]!.data, "base64"),
|
||||
contentType: "image/jpeg",
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
20
packages/app/e2e/utils/visual-stability/scenario.ts
Normal file
20
packages/app/e2e/utils/visual-stability/scenario.ts
Normal file
|
|
@ -0,0 +1,20 @@
|
|||
import type { Page, TestInfo } from "@playwright/test"
|
||||
import type { VisualPlan } from "./invariant"
|
||||
import { startVisualProbe, stopVisualProbe } from "./probe"
|
||||
import type { VisualRegionDefinition } from "./regions"
|
||||
import { reportVisualStability } from "./reporter"
|
||||
|
||||
export async function runVisualStabilityScenario<const Regions extends Record<string, VisualRegionDefinition>>(input: {
|
||||
page: Page
|
||||
testInfo: TestInfo
|
||||
name: string
|
||||
regions: Regions
|
||||
plan: VisualPlan<Extract<keyof Regions, string>>
|
||||
run: () => Promise<void>
|
||||
}) {
|
||||
await startVisualProbe(input.page, input.regions)
|
||||
await input.run()
|
||||
const result = await stopVisualProbe<Extract<keyof Regions, string>>(input.page)
|
||||
await reportVisualStability(input.testInfo, input.name, result, input.plan)
|
||||
return result
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue