mirror of
https://github.com/block/buzz.git
synced 2026-11-03 07:51:22 +00:00
Merge desktop menu and smoke-test fixes into invitation maintenance candidate
* codex/pr8237-desktop-fix-20261023-core: fix(desktop): keep community actions stable across hover transitions Signed-off-by: seiler <seiler@squareup.com>
This commit is contained in:
@@ -0,0 +1,64 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { mock, test } from "node:test";
|
||||
import React, { act } from "react";
|
||||
import { cleanup, fireEvent, render } from "@testing-library/react";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
|
||||
import { CommunitySwitcher } from "./CommunitySwitcher.tsx";
|
||||
|
||||
const community = {
|
||||
id: "alpha",
|
||||
name: "Alpha",
|
||||
relayUrl: "wss://alpha.example",
|
||||
addedAt: "2026-01-01T00:00:00Z",
|
||||
};
|
||||
|
||||
function renderSwitcher(t) {
|
||||
const client = new QueryClient();
|
||||
client.setQueryData(["communityIcon", community.relayUrl], null);
|
||||
const view = render(
|
||||
React.createElement(
|
||||
QueryClientProvider,
|
||||
{ client },
|
||||
React.createElement(CommunitySwitcher, {
|
||||
activeCommunity: community,
|
||||
communities: [community],
|
||||
variant: "profile-menu",
|
||||
onSwitchCommunity() {},
|
||||
onAddCommunity() {},
|
||||
onUpdateCommunity() {},
|
||||
async onLeaveCommunity() {},
|
||||
async onRemoveCommunityFromDevice() {},
|
||||
}),
|
||||
),
|
||||
);
|
||||
t.after(() => {
|
||||
cleanup();
|
||||
client.clear();
|
||||
mock.timers.reset();
|
||||
});
|
||||
mock.timers.enable({ apis: ["setTimeout"] });
|
||||
return view;
|
||||
}
|
||||
|
||||
test("explicitly opening community actions cancels a pending hover close", (t) => {
|
||||
const view = renderSwitcher(t);
|
||||
const trigger = view.getByTestId("community-switcher");
|
||||
fireEvent.mouseEnter(trigger);
|
||||
fireEvent.mouseLeave(trigger);
|
||||
fireEvent.click(trigger);
|
||||
act(() => mock.timers.tick(160));
|
||||
assert.equal(trigger.getAttribute("aria-expanded"), "true");
|
||||
assert.ok(view.getByRole("menu", { name: "Community actions" }));
|
||||
});
|
||||
|
||||
test("selecting a community action cancels a pending hover open", (t) => {
|
||||
const view = renderSwitcher(t);
|
||||
const trigger = view.getByTestId("community-switcher");
|
||||
fireEvent.click(trigger);
|
||||
fireEvent.mouseEnter(view.getByRole("menu", { name: "Community actions" }));
|
||||
fireEvent.click(view.getByRole("menuitem", { name: "Add a community" }));
|
||||
act(() => mock.timers.tick(80));
|
||||
assert.equal(trigger.getAttribute("aria-expanded"), "false");
|
||||
assert.equal(view.queryByRole("menu", { name: "Community actions" }), null);
|
||||
});
|
||||
@@ -156,13 +156,8 @@ export function CommunitySwitcher({
|
||||
}
|
||||
|
||||
function handleProfileMenuOpenChange(nextOpen: boolean) {
|
||||
if (variant !== "profile-menu") {
|
||||
setDropdownOpen(nextOpen);
|
||||
return;
|
||||
}
|
||||
if (!nextOpen) {
|
||||
clearProfileMenuHoverTimer();
|
||||
}
|
||||
// Explicit actions take precedence over an earlier pointer transition.
|
||||
clearProfileMenuHoverTimer();
|
||||
setDropdownOpen(nextOpen);
|
||||
}
|
||||
|
||||
@@ -318,7 +313,7 @@ export function CommunitySwitcher({
|
||||
<button
|
||||
className="flex min-h-9 w-full items-center gap-2 rounded-lg px-3 py-2 text-left text-sm outline-hidden transition-colors hover:bg-muted/50 focus:bg-muted/50 focus:outline-none focus-visible:bg-muted/50 focus-visible:outline-none"
|
||||
onClick={() => {
|
||||
setDropdownOpen(false);
|
||||
handleProfileMenuOpenChange(false);
|
||||
void writeTextToClipboard(activeCommunity.relayUrl);
|
||||
}}
|
||||
role="menuitem"
|
||||
@@ -331,7 +326,7 @@ export function CommunitySwitcher({
|
||||
<button
|
||||
className="flex min-h-9 w-full items-center gap-2 rounded-lg px-3 py-2 text-left text-sm outline-hidden transition-colors hover:bg-muted/50 focus:bg-muted/50 focus:outline-none focus-visible:bg-muted/50 focus-visible:outline-none"
|
||||
onClick={() => {
|
||||
setDropdownOpen(false);
|
||||
handleProfileMenuOpenChange(false);
|
||||
onInvite();
|
||||
}}
|
||||
role="menuitem"
|
||||
@@ -344,7 +339,7 @@ export function CommunitySwitcher({
|
||||
<button
|
||||
className="flex min-h-9 w-full items-center gap-2 rounded-lg px-3 py-2 text-left text-sm outline-hidden transition-colors hover:bg-muted/50 focus:bg-muted/50 focus:outline-none focus-visible:bg-muted/50 focus-visible:outline-none"
|
||||
onClick={() => {
|
||||
setDropdownOpen(false);
|
||||
handleProfileMenuOpenChange(false);
|
||||
setEditingCommunity(activeCommunity);
|
||||
}}
|
||||
role="menuitem"
|
||||
@@ -367,7 +362,7 @@ export function CommunitySwitcher({
|
||||
className="flex min-h-9 w-full items-center gap-2 rounded-lg px-3 py-2 text-left text-sm text-destructive outline-hidden transition-colors hover:bg-destructive/10 focus:bg-destructive/10 focus:outline-none focus-visible:bg-destructive/10 focus-visible:outline-none disabled:pointer-events-none disabled:opacity-50"
|
||||
disabled={isLeaving}
|
||||
onClick={() => {
|
||||
setDropdownOpen(false);
|
||||
handleProfileMenuOpenChange(false);
|
||||
setRemovingCommunity(activeCommunity);
|
||||
}}
|
||||
role="menuitem"
|
||||
@@ -390,7 +385,7 @@ export function CommunitySwitcher({
|
||||
<button
|
||||
className="flex min-h-9 w-full items-center gap-2 rounded-lg px-3 py-2 text-left text-sm outline-hidden transition-colors hover:bg-muted/50 focus:bg-muted/50 focus:outline-none focus-visible:bg-muted/50 focus-visible:outline-none"
|
||||
onClick={() => {
|
||||
setDropdownOpen(false);
|
||||
handleProfileMenuOpenChange(false);
|
||||
onAddCommunity();
|
||||
}}
|
||||
role="menuitem"
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { expect, test } from "@playwright/test";
|
||||
|
||||
import { waitForAnimations } from "../helpers/animations";
|
||||
import { installMockBridge } from "../helpers/bridge";
|
||||
import { FEATURE_OVERRIDES_STORAGE_KEY } from "../helpers/features";
|
||||
|
||||
@@ -298,24 +299,11 @@ test.describe("community rail", () => {
|
||||
expect(feedbackBox?.y).toBeLessThan(settingsBox?.y ?? 0);
|
||||
|
||||
const menu = page.getByRole("menu", { name: "Community actions" });
|
||||
await waitForAnimations(page);
|
||||
await page.clock.install({ time: new Date("2026-10-23T12:00:00Z") });
|
||||
await page.clock.pauseAt(new Date("2026-10-23T12:00:01Z"));
|
||||
await communityTrigger.evaluate((trigger) => {
|
||||
trigger.addEventListener(
|
||||
"mouseenter",
|
||||
() => {
|
||||
trigger.dataset.hoverStartedAt = String(performance.now());
|
||||
},
|
||||
{ once: true },
|
||||
);
|
||||
trigger.addEventListener("mouseleave", () => {
|
||||
trigger.dataset.leftAt = String(performance.now());
|
||||
});
|
||||
const observer = new MutationObserver((records) => {
|
||||
if (
|
||||
trigger.getAttribute("aria-expanded") === "true" &&
|
||||
!trigger.dataset.expandedAt
|
||||
) {
|
||||
trigger.dataset.expandedAt = String(performance.now());
|
||||
}
|
||||
if (
|
||||
records.some(
|
||||
(record) =>
|
||||
@@ -333,17 +321,11 @@ test.describe("community rail", () => {
|
||||
});
|
||||
});
|
||||
await communityTrigger.hover();
|
||||
await expect(menu).toBeVisible({ timeout: 700 });
|
||||
const openDelayMs = await communityTrigger.evaluate((trigger) => {
|
||||
const hoverStartedAt = Number(trigger.dataset.hoverStartedAt);
|
||||
const expandedAt = Number(trigger.dataset.expandedAt);
|
||||
if (!Number.isFinite(hoverStartedAt) || !Number.isFinite(expandedAt)) {
|
||||
throw new Error("Community actions open timing was not recorded");
|
||||
}
|
||||
return expandedAt - hoverStartedAt;
|
||||
});
|
||||
expect(openDelayMs).toBeGreaterThanOrEqual(40);
|
||||
expect(openDelayMs).toBeLessThan(300);
|
||||
await page.clock.runFor(40);
|
||||
await expect(communityTrigger).toHaveAttribute("aria-expanded", "false");
|
||||
await page.clock.runFor(40);
|
||||
await expect(menu).toBeVisible();
|
||||
await page.clock.runFor(150);
|
||||
|
||||
const openTriggerBox = await communityTrigger.boundingBox();
|
||||
const menuBox = await menu.boundingBox();
|
||||
@@ -359,25 +341,15 @@ test.describe("community rail", () => {
|
||||
);
|
||||
await page.mouse.move(triggerExitX, triggerExitY);
|
||||
await page.mouse.move(menuBox.x + 8, menuBox.y - 8);
|
||||
await page.waitForTimeout(80);
|
||||
await page.clock.runFor(80);
|
||||
await page.mouse.move(menuBox.x + 8, menuBox.y + 8);
|
||||
const bridgeDurationMs = await communityTrigger.evaluate((trigger) => {
|
||||
const leftAt = Number(trigger.dataset.leftAt);
|
||||
if (!Number.isFinite(leftAt)) {
|
||||
throw new Error(
|
||||
"Community actions trigger exit timing was not recorded",
|
||||
);
|
||||
}
|
||||
return performance.now() - leftAt;
|
||||
});
|
||||
expect(bridgeDurationMs).toBeGreaterThanOrEqual(60);
|
||||
expect(bridgeDurationMs).toBeLessThan(140);
|
||||
await page.waitForTimeout(180);
|
||||
await page.clock.runFor(180);
|
||||
await expect(menu).toBeVisible();
|
||||
await expect(communityTrigger).not.toHaveAttribute(
|
||||
"data-closed-after-opening",
|
||||
"true",
|
||||
);
|
||||
await page.clock.resume();
|
||||
await expect(
|
||||
menu.getByRole("menuitem", { name: "Copy community URL" }),
|
||||
).toBeVisible();
|
||||
|
||||
@@ -8,6 +8,15 @@ async function openGeneral(page: Page) {
|
||||
await expect(page.getByTestId("chat-title")).toHaveText("general");
|
||||
}
|
||||
|
||||
async function lastSignedMessageContent(page: Page) {
|
||||
// Read-state and presence events can be signed after the message.
|
||||
return page.evaluate(
|
||||
() =>
|
||||
window.__BUZZ_E2E_SIGNED_EVENTS__?.findLast((event) => event.kind === 9)
|
||||
?.content,
|
||||
);
|
||||
}
|
||||
|
||||
async function selectText(input: Locator, selectedText: string) {
|
||||
await input.evaluate((element, text) => {
|
||||
const walker = document.createTreeWalker(element, NodeFilter.SHOW_TEXT);
|
||||
@@ -478,16 +487,7 @@ test("selected hard-break lines stay newline-separated in one code block", async
|
||||
|
||||
await page.getByTestId("send-message").click();
|
||||
await expect
|
||||
.poll(() =>
|
||||
page.evaluate(
|
||||
() =>
|
||||
(
|
||||
window as Window & {
|
||||
__BUZZ_E2E_SIGNED_EVENTS__?: Array<{ content: string }>;
|
||||
}
|
||||
).__BUZZ_E2E_SIGNED_EVENTS__?.at(-1)?.content,
|
||||
),
|
||||
)
|
||||
.poll(() => lastSignedMessageContent(page))
|
||||
.toBe("```\none\ntwo\nthree\n```");
|
||||
});
|
||||
|
||||
@@ -521,16 +521,7 @@ test("selected list items become one multiline code block and keep neighbors", a
|
||||
|
||||
await page.getByTestId("send-message").click();
|
||||
await expect
|
||||
.poll(() =>
|
||||
page.evaluate(
|
||||
() =>
|
||||
(
|
||||
window as Window & {
|
||||
__BUZZ_E2E_SIGNED_EVENTS__?: Array<{ content: string }>;
|
||||
}
|
||||
).__BUZZ_E2E_SIGNED_EVENTS__?.at(-1)?.content,
|
||||
),
|
||||
)
|
||||
.poll(() => lastSignedMessageContent(page))
|
||||
.toBe("- before\n\n```\none\ntwo\n```\n\n- after");
|
||||
});
|
||||
|
||||
@@ -548,16 +539,7 @@ test("caret-only block formatting serializes the prior draft unchanged", async (
|
||||
|
||||
await page.getByTestId("send-message").click();
|
||||
await expect
|
||||
.poll(() =>
|
||||
page.evaluate(
|
||||
() =>
|
||||
(
|
||||
window as Window & {
|
||||
__BUZZ_E2E_SIGNED_EVENTS__?: Array<{ content: string }>;
|
||||
}
|
||||
).__BUZZ_E2E_SIGNED_EVENTS__?.at(-1)?.content,
|
||||
),
|
||||
)
|
||||
.poll(() => lastSignedMessageContent(page))
|
||||
.toBe("before\n\n- item");
|
||||
});
|
||||
|
||||
@@ -581,16 +563,7 @@ test("block formatting preserves the lines around a selected composer line", asy
|
||||
|
||||
await page.getByTestId("send-message").click();
|
||||
await expect
|
||||
.poll(() =>
|
||||
page.evaluate(
|
||||
() =>
|
||||
(
|
||||
window as Window & {
|
||||
__BUZZ_E2E_SIGNED_EVENTS__?: Array<{ content: string }>;
|
||||
}
|
||||
).__BUZZ_E2E_SIGNED_EVENTS__?.at(-1)?.content,
|
||||
),
|
||||
)
|
||||
.poll(() => lastSignedMessageContent(page))
|
||||
.toBe("before\n\n- selected\n\nafter");
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user