Skip to content
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
183 changes: 183 additions & 0 deletions src/frontend/src/components/portal/lens-rail.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,183 @@
// @vitest-environment jsdom
/**
* The rail's open state.
*
* Every case here is about the interaction rather than the look, because the
* look is the easy half. The one that matters is the click: a click navigates
* and leaves the pointer sitting on the rail, so without an explicit dismissal
* the rail reopens on top of the pane the click was aimed at. That failed
* silently once already when the state was expressed as CSS variants — the
* rules simply never matched and nothing said so.
*/
import { act, render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";

const mocks = vi.hoisted(() => ({
layout: "wide" as "phone" | "narrow" | "wide",
selected: [] as string[],
}));

vi.mock("@/lib/portal/use-shell-layout", () => ({
useShellLayout: () => mocks.layout,
}));
vi.mock("@/lib/portal/use-zone-nav", () => ({
useZoneNav: () => ({
zones: [
{ id: "overview", label: "Overview", icon: () => null },
{ id: "people", label: "People", icon: () => null },
],
activeZone: "overview",
selectZone: (z: { id: string }) => mocks.selected.push(z.id),
}),
}));
vi.mock("@/components/app-sidebar-footer", () => ({
AppSidebarFooter: () => null,
}));

import { SidebarProvider } from "@/components/ui/sidebar";
import { LensRail } from "./lens-rail";

/** The rail opens on a timer, so a hover only counts once the wait is over. */
const settle = () => act(() => { vi.advanceTimersByTime(400); });

const rail = () =>
render(
<SidebarProvider>
<LensRail />
</SidebarProvider>,
);

/** The label is present either way; what changes is whether it can be seen. */
const labelOf = (name: string) =>
screen.getByRole("button", { name }).querySelector("span:not(.sr-only)");

beforeEach(() => {
vi.useFakeTimers({ shouldAdvanceTime: true });
Comment on lines +55 to +56

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🩺 Stability & Availability | 🟡 Minor | ⚡ Quick win

🧩 Analysis chain

🌐 Web query:

Vitest useFakeTimers shouldAdvanceTime with user-event advanceTimers option conflict

💡 Result:

When using Vitest fake timers with the Testing Library user-event package, historical compatibility issues often lead to tests hanging or timing out [1][2]. These issues typically arise because user-event expects a specific timer advancement interface, historically modeled after Jest, which Vitest does not provide by default in the global scope [3][2]. There is no conflict between vi.useFakeTimers({ shouldAdvanceTime: true }) and the user-event advanceTimers option in terms of logical contradictions, but they serve different purposes and can be confusing when used together [4][5]. 1. Understanding the Options: - vi.useFakeTimers({ shouldAdvanceTime: true }): This tells Vitest to automatically increment the mocked clock based on the real system time passage [4]. It is rarely the recommended solution for standard component interaction tests, as it can introduce flakiness by tying your test execution speed to the real system clock [6]. - userEvent.setup({ advanceTimers: ... }): This allows user-event to trigger time advancement explicitly when it needs to wait for interaction delays [7][5]. 2. Resolution and Best Practices: - Modern versions of @testing-library/user-event (v14.4.0+) have built-in support to auto-detect Vitest's vi global [3]. This eliminates the need for manual configuration or global stubs in most cases [3]. - If you encounter issues, ensure you are using the latest version of user-event [3]. - If you must configure it manually, explicitly pass Vitest's advancement function [5]: const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime, }); - Avoid shouldAdvanceTime: true unless you have a specific requirement to simulate real-time passage within a fake timer environment, as it often defeats the purpose of deterministic testing [6]. If you still experience timeouts, verify that you are awaiting the user-event calls (e.g., await user.click(...)) and that your test setup does not inadvertently leak timers by failing to call vi.useRealTimers() or vi.restoreAllMocks() in an afterEach block [8].

Citations:


🏁 Script executed:

#!/bin/bash
set -eu

printf '%s\n' '--- candidate files ---'
git ls-files 'src/frontend/src/components/portal/lens-rail.test.tsx' '*package.json' '*vitest*' '*vite.config*' | sed -n '1,120p'

printf '%s\n' '--- test structure and timer usage ---'
sed -n '1,230p' src/frontend/src/components/portal/lens-rail.test.tsx

printf '%s\n' '--- relevant dependency versions and configuration ---'
rg -n --glob 'package.json' --glob '*lock*' --glob '*vitest*' --glob '*vite.config*' \
  '(`@testing-library/user-event`|vitest|fakeTimers|shouldAdvanceTime|advanceTimers)' . | sed -n '1,220p'

Repository: constructorfabric/insight

Length of output: 19980


🏁 Script executed:

#!/bin/bash
set -eu

python3 - <<'PY'
from pathlib import Path
p = Path("src/frontend/src/components/portal/lens-rail.test.tsx")
text = p.read_text()
for i, line in enumerate(text.splitlines(), 1):
    if "shouldAdvanceTime" in line or "advanceTimers" in line or "stays shut" in line or "hover(" in line or "unhover(" in line or "200" in line:
        print(f"{i}: {line}")
PY

Repository: constructorfabric/insight

Length of output: 1335


🏁 Script executed:

#!/bin/bash
set -eu

printf '%s\n' '--- LensRail timer implementation ---'
rg -n -C 12 'setTimeout|clearTimeout|hover|pointer|open|close|isOpen' \
  src/frontend/src/components/portal/lens-rail.tsx src/frontend/src/components/portal/lens-rail.test.tsx

printf '%s\n' '--- fake-timer configuration ---'
sed -n '1,190p' src/frontend/vitest.config.ts

printf '%s\n' '--- user-event package metadata from lockfile ---'
sed -n '1565,1595p' src/frontend/pnpm-lock.yaml
sed -n '5500,5525p' src/frontend/pnpm-lock.yaml

Repository: constructorfabric/insight

Length of output: 50381


🌐 Web query:

site:vitest.dev/config/faketimers shouldAdvanceTime advanceTimersByTime real time

💡 Result:

  • shouldAdvanceTime: true makes Vitest advance fake time automatically according to real elapsed time. By default, it advances in 20 ms increments; configure this with advanceTimeDelta. [1]
  • vi.advanceTimersByTime(ms) is manual: it immediately advances mocked time by exactly ms, without waiting for real time. [2]
vi.useFakeTimers({
  shouldAdvanceTime: true,
  advanceTimeDelta: 50,
})

// Real 50 ms passing advances fake time by about 50 ms.

So, use shouldAdvanceTime when timers should track wall-clock time; use advanceTimersByTime for deterministic, instant test control. [1][2]


🌐 Web query:

site:testing-library.com/docs/user-event/options advanceTimers fake timers

💡 Result:

In testing environments, particularly when using testing-library's user-event, advanceTimers is a configuration option that allows the library to coordinate with your test runner's fake timers [1][2]. When user-event simulates interactions (such as typing or clicking), it often introduces artificial delays between events to mimic real user behavior [1]. When tests use fake timers (a feature provided by libraries like Jest or Vitest to mock time), these internal delays can conflict with the test runner, often causing tests to hang or time out if the fake clock is not advanced correctly [1][2]. To resolve this, you pass the test runner's time advancement function (e.g., jest.advanceTimersByTime or vitest.advanceTimersByTime) to the advanceTimers option in the userEvent.setup configuration [1][2]. This enables user-event to programmatically advance the fake clock by the necessary delay duration, allowing the simulated interactions to proceed without stalling the test [1]. Example usage with Jest: const user = userEvent.setup({ advanceTimers: jest.advanceTimersByTime }); This approach is recommended over setting delay: null, as the latter can lead to unexpected behavior during test execution [1].

Citations:


🌐 Web query:

site:github.com/testing-library/user-event 14.6.1 advanceTimers pointer hover unhover delay

💡 Result:

In @testing-library/user-event v14.6.1, the advanceTimers option and the delay configuration are used to manage asynchronous timing, particularly when working with fake timers in testing frameworks like Jest [1][2]. The advanceTimers option allows you to integrate your test runner's timer-advancing function (e.g., jest.advanceTimersByTime) into the user-event workflow [1][2]. This ensures that internal delays used by the library—which are intended to move events to the next macro task and allow asynchronous code to process—do not cause tests to hang or timeout when fake timers are active [3][1][2]. Usage typically involves configuring it during the setup phase: const user = userEvent.setup({ advanceTimers: jest.advanceTimersByTime, }); Regarding pointer interactions (hover/unhover) and delays: 1. The delay option dictates the pause duration between sequential user-event actions [1]. 2. By default, user-event introduces these small delays to better simulate real-user behavior and prevent race conditions with asynchronous state updates [1][2]. 3. If these internal delays conflict with your test environment (especially when using fake timers), you must either provide the advanceTimers callback to "fast-forward" through these delays [1] or, in specific scenarios where the overhead is not desired, set delay to null [1][2]. Version 14.6.1 included a fix to correct the documentation/description for the delay option [4], ensuring developers better understand how this configuration affects event execution timing.

Citations:


🏁 Script executed:

#!/bin/bash
set -eu

python3 - <<'PY'
from urllib.request import urlopen
urls = {
    "user-event setup": "https://unpkg.com/@testing-library/user-event@14.6.1/dist/esm/setup/setup.js",
    "user-event wait": "https://unpkg.com/@testing-library/user-event@14.6.1/dist/esm/utils/misc/wait.js",
    "user-event pointer": "https://unpkg.com/@testing-library/user-event@14.6.1/dist/esm/system/pointer/index.js",
}
for name, url in urls.items():
    print(f"--- {name}: {url} ---")
    try:
        text = urlopen(url, timeout=10).read().decode()
    except Exception as exc:
        print(f"FETCH_ERROR: {exc}")
        continue
    for i, line in enumerate(text.splitlines(), 1):
        if any(term in line for term in ("advanceTimers", "delay", "setTimeout", "async pointer", "wait(")):
            print(f"{i}: {line}")
PY

printf '%s\n' '--- repository timing invariant ---'
python3 - <<'PY'
# Read-only timing model for the test's relevant sequence.
OPEN_AFTER_MS = 200
manual_user_event_advances = [0, 0]  # default user-event delay for hover/unhover
explicit_settle = 400
for real_elapsed in (0, 199, 200, 250):
    fake_before_settle = real_elapsed + sum(manual_user_event_advances)
    opened_before_settle = fake_before_settle >= OPEN_AFTER_MS
    print({
        "real_elapsed_between_hover_and_unhover_ms": real_elapsed,
        "fake_time_before_settle_ms": fake_before_settle,
        "open_timer_fired_before_unhover": opened_before_settle,
        "final_fake_time_after_settle_ms": fake_before_settle + explicit_settle,
    })
PY

Repository: constructorfabric/insight

Length of output: 1632


Remove shouldAdvanceTime from the fake-timer setup.

Real elapsed time can advance the mocked clock past the 200 ms opening timer before unhover. Keep vi.useFakeTimers() and the explicit advanceTimers callback for deterministic control.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/frontend/src/components/portal/lens-rail.test.tsx` around lines 55 - 56,
Update the beforeEach fake-timer setup to call vi.useFakeTimers() without
shouldAdvanceTime. Preserve the explicit advanceTimers callback so the 200 ms
opening timer remains under deterministic test control.

mocks.layout = "wide";
mocks.selected = [];
window.matchMedia ??= ((query: string) => ({
matches: false,
media: query,
onchange: null,
addEventListener: () => {},
removeEventListener: () => {},
addListener: () => {},
removeListener: () => {},
dispatchEvent: () => false,
})) as unknown as typeof window.matchMedia;
});

describe("LensRail", () => {
it("shows labels while the pointer is on it", async () => {
const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime });
rail();
expect(labelOf("Overview")).toHaveClass("opacity-0");

await user.hover(screen.getByTestId("lens-rail"));
settle();
expect(labelOf("Overview")).toHaveClass("opacity-100");
});

it("collapses on a click and stays collapsed under the pointer", async () => {
// The whole reason this state exists. The click navigates; the pointer has
// not moved; reopening here would cover the pane that was just asked for.
const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime });
rail();
await user.hover(screen.getByTestId("lens-rail"));
settle();
expect(labelOf("People")).toHaveClass("opacity-100");

await user.click(screen.getByRole("button", { name: "People" }));
expect(mocks.selected).toEqual(["people"]);
expect(labelOf("People")).toHaveClass("opacity-0");
});

it("expands again once the pointer has left and come back", async () => {
const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime });
rail();
const el = screen.getByTestId("lens-rail");

await user.hover(el);
settle();
await user.click(screen.getByRole("button", { name: "People" }));
expect(labelOf("People")).toHaveClass("opacity-0");

await user.unhover(el);
await user.hover(el);
settle();
expect(labelOf("People")).toHaveClass("opacity-100");
});

it("renders nothing on a phone", () => {
// 56px of rail plus a 256px pane left a phone with almost no content; the
// zones live in the context pane's drawer there instead.
mocks.layout = "phone";
rail();
expect(screen.queryByTestId("lens-rail")).not.toBeInTheDocument();
});
});

afterEach(() => {
vi.useRealTimers();
});

describe("LensRail state that only breaks in a particular order", () => {
it("does not strand itself when a zone is chosen from the keyboard", async () => {
// Enter on a focused button produces a click, and a click used to mean
// "the pointer is resting on me, stay shut until it leaves". There is no
// pointer in this story, so nothing would ever clear that — the rail was
// dead to the mouse from then on.
const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime });
rail();
await user.tab();
await user.tab();
await user.keyboard("{Enter}");
expect(mocks.selected).toEqual(["people"]);

await user.hover(screen.getByTestId("lens-rail"));
settle();
expect(labelOf("People")).toHaveClass("opacity-100");
});

it("shows the labels to a keyboard user at all", async () => {
// Eight identical icons and the text at zero opacity is not navigable by
// anyone who can see but is not using a pointer.
const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime });
rail();
await user.tab();
await user.tab();
expect(labelOf("Overview")).toHaveClass("opacity-100");
});

it("comes back shut after the rail is unmounted under the pointer", async () => {
// A width change unmounts the rail without a pointer-leave, so the state
// it left behind used to survive: widen again and it was already open,
// with the pointer nowhere near it.
const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime });
const { rerender } = rail();
await user.hover(screen.getByTestId("lens-rail"));
settle();
expect(labelOf("Overview")).toHaveClass("opacity-100");

mocks.layout = "phone";
rerender(<SidebarProvider><LensRail /></SidebarProvider>);
mocks.layout = "wide";
rerender(<SidebarProvider><LensRail /></SidebarProvider>);
expect(labelOf("Overview")).toHaveClass("opacity-0");
});

it("stays shut for a pointer that is only passing through", async () => {
// The wait is the whole guard. Before it was a timer, the panel became
// clickable at once and merely being over it counted as staying, so a
// crossing pointer opened the rail anyway — over the row it was heading
// for, having swallowed any click on the way.
const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime });
rail();
const el = screen.getByTestId("lens-rail");
await user.hover(el);
await user.unhover(el);
settle();
expect(labelOf("Overview")).toHaveClass("opacity-0");
});
});
Loading
Loading