mirror of
https://github.com/block/buzz.git
synced 2026-10-27 04:30:52 +00:00
**Category:** improvement **User Impact:** People can leave their final Buzz community and return to **Join or create a community** without losing their signed-in identity. **Problem:** Buzz Desktop blocked people from leaving when only one community remained. Its existing remove action also changed local configuration without ending relay membership. **Solution:** Allow the final community to be left. Buzz now asks the relay to end membership, removes the community locally only after acceptance, and returns the person to the community selector while keeping their identity signed in. If other communities remain, Buzz switches to one of them. Relay rejection or timeout keeps the community in place and shows an actionable retry error. <details> <summary>File changes</summary> **desktop/src/features/communities/leaveCommunity.ts** Adds signed kind 28936 publishing for active and inactive community relays with actionable timeout handling. **desktop/src/features/communities/leaveCommunity.test.mjs** Covers event shape, relay selection, acceptance gating, rejection, timeout messaging, and cleanup. **desktop/src/features/communities/useCommunities.tsx** Allows final-community removal and clears community-specific storage without touching identity. **desktop/src/features/communities/resolveCommunityRemoval.test.mjs** Covers final, active, and inactive community removal state transitions. **desktop/src/app/useCommunityNavigationTransitions.ts** Gates local removal on relay acceptance and routes to a fallback community or setup selector. **desktop/src/app/AppShell.tsx** Passes the asynchronous leave operation through shell entry points. **desktop/src/features/communities/ui/EditCommunityDialog.tsx** Replaces the local-only remove action with a pending-aware Leave Community action that retains actionable errors. **desktop/src/features/communities/ui/CommunitySwitcher.tsx** Enables leaving the final community and carries the asynchronous callback. **desktop/src/features/sidebar/ui/AppSidebar.tsx** Carries the asynchronous leave callback through sidebar props. **desktop/src/features/sidebar/ui/CommunityRail.tsx** Enables leaving the final community from rail settings. **desktop/src/features/sidebar/ui/SidebarProfileCard.tsx** Carries the asynchronous leave callback through profile community settings. **desktop/src/testing/e2eBridge.ts** Teaches the mock relay to accept NIP-43 leave events. **desktop/tests/e2e/community-rail.spec.ts** Updates leave interactions and verifies final-community setup navigation, storage cleanup, and identity preservation. </details> ### Reproduction steps 1. Run Buzz Desktop with a signed-in identity and one joined community. 2. Open Community settings and choose **Leave Community**. 3. Confirm the app shows **Join or create a community** and the existing identity remains signed in. 4. Repeat with two communities and confirm leaving the active one switches cleanly to the remaining community. 5. Reject or withhold the relay `OK` response and confirm the community remains configured with an actionable error in the dialog. ### Test plan - `pnpm check` - `pnpm build` - `pnpm test` (3,913 passing) - `pnpm build:e2e && pnpm exec playwright test tests/e2e/community-rail.spec.ts --grep "final community"` <img width="557" height="316" alt="image" src="https://github.com/user-attachments/assets/b628182f-cba5-451d-ae4b-bee8d8dd19aa" /> --------- Signed-off-by: Taylor Ho <taylorkmho@gmail.com> Signed-off-by: npub14ndfusear8wdpe4kss8h7juc7wjk78atnqzf63zvppcpneknv4sq6x9370 <acda9e433d19dcd0e6b6840f7f4b98f3a56f1fab98049d444c087019e6d36560@buzz.block.builderlab.xyz> Co-authored-by: npub1223z34hd7vtwc6qj4s7flsxkj644nlre2nthu7lrrmkumhu3xddsrx9r6w <52a228d6edf316ec6812ac3c9fc0d696ab59fc7954d77e7be31eedcddf91335b@buzz.block.builderlab.xyz> Co-authored-by: npub14ndfusear8wdpe4kss8h7juc7wjk78atnqzf63zvppcpneknv4sq6x9370 <acda9e433d19dcd0e6b6840f7f4b98f3a56f1fab98049d444c087019e6d36560@buzz.block.builderlab.xyz>
1093 lines
38 KiB
TypeScript
1093 lines
38 KiB
TypeScript
import { expect, test } from "@playwright/test";
|
|
|
|
import { installMockBridge } from "../helpers/bridge";
|
|
import { FEATURE_OVERRIDES_STORAGE_KEY } from "../helpers/features";
|
|
|
|
const RELAY_URL = "ws://localhost:3000";
|
|
|
|
const COMMUNITY_A = {
|
|
id: "ws-a",
|
|
name: "Alpha",
|
|
relayUrl: RELAY_URL,
|
|
addedAt: "2026-01-01T00:00:00.000Z",
|
|
};
|
|
const COMMUNITY_B = {
|
|
id: "ws-b",
|
|
name: "Bravo",
|
|
relayUrl: "ws://localhost:3001",
|
|
addedAt: "2026-01-02T00:00:00.000Z",
|
|
};
|
|
|
|
async function seedCommunities(
|
|
page: import("@playwright/test").Page,
|
|
communities: Array<Record<string, unknown>>,
|
|
activeId: string,
|
|
) {
|
|
await page.addInitScript(
|
|
({ list, active }) => {
|
|
window.localStorage.setItem("buzz-communities", JSON.stringify(list));
|
|
window.localStorage.setItem("buzz-active-community-id", active);
|
|
},
|
|
{ list: communities, active: activeId },
|
|
);
|
|
}
|
|
|
|
test.describe("community rail", () => {
|
|
test("shows the rail with multiple communities despite a stale opt-out", async ({
|
|
page,
|
|
}) => {
|
|
await installMockBridge(page, undefined, {
|
|
seedPreviewFeatures: false,
|
|
skipCommunitySeed: true,
|
|
});
|
|
await seedCommunities(page, [COMMUNITY_A, COMMUNITY_B], COMMUNITY_A.id);
|
|
await page.addInitScript((overridesKey) => {
|
|
window.localStorage.setItem(
|
|
overridesKey,
|
|
JSON.stringify({ workspaceRail: false }),
|
|
);
|
|
}, FEATURE_OVERRIDES_STORAGE_KEY);
|
|
await page.goto("/");
|
|
|
|
const rail = page.getByTestId("community-rail");
|
|
await expect(rail).toBeVisible();
|
|
await expect(page.getByTestId("app-sidebar-layer")).toHaveCSS(
|
|
"z-index",
|
|
"10",
|
|
);
|
|
await expect(page.getByTestId("app-sidebar-layer")).toHaveCSS(
|
|
"overflow",
|
|
"visible",
|
|
);
|
|
await expect(rail).toHaveCSS("z-index", "0");
|
|
|
|
const buttonA = page.getByTestId(`community-rail-button-${COMMUNITY_A.id}`);
|
|
const buttonB = page.getByTestId(`community-rail-button-${COMMUNITY_B.id}`);
|
|
await expect(buttonA).toBeVisible();
|
|
await expect(buttonB).toBeVisible();
|
|
|
|
// The active community is marked via aria-current.
|
|
await expect(buttonA).toHaveAttribute("aria-current", "true");
|
|
await expect(buttonB).not.toHaveAttribute("aria-current", "true");
|
|
await expect(buttonA.locator(":scope > span").first()).toHaveCSS(
|
|
"opacity",
|
|
"1",
|
|
);
|
|
|
|
// The add-community affordance lives at the bottom of the rail.
|
|
await expect(page.getByTestId("community-rail-add")).toBeVisible();
|
|
});
|
|
|
|
test("restores pointer events after dismissing community settings", async ({
|
|
page,
|
|
}) => {
|
|
await installMockBridge(page, undefined, { skipCommunitySeed: true });
|
|
await seedCommunities(page, [COMMUNITY_A, COMMUNITY_B], COMMUNITY_A.id);
|
|
await page.goto("/");
|
|
|
|
const communityButton = page.getByTestId(
|
|
`community-rail-button-${COMMUNITY_A.id}`,
|
|
);
|
|
await communityButton.click({ button: "right" });
|
|
await page.getByRole("menuitem", { name: "Community settings" }).click();
|
|
|
|
await expect(
|
|
page.getByRole("dialog", { name: "Edit Community" }),
|
|
).toBeVisible();
|
|
await expect(page.getByTestId("community-icon-settings")).toBeVisible();
|
|
await page.mouse.click(0, 0);
|
|
|
|
await expect(
|
|
page.getByRole("dialog", { name: "Edit Community" }),
|
|
).toHaveCount(0);
|
|
await expect(page.locator("body")).not.toHaveCSS("pointer-events", "none");
|
|
await page.getByTestId(`community-rail-button-${COMMUNITY_B.id}`).click();
|
|
await expect
|
|
.poll(() =>
|
|
page.evaluate(() =>
|
|
window.localStorage.getItem("buzz-active-community-id"),
|
|
),
|
|
)
|
|
.toBe(COMMUNITY_B.id);
|
|
});
|
|
|
|
test("lets community admins open invite controls from the rail", async ({
|
|
page,
|
|
}) => {
|
|
await installMockBridge(
|
|
page,
|
|
{
|
|
relayRequiresMembership: true,
|
|
relayRole: "admin",
|
|
},
|
|
{ skipCommunitySeed: true },
|
|
);
|
|
await seedCommunities(page, [COMMUNITY_A, COMMUNITY_B], COMMUNITY_A.id);
|
|
await page.goto("/");
|
|
|
|
await page
|
|
.getByTestId(`community-rail-button-${COMMUNITY_A.id}`)
|
|
.click({ button: "right" });
|
|
const railMenu = page.getByTestId(`community-rail-menu-${COMMUNITY_A.id}`);
|
|
await expect(railMenu.getByRole("separator")).toHaveCount(1);
|
|
await expect(railMenu.getByRole("menuitem")).toHaveText([
|
|
"Mark all as read",
|
|
"Copy community URL",
|
|
"Invite to community",
|
|
"Community settings",
|
|
]);
|
|
await page.getByRole("menuitem", { name: "Invite to community" }).click();
|
|
|
|
await expect(page).toHaveURL(/#\/settings\?section=community-members$/);
|
|
await expect(page.getByTestId("settings-community-members")).toBeVisible();
|
|
await expect(
|
|
page.getByRole("heading", { name: "Invites", exact: true }),
|
|
).toBeVisible();
|
|
await expect(page.getByTestId("community-icon-settings")).toHaveCount(0);
|
|
await expect(
|
|
page.getByTestId("community-invite-dialog-trigger"),
|
|
).toBeVisible();
|
|
await expect(page.getByTestId("community-invite-email-field")).toHaveCount(
|
|
0,
|
|
);
|
|
await page.getByTestId("community-invite-dialog-trigger").click();
|
|
await expect(page.getByTestId("community-invite-email-field")).toHaveCount(
|
|
0,
|
|
);
|
|
await expect(page.getByTestId("member-pubkey-input")).toBeVisible();
|
|
await expect(page.getByTestId("copy-invite-link")).toBeVisible();
|
|
});
|
|
|
|
test("hides rail invite controls from community members", async ({
|
|
page,
|
|
}) => {
|
|
await installMockBridge(
|
|
page,
|
|
{
|
|
relayRequiresMembership: true,
|
|
relayRole: "member",
|
|
},
|
|
{ skipCommunitySeed: true },
|
|
);
|
|
await seedCommunities(page, [COMMUNITY_A, COMMUNITY_B], COMMUNITY_A.id);
|
|
await page.goto("/");
|
|
|
|
await page
|
|
.getByTestId(`community-rail-button-${COMMUNITY_A.id}`)
|
|
.click({ button: "right" });
|
|
|
|
await expect(
|
|
page.getByRole("menuitem", { name: "Invite to community" }),
|
|
).toHaveCount(0);
|
|
await page.getByRole("menuitem", { name: "Community settings" }).click();
|
|
await expect(
|
|
page.getByRole("dialog", { name: "Edit Community" }),
|
|
).toBeVisible();
|
|
await expect(page.getByTestId("community-icon-settings")).toHaveCount(0);
|
|
});
|
|
|
|
test("shows active community actions instead of another switcher in the profile menu", async ({
|
|
page,
|
|
}) => {
|
|
await installMockBridge(
|
|
page,
|
|
{
|
|
relayMembershipEoseDelayMs: 30_000,
|
|
relayRequiresMembership: true,
|
|
relayRole: "admin",
|
|
},
|
|
{ skipCommunitySeed: true },
|
|
);
|
|
await seedCommunities(page, [COMMUNITY_A, COMMUNITY_B], COMMUNITY_A.id);
|
|
await page.goto("/");
|
|
|
|
await page.getByTestId("sidebar-profile-avatar-button").click();
|
|
const communityTrigger = page.getByTestId("community-switcher");
|
|
const feedback = page.getByTestId("profile-popover-send-feedback");
|
|
const settings = page.getByTestId("profile-popover-settings");
|
|
const communityBox = await communityTrigger.boundingBox();
|
|
const feedbackBox = await feedback.boundingBox();
|
|
const settingsBox = await settings.boundingBox();
|
|
expect(communityBox).not.toBeNull();
|
|
expect(feedbackBox).not.toBeNull();
|
|
expect(settingsBox).not.toBeNull();
|
|
expect(communityBox?.y).toBeLessThan(feedbackBox?.y ?? 0);
|
|
expect(feedbackBox?.y).toBeLessThan(settingsBox?.y ?? 0);
|
|
|
|
await page.getByTestId("community-switcher").click();
|
|
|
|
const menu = page.getByRole("menu", { name: "Community actions" });
|
|
await expect(menu).toBeVisible();
|
|
await expect(
|
|
menu.getByRole("menuitem", { name: "Copy community URL" }),
|
|
).toBeVisible();
|
|
await expect(
|
|
menu.getByRole("menuitem", { name: "Copy community URL" }),
|
|
).not.toBeFocused();
|
|
await expect(
|
|
menu.getByRole("menuitem", { name: "Invite to community" }),
|
|
).toBeVisible({ timeout: 1_000 });
|
|
await expect(
|
|
menu.getByRole("menuitem", { name: "Community settings" }),
|
|
).toBeVisible();
|
|
await expect(
|
|
menu.getByRole("menuitem", { name: "Leave community" }),
|
|
).toBeVisible();
|
|
await expect(
|
|
menu.getByRole("menuitem", { name: "Add a community" }),
|
|
).toBeVisible();
|
|
await expect(menu.getByRole("separator")).toHaveCount(1);
|
|
await expect(menu.getByRole("menuitem", { name: "Alpha" })).toHaveCount(0);
|
|
await expect(menu.getByRole("menuitem", { name: "Bravo" })).toHaveCount(0);
|
|
|
|
await menu.getByRole("menuitem", { name: "Invite to community" }).click();
|
|
await expect(page).toHaveURL(/#\/settings\?section=community-members$/);
|
|
});
|
|
|
|
test("keeps profile community actions available to members without invite access", async ({
|
|
page,
|
|
}) => {
|
|
await page
|
|
.context()
|
|
.grantPermissions(["clipboard-read", "clipboard-write"], {
|
|
origin: "http://127.0.0.1:4173",
|
|
});
|
|
await installMockBridge(
|
|
page,
|
|
{
|
|
relayRequiresMembership: true,
|
|
relayRole: "member",
|
|
},
|
|
{ skipCommunitySeed: true },
|
|
);
|
|
await seedCommunities(page, [COMMUNITY_A, COMMUNITY_B], COMMUNITY_A.id);
|
|
await page.goto("/");
|
|
|
|
await page.getByTestId("sidebar-profile-avatar-button").click();
|
|
await page.getByTestId("community-switcher").click();
|
|
|
|
const menu = page.getByRole("menu", { name: "Community actions" });
|
|
await expect(
|
|
menu.getByRole("menuitem", { name: "Invite to community" }),
|
|
).toHaveCount(0);
|
|
await expect(
|
|
menu.getByRole("menuitem", { name: "Copy community URL" }),
|
|
).toBeVisible();
|
|
await expect(
|
|
menu.getByRole("menuitem", { name: "Add a community" }),
|
|
).toBeVisible();
|
|
|
|
await menu.getByRole("menuitem", { name: "Copy community URL" }).click();
|
|
await expect
|
|
.poll(() =>
|
|
page.evaluate(() => {
|
|
return (window.__BUZZ_E2E_COMMAND_LOG__ ?? []).findLast(
|
|
(entry) => entry.command === "copy_text_to_clipboard",
|
|
)?.payload;
|
|
}),
|
|
)
|
|
.toEqual({ text: COMMUNITY_A.relayUrl });
|
|
|
|
await page.getByTestId("community-switcher").click();
|
|
await menu.getByRole("menuitem", { name: "Community settings" }).click();
|
|
await expect(
|
|
page.getByRole("dialog", { name: "Edit Community" }),
|
|
).toBeVisible();
|
|
await expect(
|
|
page.getByRole("button", { name: "Leave Community" }),
|
|
).toHaveCount(0);
|
|
});
|
|
|
|
test("switches the active community on click", async ({ page }) => {
|
|
await installMockBridge(page, undefined, { skipCommunitySeed: true });
|
|
await seedCommunities(page, [COMMUNITY_A, COMMUNITY_B], COMMUNITY_A.id);
|
|
|
|
await page.goto("/");
|
|
|
|
await page.getByTestId(`community-rail-button-${COMMUNITY_B.id}`).click();
|
|
|
|
// Switching persists the newly active community id (the app then remounts
|
|
// against that relay via the existing community-init path).
|
|
await expect
|
|
.poll(() =>
|
|
page.evaluate(() =>
|
|
window.localStorage.getItem("buzz-active-community-id"),
|
|
),
|
|
)
|
|
.toBe(COMMUNITY_B.id);
|
|
});
|
|
|
|
test("restores the last Home or channel destination per community", async ({
|
|
page,
|
|
}) => {
|
|
await installMockBridge(page, undefined, { skipCommunitySeed: true });
|
|
await seedCommunities(page, [COMMUNITY_A, COMMUNITY_B], COMMUNITY_A.id);
|
|
await page.goto("/");
|
|
|
|
await page.getByTestId("channel-general").click();
|
|
await expect(page).toHaveURL(/#\/channels\//);
|
|
const generalUrl = page.url();
|
|
|
|
await page.getByTestId(`community-rail-button-${COMMUNITY_B.id}`).click();
|
|
await expect(page).toHaveURL(/#\/$/);
|
|
|
|
await page.getByTestId("channel-random").click();
|
|
await expect(page).toHaveURL(/#\/channels\//);
|
|
const randomUrl = page.url();
|
|
|
|
await page.getByTestId(`community-rail-button-${COMMUNITY_A.id}`).click();
|
|
await expect(page).toHaveURL(generalUrl);
|
|
|
|
await page.getByTestId(`community-rail-button-${COMMUNITY_B.id}`).click();
|
|
await expect(page).toHaveURL(randomUrl);
|
|
|
|
await page.getByRole("button", { name: "Inbox" }).click();
|
|
await expect(page).toHaveURL(/#\/$/);
|
|
await page.getByTestId(`community-rail-button-${COMMUNITY_A.id}`).click();
|
|
await expect(page).toHaveURL(generalUrl);
|
|
await page.getByTestId(`community-rail-button-${COMMUNITY_B.id}`).click();
|
|
await expect(page).toHaveURL(/#\/$/);
|
|
});
|
|
|
|
test("enters a remembered channel before live validation completes", async ({
|
|
page,
|
|
}) => {
|
|
await installMockBridge(page, undefined, { skipCommunitySeed: true });
|
|
await seedCommunities(page, [COMMUNITY_A, COMMUNITY_B], COMMUNITY_A.id);
|
|
await page.goto("/");
|
|
await expect(page.getByTestId("app-sidebar")).toBeVisible();
|
|
const rememberedChannelId = await page.evaluate((communityId) => {
|
|
const source = window.localStorage.getItem(
|
|
"buzz-channels.v1:ws://localhost:3000",
|
|
);
|
|
if (!source) throw new Error("missing source channel snapshot");
|
|
const snapshot = JSON.parse(source) as {
|
|
channels: Array<{ id: string; name: string }>;
|
|
};
|
|
const generalChannel = snapshot.channels.find(
|
|
(channel) => channel.name === "general",
|
|
);
|
|
if (!generalChannel) throw new Error("missing general channel snapshot");
|
|
window.localStorage.setItem(
|
|
"buzz-channels.v1:ws://localhost:3001",
|
|
source,
|
|
);
|
|
window.localStorage.setItem(
|
|
"buzz-community-destinations",
|
|
JSON.stringify({
|
|
[communityId]: {
|
|
kind: "channel",
|
|
channelId: generalChannel.id,
|
|
},
|
|
}),
|
|
);
|
|
return generalChannel.id;
|
|
}, COMMUNITY_B.id);
|
|
|
|
await page.evaluate(() => {
|
|
const testWindow = window as typeof window & {
|
|
__BUZZ_E2E__?: { mock?: { channelsReadDelayMs?: number } };
|
|
};
|
|
if (!testWindow.__BUZZ_E2E__) {
|
|
throw new Error("missing E2E config");
|
|
}
|
|
testWindow.__BUZZ_E2E__.mock = {
|
|
...testWindow.__BUZZ_E2E__.mock,
|
|
channelsReadDelayMs: 800,
|
|
};
|
|
});
|
|
await page.getByTestId(`community-rail-button-${COMMUNITY_B.id}`).click();
|
|
|
|
await expect(page).toHaveURL(
|
|
new RegExp(`#/channels/${rememberedChannelId}$`),
|
|
{ timeout: 700 },
|
|
);
|
|
await expect(page.getByTestId("message-timeline")).toBeVisible({
|
|
timeout: 700,
|
|
});
|
|
});
|
|
|
|
test("clears a remembered channel that is unavailable after switching", async ({
|
|
page,
|
|
}) => {
|
|
await installMockBridge(page, undefined, { skipCommunitySeed: true });
|
|
await seedCommunities(page, [COMMUNITY_A, COMMUNITY_B], COMMUNITY_A.id);
|
|
await page.addInitScript((communityId) => {
|
|
window.localStorage.setItem(
|
|
"buzz-community-destinations",
|
|
JSON.stringify({
|
|
[communityId]: { kind: "channel", channelId: "missing-channel" },
|
|
}),
|
|
);
|
|
}, COMMUNITY_B.id);
|
|
|
|
await page.goto("/");
|
|
await expect
|
|
.poll(() =>
|
|
page.evaluate(() =>
|
|
window.localStorage.getItem("buzz-channels.v1:ws://localhost:3000"),
|
|
),
|
|
)
|
|
.not.toBeNull();
|
|
await page.evaluate(() => {
|
|
const source = window.localStorage.getItem(
|
|
"buzz-channels.v1:ws://localhost:3000",
|
|
);
|
|
if (!source) throw new Error("missing source channel snapshot");
|
|
const snapshot = JSON.parse(source);
|
|
snapshot.channels = snapshot.channels.map(
|
|
(channel: Record<string, unknown>, index: number) =>
|
|
index === 0 ? { ...channel, id: "missing-channel" } : channel,
|
|
);
|
|
window.localStorage.setItem(
|
|
"buzz-channels.v1:ws://localhost:3001",
|
|
JSON.stringify(snapshot),
|
|
);
|
|
});
|
|
await page.getByTestId(`community-rail-button-${COMMUNITY_B.id}`).click();
|
|
|
|
await expect(page).not.toHaveURL(/#\/channels\//);
|
|
await expect
|
|
.poll(() =>
|
|
page.evaluate((communityId) => {
|
|
const raw = window.localStorage.getItem(
|
|
"buzz-community-destinations",
|
|
);
|
|
if (!raw) return null;
|
|
return JSON.parse(raw)[communityId];
|
|
}, COMMUNITY_B.id),
|
|
)
|
|
.toEqual({ kind: "home" });
|
|
});
|
|
|
|
test("does not repair a remembered channel until live validation succeeds", async ({
|
|
page,
|
|
}) => {
|
|
await installMockBridge(page, undefined, { skipCommunitySeed: true });
|
|
await seedCommunities(page, [COMMUNITY_A, COMMUNITY_B], COMMUNITY_A.id);
|
|
await page.addInitScript((communityId) => {
|
|
window.localStorage.setItem(
|
|
"buzz-community-destinations",
|
|
JSON.stringify({
|
|
[communityId]: { kind: "channel", channelId: "general" },
|
|
}),
|
|
);
|
|
}, COMMUNITY_B.id);
|
|
await page.goto("/");
|
|
await expect(page.getByTestId("app-sidebar")).toBeVisible();
|
|
await expect
|
|
.poll(() =>
|
|
page.evaluate(() =>
|
|
window.localStorage.getItem("buzz-channels.v1:ws://localhost:3000"),
|
|
),
|
|
)
|
|
.not.toBeNull();
|
|
expect(
|
|
await page.evaluate(async () => {
|
|
const testWindow = window as Window & {
|
|
__BUZZ_E2E_DEFER_NEXT_CHANNELS_READ__?: () => void;
|
|
__BUZZ_E2E_RELEASE_CHANNELS_READ__?: () => number;
|
|
__BUZZ_E2E_CHANNELS_READ_PENDING__?: number;
|
|
__BUZZ_E2E_INVOKE_MOCK_COMMAND__?: (
|
|
command: string,
|
|
) => Promise<unknown>;
|
|
};
|
|
const deferNext = testWindow.__BUZZ_E2E_DEFER_NEXT_CHANNELS_READ__;
|
|
const release = testWindow.__BUZZ_E2E_RELEASE_CHANNELS_READ__;
|
|
const invoke = testWindow.__BUZZ_E2E_INVOKE_MOCK_COMMAND__;
|
|
if (!deferNext || !release || !invoke) {
|
|
throw new Error("missing channel-read latch seam");
|
|
}
|
|
deferNext();
|
|
const released = release();
|
|
await invoke("get_channels");
|
|
return {
|
|
released,
|
|
pending: testWindow.__BUZZ_E2E_CHANNELS_READ_PENDING__,
|
|
};
|
|
}),
|
|
).toEqual({ released: 0, pending: 0 });
|
|
await page.evaluate(() => {
|
|
const source = window.localStorage.getItem(
|
|
"buzz-channels.v1:ws://localhost:3000",
|
|
);
|
|
if (!source) throw new Error("missing source channel snapshot");
|
|
const snapshot = JSON.parse(source);
|
|
snapshot.channels = snapshot.channels.filter(
|
|
(channel: { id: string }) => channel.id !== "general",
|
|
);
|
|
window.localStorage.setItem(
|
|
"buzz-channels.v1:ws://localhost:3001",
|
|
JSON.stringify(snapshot),
|
|
);
|
|
});
|
|
await page.evaluate(() => {
|
|
const config = (
|
|
window as Window & {
|
|
__BUZZ_E2E__?: {
|
|
mock?: {
|
|
channelsReadError?: string;
|
|
channelsReadErrors?: (string | null)[];
|
|
};
|
|
};
|
|
}
|
|
).__BUZZ_E2E__;
|
|
if (!config) throw new Error("missing E2E config");
|
|
config.mock = {
|
|
...config.mock,
|
|
channelsReadError: "temporary channel read failure",
|
|
channelsReadErrors: ["temporary channel read failure"],
|
|
};
|
|
});
|
|
await expect
|
|
.poll(() =>
|
|
page.evaluate(
|
|
() => window.__BUZZ_E2E__?.mock?.channelsReadErrors?.length ?? 0,
|
|
),
|
|
)
|
|
.toBe(1);
|
|
await page.evaluate(() => {
|
|
const testWindow = window as Window & {
|
|
__BUZZ_E2E_DEFER_NEXT_CHANNELS_READ__?: () => void;
|
|
__BUZZ_E2E_INVALIDATE_CHANNELS__?: () => Promise<void>;
|
|
};
|
|
const deferNextChannelsRead =
|
|
testWindow.__BUZZ_E2E_DEFER_NEXT_CHANNELS_READ__;
|
|
const invalidateChannels = testWindow.__BUZZ_E2E_INVALIDATE_CHANNELS__;
|
|
if (!deferNextChannelsRead) {
|
|
throw new Error("missing channel-read defer seam");
|
|
}
|
|
if (!invalidateChannels) {
|
|
throw new Error("missing channel invalidation seam");
|
|
}
|
|
// Arm and trigger in one browser task so unrelated callbacks cannot
|
|
// claim the one-shot before the validation read starts.
|
|
deferNextChannelsRead();
|
|
void invalidateChannels();
|
|
});
|
|
await page.waitForFunction(
|
|
() =>
|
|
(
|
|
window as Window & {
|
|
__BUZZ_E2E_CHANNELS_READ_PENDING__?: number;
|
|
}
|
|
).__BUZZ_E2E_CHANNELS_READ_PENDING__ === 1,
|
|
);
|
|
expect(
|
|
await page.evaluate(async () => {
|
|
const invoke = (
|
|
window as Window & {
|
|
__BUZZ_E2E_INVOKE_MOCK_COMMAND__?: (
|
|
command: string,
|
|
) => Promise<unknown>;
|
|
}
|
|
).__BUZZ_E2E_INVOKE_MOCK_COMMAND__;
|
|
if (!invoke) throw new Error("missing mock command seam");
|
|
try {
|
|
await invoke("get_channels");
|
|
return null;
|
|
} catch (error) {
|
|
return error instanceof Error ? error.message : String(error);
|
|
}
|
|
}),
|
|
).toBe("temporary channel read failure");
|
|
expect(
|
|
await page.evaluate(
|
|
() =>
|
|
(
|
|
window as Window & {
|
|
__BUZZ_E2E_CHANNELS_READ_PENDING__?: number;
|
|
}
|
|
).__BUZZ_E2E_CHANNELS_READ_PENDING__,
|
|
),
|
|
).toBe(1);
|
|
await page.getByTestId(`community-rail-button-${COMMUNITY_B.id}`).click();
|
|
await expect(page).not.toHaveURL(/#\/channels\/general$/);
|
|
await expect
|
|
.poll(() =>
|
|
page.evaluate((communityId) => {
|
|
const raw = window.localStorage.getItem(
|
|
"buzz-community-destinations",
|
|
);
|
|
return raw ? JSON.parse(raw)[communityId] : null;
|
|
}, COMMUNITY_B.id),
|
|
)
|
|
.toEqual({ kind: "channel", channelId: "general" });
|
|
|
|
const released = await page.evaluate(
|
|
() =>
|
|
(
|
|
window as Window & {
|
|
__BUZZ_E2E_RELEASE_CHANNELS_READ__?: () => number;
|
|
}
|
|
).__BUZZ_E2E_RELEASE_CHANNELS_READ__?.() ?? 0,
|
|
);
|
|
expect(released).toBe(1);
|
|
expect(
|
|
await page.evaluate(async () => {
|
|
const testWindow = window as Window & {
|
|
__BUZZ_E2E_INVOKE_MOCK_COMMAND__?: (
|
|
command: string,
|
|
) => Promise<unknown>;
|
|
__BUZZ_E2E_CHANNELS_READ_PENDING__?: number;
|
|
};
|
|
const invoke = testWindow.__BUZZ_E2E_INVOKE_MOCK_COMMAND__;
|
|
if (!invoke) throw new Error("missing mock command seam");
|
|
let message: string | null = null;
|
|
try {
|
|
await invoke("get_channels");
|
|
} catch (error) {
|
|
message = error instanceof Error ? error.message : String(error);
|
|
}
|
|
return {
|
|
message,
|
|
pending: testWindow.__BUZZ_E2E_CHANNELS_READ_PENDING__,
|
|
};
|
|
}),
|
|
).toEqual({ message: "temporary channel read failure", pending: 0 });
|
|
await expect
|
|
.poll(() =>
|
|
page.evaluate(
|
|
() => window.__BUZZ_E2E__?.mock?.channelsReadErrors?.length ?? 0,
|
|
),
|
|
)
|
|
.toBe(0);
|
|
|
|
await expect
|
|
.poll(() =>
|
|
page.evaluate((communityId) => {
|
|
const raw = window.localStorage.getItem(
|
|
"buzz-community-destinations",
|
|
);
|
|
return raw ? JSON.parse(raw)[communityId] : null;
|
|
}, COMMUNITY_B.id),
|
|
)
|
|
.toEqual({ kind: "channel", channelId: "general" });
|
|
await page.evaluate(async () => {
|
|
const testWindow = window as Window & {
|
|
__BUZZ_E2E__?: {
|
|
mock?: { channelsReadError?: string };
|
|
};
|
|
__BUZZ_E2E_INVALIDATE_CHANNELS__?: () => Promise<void>;
|
|
};
|
|
const config = testWindow.__BUZZ_E2E__;
|
|
const invalidateChannels = testWindow.__BUZZ_E2E_INVALIDATE_CHANNELS__;
|
|
if (!config?.mock) throw new Error("missing E2E mock config");
|
|
if (!invalidateChannels) {
|
|
throw new Error("missing channel invalidation seam");
|
|
}
|
|
config.mock.channelsReadError = undefined;
|
|
await invalidateChannels();
|
|
});
|
|
await expect
|
|
.poll(() =>
|
|
page.evaluate((communityId) => {
|
|
const raw = window.localStorage.getItem(
|
|
"buzz-community-destinations",
|
|
);
|
|
return raw ? JSON.parse(raw)[communityId] : null;
|
|
}, COMMUNITY_B.id),
|
|
)
|
|
.toEqual({ kind: "home" });
|
|
});
|
|
|
|
test("does not restore a remembered destination on cold boot", async ({
|
|
page,
|
|
}) => {
|
|
await installMockBridge(page, undefined, { skipCommunitySeed: true });
|
|
await seedCommunities(page, [COMMUNITY_A, COMMUNITY_B], COMMUNITY_A.id);
|
|
await page.addInitScript((communityId) => {
|
|
window.localStorage.setItem(
|
|
"buzz-community-destinations",
|
|
JSON.stringify({
|
|
[communityId]: { kind: "channel", channelId: "general" },
|
|
}),
|
|
);
|
|
}, COMMUNITY_A.id);
|
|
|
|
await page.goto("/");
|
|
|
|
await expect(page).not.toHaveURL(/#\/channels\//);
|
|
});
|
|
|
|
test("removing the active community restores the fallback destination", async ({
|
|
page,
|
|
}) => {
|
|
await installMockBridge(page, undefined, { skipCommunitySeed: true });
|
|
await seedCommunities(page, [COMMUNITY_A, COMMUNITY_B], COMMUNITY_A.id);
|
|
await page.goto("/");
|
|
|
|
await page.getByTestId(`community-rail-button-${COMMUNITY_B.id}`).click();
|
|
await page.getByTestId("channel-random").click();
|
|
const randomUrl = page.url();
|
|
await page.getByTestId(`community-rail-button-${COMMUNITY_A.id}`).click();
|
|
await page.getByTestId("channel-general").click();
|
|
|
|
await page.getByTestId("sidebar-profile-avatar-button").click();
|
|
await page.getByTestId("community-switcher").click();
|
|
await page
|
|
.getByRole("menu", { name: "Community actions" })
|
|
.getByRole("menuitem", { name: "Leave community" })
|
|
.click();
|
|
|
|
await expect(page).toHaveURL(randomUrl);
|
|
await expect
|
|
.poll(() =>
|
|
page.evaluate(() =>
|
|
window.localStorage.getItem("buzz-active-community-id"),
|
|
),
|
|
)
|
|
.toBe(COMMUNITY_B.id);
|
|
});
|
|
|
|
test("shows the quiet switch gate, not the boot splash, while switching", async ({
|
|
page,
|
|
}) => {
|
|
// Slow down apply_workspace so the loading phase is observable.
|
|
await installMockBridge(
|
|
page,
|
|
{ applyCommunityDelayMs: 800 },
|
|
{ skipCommunitySeed: true },
|
|
);
|
|
await seedCommunities(page, [COMMUNITY_A, COMMUNITY_B], COMMUNITY_A.id);
|
|
await page.goto("/");
|
|
|
|
// Cold boot still uses the full splash.
|
|
await expect(page.getByTestId("app-loading-gate")).toBeVisible();
|
|
const buttonB = page.getByTestId(`community-rail-button-${COMMUNITY_B.id}`);
|
|
await expect(buttonB).toBeVisible();
|
|
|
|
await buttonB.click();
|
|
|
|
// The switch renders the quiet gate; the "Setting up your community"
|
|
// splash must not reappear.
|
|
await expect(page.getByTestId("community-switch-gate")).toBeVisible();
|
|
await expect(page.getByTestId("app-loading-gate")).toHaveCount(0);
|
|
|
|
// The app settles into the new community once apply completes.
|
|
await expect(buttonB).toHaveAttribute("aria-current", "true");
|
|
});
|
|
|
|
test("leaving the final community returns to setup without resetting identity", async ({
|
|
context,
|
|
page,
|
|
}) => {
|
|
await installMockBridge(page, undefined, {
|
|
autoConnectDefaultRelay: true,
|
|
skipCommunitySeed: true,
|
|
});
|
|
await seedCommunities(page, [COMMUNITY_A], COMMUNITY_A.id);
|
|
await page.goto("/");
|
|
|
|
await expect
|
|
.poll(() =>
|
|
page.evaluate(() => typeof window.__BUZZ_E2E_INVOKE_MOCK_COMMAND__),
|
|
)
|
|
.toBe("function");
|
|
const identityBefore = await page.evaluate(async () =>
|
|
window.__BUZZ_E2E_INVOKE_MOCK_COMMAND__("get_identity"),
|
|
);
|
|
await page.getByTestId("sidebar-profile-avatar-button").click();
|
|
await page.getByTestId("community-switcher").click();
|
|
await page
|
|
.getByRole("menu", { name: "Community actions" })
|
|
.getByRole("menuitem", { name: "Leave community" })
|
|
.click();
|
|
|
|
await expect(page.getByText("Join or create a community")).toBeVisible();
|
|
await expect(page.getByTestId("welcome-setup-back")).toHaveCount(0);
|
|
await expect(page.getByTestId("community-choice-join")).toBeVisible();
|
|
await expect
|
|
.poll(() =>
|
|
page.evaluate(() => window.localStorage.getItem("buzz-communities")),
|
|
)
|
|
.toBeNull();
|
|
await expect
|
|
.poll(() =>
|
|
page.evaluate(() =>
|
|
window.localStorage.getItem("buzz-community-discovery-after-leave"),
|
|
),
|
|
)
|
|
.toBe("1");
|
|
|
|
const relaunchPage = await context.newPage();
|
|
await installMockBridge(relaunchPage, undefined, {
|
|
autoConnectDefaultRelay: true,
|
|
skipCommunitySeed: true,
|
|
});
|
|
await relaunchPage.goto("/");
|
|
await expect(
|
|
relaunchPage.getByText("Join or create a community"),
|
|
).toBeVisible();
|
|
await expect(relaunchPage.getByTestId("welcome-setup-back")).toHaveCount(0);
|
|
await expect
|
|
.poll(() =>
|
|
relaunchPage.evaluate(() =>
|
|
window.localStorage.getItem("buzz-communities"),
|
|
),
|
|
)
|
|
.toBeNull();
|
|
await expect
|
|
.poll(() =>
|
|
relaunchPage.evaluate(async () =>
|
|
window.__BUZZ_E2E_INVOKE_MOCK_COMMAND__("get_identity"),
|
|
),
|
|
)
|
|
.toEqual(identityBefore);
|
|
});
|
|
|
|
test("hides the rail with a single community", async ({ page }) => {
|
|
await installMockBridge(page, undefined, { skipCommunitySeed: true });
|
|
await seedCommunities(page, [COMMUNITY_A], COMMUNITY_A.id);
|
|
await page.goto("/");
|
|
|
|
// The channel sidebar still renders; the rail is omitted (a rail of one
|
|
// adds nothing).
|
|
await expect(page.getByTestId("app-sidebar")).toBeVisible();
|
|
await expect(page.getByTestId("community-rail")).toHaveCount(0);
|
|
});
|
|
|
|
test("keeps the rail visible when the sidebar is collapsed", async ({
|
|
page,
|
|
}) => {
|
|
await installMockBridge(page, undefined, { skipCommunitySeed: true });
|
|
await seedCommunities(page, [COMMUNITY_A, COMMUNITY_B], COMMUNITY_A.id);
|
|
await page.goto("/");
|
|
|
|
const rail = page.getByTestId("community-rail");
|
|
await expect(rail).toBeVisible();
|
|
|
|
// Collapse the sidebar via its keyboard shortcut. The rail is a sibling of
|
|
// the sidebar, not inside it, so it must stay fully visible and unshifted.
|
|
await page.evaluate(() => {
|
|
const isMac = /mac|iphone|ipad|ipod/i.test(navigator.platform);
|
|
window.dispatchEvent(
|
|
new KeyboardEvent("keydown", {
|
|
bubbles: true,
|
|
cancelable: true,
|
|
key: "s",
|
|
ctrlKey: !isMac,
|
|
metaKey: isMac,
|
|
}),
|
|
);
|
|
});
|
|
|
|
await expect(rail).toBeVisible();
|
|
await expect(
|
|
page.getByTestId(`community-rail-button-${COMMUNITY_B.id}`),
|
|
).toBeVisible();
|
|
await expect(page.getByTestId("community-rail-add")).toBeVisible();
|
|
});
|
|
|
|
test("clears the macOS traffic lights", async ({ page }) => {
|
|
// Spoof macOS so the rail applies its traffic-light top inset.
|
|
await page.addInitScript(() => {
|
|
Object.defineProperty(navigator, "platform", { get: () => "MacIntel" });
|
|
});
|
|
await installMockBridge(page, undefined, { skipCommunitySeed: true });
|
|
await seedCommunities(page, [COMMUNITY_A, COMMUNITY_B], COMMUNITY_A.id);
|
|
await page.goto("/");
|
|
|
|
// The first community button must start below the traffic-light band
|
|
// (native controls sit around y<=31 with trafficLightPosition y:24).
|
|
const firstButton = page.getByTestId(
|
|
`community-rail-button-${COMMUNITY_A.id}`,
|
|
);
|
|
await expect(firstButton).toBeVisible();
|
|
const buttonBox = await firstButton.boundingBox();
|
|
const railBox = await page.getByTestId("community-rail").boundingBox();
|
|
const searchBox = await page.getByTestId("open-search").boundingBox();
|
|
const appSurfaceBox = await page
|
|
.locator(".buzz-huddle-app-surface")
|
|
.boundingBox();
|
|
const contentBox = await page
|
|
.locator("[data-buzz-content-surface]")
|
|
.first()
|
|
.boundingBox();
|
|
expect(buttonBox).not.toBeNull();
|
|
expect(railBox).not.toBeNull();
|
|
expect(searchBox).not.toBeNull();
|
|
expect(appSurfaceBox).not.toBeNull();
|
|
expect(contentBox).not.toBeNull();
|
|
expect(buttonBox?.y ?? 0).toBeGreaterThanOrEqual(32);
|
|
expect(
|
|
Math.abs((buttonBox?.y ?? 0) - (contentBox?.y ?? 0) - 6),
|
|
).toBeLessThan(0.5);
|
|
expect(Math.abs((railBox?.y ?? 0) - (appSurfaceBox?.y ?? 0))).toBeLessThan(
|
|
0.5,
|
|
);
|
|
expect(
|
|
Math.abs(
|
|
(railBox?.y ?? 0) +
|
|
(railBox?.height ?? 0) -
|
|
((appSurfaceBox?.y ?? 0) + (appSurfaceBox?.height ?? 0)),
|
|
),
|
|
).toBeLessThan(0.5);
|
|
|
|
const leftInset = (buttonBox?.x ?? 0) - (railBox?.x ?? 0);
|
|
const rightInset =
|
|
(railBox?.x ?? 0) +
|
|
(railBox?.width ?? 0) -
|
|
((buttonBox?.x ?? 0) + (buttonBox?.width ?? 0));
|
|
expect(Math.abs(leftInset - 10)).toBeLessThan(0.5);
|
|
expect(Math.abs(leftInset - rightInset)).toBeLessThan(0.5);
|
|
const visibleRightGap =
|
|
(searchBox?.x ?? 0) - ((buttonBox?.x ?? 0) + (buttonBox?.width ?? 0));
|
|
expect(Math.abs(leftInset - visibleRightGap)).toBeLessThan(0.5);
|
|
|
|
// With the rail visible, the top-chrome controls (sidebar toggle, back/
|
|
// forward) sit just past the traffic lights near the rail edge — not
|
|
// shifted far right by a redundant traffic-light offset.
|
|
const toggle = page
|
|
.locator('[data-testid="app-top-chrome"] button')
|
|
.first();
|
|
const toggleBox = await toggle.boundingBox();
|
|
expect(toggleBox).not.toBeNull();
|
|
expect(toggleBox?.x ?? 0).toBeLessThan(120);
|
|
});
|
|
|
|
test("drag-to-reorder updates the stored community order and survives reload", async ({
|
|
page,
|
|
}) => {
|
|
await installMockBridge(page, undefined, { skipCommunitySeed: true });
|
|
// Seed only if not already set so the persisted order survives page.reload().
|
|
await page.addInitScript(
|
|
({ list, active }) => {
|
|
if (!window.localStorage.getItem("buzz-communities")) {
|
|
window.localStorage.setItem("buzz-communities", JSON.stringify(list));
|
|
}
|
|
if (!window.localStorage.getItem("buzz-active-community-id")) {
|
|
window.localStorage.setItem("buzz-active-community-id", active);
|
|
}
|
|
},
|
|
{ list: [COMMUNITY_A, COMMUNITY_B], active: COMMUNITY_A.id },
|
|
);
|
|
await page.goto("/");
|
|
|
|
const buttonA = page.getByTestId(`community-rail-button-${COMMUNITY_A.id}`);
|
|
const buttonB = page.getByTestId(`community-rail-button-${COMMUNITY_B.id}`);
|
|
await expect(buttonA).toBeVisible();
|
|
await expect(buttonB).toBeVisible();
|
|
|
|
// Drag B (lower) up over A (higher) so the order becomes [B, A].
|
|
const boxA = await buttonA.boundingBox();
|
|
const boxB = await buttonB.boundingBox();
|
|
if (!boxA || !boxB) throw new Error("community buttons not laid out");
|
|
|
|
const startX = boxB.x + boxB.width / 2;
|
|
const startY = boxB.y + boxB.height / 2;
|
|
const targetY = boxA.y + boxA.height / 2;
|
|
|
|
// dnd-kit PointerSensor requires a 6px activation distance before it picks
|
|
// up the drag. Move in small steps so pointermove events fire on every pixel.
|
|
await page.mouse.move(startX, startY);
|
|
await page.mouse.down();
|
|
await page.mouse.move(startX, startY - 3, { steps: 3 });
|
|
await page.mouse.move(startX, targetY, { steps: 20 });
|
|
await page.mouse.up();
|
|
|
|
// The community list in localStorage must now be [B, A].
|
|
await expect
|
|
.poll(() =>
|
|
page.evaluate(() => {
|
|
const raw = window.localStorage.getItem("buzz-communities");
|
|
if (!raw) return null;
|
|
const list = JSON.parse(raw) as Array<{ id: string }>;
|
|
return list.map((c) => c.id);
|
|
}),
|
|
)
|
|
.toEqual([COMMUNITY_B.id, COMMUNITY_A.id]);
|
|
|
|
// Verify the new order is also reflected in the rendered DOM — B button
|
|
// must appear above A button.
|
|
const newBoxA = await buttonA.boundingBox();
|
|
const newBoxB = await buttonB.boundingBox();
|
|
if (!newBoxA || !newBoxB)
|
|
throw new Error("community buttons not laid out after drag");
|
|
expect(newBoxB.y).toBeLessThan(newBoxA.y);
|
|
|
|
// Reload and confirm the order survives restart: addInitScript is
|
|
// conditional (no-op when data already exists), so the dragged [B, A]
|
|
// order is what React reads on boot.
|
|
await page.reload();
|
|
await expect(page.getByTestId("community-rail")).toBeVisible();
|
|
|
|
// Storage must still be [B, A] after reload.
|
|
const storedOrder = await page.evaluate(() => {
|
|
const raw = window.localStorage.getItem("buzz-communities");
|
|
if (!raw) return null;
|
|
const list = JSON.parse(raw) as Array<{ id: string }>;
|
|
return list.map((c) => c.id);
|
|
});
|
|
expect(storedOrder).toEqual([COMMUNITY_B.id, COMMUNITY_A.id]);
|
|
|
|
// DOM order must also be [B, A] after reload.
|
|
const reloadBoxA = await buttonA.boundingBox();
|
|
const reloadBoxB = await buttonB.boundingBox();
|
|
if (!reloadBoxA || !reloadBoxB)
|
|
throw new Error("community buttons not laid out after reload");
|
|
expect(reloadBoxB.y).toBeLessThan(reloadBoxA.y);
|
|
});
|
|
|
|
test("keyboard reorder: Space to pick up, ArrowUp to move, Space to drop updates stored order", async ({
|
|
page,
|
|
}) => {
|
|
await installMockBridge(page, undefined, { skipCommunitySeed: true });
|
|
await seedCommunities(page, [COMMUNITY_A, COMMUNITY_B], COMMUNITY_A.id);
|
|
await page.goto("/");
|
|
|
|
const buttonA = page.getByTestId(`community-rail-button-${COMMUNITY_A.id}`);
|
|
const buttonB = page.getByTestId(`community-rail-button-${COMMUNITY_B.id}`);
|
|
await expect(buttonA).toBeVisible();
|
|
await expect(buttonB).toBeVisible();
|
|
|
|
// Focus B (the second/lower item) and use keyboard to move it above A.
|
|
// Note: page.keyboard.press("Space") fires the button's native click on this
|
|
// Chromium build even when React's onKeyDown calls preventDefault — a CDP
|
|
// input-injection quirk. The synthetic dispatch below goes directly through
|
|
// React's event system where preventDefault correctly suppresses the click,
|
|
// while still exercising the real KeyboardSensor path (Thufir verified the
|
|
// test fails when KeyboardSensor is removed).
|
|
await buttonB.focus();
|
|
await page.evaluate((testId) => {
|
|
const el = document.querySelector(`[data-testid="${testId}"]`);
|
|
if (!el) throw new Error(`button not found: ${testId}`);
|
|
el.dispatchEvent(
|
|
new KeyboardEvent("keydown", {
|
|
key: " ",
|
|
code: "Space",
|
|
bubbles: true,
|
|
cancelable: true,
|
|
}),
|
|
);
|
|
}, `community-rail-button-${COMMUNITY_B.id}`);
|
|
// ArrowUp moves the active item one slot up.
|
|
await page.keyboard.press("ArrowUp");
|
|
// Space drops the item — same synthetic dispatch for consistency.
|
|
await page.evaluate((testId) => {
|
|
const el = document.querySelector(`[data-testid="${testId}"]`);
|
|
if (!el) throw new Error(`button not found: ${testId}`);
|
|
el.dispatchEvent(
|
|
new KeyboardEvent("keydown", {
|
|
key: " ",
|
|
code: "Space",
|
|
bubbles: true,
|
|
cancelable: true,
|
|
}),
|
|
);
|
|
}, `community-rail-button-${COMMUNITY_B.id}`);
|
|
|
|
// The community list in localStorage must now be [B, A].
|
|
await expect
|
|
.poll(() =>
|
|
page.evaluate(() => {
|
|
const raw = window.localStorage.getItem("buzz-communities");
|
|
if (!raw) return null;
|
|
const list = JSON.parse(raw) as Array<{ id: string }>;
|
|
return list.map((c) => c.id);
|
|
}),
|
|
)
|
|
.toEqual([COMMUNITY_B.id, COMMUNITY_A.id]);
|
|
});
|
|
});
|