mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-25 20:05:46 -06:00
38e0ae6ec8
* fix(ui): keep the GitHub hovercard open while the pointer reaches it Hover intent now spans the link and the portaled card: leaving one for the other keeps the card open through a bounded traversal grace, and only leaving both dismisses it. The card also receives pointer events again, so its text can be selected. Escape, click, focus, viewport, and route-removal dismissals are unchanged, and the close timer is cleared on every close and disconnect. * fix(ui): make the GitHub hovercard title a navigable link The preview card's title now renders as an <a> reusing the exact activation href, opening in a new tab like other external Control UI links (buildExternalLinkRel/EXTERNAL_LINK_TARGET). Because the card is portaled to document.body outside the provider's delegated focus listeners, focus landing on the title link needed its own tracking, mirroring the existing pointerenter/pointerleave pattern; that focus hold releases once the pointer leaves the card after a pointer-initiated open, so clicking the title link can never leave a hover-opened card stuck open with the mouse gone. * test(ui): prove hovercard pointer traversal and title-link click in real Chromium Extends the existing github-link-hovercard e2e test with two cases: one drives real page.mouse.move coordinates from the source link, across the gap, onto the portaled card, and confirms it stays open and only closes once the pointer leaves both surfaces; the other clicks the card's title link and confirms the resulting popup navigates to the item URL. * fix(ui): make the GitHub hovercard an accessible interactive popover The card declared role="tooltip" while owning a focusable title link. ARIA tooltips are descriptive text: their content is flattened and unreachable, so the link existed for the pointer only. Drop the tooltip contract instead of the link, and give the card the popover semantics its content already needs. The card is now a non-modal role="dialog" named by every render state, and the trigger carries aria-haspopup/aria-expanded/aria-controls in place of the old aria-describedby juggling. Because the card is portaled to document.body it has no tab-sequence neighbour, so Tab from the trigger forwards into it, Tab moves between its links natively, and Tab past either edge or Escape returns focus to the trigger with the card closed and no reopen. The card's other references are links too: the repo reference and the title open the item, the author opens their profile, and a pull request's diff-size chip deep-links to the files-changed view. The title keeps the card's only underline; the rest stay quiet until hovered or focused. Pointer behavior is unchanged - leaving both the link and the card still dismisses it after the traversal grace period, with no click required.
406 lines
15 KiB
TypeScript
406 lines
15 KiB
TypeScript
/* @vitest-environment jsdom */
|
||
|
||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||
import type { GatewayBrowserClient } from "../api/gateway.ts";
|
||
import { i18n } from "../i18n/index.ts";
|
||
import { GitHubLinkHovercardProvider } from "./github-link-hovercard.runtime.ts";
|
||
|
||
// Mirrors CLOSE_DELAY_MS in the runtime, like the 250ms open delay used below.
|
||
const GITHUB_HOVERCARD_CLOSE_DELAY_MS = 120;
|
||
|
||
const GITHUB_LINK_HOVERCARD_ELEMENT_NAME = `test-openclaw-github-link-hovercard-provider-${crypto.randomUUID()}`;
|
||
|
||
customElements.define(
|
||
GITHUB_LINK_HOVERCARD_ELEMENT_NAME,
|
||
class extends GitHubLinkHovercardProvider {},
|
||
);
|
||
|
||
type GitHubLinkHovercardProviderElement = HTMLElement & {
|
||
client: GatewayBrowserClient | null;
|
||
};
|
||
|
||
function createLink(href: string, label = "GitHub item") {
|
||
const provider = document.createElement(
|
||
GITHUB_LINK_HOVERCARD_ELEMENT_NAME,
|
||
) as GitHubLinkHovercardProviderElement;
|
||
const anchor = document.createElement("a");
|
||
anchor.href = href;
|
||
anchor.textContent = label;
|
||
provider.append(anchor);
|
||
document.body.append(provider);
|
||
return { anchor, provider };
|
||
}
|
||
|
||
const ISSUE_HREF = "https://github.com/openclaw/openclaw/issues/99815";
|
||
|
||
function issuePreviewResponse(overrides: Record<string, unknown> = {}) {
|
||
return {
|
||
comments: 2,
|
||
createdAt: "2026-07-05T08:00:00Z",
|
||
kind: "issue",
|
||
login: "octocat",
|
||
number: 99815,
|
||
owner: "openclaw",
|
||
repo: "openclaw",
|
||
state: "open",
|
||
title: "Keep hover previews reachable",
|
||
updatedAt: "2026-07-05T09:55:00Z",
|
||
...overrides,
|
||
};
|
||
}
|
||
|
||
function createIssueLink(response: Record<string, unknown> = issuePreviewResponse()) {
|
||
const link = createLink(ISSUE_HREF, "#99815");
|
||
link.provider.client = {
|
||
request: vi.fn().mockResolvedValue(response),
|
||
} as unknown as GatewayBrowserClient;
|
||
return link;
|
||
}
|
||
|
||
function titleLinkInCard(): HTMLAnchorElement | null {
|
||
return document.querySelector<HTMLAnchorElement>(".github-link-hovercard__title");
|
||
}
|
||
|
||
function cardLinks(): HTMLAnchorElement[] {
|
||
return [...document.querySelectorAll<HTMLAnchorElement>(".github-link-hovercard a[href]")];
|
||
}
|
||
|
||
async function hover(anchor: HTMLAnchorElement): Promise<void> {
|
||
anchor.dispatchEvent(new MouseEvent("pointerover", { bubbles: true, composed: true }));
|
||
await vi.advanceTimersByTimeAsync(250);
|
||
}
|
||
|
||
function leave(anchor: HTMLAnchorElement, relatedTarget: EventTarget = document.body): void {
|
||
anchor.dispatchEvent(
|
||
new MouseEvent("pointerout", {
|
||
bubbles: true,
|
||
composed: true,
|
||
relatedTarget,
|
||
}),
|
||
);
|
||
}
|
||
|
||
function hovercard(): HTMLElement | null {
|
||
return document.querySelector<HTMLElement>(".github-link-hovercard");
|
||
}
|
||
|
||
describe("openclaw-github-link-hovercard-provider", () => {
|
||
beforeEach(() => {
|
||
vi.useFakeTimers();
|
||
vi.setSystemTime(new Date("2026-07-05T10:00:00Z"));
|
||
});
|
||
|
||
afterEach(async () => {
|
||
await i18n.setLocale("en");
|
||
document.body.replaceChildren();
|
||
vi.useRealTimers();
|
||
vi.restoreAllMocks();
|
||
});
|
||
|
||
it("renders and caches pull request details without changing the link", async () => {
|
||
const request = vi.fn().mockResolvedValue({
|
||
additions: 101,
|
||
avatarDataUrl:
|
||
"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAusB9WlY9Z8AAAAASUVORK5CYII=",
|
||
changedFiles: 3,
|
||
closedAt: "2026-07-04T09:53:52Z",
|
||
createdAt: "2026-07-04T05:03:47Z",
|
||
deletions: 12,
|
||
draft: false,
|
||
kind: "pull",
|
||
login: "steipete",
|
||
mergedAt: "2026-07-04T09:53:52Z",
|
||
number: 99816,
|
||
owner: "OpenClaw",
|
||
repo: "OpenClaw",
|
||
state: "closed",
|
||
title: "fix(agents): derive conversation scope from trusted group facts",
|
||
updatedAt: "2026-07-05T09:55:00Z",
|
||
});
|
||
const href = "https://github.com/openclaw/openclaw/pull/99816";
|
||
const { anchor, provider } = createLink(href, "#99816");
|
||
provider.client = { request } as unknown as GatewayBrowserClient;
|
||
|
||
await hover(anchor);
|
||
|
||
const card = document.querySelector<HTMLElement>(".github-link-hovercard");
|
||
expect(card?.textContent).toContain("Merged");
|
||
expect(card?.textContent).toContain("openclaw/openclaw #99816");
|
||
expect(card?.textContent).toContain(
|
||
"fix(agents): derive conversation scope from trusted group facts",
|
||
);
|
||
expect(card?.textContent).toContain("steipete");
|
||
expect(card?.textContent).toContain("+101");
|
||
expect(card?.textContent).toContain("−12");
|
||
expect(card?.textContent).toContain("3 files");
|
||
expect(card?.textContent).toContain("5m ago");
|
||
expect(anchor.href).toBe(href);
|
||
// A card that owns a link is an interactive popover, never an ARIA tooltip.
|
||
expect(card?.getAttribute("role")).toBe("dialog");
|
||
expect(card?.getAttribute("aria-label")).toContain(
|
||
"fix(agents): derive conversation scope from trusted group facts",
|
||
);
|
||
expect(anchor.getAttribute("aria-haspopup")).toBe("dialog");
|
||
expect(anchor.getAttribute("aria-expanded")).toBe("true");
|
||
expect(anchor.getAttribute("aria-controls")).toBe(card?.id);
|
||
// Title, repo reference, author and the files chip are all real links, which
|
||
// is what makes the card a popover rather than a tooltip.
|
||
const cardLink = (selector: string) =>
|
||
card?.querySelector<HTMLAnchorElement>(`.github-link-hovercard__${selector}`);
|
||
expect(cardLink("title")?.getAttribute("href")).toBe(href);
|
||
expect(cardLink("repo")?.getAttribute("href")).toBe(href);
|
||
expect(cardLink("author")?.getAttribute("href")).toBe("https://github.com/steipete");
|
||
expect(cardLink("metric--files")?.getAttribute("href")).toBe(`${href}/files`);
|
||
for (const selector of ["title", "repo", "author", "metric--files"]) {
|
||
expect(cardLink(selector)?.target).toBe("_blank");
|
||
expect(cardLink(selector)?.rel.split(/\s+/)).toEqual(
|
||
expect.arrayContaining(["noopener", "noreferrer"]),
|
||
);
|
||
}
|
||
expect(request).toHaveBeenCalledWith(
|
||
"controlUi.githubPreview",
|
||
{
|
||
kind: "pull",
|
||
number: 99816,
|
||
owner: "openclaw",
|
||
repo: "openclaw",
|
||
},
|
||
{ signal: expect.any(AbortSignal) },
|
||
);
|
||
|
||
leave(anchor);
|
||
await vi.advanceTimersByTimeAsync(GITHUB_HOVERCARD_CLOSE_DELAY_MS);
|
||
expect(hovercard()).toBeNull();
|
||
await hover(anchor);
|
||
expect(request).toHaveBeenCalledTimes(1);
|
||
});
|
||
|
||
it("stays open while the pointer travels from the link onto the card", async () => {
|
||
const { anchor } = createIssueLink();
|
||
|
||
await hover(anchor);
|
||
const card = hovercard();
|
||
expect(card).not.toBeNull();
|
||
|
||
// Crossing the gap between the link and the card leaves both unhovered.
|
||
leave(anchor, card as EventTarget);
|
||
await vi.advanceTimersByTimeAsync(GITHUB_HOVERCARD_CLOSE_DELAY_MS - 1);
|
||
expect(hovercard()).toBe(card);
|
||
|
||
card?.dispatchEvent(new MouseEvent("pointerenter"));
|
||
await vi.advanceTimersByTimeAsync(GITHUB_HOVERCARD_CLOSE_DELAY_MS * 10);
|
||
expect(hovercard()).toBe(card);
|
||
|
||
card?.dispatchEvent(new MouseEvent("pointerleave"));
|
||
expect(hovercard()).toBe(card);
|
||
await vi.advanceTimersByTimeAsync(GITHUB_HOVERCARD_CLOSE_DELAY_MS);
|
||
expect(hovercard()).toBeNull();
|
||
expect(anchor.hasAttribute("aria-expanded")).toBe(false);
|
||
expect(anchor.hasAttribute("aria-controls")).toBe(false);
|
||
expect(anchor.hasAttribute("aria-haspopup")).toBe(false);
|
||
});
|
||
|
||
it("closes on pointer-out even after the title link inside the card was clicked", async () => {
|
||
const { anchor } = createIssueLink();
|
||
|
||
await hover(anchor);
|
||
const card = hovercard();
|
||
expect(card).not.toBeNull();
|
||
|
||
// Pointer travels from the link onto the card, same as the traversal test above.
|
||
leave(anchor, card as EventTarget);
|
||
card?.dispatchEvent(new MouseEvent("pointerenter"));
|
||
await vi.advanceTimersByTimeAsync(0);
|
||
expect(hovercard()).toBe(card);
|
||
|
||
// Clicking the title link focuses it (a click's real-world side effect); a
|
||
// pointer-initiated open must still release once the pointer leaves, with no
|
||
// click-outside required to dismiss the card.
|
||
const titleLink = titleLinkInCard();
|
||
titleLink?.addEventListener("click", (event) => event.preventDefault());
|
||
titleLink?.dispatchEvent(new MouseEvent("click", { bubbles: true, composed: true }));
|
||
titleLink?.dispatchEvent(new FocusEvent("focusin", { bubbles: true, composed: true }));
|
||
|
||
card?.dispatchEvent(new MouseEvent("pointerleave"));
|
||
await vi.advanceTimersByTimeAsync(GITHUB_HOVERCARD_CLOSE_DELAY_MS);
|
||
expect(hovercard()).toBeNull();
|
||
});
|
||
|
||
it("renders issue comments and supports focus plus Escape", async () => {
|
||
const { anchor } = createIssueLink(issuePreviewResponse({ comments: 4 }));
|
||
|
||
anchor.dispatchEvent(new FocusEvent("focusin", { bubbles: true, composed: true }));
|
||
await vi.advanceTimersByTimeAsync(0);
|
||
|
||
expect(hovercard()?.textContent).toContain("4 comments");
|
||
expect(hovercard()?.textContent).toContain("Open");
|
||
// Issues have no files-changed view, so their metric stays plain text.
|
||
expect(hovercard()?.querySelector(".github-link-hovercard__metric--files")).toBeNull();
|
||
anchor.dispatchEvent(new KeyboardEvent("keydown", { key: "Escape", bubbles: true }));
|
||
expect(hovercard()).toBeNull();
|
||
});
|
||
|
||
it("moves keyboard focus through the card's links and hands it back at the edges", async () => {
|
||
const { anchor } = createIssueLink();
|
||
|
||
anchor.focus();
|
||
await vi.advanceTimersByTimeAsync(0);
|
||
expect(hovercard()).not.toBeNull();
|
||
|
||
// The card is portaled to document.body, so Tab has to be forwarded for any
|
||
// of its links to be reachable at all.
|
||
anchor.dispatchEvent(new KeyboardEvent("keydown", { bubbles: true, key: "Tab" }));
|
||
expect(document.activeElement).toBe(cardLinks()[0]);
|
||
|
||
// Inside the run of card links Tab belongs to the browser, not to the card.
|
||
const middle = cardLinks()[1];
|
||
middle?.focus();
|
||
const insideTab = new KeyboardEvent("keydown", { bubbles: true, cancelable: true, key: "Tab" });
|
||
middle?.dispatchEvent(insideTab);
|
||
expect(insideTab.defaultPrevented).toBe(false);
|
||
expect(hovercard()).not.toBeNull();
|
||
|
||
// Leaving the last link returns focus to the trigger with the card closed,
|
||
// and that returned focus must not immediately reopen what was dismissed.
|
||
const last = cardLinks().at(-1);
|
||
last?.focus();
|
||
last?.dispatchEvent(new KeyboardEvent("keydown", { bubbles: true, key: "Tab" }));
|
||
expect(hovercard()).toBeNull();
|
||
expect(document.activeElement).toBe(anchor);
|
||
await vi.advanceTimersByTimeAsync(GITHUB_HOVERCARD_CLOSE_DELAY_MS * 2);
|
||
expect(hovercard()).toBeNull();
|
||
});
|
||
|
||
it("closes on Escape from inside the card and returns focus to the link", async () => {
|
||
const { anchor } = createIssueLink();
|
||
|
||
anchor.focus();
|
||
await vi.advanceTimersByTimeAsync(0);
|
||
const title = titleLinkInCard();
|
||
title?.focus();
|
||
title?.dispatchEvent(new KeyboardEvent("keydown", { bubbles: true, key: "Escape" }));
|
||
|
||
expect(hovercard()).toBeNull();
|
||
expect(document.activeElement).toBe(anchor);
|
||
});
|
||
|
||
it("closes once focus leaves both the link and the card", async () => {
|
||
const { anchor } = createIssueLink();
|
||
const outside = document.createElement("button");
|
||
document.body.append(outside);
|
||
|
||
anchor.focus();
|
||
await vi.advanceTimersByTimeAsync(0);
|
||
anchor.dispatchEvent(new KeyboardEvent("keydown", { bubbles: true, key: "Tab" }));
|
||
expect(document.activeElement).toBe(cardLinks()[0]);
|
||
|
||
outside.focus();
|
||
await vi.advanceTimersByTimeAsync(GITHUB_HOVERCARD_CLOSE_DELAY_MS);
|
||
expect(hovercard()).toBeNull();
|
||
expect(anchor.hasAttribute("aria-expanded")).toBe(false);
|
||
});
|
||
|
||
it("ignores unsupported GitHub links and shows a quiet unavailable state", async () => {
|
||
const request = vi.fn().mockRejectedValue(new Error("Not Found"));
|
||
const unsupportedLink = createLink("https://github.com/openclaw/openclaw", "repository");
|
||
unsupportedLink.provider.client = { request } as unknown as GatewayBrowserClient;
|
||
|
||
await hover(unsupportedLink.anchor);
|
||
expect(request).not.toHaveBeenCalled();
|
||
expect(document.querySelector(".github-link-hovercard")).toBeNull();
|
||
|
||
const missingLink = createLink("https://github.com/openclaw/openclaw/issues/999999", "missing");
|
||
missingLink.provider.client = { request } as unknown as GatewayBrowserClient;
|
||
await hover(missingLink.anchor);
|
||
expect(document.querySelector(".github-link-hovercard")?.textContent).toContain(
|
||
"GitHub preview unavailable",
|
||
);
|
||
});
|
||
|
||
it.each([
|
||
"http://github.com/openclaw/openclaw/issues/99815",
|
||
"https://user:password@github.com/openclaw/openclaw/issues/99815",
|
||
"https://example.com/openclaw/openclaw/issues/99815",
|
||
"javascript:alert(1)",
|
||
])("does not preview an untrusted item URL: %s", async (href) => {
|
||
const request = vi.fn();
|
||
const { anchor, provider } = createLink(href);
|
||
provider.client = { request } as unknown as GatewayBrowserClient;
|
||
|
||
await hover(anchor);
|
||
|
||
expect(request).not.toHaveBeenCalled();
|
||
expect(document.querySelector(".github-link-hovercard")).toBeNull();
|
||
});
|
||
|
||
it("leaves no popup state on the link when hover ends before opening", async () => {
|
||
const request = vi.fn();
|
||
const { anchor, provider } = createLink("https://github.com/openclaw/openclaw/issues/99815");
|
||
provider.client = { request } as unknown as GatewayBrowserClient;
|
||
|
||
anchor.dispatchEvent(new MouseEvent("pointerover", { bubbles: true, composed: true }));
|
||
leave(anchor);
|
||
await vi.advanceTimersByTimeAsync(250);
|
||
|
||
expect(anchor.hasAttribute("aria-haspopup")).toBe(false);
|
||
expect(anchor.hasAttribute("aria-expanded")).toBe(false);
|
||
expect(request).not.toHaveBeenCalled();
|
||
});
|
||
|
||
it("closes when route replacement removes its active link", async () => {
|
||
const provider = document.createElement(
|
||
GITHUB_LINK_HOVERCARD_ELEMENT_NAME,
|
||
) as GitHubLinkHovercardProviderElement;
|
||
provider.client = {
|
||
request: vi.fn().mockResolvedValue(issuePreviewResponse({ comments: 1 })),
|
||
} as unknown as GatewayBrowserClient;
|
||
const route = document.createElement("main");
|
||
const anchor = document.createElement("a");
|
||
anchor.href = ISSUE_HREF;
|
||
route.append(anchor);
|
||
provider.append(route);
|
||
document.body.append(provider);
|
||
|
||
await hover(anchor);
|
||
expect(document.querySelector(".github-link-hovercard")).not.toBeNull();
|
||
|
||
route.replaceChildren(document.createElement("p"));
|
||
await Promise.resolve();
|
||
|
||
expect(document.querySelector(".github-link-hovercard")).toBeNull();
|
||
expect(anchor.hasAttribute("aria-expanded")).toBe(false);
|
||
});
|
||
|
||
it("rerenders an open preview when the locale changes", async () => {
|
||
const { anchor } = createIssueLink(issuePreviewResponse({ comments: 1 }));
|
||
await hover(anchor);
|
||
|
||
i18n.registerTranslation("pt-BR", {
|
||
githubPreview: {
|
||
loading: "Carregando detalhes do GitHub…",
|
||
unavailable: "Prévia do GitHub indisponível",
|
||
states: {
|
||
merged: "Mesclado",
|
||
draft: "Rascunho",
|
||
open: "Aberto",
|
||
closed: "Fechado",
|
||
notPlanned: "Não planejado",
|
||
},
|
||
file: "{count} arquivo",
|
||
files: "{count} arquivos",
|
||
comment: "{count} comentário",
|
||
comments: "{count} comentários",
|
||
pullRequest: "pull request",
|
||
issue: "issue",
|
||
ariaLabel: "{state} {kind} {repo} #{number}: {title}, por {author}",
|
||
},
|
||
});
|
||
await i18n.setLocale("pt-BR");
|
||
|
||
const card = document.querySelector<HTMLElement>(".github-link-hovercard");
|
||
expect(card?.textContent).toContain("Aberto");
|
||
expect(card?.textContent).toContain("1 comentário");
|
||
expect(card?.getAttribute("aria-label")).toContain("por octocat");
|
||
});
|
||
});
|