7bc4594b94
Server (git-diff.js, git-ops.js, worktree.js, routes/lanes.js): - Every lane git call runs with GIT_LITERAL_PATHSPECS=1 and -z porcelain, so a file name is a file name, never a glob, and odd names parse correctly. - A lane that is a subdirectory of its repo sees and acts on ONLY its own paths (reads, stage/unstage/discard/commit, and gitFacts.files). - Any file named settings.local.json (the lane's router API key) is refused as 403 ESECRET on disk (symlinks resolved) and at every committed rev. - commit refuses with 400 ESTAGEDOUTSIDE while anything outside the lane is staged (checked with --no-renames, so a move into the lane is caught). - push resolves its remote (upstream → only remote → origin), else 400 ENOREMOTE; unstaging a rename unstages both halves. - Git writes serialize on the repo's git dir, not the lane id, so two lanes on one index cannot collide on index.lock. - git refusing an action (a numeric exit status) answers 409 EGIT with git's stderr instead of a 500 carrying a numeric code; one shared parseNumstat. Client: - Changes panel: no stale answer from a previous lane or an older quick-open query repaints the panel; discard disarms on lane switch; Enter on a file path opens it instead of folding; bounded tree expand-all. - Large diffs (> 500 rows) render only the visible rows, with stable width and in-sync split columns; mirrored split scroll is clamped. - Git bar: no "in sync" without counts, a clear no-remote message, branch list re-read after a switch, and no previous-lane facts after a lane switch. - Tooltips no longer flicker or close on unrelated scrolls.
289 lines
9.6 KiB
TypeScript
289 lines
9.6 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();
|
|
});
|
|
});
|