Files
AgentHub/client/src/components/__tests__/Tooltip.test.tsx
T
nntrivi2001 ad6e80192d feat: right-click menus everywhere, workspace/session cost, long-term DB safety
Right-click menus: one portal shell (components/ContextMenu.tsx) for every
item with actions - sidebar/Recents/archived session rows (SessionMenu),
console tabs, pane headers, the Changes panel's files/tree/commits, the
workspace home, sessions outside a workspace (SessionRowMenu: Sessions
table, Dashboard, Activity, Workflow runs), prompt images, every Settings
list and every Claude Code/Codex config list (FileItemMenu). Buttons stay;
a delete keeps its button's confirm; the shell stops portal clicks from
reaching the card that opened it.

Status bar: Workspace $ and Session $ on the Workspace page, from
?lane= on GET /api/pricing/cost and the focused/replayed session's cost.

Database, no data dropped: synchronous=NORMAL, journal_size_limit 64 MB,
hourly PASSIVE checkpoint + PRAGMA optimize, idx_events_session_created
(replacing the redundant idx_events_session), watchdog reads
sessions.transcript_path, transcript parsed before the hook's write
transaction, GET /api/sessions/:id?events=0 for the web page (reloads only
on its own session, debounced), streamed export, transactional clear-data
and chunked cleanup, subagent import reads tool_use_ids once, runtime logs
rotated past 5 MB.
2026-10-05 09:37:48 +07:00

336 lines
11 KiB
TypeScript

