Files
kandev/apps/web/e2e/helpers/settled-box.ts
T
2026-09-06 17:14:32 +01:00

50 lines
2.0 KiB
TypeScript

import { expect, type Locator } from "@playwright/test";
type BoundingBox = { x: number; y: number; width: number; height: number };
/**
* Scroll `locator` into view and resolve its bounding box once it stops moving.
*
* `scrollIntoViewIfNeeded()` resolves before an animated or momentum scroll has
* finished, so reading `boundingBox()` straight afterwards can capture
* coordinates that are already stale by the time a pointer event is dispatched
* at them. Drag helpers that then aim at those coordinates miss their target
* intermittently.
*
* Poll until two consecutive samples agree rather than sleeping for a budget
* that is guessed rather than measured: this returns as soon as the element is
* actually still, and keeps waiting when the scroll or an opening animation is
* slow. Position and dimensions are sampled together so a centered dialog
* cannot be reported settled while its scale animation is still changing its
* width.
*/
export async function settledBoundingBox(locator: Locator, timeout = 5_000): Promise<BoundingBox> {
await locator.scrollIntoViewIfNeeded();
let previous: string | null = null;
let latest: BoundingBox | null = null;
let agreeingReads = 0;
// Three consecutive agreeing reads, not two. With two, both samples can land
// before an animated scroll has begun moving the element at all, in which
// case they agree on the pre-scroll position and this returns exactly the
// stale coordinates it exists to avoid.
await expect
.poll(
async () => {
latest = await locator.boundingBox();
const current = latest
? [latest.x, latest.y, latest.width, latest.height].map(Math.round).join(",")
: null;
agreeingReads = current !== null && current === previous ? agreeingReads + 1 : 0;
previous = current;
return agreeingReads >= 2;
},
{ timeout, message: "element position did not settle" },
)
.toBe(true);
if (!latest) throw new Error("element has no bounding box after settling");
return latest;
}