test(ui): wait for conditions, not durations, in three flaky tests (#11499)

Three tests yielded a fixed number of macrotasks before asserting - five in one
case, one in another - which is ample on an idle machine and not when the suite
runs many workers in parallel. The container was still empty, or the state had
not landed, and the assertion failed on behaviour that works. `vi.waitFor`
retries against a time budget instead, so a loaded worker gets more turns
rather than a failure.

`DocumentAnnotationPopover` is a different race and is fixed differently. The
popover element is in the DOM as soon as React commits, while the effect that
registers the document-level keydown and pointerdown listeners runs afterwards.
A test dispatching in that gap loses the event outright, and a lost event
cannot be recovered by retrying an assertion - so the render is wrapped in
`act` to flush passive effects, and the waits only cover the smaller race that
remains.

Refs #11484.

Verified stable over six consecutive runs of the three files, and the full ui
suite passes. Other instances of the same class remain: the full suite still
shows an occasional failure in a different unrelated test on each run.
`App.cases-routing.test.tsx:104-108` is the clearest one - the identical
fixed-turn loop this PR replaced in its sibling `App.activity-routing.test.tsx`,
three turns instead of five - and takes the same one-line fix.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
TonioandClaude Opus 5 authored and GitHub committed 2026-08-17 00:29:42 -07:00
1 parent 870c305410
commit e07d605dfc
3 files changed
+52 -14

No files matched your search

@@ -56,6 +56,20 @@ describe("TaskMessageScroller", () => {
return container.querySelector<HTMLButtonElement>(PILL_SELECTOR);
}
/**
* Wait for the pill to reach a state, rather than for a fixed number of turns.
* `flushEvents` yields exactly one macrotask, which is enough on an idle
* machine and not when the suite runs many workers in parallel — React
* flushes continuous-priority updates asynchronously, so the DOM read can
* land before the state does. Sites that dereference `pill()!` turn that into
* a hard failure rather than a retry.
*/
async function waitForPill(present: boolean): Promise<void> {
await vi.waitFor(() =>
present ? expect(pill()).not.toBeNull() : expect(pill()).toBeNull(),
);
}
/** Set scrollTop and fire a scroll event, like a user or the browser would. */
async function scrollTo(el: HTMLElement, top: number) {
el.scrollTop = top;
@@ -101,6 +115,7 @@ describe("TaskMessageScroller", () => {
const el = scroller();
fakeGeometry(el);
await scrollTo(el, 600); // bottom: 1000 - 600 - 400 = 0 → pinned
await waitForPill(false); // pinned state settled before content grows
render(2);
expect(el.scrollTop).toBe(1000);
expect(pill()).toBeNull();
@@ -111,6 +126,7 @@ describe("TaskMessageScroller", () => {
const el = scroller();
fakeGeometry(el);
await scrollTo(el, 100); // 500px from bottom → unpinned
await waitForPill(true);
const btn = pill();
expect(btn).not.toBeNull();
expect(btn!.className).toContain("tc-scroll-pill-in");
@@ -139,6 +155,7 @@ describe("TaskMessageScroller", () => {
el.scrollTo = scrollToSpy as unknown as typeof el.scrollTo;
await scrollTo(el, 100);
await waitForPill(true);
const btn = pill()!;
btn.click();
await flushEvents();
@@ -167,6 +184,7 @@ describe("TaskMessageScroller", () => {
el.scrollTo = vi.fn() as unknown as typeof el.scrollTo;
await scrollTo(el, 100);
await waitForPill(true);
pill()!.click();
await flushEvents();
await dispatch(el, new Event("wheel", { bubbles: true }));
@@ -190,20 +208,20 @@ describe("TaskMessageScroller", () => {
const el = scroller();
fakeGeometry(el);
await scrollTo(el, 100);
expect(pill()!.className).toContain("tc-scroll-pill-in");
await vi.waitFor(() => expect(pill()?.className).toContain("tc-scroll-pill-in"));
// Scrolling back to the bottom starts the exit animation but keeps the
// pill mounted until animationend.
await scrollTo(el, 600);
await vi.waitFor(() => expect(pill()?.className).toContain("tc-scroll-pill-out"));
const exiting = pill();
expect(exiting).not.toBeNull();
expect(exiting!.className).toContain("tc-scroll-pill-out");
// jsdom has no window.AnimationEvent, so React's vendor-prefix detection
// maps onAnimationEnd to "webkitAnimationEnd" here (real browsers get the
// unprefixed event).
await dispatch(exiting!, new Event("webkitAnimationEnd", { bubbles: true }));
expect(pill()).toBeNull();
await waitForPill(false);
});
it("unmounts immediately on hide under prefers-reduced-motion", async () => {
@@ -215,8 +233,8 @@ describe("TaskMessageScroller", () => {
const el = scroller();
fakeGeometry(el);
await scrollTo(el, 100);
expect(pill()).not.toBeNull();
await waitForPill(true);
await scrollTo(el, 600);
expect(pill()).toBeNull(); // no animationend needed
await waitForPill(false); // no animationend needed
});
});