/**
* @file Tooltip.test.tsx
* @description Pins the app-wide tooltip renderer: a control's native `title`
* becomes the designed bubble on hover and on keyboard focus, the native
* tooltip is suppressed only while the bubble is up and the attribute is put
* back afterwards (it is the control's accessible name), and `data-tip-off`
* opts out.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
import { render, screen, fireEvent, act } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { GlobalTooltip } from "../Tooltip";
function setup(extra?: React.ReactNode) {
return render(
<>
<GlobalTooltip />
<button title="Refresh the working copy">refresh</button>
{extra}
</>
);
}
const hover = (el: Element) => fireEvent.pointerOver(el, { bubbles: true });
const unhover = (el: Element) => fireEvent.pointerOut(el, { bubbles: true });
// jsdom's :focus-visible is a heuristic over the previous events and leaks
// between tests, so the tests say which kind of focus they mean: a spy answers
// `:focus-visible` from this flag and passes every other selector through.
let keyboardFocus = false;
const realMatches = Element.prototype.matches;
beforeEach(() => {
keyboardFocus = false;
vi.spyOn(Element.prototype, "matches").mockImplementation(function (this: Element, sel) {
return sel === ":focus-visible"
? keyboardFocus && document.activeElement === this
: realMatches.call(this, sel);
});
});
afterEach(() => vi.restoreAllMocks());
const tabTo = (el: HTMLElement) => {
keyboardFocus = true;
act(() => el.focus());
};
beforeEach(() => vi.useFakeTimers({ shouldAdvanceTime: true }));
afterEach(() => vi.useRealTimers());
const settle = async () => {
await act(async () => {
vi.advanceTimersByTime(400);
});
};
describe("GlobalTooltip", () => {
it("shows the bubble after a delay and takes the native title while it is up", async () => {
setup();
const button = screen.getByRole("button", { name: "refresh" });
hover(button);
// Nothing yet: a cursor crossing a toolbar must not strobe every control.
expect(screen.queryByTestId("global-tooltip")).toBeNull();
await settle();
expect(screen.getByTestId("global-tooltip")).toHaveTextContent("Refresh the working copy");
expect(button).not.toHaveAttribute("title");
});
it("hides and gives the title back on the way out", async () => {
setup();
const button = screen.getByRole("button", { name: "refresh" });
hover(button);
await settle();
unhover(button);
expect(screen.queryByTestId("global-tooltip")).toBeNull();
expect(button).toHaveAttribute("title", "Refresh the working copy");
});
it("opens immediately on keyboard focus and closes on Escape", async () => {
setup();
const button = screen.getByRole("button", { name: "refresh" });
tabTo(button);
await act(async () => {
vi.advanceTimersByTime(0);
});
expect(screen.getByTestId("global-tooltip")).toBeInTheDocument();
fireEvent.keyDown(document, { key: "Escape" });
expect(screen.queryByTestId("global-tooltip")).toBeNull();
expect(button).toHaveAttribute("title", "Refresh the working copy");
});
it("leaves a data-tip-off control on the browser's own tooltip", async () => {
setup(
<button title="Raw value" data-tip-off>
opted out
</button>
);
const opted = screen.getByRole("button", { name: "opted out" });
hover(opted);
await settle();
expect(screen.queryByTestId("global-tooltip")).toBeNull();
expect(opted).toHaveAttribute("title", "Raw value");
});
it("reads the title through a child element, and ignores an empty one", async () => {
setup(
<button title="">
<span data-testid="blank-child">blank</span>
</button>
);
hover(screen.getByTestId("blank-child"));
await settle();
expect(screen.queryByTestId("global-tooltip")).toBeNull();
const button = screen.getByRole("button", { name: "refresh" });
button.innerHTML = "<span data-testid='deep'>refresh</span>";
hover(screen.getByTestId("deep"));
await settle();
expect(screen.getByTestId("global-tooltip")).toHaveTextContent("Refresh the working copy");
});
it("keeps an icon-only control's accessible name while its title is parked", async () => {
setup(
<button title="Stage this file">
<svg aria-hidden="true" />
</button>
);
const icon = screen.getByRole("button", { name: "Stage this file" });
tabTo(icon);
await act(async () => {
vi.advanceTimersByTime(0);
});
expect(screen.getByTestId("global-tooltip")).toBeInTheDocument();
expect(icon).not.toHaveAttribute("title");
expect(screen.getByRole("button", { name: "Stage this file" })).toBe(icon);
fireEvent.keyDown(document, { key: "Escape" });
// Put back exactly as found: no aria-label left behind.
expect(icon).toHaveAttribute("title", "Stage this file");
expect(icon).not.toHaveAttribute("aria-label");
});
it("does not flicker while the pointer crosses the control's own children", async () => {
setup(
<button title="Two parts">
<span data-testid="part-a">a</span>
<span data-testid="part-b">b</span>
</button>
);
const a = screen.getByTestId("part-a");
const b = screen.getByTestId("part-b");
hover(a);
await settle();
expect(screen.getByTestId("global-tooltip")).toHaveTextContent("Two parts");
fireEvent.pointerOut(a, { bubbles: true, relatedTarget: b });
fireEvent.pointerOver(b, { bubbles: true, relatedTarget: a });
// Still up, with no fresh delay.
expect(screen.getByTestId("global-tooltip")).toHaveTextContent("Two parts");
});
it("hides when its control is removed from the page", async () => {
const { rerender } = render(
<>
<GlobalTooltip />
<button title="Going away">x</button>
</>
);
hover(screen.getByRole("button", { name: "x" }));
await settle();
expect(screen.getByTestId("global-tooltip")).toBeInTheDocument();
rerender(
<>
<GlobalTooltip />
</>
);
await settle();
expect(screen.queryByTestId("global-tooltip")).toBeNull();
});
it("keeps a title React changed while the bubble was up, instead of writing the stale one back", async () => {
const { rerender } = render(
<>
<GlobalTooltip />
<button title="Pull 2 commits">pull</button>
</>
);
const button = screen.getByRole("button", { name: "pull" });
hover(button);
await settle();
expect(screen.getByTestId("global-tooltip")).toHaveTextContent("Pull 2 commits");
// A poll flips the hint while the pointer rests on the control.
rerender(
<>
<GlobalTooltip />
<button title="Up to date">pull</button>
</>
);
await settle();
// The bubble must not keep advertising the old text.
expect(screen.queryByText("Pull 2 commits")).toBeNull();
unhover(button);
expect(button).toHaveAttribute("title", "Up to date");
});
it("leaves no title behind when React removed it while the bubble was up", async () => {
const { rerender } = render(
<>
<GlobalTooltip />
<button title="Pull 2 commits">pull</button>
</>
);
const button = screen.getByRole("button", { name: "pull" });
hover(button);
await settle();
expect(screen.getByTestId("global-tooltip")).toHaveTextContent("Pull 2 commits");
// React "removes" an attribute that is already parked: no MutationRecord.
rerender(
<>
<GlobalTooltip />
<button>pull</button>
</>
);
await settle();
unhover(button);
expect(button).not.toHaveAttribute("title");
expect(button).not.toHaveAttribute("data-tip");
expect(screen.queryByTestId("global-tooltip")).toBeNull();
});
it("does not open on the focus a mouse click gives, only on keyboard focus", async () => {
const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime });
setup(<button title="Second control">second</button>);
// A click is pointer focus: :focus-visible does not match.
const button = screen.getByRole("button", { name: "refresh" });
await user.click(button);
await act(async () => {
vi.advanceTimersByTime(0);
});
expect(screen.queryByTestId("global-tooltip")).toBeNull();
tabTo(screen.getByRole("button", { name: "second" }));
await act(async () => {
vi.advanceTimersByTime(0);
});
expect(screen.getByTestId("global-tooltip")).toHaveTextContent("Second control");
});
it("stays up when an unrelated panel scrolls, and hides when the anchor's own scroller moves", async () => {
render(
<>
<GlobalTooltip />
<div data-testid="scroller">
<button title="Inside the scroller">inside</button>
</div>
<div data-testid="elsewhere" />
</>
);
const button = screen.getByRole("button", { name: "inside" });
hover(button);
await settle();
fireEvent.scroll(screen.getByTestId("elsewhere"));
expect(screen.getByTestId("global-tooltip")).toHaveTextContent("Inside the scroller");
fireEvent.scroll(screen.getByTestId("scroller"));
expect(screen.queryByTestId("global-tooltip")).toBeNull();
expect(button).toHaveAttribute("title", "Inside the scroller");
});
it("hides when the document itself scrolls", async () => {
setup();
hover(screen.getByRole("button", { name: "refresh" }));
await settle();
fireEvent.scroll(document);
expect(screen.queryByTestId("global-tooltip")).toBeNull();
});
it("silences an ancestor's native title while a child's bubble is up", async () => {
// Parking only the child's title let the browser fall back to the nearest
// ancestor's — a native black box over the designed bubble.
render(
<>
<GlobalTooltip />
<div data-testid="header" title="Drag to move this pane">
<button title="Close pane">x</button>
</div>
</>
);
const header = screen.getByTestId("header");
const button = screen.getByRole("button", { name: "x" });
hover(button);
await settle();
expect(screen.getByTestId("global-tooltip")).toHaveTextContent("Close pane");
expect(header).not.toHaveAttribute("title");
unhover(button);
expect(header).toHaveAttribute("title", "Drag to move this pane");
expect(button).toHaveAttribute("title", "Close pane");
});
it("does not bring back an ancestor title React removed while it was parked", async () => {
const { rerender } = render(
<>
<GlobalTooltip />
<div data-testid="header" title="Drag">
<button title="Close">x</button>
</div>
</>
);
const button = screen.getByRole("button", { name: "x" });
hover(button);
await settle();
rerender(
<>
<GlobalTooltip />
<div data-testid="header">
<button title="Close">x</button>
</div>
</>
);
unhover(button);
expect(screen.getByTestId("header")).not.toHaveAttribute("title");
});
});