Skip to content
This repository was archived by the owner on Oct 4, 2026. It is now read-only.
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
53 changes: 46 additions & 7 deletions invokeai/frontend/webv2/src/platform/ui/Tabs.browser.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -79,7 +79,7 @@ describe('tab hover styles', () => {

await act(async () => {
await userEvent.hover(idle);
await waitForTransition();
await waitForSettledStyles(idle);
});
const idleHovered = getInteractionStyles(idle);
expect(idleHovered.transitionDuration).toBe('0.1s');
Expand All @@ -97,14 +97,14 @@ describe('tab hover styles', () => {
await act(async () => {
await userEvent.unhover(idle);
await userEvent.hover(selected);
await waitForTransition();
await waitForSettledStyles(selected);
});
expect(getInteractionStyles(selected)).toEqual(selectedBefore);

await act(async () => {
await userEvent.unhover(selected);
await userEvent.hover(disabled);
await waitForTransition();
await waitForSettledStyles(disabled);
});
expect(getInteractionStyles(disabled)).toEqual(disabledBefore);
await act(() => userEvent.unhover(disabled));
Expand All @@ -122,7 +122,7 @@ describe('tab hover styles', () => {

await act(async () => {
await userEvent.hover(lineIdle);
await waitForTransition();
await waitForSettledStyles(lineIdle);
});
expect(getComputedStyle(lineIdle).outline).toBe(focusOutline);
expect(lineSelected.dataset.selected).toBe('');
Expand All @@ -141,9 +141,48 @@ const getInteractionStyles = (element: HTMLElement) => {
};
};

const waitForTransition = () =>
new Promise<void>((resolve) => {
globalThis.setTimeout(resolve, 200);
/** Consecutive unchanged samples that count as "the transition has finished". */
const STABLE_SAMPLES = 3;
const SAMPLE_INTERVAL_MS = 16;

/**
* Waits until an element's interaction styles stop changing, rather than
* sleeping a fixed interval after each hover.
*
* These transitions run for 0.1s, but the test hovers fifteen triggers and
* used to wait 200ms every time — three seconds of sleeping against a 15s
* test timeout, which is what made this fail on a loaded CI runner. Sampling
* until the values hold still takes as long as the machine actually needs,
* and usually far less.
*/
const waitForSettledStyles = (element: HTMLElement, timeoutMs = 5000): Promise<void> =>
new Promise<void>((resolve, reject) => {
const deadline = Date.now() + timeoutMs;
let previous = JSON.stringify(getInteractionStyles(element));
let stableSamples = 0;

const sample = () => {
const current = JSON.stringify(getInteractionStyles(element));

stableSamples = current === previous ? stableSamples + 1 : 0;
previous = current;

if (stableSamples >= STABLE_SAMPLES) {
resolve();

return;
}

if (Date.now() > deadline) {
reject(new Error(`Timed out after ${timeoutMs}ms waiting for styles to settle; last value ${current}`));

return;
}

globalThis.setTimeout(sample, SAMPLE_INTERVAL_MS);
};

globalThis.setTimeout(sample, SAMPLE_INTERVAL_MS);
});

const getProbeStyle = (container: HTMLElement, label: string) =>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -71,13 +71,49 @@ const interact = (action: () => void): Promise<void> =>
});
});

const waitForMotion = (): Promise<void> =>
/**
* Waits until a transition has actually settled on `expected`, rather than
* sleeping a fixed interval and asserting immediately after.
*
* A loaded CI runner can starve the compositor for longer than any constant
* worth waiting for, and the failure mode is silent: the transition has not
* advanced, so the assertion reads the value it started from and reports a
* plausible-looking mismatch. Polling makes the wait as long as the machine
* needs and no longer, and a timeout still fails loudly.
*/
const waitForOpacity = (element: HTMLElement, expected: string, label = '', timeoutMs = 5000): Promise<void> =>
act(async () => {
await new Promise<void>((resolve) => {
globalThis.setTimeout(resolve, 350);
});
const deadline = Date.now() + timeoutMs;

while (getComputedStyle(element).opacity !== expected) {
if (Date.now() > deadline) {
throw new Error(
`Timed out after ${timeoutMs}ms waiting for opacity ${expected} at [${label}]; last value ${getComputedStyle(element).opacity}; activeElement=${document.activeElement?.getAttribute('aria-label') ?? document.activeElement?.tagName}`
);
}

await new Promise<void>((resolve) => {
globalThis.setTimeout(resolve, 16);
});
}
});

/**
* React derives `onPointerLeave` from `pointerout`, so this clears a hover the
* component may have picked up from the real cursor without moving it.
*/
const clearMouseHover = (element: HTMLElement): Promise<void> =>
interact(() =>
element.dispatchEvent(
new PointerEvent('pointerout', {
bubbles: true,
pointerId: 1,
pointerType: 'mouse',
relatedTarget: document.body,
})
)
);

const renderComparison = async ({
baseImage = createImage('base', 1200, 800),
initialMode = 'hover',
Expand Down Expand Up @@ -141,13 +177,19 @@ describe('PreviewCompare', () => {
const frame = host!.querySelector<HTMLElement>('[aria-label*="Reveal comparison"]')!;
const compareOverlay = host!.querySelector<HTMLImageElement>('img[alt="compare"]')?.parentElement as HTMLElement;

expect(getComputedStyle(compareOverlay).opacity).toBe('0');
// The overlay reveals on focus OR hover OR touch press. The runner's
// cursor stays wherever the previous test left it, so a component that
// mounts underneath it gets a real `pointerenter` and stays revealed
// through blur. Clearing the hover synthetically keeps this test about
// focus, and keeps it independent of where the pointer happens to be.
// Waited for, not asserted outright: if the cursor had been resting on the
// frame, clearing it starts a fade that is still in flight right now.
await clearMouseHover(frame);
await waitForOpacity(compareOverlay, '0', 'initial');
await interact(() => frame.focus());
await waitForMotion();
expect(getComputedStyle(compareOverlay).opacity).toBe('1');
await waitForOpacity(compareOverlay, '1', 'after-focus');
await interact(() => frame.blur());
await waitForMotion();
expect(getComputedStyle(compareOverlay).opacity).toBe('0');
await waitForOpacity(compareOverlay, '0', 'after-blur');

Object.defineProperties(frame, {
hasPointerCapture: { value: () => true },
Expand All @@ -157,13 +199,11 @@ describe('PreviewCompare', () => {
await interact(() =>
frame.dispatchEvent(new PointerEvent('pointerdown', { bubbles: true, pointerId: 7, pointerType: 'touch' }))
);
await waitForMotion();
expect(getComputedStyle(compareOverlay).opacity).toBe('1');
await waitForOpacity(compareOverlay, '1', 'after-pointerdown');
await interact(() =>
frame.dispatchEvent(new PointerEvent('pointerup', { bubbles: true, pointerId: 7, pointerType: 'touch' }))
);
await waitForMotion();
expect(getComputedStyle(compareOverlay).opacity).toBe('0');
await waitForOpacity(compareOverlay, '0', 'after-pointerup');
expect(getComputedStyle(compareOverlay).transitionProperty).toContain('opacity');
});

Expand Down
Loading