mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-22 16:51:28 +02:00
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:
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
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user