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:
seiler
2026-10-23 18:27:17 +00:00
4 changed files with 96 additions and 92 deletions

View File

@@ -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);
});

View File

@@ -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"

View File

@@ -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();

View File

@@ -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");
});