Files
buzz/desktop/tests/e2e/channels.spec.ts
Taylor Ho ec11f8e2f3 fix(avatars): scale agent squircles from normalized paths (#7307)
## Overview

**Category:** fix

**User Impact:** Agent avatars and empty agent-team placeholders now
keep the same intentional squircle silhouette at every size on desktop,
while human avatars remain circular.

**Problem:** Desktop agent avatars used percentage-rounded rectangles,
which made the silhouette vary by surface and required each avatar
implementation to recreate the shape. Empty team placeholders also drew
a regular CSS border inside the clip, leaving straight inset edges that
looked like a cropped square. **Solution:** Reuse one normalized SVG
clip path across desktop artwork. Empty team placeholders use a
separately clipped squircle outline behind the clipped surface, so both
the fill and outline follow the canonical silhouette.

> **Scope correction:** Mobile is deferred from this PR because a real
Flutter capture could not be obtained reliably in the current build
environment. Previously attached mobile images were hand-composed
synthetic layouts, not captures from the running app; they have been
removed and must not be treated as validation. The mobile implementation
is being reverted in one follow-up commit so it can be reapplied and
verified separately.

### Related issue

Follow-up to #7106.

## Changes

<details>
<summary>File changes</summary>

**desktop/src/features/agents/ui/AgentCreationPreview.tsx**
Applies the shared squircle clip to agent creation preview images and
fallbacks.


**desktop/src/features/agents/ui/AgentSessionToolItem/CompactMessageSummary.tsx**
Uses the shared agent-avatar silhouette in compact session summaries.

**desktop/src/features/agents/ui/IdentityInitialsAvatar.tsx**
Clips agent initials with the normalized squircle instead of a
percentage radius.

**desktop/src/features/agents/ui/TeamIdentityCard.tsx**
Uses the shared squircle for agent identities and gives empty teams a
separately clipped squircle outline instead of a regular inset border.


**desktop/src/features/agents/ui/activityRenderClasses/UserMessageBubble.tsx**
Keeps agent avatars in activity message bubbles on the canonical
silhouette.

**desktop/src/features/home/ui/InboxListPane.tsx**
Applies the shared clip to agent avatars in the inbox list.

**desktop/src/features/home/ui/InboxMessageRow.tsx**
Applies the shared clip to agent avatars in inbox message rows.

**desktop/src/features/huddle/components/ParticipantList.tsx**
Uses the normalized squircle for agent participants while preserving
circular human avatars.

**desktop/src/features/messages/ui/MessageRow.tsx**
Clips agent message avatars through the shared definition.

**desktop/src/features/messages/ui/SystemMessageAvatars.tsx**
Uses the canonical agent shape in system-message avatar groups.

**desktop/src/features/messages/ui/TypingIndicatorRow.tsx**
Uses the canonical agent shape in typing indicators.

**desktop/src/features/profile/ui/ProfileAvatar.tsx**
Scales the same squircle to the larger agent profile avatar.

**desktop/src/features/profile/ui/SelectedRecipientChip.tsx**
Uses the shared shape for selected agent recipients.

**desktop/src/features/projects/ui/IssueAssigneesRow.tsx**
Applies the agent squircle to issue assignees without changing human
avatar geometry.

**desktop/src/features/projects/ui/ProjectCards.tsx**
Uses the canonical shape for agent avatars on project cards.

**desktop/src/features/projects/ui/ProjectEntityListRow.tsx**
Uses the canonical shape for agent identities in project entity lists.

**desktop/src/features/projects/ui/ProjectsActivityFeed.tsx**
Uses the shared clip for agent avatars in project activity.

**desktop/src/features/projects/ui/ProjectsOverviewRail.tsx**
Uses the shared clip for agent avatars in the project overview rail.

**desktop/src/main.tsx**
Mounts the single shared SVG clip-path definition once for the desktop
application.

**desktop/src/shared/lib/cn.ts**
Registers `rounded-squircle` in the border-radius class group so
shadcn-style `cn(...)` composition resolves it against `rounded-full` by
normal last-class precedence.

**desktop/src/shared/styles/globals/utilities.css**
Registers the reusable Tailwind `rounded-squircle` utility and
references the normalized SVG clip path without agent-specific CSS
selectors.

**desktop/src/shared/ui/AvatarClipPaths.tsx**
Owns the reusable normalized object-bounding-box `rounded-squircle` path
so all desktop sizes reuse one definition.

**desktop/src/shared/ui/UserAvatar.tsx**
Routes the existing squircle shape option through the shared SVG clip.

**desktop/tests/e2e/agents.spec.ts**
Verifies team facepile and empty-team placeholder surfaces/outlines use
the canonical clip without a rectangular border.

**desktop/tests/e2e/messaging.spec.ts**
Verifies agent message and profile avatars reuse one clip definition and
scale across rendered sizes.

</details>

## Reproduction steps

1. Run the desktop app and open a channel containing both agent and
human messages.
2. Compare agent avatars in the message list, inbox, huddle
participants, projects, and profile panel. Agent images and fallbacks
should share one smooth squircle silhouette at each size; human avatars
should remain circular.
3. Open **Agents** and inspect an empty agent-team card. Its placeholder
fill and outline should both be smooth squircles, with no straight inset
cropped-square edges.
4. Open an agent profile from a message and confirm the larger profile
avatar keeps the same proportions as the smaller message avatar.

## Testing

- Desktop Biome check passed for the changed empty-team component and
Agents E2E spec.
- Desktop typecheck and differential file-size check passed.
- Full desktop unit suite passed: 6,453 tests.
- Full Agents integration spec passed: 37/37, including the empty-team
outline regression.
- Post-rebase pre-push gates passed at `304b159a2`: desktop checks,
TypeScript typecheck, differential file-size gate, and all 6,453 desktop
unit tests.
- The shadcn-aligned `rounded-squircle` registration passed changed-file
Biome, desktop TypeScript/build, and pre-push desktop checks at
`27e0c12d3`; the full desktop unit suite passed 6,459/6,459.

## Screenshots

Screenshots are temporarily omitted. The previous captures used the
desktop E2E mock bridge and were not evidence from a live running app,
so they were removed.

---------

Signed-off-by: Taylor Ho <taylorkmho@gmail.com>
Co-authored-by: Carl <acda9e433d19dcd0e6b6840f7f4b98f3a56f1fab98049d444c087019e6d36560@buzz.block.builderlab.xyz>
Co-authored-by: Rizz <302abe414ca6e3134763d2539bfcf145aea2a63fe5f8455204ed602fd40cf381@buzz.block.builderlab.xyz>
2026-09-11 06:08:44 -10:00

5310 lines
178 KiB
TypeScript

import { expect, test } from "@playwright/test";
import {
KIND_HUDDLE_ENDED,
KIND_HUDDLE_STARTED,
KIND_TYPING_INDICATOR,
} from "../../src/shared/constants/kinds";
import { FEATURE_OVERRIDES_STORAGE_KEY } from "../helpers/features";
import {
TEST_IDENTITIES,
installMockBridge,
openChannelBrowser,
openCreateChannelDialog,
openNewMessagePage,
} from "../helpers/bridge";
const GENERAL_CHANNEL_ID = "9a1657ac-f7aa-5db0-b632-d8bbeb6dfb50";
const RANDOM_CHANNEL_ID = "9dae0116-799b-5071-a0a8-fdd30a91a35d";
const AGENTS_CHANNEL_ID = "94a444a4-c0a3-5966-ab05-530c6ddc2301";
const MOCK_IDENTITY_PUBKEY = "deadbeef".repeat(8);
const CACHED_PROFILE_LABELS_TAG = "@cached-profile-labels";
// Relay-only agent owned by the mock viewer (see e2eBridge.ts
// OWNED_RELAY_AGENT_PUBKEY). Classified as a bot via mockRelayAgents and
// owned-by-viewer via its mockProfiles owner_pubkey, so the sidebar
// view-activity gate (memberIsBot && viewerIsOwner) opens for it.
const OWNED_RELAY_AGENT_PUBKEY =
"a1b2c3d4e5f60718293a4b5c6d7e8f90112233445566778899aabbccddeeff00";
const DM_RELAY_AGENT_PUBKEY =
"eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee";
// Unnamed roster fixtures whose two plausible orders disagree (the e2e twin
// of the memberUtils unit pair): both keys share the `npub1qqq…` head, so
// the compact display labels order V5 first (`…2hcx` < `…2w5c`) while the
// full canonical npubs order V24 first (`…vq53…` < `…zsfj…`). Only the
// full-npub order is correct for the roster.
const UNNAMED_MEMBER_V5_PUBKEY =
"0000000000000000000000000000000000000000000000000000000000000005";
const UNNAMED_MEMBER_V24_PUBKEY =
"0000000000000000000000000000000000000000000000000000000000000018";
const NEW_DM_AGENT_PUBKEY = "f".repeat(64);
type MockFeedWindow = Window & {
__BUZZ_E2E_EMIT_MOCK_MESSAGE__?: (input: {
channelName: string;
content: string;
createdAt?: number;
id?: string;
kind?: number;
mentionPubkeys?: string[];
parentEventId?: string;
pubkey?: string;
}) => {
content: string;
created_at: number;
id: string;
kind: number;
pubkey: string;
tags: string[][];
};
__BUZZ_E2E_SEED_ACTIVE_TURNS__?: (input: {
agentPubkey: string;
channelId: string;
turnId: string;
kind?: "turn_started" | "turn_completed";
}) => void;
__BUZZ_E2E_PUSH_MOCK_FEED_ITEM__?: (item: {
category: "mention" | "needs_action" | "activity" | "agent_activity";
channel_id: string | null;
channel_name: string;
channel_type?: string | null;
content: string;
created_at: number;
id: string;
kind: number;
pubkey: string;
tags: string[][];
}) => unknown;
};
async function hasOutgoingEventWithContent(
page: import("@playwright/test").Page,
content: string,
) {
return page.evaluate((expectedContent) => {
return (
(
window as Window & {
__BUZZ_E2E_COMMAND_LOG__?: Array<{
command: string;
payload: unknown;
}>;
}
).__BUZZ_E2E_COMMAND_LOG__?.some((entry) => {
if (entry.command !== "plugin:websocket|send") {
return false;
}
const data = (
entry.payload as {
message?: { data?: string; type?: string };
}
)?.message?.data;
if (!data) {
return false;
}
try {
const frame = JSON.parse(data) as unknown[];
const event = frame[1] as { content?: unknown } | undefined;
return frame[0] === "EVENT" && event?.content === expectedContent;
} catch {
return false;
}
}) ?? false
);
}, content);
}
async function readOutgoingChannelId(
page: import("@playwright/test").Page,
content: string,
) {
return page.evaluate((expectedContent) => {
const entries =
(
window as Window & {
__BUZZ_E2E_COMMAND_LOG__?: Array<{
command: string;
payload: unknown;
}>;
}
).__BUZZ_E2E_COMMAND_LOG__ ?? [];
for (const entry of entries) {
if (entry.command !== "plugin:websocket|send") {
continue;
}
const data = (
entry.payload as { message?: { data?: string } } | undefined
)?.message?.data;
if (!data) {
continue;
}
try {
const frame = JSON.parse(data) as [
string,
{ content?: string; tags?: string[][] },
];
if (
frame[0] !== "EVENT" ||
!frame[1]?.content?.includes(expectedContent)
) {
continue;
}
return frame[1].tags?.find((tag) => tag[0] === "h")?.[1] ?? null;
} catch {}
}
return null;
}, content);
}
async function openChannelManagement(
page: import("@playwright/test").Page,
channelName: string,
) {
await page.getByTestId(`channel-${channelName}`).click();
await expect(page.getByTestId("chat-title")).toHaveText(channelName);
await page.getByTestId("channel-management-trigger").click();
await expect(page.getByTestId("channel-management-sheet")).toBeVisible();
}
async function closeChannelManagement(page: import("@playwright/test").Page) {
await page.getByTestId("auxiliary-panel-close").click();
await expect(page.getByTestId("channel-management-sheet")).not.toBeVisible();
}
async function openChannelEditDialog(page: import("@playwright/test").Page) {
await page.getByTestId("channel-management-edit").click();
await expect(
page.getByRole("dialog", {
name: /Edit (?:public|private) channel/,
}),
).toBeVisible();
}
async function openMembersSidebar(
page: import("@playwright/test").Page,
channelName: string,
) {
await page.getByTestId(`channel-${channelName}`).click();
await expect(page.getByTestId("chat-title")).toHaveText(channelName);
await page.getByTestId("channel-members-trigger").click();
await expect(page.getByTestId("members-sidebar")).toBeVisible();
}
async function readMembersTriggerCount(page: import("@playwright/test").Page) {
const label =
(await page
.getByTestId("channel-members-trigger")
.getAttribute("aria-label")) ?? "";
const match = label.match(/\((\d+)\)$/);
if (!match) {
throw new Error(`Could not read member count from label: ${label}`);
}
return Number(match[1]);
}
async function expectMembersTriggerCount(
page: import("@playwright/test").Page,
count: number,
) {
await expect(page.getByTestId("channel-members-trigger")).toHaveAttribute(
"aria-label",
`View channel members (${count})`,
);
}
async function waitForMockLiveSubscription(
page: import("@playwright/test").Page,
channelName: string,
kind?: number,
) {
await expect
.poll(async () => {
return page.evaluate(
({ currentChannelName, kind }) => {
return (
(
window as Window & {
__BUZZ_E2E_HAS_MOCK_LIVE_SUBSCRIPTION__?: (input: {
channelName: string;
kind?: number;
}) => boolean;
}
).__BUZZ_E2E_HAS_MOCK_LIVE_SUBSCRIPTION__?.({
channelName: currentChannelName,
kind,
}) ?? false
);
},
{ currentChannelName: channelName, kind },
);
})
.toBe(true);
}
async function openMemberMenu(
page: import("@playwright/test").Page,
pubkey: string,
) {
const row = page.getByTestId(`sidebar-member-${pubkey}`);
const trigger = page.getByTestId(`sidebar-member-menu-${pubkey}`);
await row.scrollIntoViewIfNeeded();
await row.hover();
// Workaround: @radix-ui/react-dropdown-menu@2.1.16 ignores pointer-based
// re-opens after a menu item click (onCloseAutoFocus race). Opening via
// keyboard (focus + Enter) is reliable. Revisit if Radix fixes this.
await trigger.focus();
await trigger.press("Enter");
await expect(trigger).toHaveAttribute("data-state", "open");
}
async function addGenericAgent(
page: import("@playwright/test").Page,
channelName: string,
agentName: string,
): Promise<string> {
await page.getByTestId(`channel-${channelName}`).click();
await expect(page.getByTestId("chat-title")).toHaveText(channelName);
const channelId = await page
.getByTestId(`channel-${channelName}`)
.getAttribute("data-channel-id");
if (!channelId) {
throw new Error(`Channel ${channelName} is missing a data-channel-id.`);
}
await page.waitForFunction(() => {
return Boolean(
(
window as Window & {
__BUZZ_E2E_INVOKE_MOCK_COMMAND__?: unknown;
}
).__BUZZ_E2E_INVOKE_MOCK_COMMAND__,
);
});
return page.evaluate(
async ({ agentName, channelId }) => {
const invoke = (
window as Window & {
__BUZZ_E2E_INVOKE_MOCK_COMMAND__?: (
command: string,
payload?: Record<string, unknown>,
) => Promise<{ agent?: { pubkey: string } }>;
}
).__BUZZ_E2E_INVOKE_MOCK_COMMAND__;
if (!invoke) {
throw new Error("Mock bridge is not installed.");
}
const created = await invoke("create_managed_agent", {
input: {
name: agentName,
spawnAfterCreate: true,
systemPrompt: "Watch the channel and help when asked.",
},
});
const pubkey = created.agent?.pubkey;
if (!pubkey) {
throw new Error("Mock managed agent creation did not return a pubkey.");
}
await invoke("add_channel_members", {
channelId,
pubkeys: [pubkey],
role: "bot",
});
await (
window as Window & {
__BUZZ_E2E_QUERY_CLIENT__?: {
invalidateQueries: () => Promise<void>;
};
}
).__BUZZ_E2E_QUERY_CLIENT__?.invalidateQueries();
return pubkey;
},
{ agentName, channelId },
);
}
async function readCommandLog(page: import("@playwright/test").Page) {
return page.evaluate(() => {
return (
(window as Window & { __BUZZ_E2E_COMMANDS__?: string[] })
.__BUZZ_E2E_COMMANDS__ ?? []
);
});
}
function commandCount(commands: string[], command: string) {
return commands.filter((entry) => entry === command).length;
}
async function readCommandPayloadLog(page: import("@playwright/test").Page) {
return page.evaluate(() => {
return (
(
window as Window & {
__BUZZ_E2E_COMMAND_LOG__?: Array<{
command: string;
payload: unknown;
}>;
}
).__BUZZ_E2E_COMMAND_LOG__ ?? []
);
});
}
async function invokeMockCommand<T>(
page: import("@playwright/test").Page,
command: string,
payload?: Record<string, unknown>,
) {
await page.waitForFunction(() => {
return Boolean(
(
window as Window & {
__BUZZ_E2E_INVOKE_MOCK_COMMAND__?: unknown;
}
).__BUZZ_E2E_INVOKE_MOCK_COMMAND__,
);
});
return page.evaluate(
async ({
command,
payload,
}: {
command: string;
payload?: Record<string, unknown>;
}) => {
const invoke = (
window as Window & {
__BUZZ_E2E_INVOKE_MOCK_COMMAND__?: (
command: string,
payload?: Record<string, unknown>,
) => Promise<unknown>;
}
).__BUZZ_E2E_INVOKE_MOCK_COMMAND__;
if (!invoke) {
throw new Error("Mock bridge is not installed.");
}
return invoke(command, payload);
},
{ command, payload },
) as Promise<T>;
}
async function expectSameLeftInset(
page: import("@playwright/test").Page,
firstTestId: string,
secondTestId: string,
) {
const firstBox = await page.getByTestId(firstTestId).boundingBox();
const secondBox = await page.getByTestId(secondTestId).first().boundingBox();
if (!firstBox || !secondBox) {
throw new Error(`Could not measure ${firstTestId} against ${secondTestId}`);
}
expect(Math.abs(firstBox.x - secondBox.x)).toBeLessThanOrEqual(4);
}
async function expectIntroSpacedAboveDayDivider(
page: import("@playwright/test").Page,
introTestId: string,
) {
const introBox = await page.getByTestId(introTestId).boundingBox();
const dividerBox = await page
.getByTestId("message-timeline-day-divider")
.first()
.boundingBox();
const messageBox = await page
.getByTestId("message-row")
.first()
.boundingBox();
if (!introBox || !dividerBox || !messageBox) {
throw new Error(`Could not measure timeline spacing for ${introTestId}`);
}
const gapAboveDivider = dividerBox.y - (introBox.y + introBox.height);
const gapBelowDivider = messageBox.y - (dividerBox.y + dividerBox.height);
// The intro is a flex sibling above the timeline; the day divider and first
// message-row are virtualized items positioned by translateY inside the
// scroll container. The intro -> divider gap is the wrapper flex spacing the
// layout controls (8px, stable), so guard THAT with a tight band — a layout
// regression that collapses or balloons it fails here. The divider -> message
// gap is NOT a layout-spacing contract: virtualized rows are positioned
// back-to-back (no inter-item gap), so it is ~0 by construction plus
// MessageRow avatar/font render jitter, genuinely variable run-to-run. Assert
// only non-overlap on it (reading order: intro, divider, then message).
expect(Math.abs(gapAboveDivider - 8)).toBeLessThanOrEqual(2);
expect(gapBelowDivider).toBeGreaterThanOrEqual(0);
}
async function expectIntroActionCardLayout(
page: import("@playwright/test").Page,
actionTestId: string,
) {
const actionBox = await page.getByTestId(actionTestId).boundingBox();
const iconBox = await page.getByTestId(`${actionTestId}-icon`).boundingBox();
if (!actionBox || !iconBox) {
throw new Error(`Could not measure intro action card: ${actionTestId}`);
}
expect(actionBox.height).toBeGreaterThan(actionBox.width);
expect(Math.round(actionBox.width)).toBe(192);
expect(Math.round(iconBox.width)).toBe(40);
expect(Math.round(iconBox.height)).toBe(40);
const introIconRadius = await page
.getByTestId("message-channel-intro-icon")
.evaluate((element) => window.getComputedStyle(element).borderRadius);
const actionRadius = await page
.getByTestId(actionTestId)
.evaluate((element) => window.getComputedStyle(element).borderRadius);
expect(actionRadius).toBe(introIconRadius);
await expect(page.getByTestId(`${actionTestId}-title`)).toHaveCSS(
"white-space",
"normal",
);
await expect(page.getByTestId(`${actionTestId}-description`)).toHaveCSS(
"white-space",
"normal",
);
}
async function expectIntroActionsShareRow(
page: import("@playwright/test").Page,
actionTestIds: string[],
) {
const boxes = await Promise.all(
actionTestIds.map((testId) => page.getByTestId(testId).boundingBox()),
);
const measuredBoxes = boxes.filter(
(box): box is NonNullable<typeof box> => box !== null,
);
const [firstBox] = measuredBoxes;
if (!firstBox || measuredBoxes.length !== actionTestIds.length) {
throw new Error("Could not measure intro action row");
}
for (const box of measuredBoxes.slice(1)) {
expect(Math.abs(firstBox.y - box.y)).toBeLessThanOrEqual(1);
}
}
test.beforeEach(async ({ page }, testInfo) => {
await installMockBridge(
page,
testInfo.tags.includes(CACHED_PROFILE_LABELS_TAG)
? { usersBatchDelayMs: 10_000 }
: undefined,
);
});
test("sidebar shows all channel types", async ({ page }) => {
await page.goto("/");
await expect(page.getByTestId("app-sidebar")).toBeVisible();
await expect(page.getByTestId("sidebar-agents-count")).toHaveCount(0);
// Streams
const streamList = page.getByTestId("stream-list");
await expect(streamList).toContainText("general");
await expect(streamList).toContainText("random");
await expect(streamList).toContainText("engineering");
await expect(streamList).toContainText("agents");
// Forums
const forumList = page.getByTestId("forum-list");
await expect(forumList).toContainText("watercooler");
await expect(forumList).toContainText("announcements");
// DMs
const dmList = page.getByTestId("dm-list");
await expect(dmList).toContainText("alice-tyler");
await expect(dmList).toContainText("bob-tyler");
});
test("shows cached profile labels while relay profiles revalidate", {
tag: CACHED_PROFILE_LABELS_TAG,
}, async ({ page }) => {
await page.addInitScript(
({ alicePubkey }) => {
window.localStorage.setItem(
"buzz-user-labels.v1:ws://localhost:3000",
JSON.stringify({
version: 1,
updatedAt: Date.now(),
profiles: {
[alicePubkey]: {
displayName: "Cached Alice",
name: "alice",
nip05Handle: null,
},
},
}),
);
},
{ alicePubkey: TEST_IDENTITIES.alice.pubkey },
);
await page.goto("/");
await page.getByTestId("channel-general").click();
const aliceMessage = page
.getByTestId("message-row")
.filter({ hasText: "Hey team — checking in." });
await expect(aliceMessage.getByTestId("message-author")).toHaveText(
"Cached Alice",
{ timeout: 1_000 },
);
});
test("shows presence in sidebar, DM header, and member list", async ({
page,
}) => {
await page.goto("/");
await expect(page.getByTestId("sidebar-profile-card")).toBeVisible();
await expect(page.getByTestId("self-presence-badge")).toHaveAttribute(
"aria-label",
"Online",
);
await expect(page.getByTestId("channel-presence-alice-tyler")).toBeVisible();
const dmAvatarMask = page.getByTestId("channel-avatar-alice-tyler-mask");
await expect(dmAvatarMask).toHaveCSS("border-radius", "0px");
await expect(dmAvatarMask).toHaveCSS("clip-path", /polygon\(/);
await expect
.poll(() =>
dmAvatarMask.evaluate(
(element) => getComputedStyle(element).clipPath.split(",").length,
),
)
.toBeGreaterThan(100);
await page.getByTestId("channel-alice-tyler").click();
await expect(page.getByTestId("chat-title")).toHaveText("alice-tyler");
await expect(page.getByTestId("chat-presence-badge")).toContainText("Online");
await openMembersSidebar(page, "general");
await expect(
page.getByTestId(`sidebar-member-presence-${TEST_IDENTITIES.alice.pubkey}`),
).toBeVisible();
await expect(
page.getByTestId(`sidebar-member-presence-${TEST_IDENTITIES.bob.pubkey}`),
).toBeVisible();
await page.keyboard.press("Escape");
await expect(page.getByTestId("members-sidebar")).not.toBeVisible();
});
test("start a new direct message from the sidebar", async ({ page }) => {
await installMockBridge(page, {
managedAgents: [
{
pubkey: NEW_DM_AGENT_PUBKEY,
name: "Squircle Agent",
status: "stopped",
},
],
searchProfiles: [
{
pubkey: NEW_DM_AGENT_PUBKEY,
displayName: "Squircle Agent",
isAgent: true,
},
],
});
await page.goto("/");
await openNewMessagePage(page);
await expect(page.getByTestId("new-message-page")).toBeVisible();
await expect(page.getByTestId("message-composer")).toBeVisible();
await expect(page.getByTestId("new-message-recipient-popover")).toBeVisible();
await expect(page.getByTestId("new-message-body")).toBeEmpty();
await page.getByTestId("new-dm-search").fill("charlie");
await expect(
page.getByTestId(`new-dm-result-${TEST_IDENTITIES.charlie.pubkey}`),
).toBeVisible();
await page.keyboard.press("Enter");
await expect(
page.getByTestId(`new-dm-selected-${TEST_IDENTITIES.charlie.pubkey}`),
).toBeVisible();
await expect(page.getByTestId("new-dm-search")).toHaveValue("");
const selectedCharlie = page.getByTestId(
`new-dm-selected-${TEST_IDENTITIES.charlie.pubkey}`,
);
await selectedCharlie.hover();
const selectedCharlieBox = await selectedCharlie.boundingBox();
expect(selectedCharlieBox).not.toBeNull();
if (!selectedCharlieBox) return;
await page.mouse.move(
selectedCharlieBox.x + selectedCharlieBox.width / 2,
selectedCharlieBox.y + selectedCharlieBox.height / 2,
);
await page.mouse.down();
await expect(page.locator(".buzz-poof-burst")).toHaveCount(1);
await page.mouse.up();
await expect(selectedCharlie).not.toBeVisible();
await page.getByTestId("new-dm-search").fill("charlie");
await expect(
page.getByTestId(`new-dm-result-${TEST_IDENTITIES.charlie.pubkey}`),
).toBeVisible();
await page
.getByTestId(`new-dm-result-${TEST_IDENTITIES.charlie.pubkey}`)
.click();
await expect(page.getByTestId("new-dm-search")).toHaveValue("");
await expect(
page.getByTestId(`new-dm-selected-${TEST_IDENTITIES.charlie.pubkey}`),
).toBeVisible();
await page.getByTestId("new-dm-search").fill("squircle agent");
await page.getByTestId(`new-dm-result-${NEW_DM_AGENT_PUBKEY}`).click();
const selectedAgent = page.getByTestId(
`new-dm-selected-${NEW_DM_AGENT_PUBKEY}`,
);
await expect(selectedAgent).toBeVisible();
await selectedAgent.focus();
await expect(selectedAgent).toBeFocused();
await expect(selectedAgent).toHaveCSS("clip-path", "none");
await expect(selectedAgent).not.toHaveClass(/rounded-squircle/);
await expect(
selectedAgent.locator("span.absolute[data-avatar-shape='squircle']"),
).toHaveCSS("clip-path", /url\(["']?#rounded-squircle-clip["']?\)/);
await page.getByTestId("message-input").fill("Hello charlie");
await page.getByTestId("send-message").click();
await expect(page.getByTestId("dm-list")).toContainText("charlie");
await expect(page.getByTestId("chat-title")).toHaveText(
"charlie, Squircle Agent",
);
await expect(page.getByTestId("section-actions-dms")).not.toBeFocused();
});
test("keeps typing focus while arrow keys traverse and select DM recipients", async ({
page,
}) => {
await page.goto("/");
await openNewMessagePage(page);
const search = page.getByTestId("new-dm-search");
await expect(search).toBeFocused();
const initialActiveDescendant = await search.getAttribute(
"aria-activedescendant",
);
expect(initialActiveDescendant).toMatch(/^new-dm-option-/);
await search.press("Enter");
await expect(
page.locator("button[data-testid^='new-dm-selected-']"),
).toHaveCount(1);
await expect(page.getByTestId("new-message-recipient-popover")).toBeVisible();
await search.fill("o");
await expect(
page.getByTestId(`new-dm-result-${TEST_IDENTITIES.bob.pubkey}`),
).toBeVisible();
await expect(
page.getByTestId(`new-dm-result-${TEST_IDENTITIES.outsider.pubkey}`),
).toBeVisible();
await expect(search).toHaveAttribute(
"aria-activedescendant",
`new-dm-option-${TEST_IDENTITIES.outsider.pubkey}`,
);
await expect(
page.locator(`#new-dm-option-${TEST_IDENTITIES.outsider.pubkey}`),
).toHaveAttribute("aria-selected", "true");
await search.press("ArrowDown");
await expect(search).toBeFocused();
await expect(search).toHaveAttribute(
"aria-activedescendant",
`new-dm-option-${TEST_IDENTITIES.bob.pubkey}`,
);
await search.press("ArrowUp");
await expect(search).toHaveAttribute(
"aria-activedescendant",
`new-dm-option-${TEST_IDENTITIES.outsider.pubkey}`,
);
await search.press("Enter");
await expect(search).toBeFocused();
await expect(search).toHaveValue("");
await expect(search).toHaveAttribute("aria-activedescendant", /.+/);
await expect(
page.getByTestId(`new-dm-selected-${TEST_IDENTITIES.outsider.pubkey}`),
).toBeVisible();
await expect(page.getByTestId("new-message-recipient-popover")).toBeVisible();
});
test("sends the first message from the new direct message composer", async ({
page,
}) => {
await page.goto("/");
await openNewMessagePage(page);
await page.getByTestId("new-dm-search").fill("charlie");
await page
.getByTestId(`new-dm-result-${TEST_IDENTITIES.charlie.pubkey}`)
.click();
const message = "First message from the new conversation";
await page.getByTestId("message-input").fill(message);
await page.getByTestId("send-message").click();
await expect(page.getByTestId("chat-title")).toHaveText("charlie");
await expect(page.getByTestId("message-timeline")).toContainText(message);
});
test("creates the DM before preparing a persona mention", async ({ page }) => {
await installMockBridge(page, {
activePersonaIds: ["builtin:fizz"],
createManagedAgentDelayMs: 1_000,
});
await page.goto("/");
await openNewMessagePage(page);
await page.getByTestId("new-dm-search").fill("charlie");
await page
.getByTestId(`new-dm-result-${TEST_IDENTITIES.charlie.pubkey}`)
.click();
const input = page.getByTestId("message-input");
await input.fill("Ask @fi");
await expect(
page
.getByTestId("message-composer")
.getByTestId("mention-autocomplete")
.locator("button", { hasText: "Fizz" }),
).toBeVisible();
await input.press("Enter");
await page.keyboard.type(" for a hand");
const baselineCommands = await readCommandLog(page);
const baselineCommandPayloads = await readCommandPayloadLog(page);
const baselineOpenDmCount = commandCount(baselineCommands, "open_dm");
const baselineCreateCount = commandCount(
baselineCommands,
"create_managed_agent",
);
await page.getByTestId("send-message").click();
await expect
.poll(async () => commandCount(await readCommandLog(page), "open_dm"))
.toBeGreaterThan(baselineOpenDmCount);
await expect(
page.getByTestId(`new-dm-selected-${TEST_IDENTITIES.charlie.pubkey}`),
).toBeDisabled();
await expect(page.getByTestId("new-dm-search")).toBeDisabled();
await expect
.poll(async () =>
commandCount(await readCommandLog(page), "create_managed_agent"),
)
.toBeGreaterThan(baselineCreateCount);
await expect(page.getByTestId("chat-title")).toContainText("charlie");
await expect(page.getByTestId("chat-title")).toContainText("Fizz");
// Assert popover hidden after chat-title settles — by this point the send
// flow has completed and the UI has fully transitioned away from the popover.
await expect(page.getByTestId("new-message-recipient-popover")).toBeHidden();
const sendCommands = (await readCommandLog(page)).slice(
baselineCommands.length,
);
const sendCommandPayloads = (await readCommandPayloadLog(page)).slice(
baselineCommandPayloads.length,
);
expect(commandCount(sendCommands, "open_dm")).toBe(2);
const firstOpenIndex = sendCommands.indexOf("open_dm");
const createIndex = sendCommands.indexOf("create_managed_agent");
const expandedOpenIndex = sendCommands.lastIndexOf("open_dm");
const startIndex = sendCommands.indexOf("start_managed_agent");
expect(firstOpenIndex).toBeLessThan(createIndex);
expect(createIndex).toBeLessThan(expandedOpenIndex);
expect(expandedOpenIndex).toBeLessThan(startIndex);
expect(sendCommands).not.toContain("add_channel_members");
const sentMessageCommand = sendCommandPayloads.find(
(entry) =>
entry.command === "send_channel_message" &&
(
entry.payload as { content?: string; channelId?: string } | undefined
)?.content?.includes("for a hand"),
);
const sentChannelId = (
sentMessageCommand?.payload as
| { content?: string; channelId?: string }
| undefined
)?.channelId;
expect(sentChannelId).toBeTruthy();
await expect(
page.locator("[data-active='true'][data-channel-id]"),
).toHaveAttribute("data-channel-id", sentChannelId ?? "");
await expect(page.getByTestId("message-timeline")).toContainText(
"for a hand",
);
await expect(
page
.getByTestId("message-row")
.last()
.locator("[data-mention].agent-mention-highlight", { hasText: "Fizz" }),
).toBeVisible();
});
test("routes an agent mention from an existing DM to the expanded conversation", async ({
page,
}) => {
// Delay persona provisioning so the follow-up expanded-DM open/start sequence
// cannot collapse into the same fast CI tick before assertions observe it.
await installMockBridge(page, {
activePersonaIds: ["builtin:fizz"],
createManagedAgentDelayMs: 100,
});
await page.goto("/");
const sourceDm = page.getByTestId("channel-alice-tyler");
const sourceDmId = await sourceDm.getAttribute("data-channel-id");
expect(sourceDmId).toBeTruthy();
await sourceDm.click();
await expect(page.getByTestId("chat-title")).toHaveText("alice-tyler");
const messageTail = "in this DM";
const input = page.getByTestId("message-input");
await input.fill("Ask @fi");
await expect(
page
.getByTestId("message-composer")
.getByTestId("mention-autocomplete")
.locator("button", { hasText: "Fizz" }),
).toBeVisible();
await input.press("Enter");
await page.keyboard.type(" in this DM");
const baselineCommands = await readCommandPayloadLog(page);
await page.getByTestId("send-message").click();
await expect
.poll(async () => readOutgoingChannelId(page, messageTail))
.not.toBeNull();
const sentChannelId = await readOutgoingChannelId(page, messageTail);
expect(sentChannelId).not.toBe(sourceDmId);
await expect(
page.locator("[data-active='true'][data-channel-id]"),
).toHaveAttribute("data-channel-id", sentChannelId ?? "");
await expect(page.getByTestId("chat-title")).toContainText("alice");
await expect(page.getByTestId("chat-title")).toContainText("Fizz");
await expect(sourceDm).not.toContainText("Fizz");
const sendCommands = (await readCommandPayloadLog(page)).slice(
baselineCommands.length,
);
expect(sendCommands).not.toEqual(
expect.arrayContaining([
expect.objectContaining({
command: "add_channel_members",
payload: expect.objectContaining({ channelId: sourceDmId }),
}),
]),
);
expect(sendCommands.map((entry) => entry.command)).toEqual(
expect.arrayContaining(["open_dm", "start_managed_agent"]),
);
});
test("routes a managed relay-agent mention from an existing DM to the expanded conversation", async ({
page,
}) => {
// Delay the expanded open_dm call so routing/navigation settles
// deterministically under fast CI execution.
await installMockBridge(page, {
openDmDelayMs: 100,
managedAgents: [
{
pubkey: DM_RELAY_AGENT_PUBKEY,
name: "quinn",
status: "stopped",
},
],
relayAgents: [
{
pubkey: DM_RELAY_AGENT_PUBKEY,
name: "quinn",
respondTo: "allowlist",
respondToAllowlist: [MOCK_IDENTITY_PUBKEY],
},
],
});
await page.goto("/");
const sourceDm = page.getByTestId("channel-alice-tyler");
const sourceDmId = await sourceDm.getAttribute("data-channel-id");
expect(sourceDmId).toBeTruthy();
await sourceDm.click();
await expect(page.getByTestId("chat-title")).toHaveText("alice-tyler");
const messageTail = "with the relay agent";
const input = page.getByTestId("message-input");
await input.fill("Ask @qui");
await expect(
page
.getByTestId("message-composer")
.getByTestId("mention-autocomplete")
.locator("button", { hasText: "quinn" }),
).toBeVisible();
await input.press("Enter");
await page.keyboard.type(` ${messageTail}`);
const baselineCommands = await readCommandPayloadLog(page);
await page.getByTestId("send-message").click();
await expect
.poll(async () => readOutgoingChannelId(page, messageTail))
.not.toBeNull();
const sentChannelId = await readOutgoingChannelId(page, messageTail);
expect(sentChannelId).not.toBe(sourceDmId);
await expect(
page.locator("[data-active='true'][data-channel-id]"),
).toHaveAttribute("data-channel-id", sentChannelId ?? "");
await expect(page.getByTestId("chat-title")).toContainText("alice");
await expect(page.getByTestId("chat-title")).toContainText("quinn");
const sendCommands = (await readCommandPayloadLog(page)).slice(
baselineCommands.length,
);
expect(sendCommands.map((entry) => entry.command)).toContain("open_dm");
expect(sendCommands.map((entry) => entry.command)).toContain(
"start_managed_agent",
);
expect(sendCommands.map((entry) => entry.command)).not.toContain(
"add_channel_members",
);
// The awaited DM expansion is a relay round-trip between the
// pre-side-effect authorization pass and the publish, so the publish
// boundary re-validates instead of reusing the earlier pass.
expect(
sendCommands.filter((entry) => entry.command === "revalidate_relay_agents")
.length,
).toBe(2);
});
test("does not reroute an expanded DM after the user navigates away", async ({
page,
}) => {
await installMockBridge(page, {
activePersonaIds: ["builtin:fizz"],
sendMessageDelayMs: 1_000,
});
await page.goto("/");
await page.getByTestId("channel-alice-tyler").click();
await expect(page.getByTestId("chat-title")).toHaveText("alice-tyler");
const input = page.getByTestId("message-input");
await input.fill("Ask @fi");
await expect(
page
.getByTestId("message-composer")
.getByTestId("mention-autocomplete")
.locator("button", { hasText: "Fizz" }),
).toBeVisible();
await input.press("Enter");
await page.keyboard.type(" while I leave");
await page.getByTestId("send-message").click();
await page.getByTestId("channel-general").click();
await expect(page.getByTestId("chat-title")).toHaveText("general");
await page.waitForTimeout(1_250);
await expect(page).toHaveURL(
new RegExp(`/channels/${GENERAL_CHANNEL_ID}(?:\\?|$)`),
);
await expect(page.getByTestId("chat-title")).toHaveText("general");
});
test("does not reroute an expanded DM after the channel pane unmounts", async ({
page,
}) => {
await installMockBridge(page, {
activePersonaIds: ["builtin:fizz"],
openDmDelayMs: 1_000,
});
await page.goto("/");
await page.getByTestId("channel-alice-tyler").click();
await expect(page.getByTestId("chat-title")).toHaveText("alice-tyler");
const input = page.getByTestId("message-input");
await input.fill("Ask @fi");
await expect(
page
.getByTestId("message-composer")
.getByTestId("mention-autocomplete")
.locator("button", { hasText: "Fizz" }),
).toBeVisible();
await input.press("Enter");
await page.keyboard.type(" while I open settings");
await page.getByTestId("send-message").click();
await page.getByTestId("open-settings").click();
await page.getByTestId("profile-popover-settings").click();
await expect(page.getByTestId("settings-view")).toBeVisible();
await page.waitForTimeout(1_250);
await expect(page.getByTestId("settings-view")).toBeVisible();
await expect
.poll(async () => readOutgoingChannelId(page, "while I open settings"))
.toBeNull();
});
test("drops an expanded DM after the first message fails", async ({ page }) => {
const retryMessage = "Retry without the agent";
const sendError = "Mock first DM send failed.";
// Delay persona provisioning so the follow-up expanded-DM open/start sequence
// cannot collapse into the same fast CI tick before assertions observe it.
await installMockBridge(page, {
activePersonaIds: ["builtin:fizz"],
createManagedAgentDelayMs: 100,
sendMessageErrors: [sendError],
});
await page.goto("/");
await openNewMessagePage(page);
await page.getByTestId("new-dm-search").fill("charlie");
await page
.getByTestId(`new-dm-result-${TEST_IDENTITIES.charlie.pubkey}`)
.click();
const input = page.getByTestId("message-input");
await input.fill("Ask @fi");
await expect(
page
.getByTestId("message-composer")
.getByTestId("mention-autocomplete")
.locator("button", { hasText: "Fizz" }),
).toBeVisible();
await input.press("Enter");
await page.keyboard.type(" for a hand");
await page.getByTestId("send-message").click();
await expect(
page.getByTestId("new-message-page").getByText(sendError, { exact: true }),
).toBeVisible();
const sendErrorToast = page
.locator("[data-sonner-toast]")
.filter({ hasText: `Message failed to send: ${sendError}` });
await expect(sendErrorToast).toBeVisible();
await expect(input).toContainText("Fizz");
const commandsAfterFailure = await readCommandPayloadLog(page);
const failedSendChannelId = (
commandsAfterFailure.find(
(entry) =>
entry.command === "send_channel_message" &&
(
entry.payload as { content?: string; channelId?: string } | undefined
)?.content?.includes("for a hand"),
)?.payload as { content?: string; channelId?: string } | undefined
)?.channelId;
expect(failedSendChannelId).toBeTruthy();
expect(commandsAfterFailure.map((entry) => entry.command)).not.toContain(
"add_channel_members",
);
const baselineOpenDmCount = commandCount(
commandsAfterFailure.map((entry) => entry.command),
"open_dm",
);
// fill() does not move the pointer off Send, where the error toast appears.
// Sonner pauses dismissal on hover; move back to the editor and observe the
// toast's normal expiry before retrying the covered button.
await input.hover();
await expect(sendErrorToast).toHaveCount(0, { timeout: 10_000 });
await input.fill(retryMessage);
const retryBaseline = commandsAfterFailure.length;
await page.getByTestId("send-message").click();
await expect(page.getByTestId("chat-title")).toHaveText("charlie");
await expect(page.getByTestId("message-timeline")).toContainText(
retryMessage,
);
const allCommands = await readCommandPayloadLog(page);
expect(
commandCount(
allCommands.map((entry) => entry.command),
"open_dm",
),
).toBe(baselineOpenDmCount + 1);
const retryCommands = allCommands.slice(retryBaseline);
const retrySend = retryCommands.find(
(entry) =>
entry.command === "send_channel_message" &&
(entry.payload as { content?: string; channelId?: string } | undefined)
?.content === retryMessage,
);
const retryChannelId = (
retrySend?.payload as { content?: string; channelId?: string } | undefined
)?.channelId;
expect(retryChannelId).toBeTruthy();
expect(retryChannelId).not.toBe(failedSendChannelId);
await expect(
page.locator("[data-active='true'][data-channel-id]"),
).toHaveAttribute("data-channel-id", retryChannelId ?? "");
});
test("publishes into an expanded DM even when agent startup fails", async ({
page,
}) => {
// Agent starts are detached from the send: the message publishes into the
// expanded DM and the start failure surfaces as a post-send toast. Failures
// that still block the publish (and drop the expanded DM) are covered by
// the preceding "drops an expanded DM after the first message fails" spec.
const startError = "Mock agent startup failed.";
await installMockBridge(page, {
activePersonaIds: ["builtin:fizz"],
startManagedAgentErrors: [startError],
});
await page.goto("/");
await openNewMessagePage(page);
await page.getByTestId("new-dm-search").fill("charlie");
await page
.getByTestId(`new-dm-result-${TEST_IDENTITIES.charlie.pubkey}`)
.click();
const input = page.getByTestId("message-input");
await input.fill("Ask @fi");
await expect(
page
.getByTestId("message-composer")
.getByTestId("mention-autocomplete")
.locator("button", { hasText: "Fizz" }),
).toBeVisible();
await input.press("Enter");
await page.keyboard.type(" before startup fails");
await page.getByTestId("send-message").click();
// The message lands in the expanded DM despite the failed start.
await expect(page.getByTestId("chat-title")).toContainText("Fizz");
await expect(page.getByTestId("message-timeline")).toContainText(
"before startup fails",
);
// The start failure surfaces as a toast, and the sent text is not restored
// into the composer — the send succeeded, so there is nothing to retry.
// (The persistent agent audience may legitimately re-seed a "@Fizz"
// auto-mention, so only the message body proves there was no restore.)
await expect(
page.getByText(startError, { exact: false }).first(),
).toBeVisible();
await expect(input).not.toContainText("before startup fails");
const commands = await readCommandPayloadLog(page);
const lastOpenDm = commands
.filter((entry) => entry.command === "open_dm")
.at(-1);
const openDmPubkeys = (
lastOpenDm?.payload as { pubkeys?: string[] } | undefined
)?.pubkeys;
expect(openDmPubkeys).toEqual(
expect.arrayContaining([TEST_IDENTITIES.charlie.pubkey]),
);
expect(openDmPubkeys).toHaveLength(2);
});
test("closes direct message results while opening", async ({ page }) => {
await page.goto("/");
await page.evaluate(() => {
const testWindow = window as Window & {
__BUZZ_E2E__?: { mock?: { openDmDelayMs?: number } };
};
testWindow.__BUZZ_E2E__ ??= {};
testWindow.__BUZZ_E2E__.mock ??= {};
testWindow.__BUZZ_E2E__.mock.openDmDelayMs = 1_000;
});
await openNewMessagePage(page);
await page.getByTestId("new-dm-search").fill("charlie");
await page
.getByTestId(`new-dm-result-${TEST_IDENTITIES.charlie.pubkey}`)
.click();
await expect(
page.getByTestId(`new-dm-selected-${TEST_IDENTITIES.charlie.pubkey}`),
).toBeVisible();
await expect(page.getByTestId("new-message-recipient-popover")).toBeVisible();
await page.getByTestId("message-input").fill("Open this conversation");
await page.getByTestId("send-message").click();
await expect(page.getByTestId("new-dm-opening")).toContainText("Opening");
await expect(page.getByTestId("new-message-recipient-popover")).toBeHidden();
await expect(page.getByTestId("chat-title")).toHaveText("charlie");
});
test("does not reopen a direct message after leaving the composer", async ({
page,
}) => {
await installMockBridge(page, { openDmDelayMs: 1_000 });
await page.goto("/");
await openNewMessagePage(page);
await page.getByTestId("new-dm-search").fill("charlie");
await page
.getByTestId(`new-dm-result-${TEST_IDENTITIES.charlie.pubkey}`)
.click();
const staleMessage = "Do not send after leaving";
await page.getByTestId("message-input").fill(staleMessage);
await page.getByTestId("send-message").click();
await expect(page.getByTestId("new-dm-opening")).toContainText("Opening");
await page.getByTestId("channel-general").click();
await expect(page.getByTestId("chat-title")).toHaveText("general");
await expect(page.getByTestId("dm-list")).toContainText("charlie");
// The DM appearing proves the delayed open completed. Give the abandoned
// continuation time to run before asserting that it did not publish.
await page.waitForTimeout(1_250);
expect(await hasOutgoingEventWithContent(page, staleMessage)).toBe(false);
await expect(page).toHaveURL(
new RegExp(`/channels/${GENERAL_CHANNEL_ID}(?:\\?|$)`),
);
await expect(page.getByTestId("chat-title")).toHaveText("general");
});
test("opens a sent direct message without waiting for a channel-list refresh", async ({
page,
}) => {
await page.goto("/");
await openNewMessagePage(page);
await page.getByTestId("new-dm-search").fill("charlie");
await page
.getByTestId(`new-dm-result-${TEST_IDENTITIES.charlie.pubkey}`)
.click();
const message = "Open without a channel-list refresh";
await page.getByTestId("message-input").fill(message);
const baselineChannelsReads = commandCount(
await readCommandLog(page),
"get_channels",
);
const baselineHttpSends = commandCount(
await readCommandLog(page),
"send_channel_message",
);
await page.evaluate(() => {
const testWindow = window as Window & {
__BUZZ_E2E__?: { mock?: { channelsReadDelayMs?: number } };
};
testWindow.__BUZZ_E2E__ ??= {};
testWindow.__BUZZ_E2E__.mock ??= {};
testWindow.__BUZZ_E2E__.mock.channelsReadDelayMs = 3_000;
});
await page.getByTestId("send-message").click();
await expect(page.getByTestId("chat-title")).toHaveText("charlie", {
timeout: 1_000,
});
await expect(page.getByTestId("message-timeline")).toContainText(message);
expect(commandCount(await readCommandLog(page), "get_channels")).toBe(
baselineChannelsReads,
);
expect(commandCount(await readCommandLog(page), "send_channel_message")).toBe(
baselineHttpSends + 1,
);
await expect(page).toHaveURL(/\/channels\/[0-9a-f-]+(?:\?|$)/);
});
test("shows capped participant stack in group direct message header", async ({
page,
}) => {
await page.goto("/");
await openNewMessagePage(page);
await expect(page.getByTestId("new-message-page")).toBeVisible();
for (const identity of [
TEST_IDENTITIES.alice,
TEST_IDENTITIES.bob,
TEST_IDENTITIES.charlie,
TEST_IDENTITIES.outsider,
]) {
await page.getByTestId("new-dm-search").fill(identity.username);
await page.getByTestId(`new-dm-result-${identity.pubkey}`).click();
}
await page.getByTestId("message-input").fill("Hello group");
await page.getByTestId("send-message").click();
await expect(page.getByTestId("channel-dm-count-Group DM (5)")).toHaveText(
"4",
);
await expect(page.getByTestId("chat-title")).toContainText("alice");
await expect(page.getByTestId("chat-title")).toContainText("bob");
await expect(page.getByTestId("chat-title")).toContainText("charlie");
await expect(page.getByTestId("chat-title")).toContainText("+1 more");
await expect(page.getByTestId("chat-title")).not.toContainText("outsider");
const chatTitle = await page.getByTestId("chat-title").innerText();
await expect(
page.getByTestId("message-input").locator("[data-placeholder]").first(),
).toHaveAttribute("data-placeholder", `Message ${chatTitle}`);
const composerColors = await page
.getByTestId("message-input")
.evaluate((element) => {
const placeholderElement =
element.querySelector<HTMLElement>("[data-placeholder]");
if (!placeholderElement) {
return null;
}
return {
placeholderColor: window.getComputedStyle(
placeholderElement,
"::before",
).color,
textColor: window.getComputedStyle(element).color,
};
});
expect(composerColors).not.toBeNull();
expect(composerColors?.placeholderColor).not.toBe(composerColors?.textColor);
await expect(page.getByTestId("chat-header-dm-avatar")).toHaveCount(0);
await expect(page.getByTestId("chat-header-dm-avatar-stack")).toBeVisible();
await expect(page.getByTestId("chat-presence-badge")).toHaveCount(0);
await expect(
page.getByTestId("chat-header-dm-avatar-stack-participant"),
).toHaveCount(3);
await expect(page.getByTestId("chat-header-dm-avatar-stack-more")).toHaveText(
"+1",
);
const headerStackBox = await page
.getByTestId("chat-header-dm-avatar-stack")
.boundingBox();
const headerTitleBox = await page.getByTestId("chat-title").boundingBox();
expect(headerStackBox).not.toBeNull();
expect(headerTitleBox).not.toBeNull();
expect((headerStackBox?.x ?? 0) + (headerStackBox?.width ?? 0)).toBeLessThan(
headerTitleBox?.x ?? 0,
);
await expect(page.getByTestId("message-dm-intro")).toContainText("alice");
await expect(page.getByTestId("message-dm-intro")).toContainText("bob");
await expect(page.getByTestId("message-dm-intro")).toContainText("charlie");
await expect(page.getByTestId("message-dm-intro")).toContainText("+1 more");
await expect(page.getByTestId("message-dm-intro")).not.toContainText(
"outsider",
);
await expect(page.getByTestId("message-dm-intro-avatar-stack")).toBeVisible();
await expect(
page.getByTestId("message-dm-intro-avatar-stack-participant"),
).toHaveCount(3);
await expect(
page.getByTestId("message-dm-intro-avatar-stack-more"),
).toHaveText("+1");
});
test("create stream with name and description", async ({ page }) => {
const channelName = `my-new-stream-${Date.now()}`;
await page.goto("/");
await openCreateChannelDialog(page);
await page.getByTestId("create-channel-name").fill(channelName);
await page
.getByTestId("create-channel-description")
.fill("A stream for testing channel creation");
await page.getByTestId("create-channel-submit").click();
await expect(page.getByTestId("stream-list")).toContainText(channelName);
await expect(page.getByTestId("chat-title")).toHaveText(channelName);
});
test("channel name values stay primary after their inputs blur", async ({
page,
}) => {
async function expectPrimaryValueAfterBlur(
input: import("@playwright/test").Locator,
blurTarget: import("@playwright/test").Locator,
) {
const placeholder = await input.getAttribute("placeholder");
const placeholderColor = placeholder
? await input.evaluate(
(element) => window.getComputedStyle(element, "::placeholder").color,
)
: null;
await input.fill("primary-channel-name");
const focusedColor = await input.evaluate(
(element) => window.getComputedStyle(element).color,
);
await blurTarget.click();
const valueColor = await input.evaluate(
(element) => window.getComputedStyle(element).color,
);
expect(valueColor).toBe(focusedColor);
if (placeholderColor) {
expect(valueColor).not.toBe(placeholderColor);
}
}
await page.goto("/");
await openCreateChannelDialog(page);
const createDialog = page.getByTestId("create-channel-dialog");
await expectPrimaryValueAfterBlur(
createDialog.getByTestId("create-channel-name"),
createDialog.getByTestId("create-channel-description"),
);
await createDialog.getByRole("button", { name: "Close" }).click();
await openChannelManagement(page, "general");
await openChannelEditDialog(page);
const editDialog = page.getByRole("dialog", {
name: /Edit (?:public|private) channel/,
});
await expectPrimaryValueAfterBlur(
editDialog.getByTestId("channel-management-name"),
editDialog.getByTestId("channel-management-description"),
);
});
test("create channel template selector matches the lifecycle controls", async ({
page,
}) => {
await installMockBridge(page, {
channelTemplates: [
{
id: "project-kickoff",
name: "Project kickoff",
description: "Coordinate a new project from planning through launch.",
channelType: "stream",
visibility: "private",
canvasTemplate: "# {channel.name}\n\nKickoff notes",
agents: {
personas: [
{
personaId: "planner",
runtime: null,
model: null,
role: null,
backend: null,
},
],
teams: [
{
teamId: "research-team",
runtime: null,
model: null,
backend: null,
},
],
},
isBuiltin: false,
createdAt: "2026-07-23T00:00:00Z",
updatedAt: "2026-07-23T00:00:00Z",
},
],
});
await page.goto("/");
await openCreateChannelDialog(page);
const templateControl = page.getByTestId("create-channel-template");
await expect(templateControl).toHaveRole("button");
await expect(templateControl).toHaveText("None");
await templateControl.click();
await expect(
page.getByRole("menuitem", { name: "Create new channel template…" }),
).toBeVisible();
await page.getByRole("menuitemradio", { name: "Project kickoff" }).click();
await expect(templateControl).toHaveText("Project kickoff");
await expect(page.getByTestId("create-channel-template-summary")).toHaveText(
"Private · Canvas included · 1 agent · 1 team",
);
await expect(page.getByTestId("create-channel-description")).toHaveValue(
"Coordinate a new project from planning through launch.",
);
await expect(
page.getByTestId("create-channel-permissions-option-private"),
).toHaveAttribute("aria-pressed", "true");
await page.getByTestId("create-channel-permissions-option-open").click();
await expect(page.getByTestId("create-channel-template-summary")).toHaveText(
"Open · Canvas included · 1 agent · 1 team",
);
});
test("create channel exposes templates when the library is empty", async ({
page,
}) => {
await installMockBridge(page, { channelTemplates: [] });
await page.goto("/");
await openCreateChannelDialog(page);
const typeContainer = page.getByTestId(
"create-channel-channel-type-container",
);
const visibilityContainer = page.getByTestId(
"create-channel-permissions-container",
);
const templateContainer = page.getByTestId(
"create-channel-template-container",
);
await expect(
page.getByTestId("create-channel-channel-type").getByRole("button"),
).toHaveText(["Temporary", "Ongoing"]);
await expect(
page.getByTestId("create-channel-permissions").getByRole("button"),
).toHaveText(["Private", "Public"]);
await expect(templateContainer).toContainText("TemplateOptional");
const typeBox = await typeContainer.boundingBox();
const visibilityBox = await visibilityContainer.boundingBox();
const templateBox = await templateContainer.boundingBox();
expect(typeBox).not.toBeNull();
expect(visibilityBox).not.toBeNull();
expect(templateBox).not.toBeNull();
expect(typeBox?.y ?? 0).toBeLessThan(visibilityBox?.y ?? 0);
expect(visibilityBox?.y ?? 0).toBeLessThan(templateBox?.y ?? 0);
const templateControl = page.getByTestId("create-channel-template");
await expect(templateControl).toHaveText("None");
await templateControl.click();
await page
.getByRole("menuitem", { name: "Create new channel template…" })
.click();
await expect(
page.getByText("Create template", { exact: true }),
).toBeVisible();
await page.locator("#template-name").fill("Weekly planning");
await page.locator("#template-description").fill("Plan the next week.");
await page.getByRole("button", { name: "Create", exact: true }).click();
await expect(templateControl).toHaveText("Weekly planning");
await expect(page.getByTestId("create-channel-description")).toHaveValue(
"Plan the next week.",
);
});
test("create ephemeral stream shows sidebar and header affordances", async ({
page,
}) => {
const channelName = `ephemeral-stream-${Date.now()}`;
await page.goto("/");
await openCreateChannelDialog(page);
await page.getByTestId("create-channel-name").fill(channelName);
await page
.getByTestId("create-channel-description")
.fill("Auto-cleaned test stream");
await page
.getByTestId("create-channel-channel-type-option-temporary")
.click();
const channelTypeContainer = page.getByTestId(
"create-channel-channel-type-container",
);
await expect(
channelTypeContainer.getByTestId("create-channel-permissions-container"),
).toHaveCount(0);
await expect(
page.getByTestId("create-channel-permissions-container"),
).toBeVisible();
await expect(
page.getByTestId("create-channel-permissions-option-open"),
).toHaveAttribute("aria-pressed", "true");
await page.getByTestId("create-channel-permissions-option-private").click();
await expect(
page.getByTestId("create-channel-permissions-option-private"),
).toHaveAttribute("aria-pressed", "true");
await expect(
page.getByTestId("create-channel-permissions-option-open"),
).toHaveAttribute("aria-pressed", "false");
await page.getByTestId("create-channel-permissions-option-open").click();
await expect(
page.getByTestId("create-channel-permissions-option-open"),
).toHaveAttribute("aria-pressed", "true");
await expect(page.getByTestId("create-channel-ttl")).toContainText("7 days");
await page.getByTestId("create-channel-ttl").click();
await page.getByTestId("create-channel-ttl-option-1209600").click();
await expect(page.getByTestId("create-channel-ttl")).toContainText("14 days");
await page.getByTestId("create-channel-submit").click();
await expect(page.getByTestId("stream-list")).toContainText(channelName);
await expect(page.getByTestId("chat-title")).toContainText(channelName);
await expect(
page.getByTestId(`channel-ephemeral-${channelName}`),
).toBeVisible();
await expect(page.getByTestId("chat-ephemeral-badge")).toBeVisible();
await expect(page.getByTestId("chat-ephemeral-badge")).toHaveAttribute(
"aria-label",
/Ephemeral channel\. Cleans up in 14 days\./,
);
const channelRow = page.getByTestId(`channel-${channelName}`);
const channelId = await channelRow.getAttribute("data-channel-id");
if (!channelId) {
throw new Error("Created ephemeral channel is missing its channel id.");
}
await waitForMockLiveSubscription(page, channelName);
await page.getByTestId("channel-general").click();
await page.evaluate(
({ agentPubkey, channelId: activeChannelId }) => {
(window as MockFeedWindow).__BUZZ_E2E_SEED_ACTIVE_TURNS__?.({
agentPubkey,
channelId: activeChannelId,
turnId: "ephemeral-sidebar-status",
});
},
{ agentPubkey: OWNED_RELAY_AGENT_PUBKEY, channelId },
);
await expect(
page.getByTestId(`channel-working-${channelName}`),
).toBeVisible();
await expect(
page.getByTestId(`channel-ephemeral-${channelName}`),
).toHaveCount(0);
await page.evaluate(
({ agentPubkey, channelId: activeChannelId }) => {
(window as MockFeedWindow).__BUZZ_E2E_SEED_ACTIVE_TURNS__?.({
agentPubkey,
channelId: activeChannelId,
turnId: "ephemeral-sidebar-status",
kind: "turn_completed",
});
},
{ agentPubkey: OWNED_RELAY_AGENT_PUBKEY, channelId },
);
await expect(
page.getByTestId(`channel-ephemeral-${channelName}`),
).toBeVisible();
await page
.getByRole("button", { name: "Toggle Sidebar", exact: true })
.click();
await expect(
page.getByTestId(`channel-ephemeral-${channelName}`),
).toBeVisible();
await page
.getByRole("button", { name: "Toggle Sidebar", exact: true })
.click();
await page.evaluate(
({ channelName: targetChannelName, mentionPubkey, senderPubkey }) => {
(window as MockFeedWindow).__BUZZ_E2E_EMIT_MOCK_MESSAGE__?.({
channelName: targetChannelName,
content: "Unread mention in an ephemeral channel",
kind: 40002,
mentionPubkeys: [mentionPubkey],
pubkey: senderPubkey,
});
},
{
channelName,
mentionPubkey: MOCK_IDENTITY_PUBKEY,
senderPubkey: TEST_IDENTITIES.alice.pubkey,
},
);
await expect(page.getByTestId(`channel-${channelName}`)).toHaveCSS(
"font-weight",
"700",
);
await expect(page.getByTestId(`channel-unread-${channelName}`)).toHaveCount(
0,
);
await expect(
page.getByTestId(`channel-ephemeral-${channelName}`),
).toBeVisible();
});
test("ephemeral countdown refreshes when switching channels after a clock jump", async ({
page,
}) => {
const firstChannelName = "ephemeral-alpha";
const secondChannelName = "ephemeral-beta";
const initialTime = new Date("2026-04-09T00:00:00.000Z");
const shiftedTime = new Date("2026-04-15T02:00:00.000Z");
await page.clock.setFixedTime(initialTime);
await page.goto("/");
for (const channelName of [firstChannelName, secondChannelName]) {
await openCreateChannelDialog(page);
await page.getByTestId("create-channel-name").fill(channelName);
await page
.getByTestId("create-channel-description")
.fill("Auto-cleaned test stream");
await page
.getByTestId("create-channel-channel-type-option-temporary")
.click();
await page.getByTestId("create-channel-submit").click();
await expect(page.getByTestId("chat-title")).toContainText(channelName);
}
await page.clock.setFixedTime(shiftedTime);
await expect
.poll(() => page.evaluate(() => Date.now()))
.toBe(shiftedTime.getTime());
await page.getByTestId(`channel-${firstChannelName}`).click();
await expect(page.getByTestId("chat-title")).toContainText(firstChannelName);
await expect(page.getByTestId("chat-ephemeral-badge")).toHaveAttribute(
"aria-label",
/Ephemeral channel\. Cleans up in 22 hours\./,
);
});
test("archived channels stay out of all sidebar sections", async ({ page }) => {
const archivedStreamName = `archived-stream-${Date.now()}`;
const archivedForumName = `archived-forum-${Date.now()}`;
await page.goto("/");
await page.waitForFunction(() => {
return Boolean(
(
window as Window & {
__BUZZ_E2E_INVOKE_MOCK_COMMAND__?: unknown;
}
).__BUZZ_E2E_INVOKE_MOCK_COMMAND__,
);
});
await page.evaluate(
async ({
archivedForumName,
archivedStreamName,
outsiderPubkey,
}: {
archivedForumName: string;
archivedStreamName: string;
outsiderPubkey: string;
}) => {
const invoke = (
window as Window & {
__BUZZ_E2E_INVOKE_MOCK_COMMAND__?: (
command: string,
payload?: Record<string, unknown>,
) => Promise<{ id: string }>;
}
).__BUZZ_E2E_INVOKE_MOCK_COMMAND__;
if (!invoke) {
throw new Error("Mock bridge is not installed.");
}
const stream = await invoke("create_channel", {
channelType: "stream",
name: archivedStreamName,
visibility: "open",
});
const forum = await invoke("create_channel", {
channelType: "forum",
name: archivedForumName,
visibility: "open",
});
const directMessage = await invoke("open_dm", {
pubkeys: [outsiderPubkey],
});
for (const channel of [stream, forum, directMessage]) {
await invoke("archive_channel", { channelId: channel.id });
}
},
{
archivedForumName,
archivedStreamName,
outsiderPubkey: TEST_IDENTITIES.outsider.pubkey,
},
);
await page.reload();
await expect(page.getByTestId("stream-list")).not.toContainText(
archivedStreamName,
);
await expect(page.getByTestId("forum-list")).not.toContainText(
archivedForumName,
);
await expect(page.getByTestId("dm-list")).toContainText("alice-tyler");
await expect(page.getByTestId("dm-list")).not.toContainText("outsider");
});
test("create stream with special characters", async ({ page }) => {
const channelName = `dev ops-${Date.now()}`;
await page.goto("/");
await openCreateChannelDialog(page);
await page.getByTestId("create-channel-name").fill(channelName);
await page
.getByTestId("create-channel-description")
.fill("Stream with spaces and hyphens");
await page.getByTestId("create-channel-submit").click();
await expect(page.getByTestId("stream-list")).toContainText(channelName);
await expect(page.getByTestId("chat-title")).toHaveText(channelName);
});
test("switch between streams", async ({ page }) => {
await page.goto("/");
await page.getByTestId("channel-general").click();
await expect(page.getByTestId("chat-title")).toHaveText("general");
await page.getByTestId("channel-random").click();
await expect(page.getByTestId("chat-title")).toHaveText("random");
await page.getByTestId("channel-engineering").click();
await expect(page.getByTestId("chat-title")).toHaveText("engineering");
});
test("switch between channel types", async ({ page }) => {
await page.goto("/");
// Stream
await page.getByTestId("channel-general").click();
await expect(page.getByTestId("chat-title")).toHaveText("general");
// Forum
await page.getByTestId("channel-watercooler").click();
await expect(page.getByTestId("chat-title")).toHaveText("watercooler");
// DM
await page.getByTestId("channel-alice-tyler").click();
await expect(page.getByTestId("chat-title")).toHaveText("alice-tyler");
});
test("empty channel shows intro actions", async ({ page }) => {
await page.goto("/");
await page.getByTestId("channel-random").click();
await expect(page.getByTestId("chat-title")).toHaveText("random");
await expect(page.getByTestId("message-channel-intro")).toBeVisible();
await expect(page.getByTestId("message-channel-intro")).toContainText(
"This is the beginning of the regular channel.",
);
await expect(
page.getByTestId("channel-intro-action-create-channel"),
).toHaveCount(0);
const addAgentsAction = page.getByTestId("channel-intro-action-create-agent");
await expect(addAgentsAction).toBeVisible();
await expect(
addAgentsAction.getByText("Add agent", { exact: true }),
).toBeVisible();
await expect(
addAgentsAction.getByText("Add an agent here.", {
exact: true,
}),
).toBeVisible();
await expect(
page.getByTestId("channel-intro-action-add-people"),
).toBeVisible();
await expect(page.getByTestId("welcome-composer-guide-banner")).toHaveCount(
0,
);
await expectIntroActionCardLayout(page, "channel-intro-action-create-agent");
await expectIntroActionsShareRow(page, [
"channel-intro-action-create-agent",
"channel-intro-action-add-people",
]);
await page.getByTestId("channel-intro-action-add-people").click();
await expect(page.getByTestId("members-sidebar")).toBeVisible();
await page.keyboard.press("Escape");
await expect(page.getByTestId("members-sidebar")).not.toBeVisible();
await addAgentsAction.click();
await expect(page.getByRole("heading", { name: "Add agents" })).toBeVisible();
await page.keyboard.press("Escape");
await expect(page.getByRole("heading", { name: "Add agents" })).toHaveCount(
0,
);
});
test("short channel with messages shows intro actions on open", async ({
page,
}) => {
const channelName = `short-intro-${Date.now()}`;
const message = "Only message in a short channel";
await page.goto("/");
await openCreateChannelDialog(page);
await page.getByTestId("create-channel-name").fill(channelName);
await page.getByTestId("create-channel-submit").click();
await expect(page.getByTestId("chat-title")).toHaveText(channelName);
await page.getByTestId("message-input").fill(message);
await page.getByTestId("send-message").click();
await expect(page.getByTestId("message-timeline")).toContainText(message);
await page.getByTestId("channel-general").click();
await expect(page.getByTestId("chat-title")).toHaveText("general");
await page.getByTestId(`channel-${channelName}`).click();
await expect(page.getByTestId("chat-title")).toHaveText(channelName);
await expect(page.getByTestId("message-timeline")).toContainText(message);
await expect(page.getByTestId("message-channel-intro")).toBeVisible();
await expect(page.getByTestId("message-channel-intro")).toContainText(
"This is the beginning of the regular channel.",
);
await expect(
page.getByTestId("channel-intro-action-create-agent"),
).toBeVisible();
await expect(
page.getByTestId("channel-intro-action-add-people"),
).toBeVisible();
});
test("scrollable channel with recent messages hides intro actions until top", async ({
page,
}) => {
const channelName = `long-intro-${Date.now()}`;
const messages = Array.from(
{ length: 24 },
(_, index) => `Scrollable channel message ${index + 1}`,
);
await page.goto("/");
await openCreateChannelDialog(page);
await page.getByTestId("create-channel-name").fill(channelName);
await page.getByTestId("create-channel-submit").click();
await expect(page.getByTestId("chat-title")).toHaveText(channelName);
for (const message of messages) {
await page.getByTestId("message-input").fill(message);
await page.getByTestId("send-message").click();
await expect(page.getByTestId("message-timeline")).toContainText(message);
}
await page.getByTestId("channel-general").click();
await expect(page.getByTestId("chat-title")).toHaveText("general");
await page.getByTestId(`channel-${channelName}`).click();
await expect(page.getByTestId("chat-title")).toHaveText(channelName);
await expect(page.getByTestId("message-channel-intro")).not.toBeInViewport();
await expect(
page.getByTestId("channel-intro-action-create-agent"),
).not.toBeInViewport();
await expect(
page.getByTestId("channel-intro-action-add-people"),
).not.toBeInViewport();
await expect(page.getByTestId("message-timeline")).toContainText(
messages[messages.length - 1],
);
await page.getByTestId("message-timeline").evaluate((element) => {
element.scrollTop = 0;
element.dispatchEvent(new Event("scroll", { bubbles: true }));
});
await expect(page.getByTestId("message-channel-intro")).toBeVisible();
await expect(
page.getByTestId("channel-intro-action-create-agent"),
).toBeVisible();
await expect(
page.getByTestId("channel-intro-action-add-people"),
).toBeVisible();
});
test("channel with messages shows content", async ({ page }) => {
await page.goto("/");
await page.getByTestId("channel-general").click();
await expect(page.getByTestId("chat-title")).toHaveText("general");
await expect(page.getByTestId("message-channel-intro")).toBeVisible();
await expect(
page.getByTestId("channel-intro-action-create-channel"),
).toHaveCount(0);
await expect(page.getByTestId("welcome-composer-guide-banner")).toHaveCount(
0,
);
// `.first()`: backdated seeds can straddle midnight UTC and render two
// dividers (Yesterday + Today); a bare locator fails Playwright strict mode.
await expect(
page.getByTestId("message-timeline-day-divider").first(),
).toBeVisible();
await expect(page.getByTestId("message-timeline")).toContainText(
"Welcome to general",
);
});
test("channel date divider keeps the date sticky while the separator rule scrolls", async ({
page,
}) => {
await page.goto("/");
await page.getByTestId("channel-engineering").click();
await expect(page.getByTestId("chat-title")).toHaveText("engineering");
await waitForMockLiveSubscription(page, "engineering");
await page.evaluate(() => {
const firstDay = 1_700_000_000;
for (let day = 0; day < 2; day += 1) {
for (let index = 0; index < 14; index += 1) {
window.__BUZZ_E2E_EMIT_MOCK_MESSAGE__?.({
channelName: "engineering",
content: `date handoff day ${day + 1} row ${index + 1}\nsecond line for scroll height`,
createdAt: firstDay + day * 86_400 + index,
pubkey:
"953d3363262e86b770419834c53d2446409db6d918a57f8f339d495d54ab001f",
});
}
}
});
await expect(page.getByTestId("message-timeline-day-group")).toHaveCount(2);
await expect(page.getByTestId("message-timeline-day-divider")).toHaveCount(2);
const timeline = page.getByTestId("message-timeline");
await timeline.evaluate((element) => {
element.scrollTop = element.scrollHeight * 0.2;
element.dispatchEvent(new Event("scroll", { bubbles: true }));
});
const [headerBox, stickyPillBox] = await Promise.all([
page.getByTestId("chat-header").boundingBox(),
page
.getByTestId("message-timeline-sticky-day-divider-content")
.locator("p")
.first()
.boundingBox(),
]);
if (!headerBox || !stickyPillBox) {
throw new Error("missing channel header or sticky day divider");
}
expect(
Math.abs(stickyPillBox.y - (headerBox.y + headerBox.height) - 8),
).toBeLessThanOrEqual(1);
await expect(
page.getByTestId("message-timeline-sticky-day-divider"),
).toHaveCSS("opacity", "1");
await expect(
page.getByTestId("message-timeline-day-divider").last().locator("p"),
).toHaveCSS("visibility", "visible");
const metrics = await timeline.evaluate((element) => {
const pinnedDivider = element.parentElement?.querySelector<HTMLElement>(
'[data-testid="message-timeline-sticky-day-divider"]',
);
const pinnedPill = pinnedDivider?.querySelector<HTMLElement>(
'[data-testid="message-timeline-sticky-day-divider-content"] p',
);
if (!pinnedDivider || !pinnedPill) {
throw new Error("missing sticky day divider");
}
return {
dividerPillBackground: getComputedStyle(pinnedPill).backgroundColor,
dividerPillShadow: getComputedStyle(pinnedPill).boxShadow,
dividerZIndex: getComputedStyle(pinnedDivider).zIndex,
};
});
expect(Number.parseInt(metrics.dividerZIndex, 10)).toBeGreaterThan(10);
await expect(
page.getByTestId("message-timeline-sticky-day-divider"),
).toHaveCSS("overflow", "visible");
const dividerAlignment = await timeline.evaluate((element) => {
const group = [
...element.querySelectorAll<HTMLElement>(
'[data-testid="message-timeline-day-group"]',
),
].find((candidate) => {
const pill = candidate.querySelector<HTMLElement>("p");
return pill && getComputedStyle(pill).visibility === "visible";
});
const pill = group?.querySelector<HTMLElement>("p");
if (!group || !pill) throw new Error("missing visible day divider");
const rule = getComputedStyle(group, "::before");
const groupBox = group.getBoundingClientRect();
const pillBox = pill.getBoundingClientRect();
return {
chipCenter: pillBox.top + pillBox.height / 2,
ruleCenter:
groupBox.top +
Number.parseFloat(rule.top) +
Number.parseFloat(rule.height) / 2,
};
});
expect(
Math.abs(dividerAlignment.chipCenter - dividerAlignment.ruleCenter),
).toBeLessThanOrEqual(0.5);
await expect
.poll(async () => {
const headerZIndex = await page
.getByTestId("chat-header")
.evaluate(
(element) =>
getComputedStyle(element.parentElement ?? element).zIndex,
);
return Number.parseInt(headerZIndex, 10);
})
.toBeGreaterThan(Number.parseInt(metrics.dividerZIndex, 10));
await expect
.poll(async () => {
const sharedBackdropZIndex = await page
.getByTestId("channel-shared-header-backdrop")
.evaluate((element) => getComputedStyle(element).zIndex);
return Number.parseInt(sharedBackdropZIndex, 10);
})
.toBeGreaterThan(Number.parseInt(metrics.dividerZIndex, 10));
const composerOverlay = page.getByTestId("channel-composer-overlay");
await expect(composerOverlay).toBeVisible();
await expect(composerOverlay).toHaveCSS("backdrop-filter", "none");
await expect(composerOverlay).toHaveCSS(
"background-color",
"rgba(0, 0, 0, 0)",
);
await expect(composerOverlay.getByTestId("message-composer")).toHaveCSS(
"backdrop-filter",
"none",
);
await expect(
composerOverlay.getByTestId("composer-dock-backdrop").locator("div"),
).not.toHaveCSS("backdrop-filter", "none");
const composerRailMask = composerOverlay.getByTestId(
"composer-dock-rail-mask",
);
await expect(composerRailMask).toHaveCSS("backdrop-filter", "none");
await expect(composerRailMask).not.toHaveCSS(
"background-color",
"rgba(0, 0, 0, 0)",
);
await expect
.poll(async () => {
const composerOverlayZIndex = await page
.getByTestId("channel-composer-overlay")
.evaluate((element) => getComputedStyle(element).zIndex);
return Number.parseInt(composerOverlayZIndex, 10);
})
.toBeGreaterThan(Number.parseInt(metrics.dividerZIndex, 10));
expect(metrics.dividerPillBackground).not.toBe("rgba(0, 0, 0, 0)");
expect(metrics.dividerPillBackground).not.toBe("transparent");
expect(metrics.dividerPillShadow).toBe("none");
});
test("shows and clears activity indicators for active channel agents", async ({
page,
}) => {
await page.goto("/");
await page.getByTestId("channel-agents").click();
await expect(page.getByTestId("chat-title")).toHaveText("agents");
await waitForMockLiveSubscription(page, "agents", KIND_TYPING_INDICATOR);
await page.evaluate((pubkey) => {
window.__BUZZ_E2E_EMIT_MOCK_TYPING__?.({
channelName: "agents",
pubkey,
});
}, TEST_IDENTITIES.alice.pubkey);
await expect(page.getByTestId("bot-activity-composer-trigger")).toBeVisible();
const activityAvatar = page.getByTestId(
`bot-activity-composer-avatar-${TEST_IDENTITIES.alice.pubkey}`,
);
await expect(activityAvatar).toHaveCSS("border-radius", "0px");
await expect(activityAvatar).toHaveCSS(
"clip-path",
'url("#rounded-squircle-clip")',
);
await expect(
page.getByTestId("bot-activity-composer-trigger"),
).not.toContainText("View activity");
await page.getByTestId("bot-activity-composer-trigger").click();
const aliceActivityItem = page.getByTestId(
`bot-activity-composer-item-${TEST_IDENTITIES.alice.pubkey}`,
);
await expect(aliceActivityItem).toBeVisible();
await expect(aliceActivityItem).toContainText("View activity");
await aliceActivityItem.click({ force: true });
await expect(page.getByTestId("agent-session-thread-panel")).toBeVisible();
await expect(page.getByTestId("agent-session-thread-panel")).toContainText(
"alice",
);
// Opened from the composer with no prior pane: there is nowhere to go
// "back" to, so the header shows only the close affordance.
await expect(page.getByTestId("agent-session-back")).toHaveCount(0);
await expect(page.getByTestId("auxiliary-panel-close")).toBeVisible();
await expect(page.getByTestId("agent-transcript-now-summary")).toHaveCount(0);
await page.getByTestId("agent-session-settings-menu-trigger").click();
await expect(page.getByTestId("agent-session-stop-turn")).toBeVisible();
await expect(page.getByTestId("agent-session-stop-turn")).toBeDisabled();
await page.keyboard.press("Escape");
await expect(page.getByTestId("agent-session-thread-panel")).toContainText(
"No ACP activity yet",
);
await expect(page.getByTestId("message-typing-indicator")).toHaveCount(0);
await page.evaluate((pubkey) => {
window.__BUZZ_E2E_EMIT_MOCK_MESSAGE__?.({
channelName: "agents",
content: "Done.",
pubkey,
});
}, TEST_IDENTITIES.alice.pubkey);
await expect(page.getByTestId("message-timeline")).toContainText("Done.");
await expect(page.getByTestId("bot-activity-composer-trigger")).toHaveCount(
0,
);
await page.waitForTimeout(1_200);
await page.evaluate((pubkey) => {
window.__BUZZ_E2E_EMIT_MOCK_TYPING__?.({
channelName: "agents",
pubkey,
});
}, TEST_IDENTITIES.alice.pubkey);
await expect(page.getByTestId("bot-activity-composer-trigger")).toHaveCount(
0,
);
});
test("members sidebar exposes view-activity for a viewer-owned relay agent", async ({
page,
}) => {
// nadia is no longer in the default relay-agent seeds (the no-relay-record
// owner path claims that fixture), so seed her relay registry entry here to
// exercise the relay-bot classification.
await installMockBridge(page, {
relayAgents: [
{
pubkey: OWNED_RELAY_AGENT_PUBKEY,
name: "nadia",
agentType: "goose",
capabilities: ["search", "summaries"],
channelNames: ["agents"],
respondTo: "anyone",
},
],
});
await page.goto("/");
await openMembersSidebar(page, "agents");
// nadia is a relay-only agent (no local managed record) owned by the mock
// viewer. The view-activity affordance must open via the declared-owner
// path, not the local-managed path.
await expect(
page.getByTestId(`sidebar-member-${OWNED_RELAY_AGENT_PUBKEY}`),
).toBeVisible();
await openMemberMenu(page, OWNED_RELAY_AGENT_PUBKEY);
await expect(
page.getByTestId(`sidebar-view-activity-${OWNED_RELAY_AGENT_PUBKEY}`),
).toBeVisible();
});
test("profile renders live activity for a viewer-owned relay agent", async ({
page,
}) => {
await page.goto("/");
await openMembersSidebar(page, "agents");
await page
.getByTestId(`sidebar-member-open-profile-${OWNED_RELAY_AGENT_PUBKEY}`)
.click();
await expect(page.getByTestId("members-sidebar")).not.toBeVisible();
await expect(
page.getByTestId(`user-profile-view-activity-${OWNED_RELAY_AGENT_PUBKEY}`),
).toBeVisible();
await page.waitForFunction(
() =>
typeof (window as MockFeedWindow).__BUZZ_E2E_SEED_ACTIVE_TURNS__ ===
"function",
);
await page.evaluate(
({ agentPubkey, channelId }) => {
const seedActiveTurns = (window as MockFeedWindow)
.__BUZZ_E2E_SEED_ACTIVE_TURNS__;
if (!seedActiveTurns) {
throw new Error("Mock active-turn helper is not installed.");
}
seedActiveTurns({
agentPubkey,
channelId,
turnId: "owned-relay-profile-turn",
});
},
{
agentPubkey: OWNED_RELAY_AGENT_PUBKEY,
channelId: AGENTS_CHANNEL_ID,
},
);
const liveActivity = page.getByTestId(
`user-profile-live-activity-${OWNED_RELAY_AGENT_PUBKEY}`,
);
await expect(liveActivity).toBeVisible();
await expect(liveActivity).toContainText("Latest Activity");
await expect(liveActivity).toContainText("#agents");
await expect(
page.getByTestId(`user-profile-activity-dot-${AGENTS_CHANNEL_ID}`),
).toHaveCount(0);
await page.evaluate(
({ agentPubkey, channelId, turnId }) => {
const seedActiveTurns = (window as MockFeedWindow)
.__BUZZ_E2E_SEED_ACTIVE_TURNS__;
if (!seedActiveTurns) {
throw new Error("Mock active-turn helper is not installed.");
}
seedActiveTurns({
agentPubkey,
channelId,
turnId,
kind: "turn_completed",
});
},
{
agentPubkey: OWNED_RELAY_AGENT_PUBKEY,
channelId: AGENTS_CHANNEL_ID,
turnId: "owned-relay-profile-turn",
},
);
await expect(liveActivity).toBeVisible();
await expect(liveActivity).toContainText("Latest Activity");
await expect(
page.getByTestId(`user-profile-view-activity-${OWNED_RELAY_AGENT_PUBKEY}`),
).not.toBeVisible();
});
test("profile activity carousel switches channels via progress dots", async ({
page,
}) => {
await page.goto("/");
await openMembersSidebar(page, "agents");
await page
.getByTestId(`sidebar-member-open-profile-${OWNED_RELAY_AGENT_PUBKEY}`)
.click();
await expect(page.getByTestId("members-sidebar")).not.toBeVisible();
await expect(
page.getByTestId(`user-profile-view-activity-${OWNED_RELAY_AGENT_PUBKEY}`),
).toBeVisible();
await page.waitForFunction(
() =>
typeof (window as MockFeedWindow).__BUZZ_E2E_SEED_ACTIVE_TURNS__ ===
"function",
);
await page.evaluate(
({ agentPubkey, channels }) => {
const seedActiveTurns = (window as MockFeedWindow)
.__BUZZ_E2E_SEED_ACTIVE_TURNS__;
if (!seedActiveTurns) {
throw new Error("Mock active-turn helper is not installed.");
}
for (const [index, channelId] of channels.entries()) {
seedActiveTurns({
agentPubkey,
channelId,
turnId: `owned-relay-profile-turn-${index}`,
});
}
},
{
agentPubkey: OWNED_RELAY_AGENT_PUBKEY,
channels: [AGENTS_CHANNEL_ID, GENERAL_CHANNEL_ID],
},
);
const liveActivity = page.getByTestId(
`user-profile-live-activity-${OWNED_RELAY_AGENT_PUBKEY}`,
);
await expect(liveActivity).toBeVisible();
await expect(liveActivity).toContainText("#agents");
await expect(
page.getByTestId(`user-profile-activity-dot-${AGENTS_CHANNEL_ID}`),
).toBeVisible();
await expect(
page.getByTestId(`user-profile-activity-dot-${GENERAL_CHANNEL_ID}`),
).toBeVisible();
await expect(
page.getByTestId(`user-profile-activity-slide-${GENERAL_CHANNEL_ID}`),
).toHaveAttribute("data-mounted", "false");
await page
.getByTestId(`user-profile-activity-dot-${GENERAL_CHANNEL_ID}`)
.click();
await expect(
page.getByTestId("user-profile-activity-channel-label"),
).toContainText("#general");
await expect(
page.getByTestId(`user-profile-activity-slide-${GENERAL_CHANNEL_ID}`),
).toHaveAttribute("data-mounted", "true");
await expect(
page.getByTestId(`user-profile-activity-slide-${AGENTS_CHANNEL_ID}`),
).toHaveAttribute("data-mounted", "true");
});
test("typing indicator shows avatars and maintains stable name order", async ({
page,
}) => {
await page.goto("/");
await page.getByTestId("channel-random").click();
await expect(page.getByTestId("chat-title")).toHaveText("random");
await waitForMockLiveSubscription(page, "random", KIND_TYPING_INDICATOR);
// Alice starts typing first
await page.evaluate((pubkey) => {
window.__BUZZ_E2E_EMIT_MOCK_TYPING__?.({
channelName: "random",
pubkey,
});
}, TEST_IDENTITIES.alice.pubkey);
await expect(page.getByTestId("message-typing-indicator")).toBeVisible();
await expect(
page.getByTestId("message-typing-indicator-label"),
).toContainText("alice is typing");
// Verify avatar is rendered for the typing user
const avatars = page
.getByTestId("message-typing-indicator")
.locator("[data-testid='message-typing-avatar']");
await expect(avatars).toHaveCount(1);
// Bob starts typing second
await page.evaluate((pubkey) => {
window.__BUZZ_E2E_EMIT_MOCK_TYPING__?.({
channelName: "random",
pubkey,
});
}, TEST_IDENTITIES.bob.pubkey);
await expect(
page.getByTestId("message-typing-indicator-label"),
).toContainText("alice and bob are typing");
await expect(avatars).toHaveCount(2);
// Alice re-broadcasts — order should stay "alice and bob", not flip
await page.evaluate((pubkey) => {
window.__BUZZ_E2E_EMIT_MOCK_TYPING__?.({
channelName: "random",
pubkey,
});
}, TEST_IDENTITIES.alice.pubkey);
await expect(
page.getByTestId("message-typing-indicator-label"),
).toContainText("alice and bob are typing");
// Bob re-broadcasts — order should still stay "alice and bob"
await page.evaluate((pubkey) => {
window.__BUZZ_E2E_EMIT_MOCK_TYPING__?.({
channelName: "random",
pubkey,
});
}, TEST_IDENTITIES.bob.pubkey);
await expect(
page.getByTestId("message-typing-indicator-label"),
).toContainText("alice and bob are typing");
});
test("sidebar shows unread indicator for newly active channels", async ({
page,
}) => {
await page.goto("/");
await expect(page.getByTestId("channel-unread-random")).toHaveCount(0);
await waitForMockLiveSubscription(page, "random");
// The unread tracker ignores the current user's own messages, so emit as
// alice — simulating a real "another user posted while I was elsewhere".
await page.evaluate(
({ pubkey }) => {
window.__BUZZ_E2E_EMIT_MOCK_MESSAGE__?.({
channelName: "random",
content: "Unread update for #random",
kind: 40002,
pubkey,
});
},
{ pubkey: TEST_IDENTITIES.alice.pubkey },
);
await expect(page.getByTestId("channel-random")).toHaveCSS(
"font-weight",
"700",
);
await expect(page.getByTestId("channel-unread-random")).toHaveCount(0);
await page.getByTestId("channel-random").click();
await expect(page.getByTestId("chat-title")).toHaveText("random");
await expect(page.getByTestId("message-timeline")).toContainText(
"Unread update for random",
);
await expect(page.getByTestId("channel-unread-random")).toHaveCount(0);
});
test("sidebar shows unread indicator for new forum posts", async ({ page }) => {
await page.goto("/");
await expect(page.getByTestId("channel-unread-watercooler")).toHaveCount(0);
await waitForMockLiveSubscription(page, "watercooler");
// Emit as alice — the unread tracker ignores self-authored messages.
await page.evaluate(
({ pubkey }) => {
window.__BUZZ_E2E_EMIT_MOCK_MESSAGE__?.({
channelName: "watercooler",
content: "Unread update for the forum",
kind: 45001,
pubkey,
});
},
{ pubkey: TEST_IDENTITIES.alice.pubkey },
);
await expect(page.getByTestId("channel-watercooler")).toHaveCSS(
"font-weight",
"700",
);
await expect(page.getByTestId("channel-unread-watercooler")).toHaveCount(0);
await page.getByTestId("channel-watercooler").click();
await expect(page.getByTestId("chat-title")).toHaveText("watercooler");
await expect(page.getByTestId("channel-unread-watercooler")).toHaveCount(0);
});
test("sidebar clears unread indicator after opening a DM", async ({ page }) => {
await page.goto("/");
await expect(page.getByTestId("channel-unread-alice-tyler")).toHaveCount(0);
await waitForMockLiveSubscription(page, "alice-tyler");
await page.evaluate((pubkey) => {
window.__BUZZ_E2E_EMIT_MOCK_MESSAGE__?.({
channelName: "alice-tyler",
content: "Unread update for the DM",
pubkey,
});
}, TEST_IDENTITIES.alice.pubkey);
await expect(page.getByTestId("channel-unread-alice-tyler")).toBeVisible();
await page.getByTestId("channel-alice-tyler").click();
await expect(page.getByTestId("chat-title")).toHaveText("alice-tyler");
await expect(page.getByTestId("message-dm-intro")).toBeVisible();
await expect(page.getByTestId("message-dm-intro")).toContainText(
"This is the beginning of your direct message with",
);
// `.first()`: backdated seeds can straddle midnight UTC and render two
// dividers (Yesterday + Today); a bare locator fails Playwright strict mode.
await expect(
page.getByTestId("message-timeline-day-divider").first(),
).toBeVisible();
await expect(page.getByTestId("message-timeline")).toContainText(
"Unread update for the DM",
);
await expectSameLeftInset(page, "message-dm-intro", "message-row");
await expectIntroSpacedAboveDayDivider(page, "message-dm-intro");
await expect(page.getByTestId("channel-unread-alice-tyler")).toHaveCount(0);
});
test("sidebar persists after channel switch", async ({ page }) => {
await page.goto("/");
await expect(page.getByTestId("app-sidebar")).toBeVisible();
await page.getByTestId("channel-general").click();
await expect(page.getByTestId("chat-title")).toHaveText("general");
await expect(page.getByTestId("app-sidebar")).toBeVisible();
await page.getByTestId("channel-random").click();
await expect(page.getByTestId("chat-title")).toHaveText("random");
await expect(page.getByTestId("app-sidebar")).toBeVisible();
await page.getByTestId("channel-watercooler").click();
await expect(page.getByTestId("chat-title")).toHaveText("watercooler");
await expect(page.getByTestId("app-sidebar")).toBeVisible();
});
test("manage channel updates details", async ({ page }) => {
const stamp = Date.now();
const newName = `release-hub-${stamp}`;
const newDescription = `Release coordination ${stamp}`;
await page.goto("/");
await openChannelManagement(page, "general");
await openChannelEditDialog(page);
const editDialog = page.getByRole("dialog", {
name: /Edit (?:public|private) channel/,
});
await editDialog.getByTestId("channel-management-name").fill(newName);
await editDialog
.getByTestId("channel-management-description")
.fill(newDescription);
await expect(editDialog.getByTestId("channel-management-topic")).toHaveCount(
0,
);
await expect(
editDialog.getByTestId("channel-management-purpose"),
).toHaveCount(0);
await editDialog.getByTestId("channel-management-save-changes").click();
await expect(editDialog).toHaveCount(0);
await expect(page.getByTestId("chat-title")).toHaveText(newName);
await expect(page.getByTestId("stream-list")).toContainText(newName);
await expect(page.getByTestId("channel-management-name-row")).toContainText(
newName,
);
await expect(
page.getByTestId("channel-management-description"),
).toContainText(newDescription);
await closeChannelManagement(page);
await page.getByTestId("channel-random").click();
await expect(page.getByTestId("chat-title")).toHaveText("random");
await page.getByTestId("stream-list").getByText(newName).click();
await expect(page.getByTestId("chat-title")).toHaveText(newName);
await page.getByTestId("channel-management-trigger").click();
await expect(page.getByTestId("channel-management-sheet")).toBeVisible();
await openChannelEditDialog(page);
const reopenedEditDialog = page.getByRole("dialog", {
name: /Edit (?:public|private) channel/,
});
await expect(
reopenedEditDialog.getByTestId("channel-management-name"),
).toHaveValue(newName);
await expect(
reopenedEditDialog.getByTestId("channel-management-description"),
).toHaveValue(newDescription);
await expect(
reopenedEditDialog.getByTestId("channel-management-topic"),
).toHaveCount(0);
await expect(
reopenedEditDialog.getByTestId("channel-management-purpose"),
).toHaveCount(0);
});
test("channel management keeps both description paragraphs visible for editors and readers", async ({
page,
}) => {
const description =
"Post deploy requests here before 3pm.\n\nFor emergencies, ping the on-call directly.\nInclude the service name and rollback plan.";
const visibleFragment = "Include the service name and rollback plan.";
await page.goto("/");
await page.waitForFunction(
() =>
typeof window.__BUZZ_E2E_MUTATE_CHANNEL__ === "function" &&
typeof window.__BUZZ_E2E_INVALIDATE_CHANNELS__ === "function",
);
await page.evaluate(
async ({ generalChannelId, randomChannelId, description }) => {
const bridge = window as Window & {
__BUZZ_E2E_INVALIDATE_CHANNELS__: () => Promise<void>;
__BUZZ_E2E_MUTATE_CHANNEL__: (options: {
channelId: string;
description?: string;
}) => void;
};
for (const channelId of [generalChannelId, randomChannelId]) {
bridge.__BUZZ_E2E_MUTATE_CHANNEL__({ channelId, description });
}
await bridge.__BUZZ_E2E_INVALIDATE_CHANNELS__();
},
{
generalChannelId: GENERAL_CHANNEL_ID,
randomChannelId: RANDOM_CHANNEL_ID,
description,
},
);
for (const [channelName, editable] of [
["general", true],
["random", false],
] as const) {
await openChannelManagement(page, channelName);
const summary = page.getByTestId("channel-management-description");
await expect(summary).toHaveText(description);
await expect(page.getByTestId("channel-management-edit")).toHaveCount(
editable ? 1 : 0,
);
const fragmentBounds = await summary.evaluate((element, fragment) => {
const textNode = Array.from(element.childNodes).find(
(node) => node.nodeType === Node.TEXT_NODE,
);
if (!textNode?.textContent) {
throw new Error("channel summary text node is missing");
}
const start = textNode.textContent.indexOf(fragment);
if (start < 0) {
throw new Error(`channel summary is missing fragment: ${fragment}`);
}
const range = document.createRange();
range.setStart(textNode, start);
range.setEnd(textNode, start + fragment.length);
const textRect = range.getBoundingClientRect();
const containerRect = element.getBoundingClientRect();
return {
containerBottom: containerRect.bottom,
containerTop: containerRect.top,
textBottom: textRect.bottom,
textTop: textRect.top,
};
}, visibleFragment);
expect(fragmentBounds.textTop).toBeGreaterThanOrEqual(
fragmentBounds.containerTop - 1,
);
expect(fragmentBounds.textBottom).toBeLessThanOrEqual(
fragmentBounds.containerBottom + 1,
);
await closeChannelManagement(page);
}
});
test("manage channel shows member avatars and owner-only row controls", async ({
page,
}) => {
await page.context().grantPermissions(["clipboard-read", "clipboard-write"]);
await page.goto("/");
await openChannelManagement(page, "general");
await expect(
page.getByTestId("channel-management-member-avatar-stack"),
).toBeVisible();
await expect(
page.getByTestId("channel-management-member-avatar"),
).toHaveCount(3);
await expect(
page.getByTestId("channel-management-member-avatar-overflow"),
).toHaveText("+1");
await expect(page.getByTestId("channel-management-hero")).toBeVisible();
await expect(
page.getByTestId("channel-management-sheet").locator(".space-y-6").first(),
).toBeVisible();
const detailsSection = page.getByTestId("channel-management-details");
const detailsTitle = detailsSection.getByRole("heading", {
exact: true,
level: 2,
name: "Details",
});
await expect(detailsTitle).toHaveClass(/text-xs/);
await expect(detailsTitle).toHaveClass(/text-muted-foreground\/70/);
await expect(
detailsSection.locator('[data-slot="panel-section-header"]'),
).toHaveClass(/px-4/);
await expect(
detailsSection
.locator('[data-slot="panel-section-card"]')
.getByRole("heading", { exact: true, name: "Details" }),
).toHaveCount(0);
const untitledChannelSections = [
page.getByTestId("channel-canvas-ingress-section"),
page.getByTestId("channel-management-actions"),
];
for (const section of untitledChannelSections) {
await expect(
section.locator('[data-slot="panel-section-header"]'),
).toHaveCount(0);
}
for (const rowTestId of [
"channel-management-type",
"channel-management-visibility",
"channel-management-member-count",
"channel-management-channel-id",
]) {
await expect(
page
.getByTestId(rowTestId)
.locator(":scope > [data-slot='field-row-icon']"),
).toHaveCount(0);
}
const leaveChannelRow = page.getByTestId("channel-management-leave");
await expect(
leaveChannelRow.locator(":scope > svg[data-slot='field-row-icon']"),
).toHaveCount(1);
await expect(
leaveChannelRow.locator(":scope > span.rounded-full"),
).toHaveCount(0);
const canvasInfo = page.getByTestId("channel-canvas-ingress-info");
await canvasInfo.hover();
await expect(page.getByRole("tooltip")).toHaveText(
"Use the canvas as a shared space for notes, plans, and other channel information.",
);
const nameEdit = page.getByTestId("channel-management-edit");
await expect(nameEdit).toBeVisible();
const nameEditIcon = page.getByTestId("channel-management-edit-icon");
await expect(nameEditIcon).toHaveCSS("opacity", "0");
await nameEdit.hover();
await expect(nameEditIcon).toHaveCSS("opacity", "1");
await expect(
page.getByTestId("channel-management-edit-description"),
).toHaveCount(0);
await expect(
page.getByTestId("channel-management-edit-channel-type"),
).toHaveCSS("opacity", "0");
await expect(
page.getByTestId("channel-management-edit-visibility"),
).toHaveCSS("opacity", "0");
await expect(page.getByTestId("channel-management-archive")).toBeVisible();
await expect(page.getByTestId("channel-management-delete")).toBeVisible();
await expect(
page.getByTestId("channel-management-copy-id-action"),
).toHaveCount(0);
const layoutOrder = await page
.getByTestId("channel-management-sheet")
.locator(
"[data-testid='channel-management-member-count'], [data-testid='channel-management-channel-id'], [data-testid='channel-canvas-ingress'], [data-testid='channel-management-leave']",
)
.evaluateAll((elements) =>
elements.map((element) => element.getAttribute("data-testid")),
);
expect(layoutOrder).toEqual([
"channel-management-member-count",
"channel-management-channel-id",
"channel-canvas-ingress",
"channel-management-leave",
]);
const channelIdRow = page.getByTestId("channel-management-channel-id");
const channelIdCopyStatus = page.getByTestId(
"channel-management-channel-id-copy-status",
);
await expect(channelIdRow.getByTitle(GENERAL_CHANNEL_ID)).toHaveText(
"9a1657ac…fb50",
);
await expect(channelIdCopyStatus).toHaveCSS("opacity", "0");
await channelIdRow.hover();
await expect(channelIdCopyStatus).toHaveCSS("opacity", "1");
await channelIdRow.click();
await expect(channelIdRow).toHaveAttribute("aria-label", "Channel ID copied");
await expect(channelIdCopyStatus).toHaveAttribute("data-copied", "true");
for (const editTrigger of [
nameEdit,
page.getByTestId("channel-management-description"),
page.getByTestId("channel-management-type"),
page.getByTestId("channel-management-visibility"),
]) {
await editTrigger.click();
const editDialog = page.getByRole("dialog", {
name: /Edit (?:public|private) channel/,
});
await expect(editDialog).toBeVisible();
await expect(editDialog).toHaveClass(/data-\[state=open\]:animate-in/);
await expect(editDialog).toHaveClass(/data-\[state=open\]:fade-in-0/);
await expect(editDialog).toHaveClass(/data-\[state=open\]:zoom-in-95/);
await editDialog.getByRole("button", { name: "Cancel" }).click();
await expect(editDialog).not.toBeVisible();
}
await page.getByTestId("channel-management-member-count").click();
await expect(page.getByTestId("channel-management-sheet")).toBeVisible();
const membersDialog = page.getByTestId("members-sidebar");
await expect(membersDialog).toBeVisible();
await expect(membersDialog).toHaveClass(/data-\[state=open\]:animate-in/);
await expect(membersDialog).toHaveClass(/data-\[state=open\]:fade-in-0/);
await expect(membersDialog).toHaveClass(/data-\[state=open\]:zoom-in-95/);
await membersDialog.getByRole("button", { name: "Close" }).click();
await expect(membersDialog).not.toBeVisible();
await expect(page.getByTestId("channel-management-sheet")).toBeVisible();
await page
.getByTestId("channel-management-sheet")
.getByTestId("auxiliary-panel-close")
.click();
await expect(page.getByTestId("channel-management-sheet")).not.toBeVisible();
await openChannelManagement(page, "random");
await expect(page.getByTestId("channel-management-edit")).toHaveCount(0);
await expect(
page.getByTestId("channel-management-edit-description"),
).toHaveCount(0);
await expect(
page.getByTestId("channel-management-edit-channel-type"),
).toHaveCount(0);
await expect(
page.getByTestId("channel-management-edit-visibility"),
).toHaveCount(0);
await expect(page.getByTestId("channel-management-archive")).toHaveCount(0);
await expect(page.getByTestId("channel-management-delete")).toHaveCount(0);
await expect(page.getByTestId("channel-management-leave")).toBeVisible();
});
test("direct message settings omit the channel name and empty actions card", async ({
page,
}) => {
await page.goto("/");
await openChannelManagement(page, "alice-tyler");
await expect(page.getByTestId("channel-management-hero")).toBeVisible();
await expect(page.getByTestId("channel-management-name-row")).toHaveCount(0);
await expect(page.getByTestId("channel-management-description")).toHaveCount(
0,
);
await expect(page.getByTestId("channel-management-actions")).toHaveCount(0);
await expect(
page
.getByTestId("channel-management-details")
.locator('[data-slot="panel-section-card"]')
.getByRole("heading", { exact: true, name: "Details" }),
).toHaveCount(0);
});
test("channel settings only prompt editors to add an empty description", async ({
page,
}) => {
await page.goto("/");
await page.waitForFunction(
() =>
typeof window.__BUZZ_E2E_MUTATE_CHANNEL__ === "function" &&
typeof window.__BUZZ_E2E_INVALIDATE_CHANNELS__ === "function",
);
await page.evaluate(
async ({ generalChannelId, randomChannelId }) => {
const bridge = window as Window & {
__BUZZ_E2E_INVALIDATE_CHANNELS__: () => Promise<void>;
__BUZZ_E2E_MUTATE_CHANNEL__: (options: {
channelId: string;
description?: string;
}) => void;
};
bridge.__BUZZ_E2E_MUTATE_CHANNEL__({
channelId: generalChannelId,
description: "",
});
bridge.__BUZZ_E2E_MUTATE_CHANNEL__({
channelId: randomChannelId,
description: "",
});
await bridge.__BUZZ_E2E_INVALIDATE_CHANNELS__();
},
{
generalChannelId: GENERAL_CHANNEL_ID,
randomChannelId: RANDOM_CHANNEL_ID,
},
);
await openChannelManagement(page, "general");
await expect(page.getByTestId("channel-management-edit")).toBeVisible();
await expect(page.getByTestId("channel-management-description")).toHaveText(
"Add a description",
);
await closeChannelManagement(page);
await openChannelManagement(page, "random");
await expect(page.getByTestId("channel-management-edit")).toHaveCount(0);
await expect(page.getByTestId("channel-management-description")).toHaveCount(
0,
);
});
test("manage channel updates visibility and ephemeral lifecycle independently", async ({
page,
}) => {
await page.goto("/");
await openChannelManagement(page, "general");
await openChannelEditDialog(page);
let saveChangesButton = page.getByTestId("channel-management-save-changes");
await expect(saveChangesButton).toBeDisabled();
await page.getByTestId("channel-management-permissions").click();
await page
.getByTestId("channel-management-permissions-option-private")
.click();
await expect(
page.getByRole("dialog", { name: "Edit private channel" }),
).toBeVisible();
await expect(
page.getByTestId("channel-management-permissions"),
).toHaveAccessibleName("Visibility: Private");
await page.getByTestId("channel-management-channel-type").click();
await page.getByLabel("Temporary channel").click();
await expect(page.getByTestId("channel-management-ttl")).toBeVisible();
await expect(saveChangesButton).toBeEnabled();
const commandCountBeforeEnable = (await readCommandPayloadLog(page)).length;
await saveChangesButton.click();
await expect
.poll(async () =>
(await readCommandPayloadLog(page)).slice(commandCountBeforeEnable),
)
.toContainEqual(
expect.objectContaining({
command: "update_channel",
payload: expect.objectContaining({
input: expect.objectContaining({ ttlSeconds: 604800 }),
}),
}),
);
await expect(
page.getByRole("dialog", {
name: /Edit (?:public|private) channel/,
}),
).toHaveCount(0);
const channelAfterEnable = await invokeMockCommand<{
ttl_seconds: number | null;
visibility: string;
}>(page, "get_channel_details", {
channelId: "9a1657ac-f7aa-5db0-b632-d8bbeb6dfb50",
});
expect(channelAfterEnable).toMatchObject({
ttl_seconds: 604800,
visibility: "private",
});
await closeChannelManagement(page);
await openChannelManagement(page, "general");
await openChannelEditDialog(page);
saveChangesButton = page.getByTestId("channel-management-save-changes");
await expect(
page.getByTestId("channel-management-permissions"),
).toHaveAccessibleName("Visibility: Private");
await expect(
page.getByTestId("channel-management-channel-type"),
).toContainText("Temporary");
await expect(page.getByTestId("channel-management-ttl")).toContainText(
"7 days",
);
await page.getByTestId("channel-management-permissions").click();
await page.getByTestId("channel-management-permissions-option-open").click();
await expect(
page.getByRole("dialog", { name: "Edit public channel" }),
).toBeVisible();
await expect(
page.getByTestId("channel-management-permissions"),
).toHaveAccessibleName("Visibility: Public");
await page.getByTestId("channel-management-channel-type").click();
await page.getByLabel("Ongoing channel").click();
await expect(saveChangesButton).toBeEnabled();
const commandCountBeforeDisable = (await readCommandPayloadLog(page)).length;
await saveChangesButton.click();
await expect
.poll(async () =>
(await readCommandPayloadLog(page)).slice(commandCountBeforeDisable),
)
.toContainEqual(
expect.objectContaining({
command: "update_channel",
payload: expect.objectContaining({
input: expect.objectContaining({ ttlSeconds: null }),
}),
}),
);
await expect(
page.getByRole("dialog", {
name: /Edit (?:public|private) channel/,
}),
).toHaveCount(0);
const channelAfterDisable = await invokeMockCommand<{
ttl_seconds: number | null;
visibility: string;
}>(page, "get_channel_details", {
channelId: "9a1657ac-f7aa-5db0-b632-d8bbeb6dfb50",
});
expect(channelAfterDisable).toMatchObject({
ttl_seconds: null,
visibility: "open",
});
await closeChannelManagement(page);
await openChannelManagement(page, "general");
await openChannelEditDialog(page);
await expect(
page.getByTestId("channel-management-permissions"),
).toHaveAccessibleName("Visibility: Public");
await expect(
page.getByTestId("channel-management-channel-type"),
).toContainText("Ongoing");
await expect(page.getByTestId("channel-management-ttl")).toHaveCount(0);
});
test("manage channel places canvas between channel info and actions", async ({
page,
}) => {
await page.goto("/");
await openChannelManagement(page, "general");
const sheet = page.getByTestId("channel-management-sheet");
const sheetBox = await sheet.boundingBox();
const timelineBox = await page.getByTestId("message-timeline").boundingBox();
// Canvas ingress separates the channel information from channel actions.
const canvasBox = await sheet
.getByTestId("channel-canvas-ingress")
.boundingBox();
const channelIdBox = await sheet
.getByTestId("channel-management-channel-id")
.boundingBox();
const leaveBox = await sheet
.getByTestId("channel-management-leave")
.boundingBox();
expect(sheetBox).not.toBeNull();
expect(timelineBox).not.toBeNull();
if (!sheetBox || !timelineBox) {
throw new Error("Expected channel management panel and timeline boxes.");
}
expect(timelineBox.x + timelineBox.width).toBeLessThanOrEqual(sheetBox.x + 1);
await page.mouse.click(timelineBox.x + 24, timelineBox.y + 180);
await expect(sheet).toBeVisible();
await page.keyboard.press("Escape");
await expect(sheet).toBeVisible();
await page.setViewportSize({ height: 720, width: 820 });
await expect(page.getByTestId("message-timeline")).toHaveCount(0);
await expect(page.getByTestId("channel-drop-zone")).toHaveCount(0);
await expect(sheet).toBeVisible();
const narrowSheetBox = await sheet.boundingBox();
if (!narrowSheetBox) {
throw new Error("Expected narrow channel management panel box.");
}
expect(narrowSheetBox.width).toBeGreaterThan(500);
expect(canvasBox).not.toBeNull();
expect(channelIdBox).not.toBeNull();
expect(leaveBox).not.toBeNull();
expect(channelIdBox?.y).toBeLessThan(canvasBox?.y);
expect(canvasBox?.y).toBeLessThan(leaveBox?.y);
});
test("channel settings hides workflows and skips its query when the experiment is disabled", async ({
page,
}) => {
await page.addInitScript((key) => {
const overrides = JSON.parse(
window.localStorage.getItem(key) ?? "{}",
) as Record<string, boolean>;
overrides.workflows = false;
window.localStorage.setItem(key, JSON.stringify(overrides));
}, FEATURE_OVERRIDES_STORAGE_KEY);
await page.goto("/");
await openChannelManagement(page, "general");
await expect(page.getByTestId("channel-management-sheet")).toBeVisible();
await expect(page.getByTestId("channel-workflows-ingress")).toHaveCount(0);
await expect
.poll(() =>
page.evaluate(() =>
(
window as Window & {
__BUZZ_E2E_COMMAND_LOG__?: Array<{ command: string }>;
}
).__BUZZ_E2E_COMMAND_LOG__?.some(
({ command }) => command === "get_channel_workflows",
),
),
)
.toBe(false);
});
test("channel settings opens and creates channel workflows over the channel", async ({
page,
}) => {
await page.goto("/");
await invokeMockCommand(page, "create_workflow", {
channelId: GENERAL_CHANNEL_ID,
yamlDefinition:
"name: Welcome responder\ntrigger:\n on: message_posted\nsteps:\n - id: reply\n run: send_message\n with:\n text: Welcome\n",
});
await openChannelManagement(page, "general");
const sheet = page.getByTestId("channel-management-sheet");
const canvasBox = await sheet
.getByTestId("channel-canvas-ingress")
.boundingBox();
const workflowsBox = await sheet
.getByTestId("channel-workflows-ingress")
.boundingBox();
expect(canvasBox).not.toBeNull();
expect(workflowsBox).not.toBeNull();
expect(canvasBox?.y).toBeLessThan(workflowsBox?.y);
await sheet.getByTestId("channel-workflows-ingress").click();
await expect(sheet.getByText("Workflows", { exact: true })).toBeVisible();
await expect(sheet.getByTestId("channel-workflows-list")).toContainText(
"Welcome responder",
);
// Opening a workflow keeps the settings Workflows view mounted beneath the
// shared editor; the channel route stays put behind both layers.
const channelUrl = new RegExp(`/channels/${GENERAL_CHANNEL_ID}(?:\\?|$)`);
await sheet.getByTestId("channel-workflow-mock-wf-1").click();
await expect(
page.getByRole("dialog", { name: "Edit workflow" }),
).toBeVisible();
await expect(page).toHaveURL(channelUrl);
await expect(sheet).toBeVisible();
await expect(sheet.getByText("Workflows", { exact: true })).toBeVisible();
await expect(page.getByTestId("message-timeline")).toBeVisible();
const overlayEditor = page.getByRole("dialog", { name: "Edit workflow" });
await overlayEditor.getByRole("tab", { name: "YAML" }).click();
const overlayYaml = overlayEditor.getByRole("textbox", {
name: "Workflow YAML",
});
await overlayYaml.fill(
(await overlayYaml.inputValue()).replace(
"Welcome responder",
"Unsaved welcome responder",
),
);
await overlayEditor.getByRole("button", { name: "Workflow actions" }).click();
await page.getByRole("menuitem", { name: "Duplicate" }).click();
const discardConfirmation = page.getByRole("alertdialog", {
name: "Discard changes?",
});
await expect(discardConfirmation).toBeVisible();
await discardConfirmation
.getByRole("button", { name: "Keep editing" })
.click();
await expect(overlayEditor).toBeVisible();
await expect(overlayYaml).toContainText("Unsaved welcome responder");
await expect(
page.getByRole("dialog", { name: "Duplicate workflow" }),
).toHaveCount(0);
await page.getByRole("button", { name: "Close" }).click();
await page
.getByRole("alertdialog", { name: "Discard changes?" })
.getByRole("button", { name: "Discard changes" })
.click();
await expect(page.getByRole("dialog", { name: "Edit workflow" })).toHaveCount(
0,
);
await expect(page).toHaveURL(channelUrl);
await expect(page.getByTestId("chat-title")).toHaveText("general");
await expect(sheet).toBeVisible();
await expect(sheet.getByText("Workflows", { exact: true })).toBeVisible();
await expect(sheet.getByTestId("channel-workflows-list")).toContainText(
"Welcome responder",
);
await page.getByTestId("channel-workflows-new").click();
await expect(
page.getByRole("dialog", { name: "Create workflow" }),
).toBeVisible();
await expect(page).toHaveURL(channelUrl);
await expect(
page.getByRole("combobox", { exact: true, name: "Channel" }),
).toContainText("general");
await page.getByRole("button", { name: "Cancel" }).click();
await expect(
page.getByRole("dialog", { name: "Create workflow" }),
).toHaveCount(0);
await expect(page).toHaveURL(channelUrl);
await expect(page.getByTestId("chat-title")).toHaveText("general");
await expect(sheet).toBeVisible();
await expect(sheet.getByText("Workflows", { exact: true })).toBeVisible();
await expect(sheet.getByTestId("channel-workflows-new")).toBeVisible();
});
async function seedHomeInboxMention(
page: import("@playwright/test").Page,
itemId: string,
tags?: string[][],
{ navigate = true }: { navigate?: boolean } = {},
) {
if (navigate) {
await page.goto("/");
}
await expect(page.getByTestId("home-inbox-list")).toBeVisible();
await page.waitForFunction(
() =>
typeof (window as MockFeedWindow).__BUZZ_E2E_PUSH_MOCK_FEED_ITEM__ ===
"function",
);
await page.evaluate(
({
channelId,
createdAt,
currentPubkey,
itemId: id,
senderPubkey,
tags: seededTags,
}) => {
const pushFeedItem = (window as MockFeedWindow)
.__BUZZ_E2E_PUSH_MOCK_FEED_ITEM__;
if (!pushFeedItem) {
throw new Error("Mock feed injection helper is not installed.");
}
pushFeedItem({
id,
kind: 9,
pubkey: senderPubkey,
content: "Please review the home panel routing.",
created_at: createdAt,
channel_id: channelId,
channel_name: "general",
tags: seededTags ?? [
["e", channelId],
["p", currentPubkey],
],
category: "mention",
});
},
{
channelId: GENERAL_CHANNEL_ID,
createdAt: Math.floor(Date.now() / 1000),
currentPubkey: TEST_IDENTITIES.tyler.pubkey,
itemId,
senderPubkey: TEST_IDENTITIES.alice.pubkey,
tags,
},
);
await page.getByTestId(`home-inbox-item-${itemId}`).click();
}
test("Inbox All excludes generic channel traffic", async ({ page }) => {
await page.goto("/");
await page.waitForFunction(() => {
const win = window as MockFeedWindow;
return typeof win.__BUZZ_E2E_PUSH_MOCK_FEED_ITEM__ === "function";
});
await page.evaluate(
({ channelId, currentPubkey, senderPubkey }) => {
const pushFeedItem = (window as MockFeedWindow)
.__BUZZ_E2E_PUSH_MOCK_FEED_ITEM__;
if (!pushFeedItem) throw new Error("Mock feed helper is not installed.");
const now = Math.floor(Date.now() / 1000);
pushFeedItem({
category: "activity",
channel_id: channelId,
channel_name: "general",
channel_type: "stream",
content: "Ordinary channel traffic",
created_at: now,
id: "inbox-generic-channel-message",
kind: 9,
pubkey: senderPubkey,
tags: [["h", channelId]],
});
pushFeedItem({
category: "mention",
channel_id: channelId,
channel_name: "general",
channel_type: "stream",
content: "A message that needs my attention",
created_at: now + 1,
id: "inbox-personal-mention",
kind: 9,
pubkey: senderPubkey,
tags: [
["h", channelId],
["p", currentPubkey],
],
});
},
{
channelId: GENERAL_CHANNEL_ID,
currentPubkey: TEST_IDENTITIES.tyler.pubkey,
senderPubkey: TEST_IDENTITIES.alice.pubkey,
},
);
await expect(
page.getByTestId("home-inbox-item-inbox-personal-mention"),
).toBeVisible();
await expect(
page.getByTestId("home-inbox-item-inbox-generic-channel-message"),
).toHaveCount(0);
});
test("Inbox type labels keep the same height with and without a channel chip", async ({
page,
}) => {
const dmId = "inbox-type-label-dm";
const mentionId = "inbox-type-label-mention";
const dmChannelId = "f48efb06-0c93-5025-aac9-2e646bb6bfa8";
await page.goto("/");
await expect(page.getByTestId("home-inbox-list")).toBeVisible();
await page.waitForFunction(() => {
const win = window as MockFeedWindow;
return (
typeof win.__BUZZ_E2E_EMIT_MOCK_MESSAGE__ === "function" &&
typeof win.__BUZZ_E2E_PUSH_MOCK_FEED_ITEM__ === "function"
);
});
await page.evaluate(
({
channelId,
currentPubkey,
dmChannelId: directChannelId,
dmId: directId,
mentionId: channelMentionId,
senderPubkey,
}) => {
const win = window as MockFeedWindow;
const emitMessage = win.__BUZZ_E2E_EMIT_MOCK_MESSAGE__;
const pushFeedItem = win.__BUZZ_E2E_PUSH_MOCK_FEED_ITEM__;
if (!emitMessage || !pushFeedItem) {
throw new Error("Mock bridge helpers are not installed.");
}
const createdAt = Math.floor(Date.now() / 1_000);
const directMessage = emitMessage({
channelName: "alice-tyler",
content: "A direct message without a channel chip",
createdAt,
id: directId,
pubkey: senderPubkey,
});
pushFeedItem({
category: "activity",
channel_id: directChannelId,
channel_name: "alice-tyler",
channel_type: null,
content: directMessage.content,
created_at: directMessage.created_at,
id: directMessage.id,
kind: directMessage.kind,
pubkey: directMessage.pubkey,
tags: directMessage.tags,
});
pushFeedItem({
category: "mention",
channel_id: channelId,
channel_name: "general",
channel_type: "stream",
content: "A channel mention with a channel chip",
created_at: createdAt + 1,
id: channelMentionId,
kind: 9,
pubkey: senderPubkey,
tags: [
["h", channelId],
["p", currentPubkey],
],
});
},
{
channelId: GENERAL_CHANNEL_ID,
currentPubkey: MOCK_IDENTITY_PUBKEY,
dmChannelId,
dmId,
mentionId,
senderPubkey: TEST_IDENTITIES.alice.pubkey,
},
);
const dmLabel = page
.getByTestId(`home-inbox-item-${dmId}`)
.locator('[data-inbox-type-label=""]');
const mentionLabel = page
.getByTestId(`home-inbox-item-${mentionId}`)
.locator('[data-inbox-type-label=""]');
await expect(dmLabel).toContainText("DM from alice");
await expect(dmLabel.locator('[data-channel-link=""]')).toHaveCount(0);
await expect(mentionLabel).toContainText("Mentioned in");
await expect(mentionLabel.locator('[data-channel-link=""]')).toHaveText(
"#general",
);
const [dmBox, mentionBox] = await Promise.all([
dmLabel.boundingBox(),
mentionLabel.boundingBox(),
]);
expect(dmBox).not.toBeNull();
expect(mentionBox).not.toBeNull();
expect(
Math.abs((dmBox?.height ?? 0) - (mentionBox?.height ?? 0)),
).toBeLessThan(0.5);
});
test("Inbox All never lists drafts and unread-only hides reminders", async ({
page,
}) => {
const draftKey = `channel:${GENERAL_CHANNEL_ID}`;
await page.addInitScript(
({ draftStoreKey, draftStorageKey }) => {
const timestamp = new Date().toISOString();
window.localStorage.setItem(
draftStoreKey,
JSON.stringify({
[draftStorageKey]: {
channelId: "9a1657ac-f7aa-5db0-b632-d8bbeb6dfb50",
content: "Finish the mixed Inbox test",
createdAt: timestamp,
pendingImeta: [],
selectionEnd: 30,
selectionStart: 30,
spoileredAttachmentUrls: [],
status: "active",
updatedAt: timestamp,
},
}),
);
},
{
draftStorageKey: draftKey,
draftStoreKey: `buzz-drafts.v2:ws://localhost:3000:${MOCK_IDENTITY_PUBKEY}`,
},
);
await page.goto("/");
await page.waitForFunction(() => {
const win = window as MockFeedWindow;
return typeof win.__BUZZ_E2E_PUSH_MOCK_FEED_ITEM__ === "function";
});
const reminderId = "inbox-unread-only-reminder";
const messageId = "inbox-unread-only-message";
await page.evaluate(
async ({
channelId,
currentPubkey,
messageId,
reminderId,
senderPubkey,
}) => {
const now = Math.floor(Date.now() / 1_000);
window.__BUZZ_E2E_SEED_MOCK_REMINDERS__?.([
{
id: reminderId,
pubkey: currentPubkey,
created_at: now - 120,
kind: 30300,
tags: [
["d", reminderId],
["not_before", String(now - 60)],
],
content: JSON.stringify({
target: {
eventId: "mock-general-alice",
channelId,
preview: "Due reminder in mixed Inbox",
authorPubkey: senderPubkey,
},
status: "pending",
}),
sig: "mocksig".repeat(20).slice(0, 128),
},
]);
await window.__BUZZ_E2E_QUERY_CLIENT__?.invalidateQueries({
queryKey: ["reminders"],
});
const pushFeedItem = (window as MockFeedWindow)
.__BUZZ_E2E_PUSH_MOCK_FEED_ITEM__;
if (!pushFeedItem) throw new Error("Mock feed helper is not installed.");
pushFeedItem({
category: "mention",
channel_id: channelId,
channel_name: "general",
channel_type: "stream",
content: "Unread message in mixed Inbox",
created_at: now,
id: messageId,
kind: 9,
pubkey: senderPubkey,
tags: [
["h", channelId],
["p", currentPubkey],
],
});
},
{
channelId: GENERAL_CHANNEL_ID,
currentPubkey: MOCK_IDENTITY_PUBKEY,
messageId,
reminderId,
senderPubkey: TEST_IDENTITIES.alice.pubkey,
},
);
const messageRow = page.getByTestId(`home-inbox-item-${messageId}`);
const reminderRow = page.getByTestId(`home-all-reminders-${reminderId}`);
const draftRow = page.getByTestId(`home-all-drafts-${draftKey}`);
await expect(messageRow).toBeVisible();
await expect(reminderRow).toBeVisible();
// Drafts belong to the dedicated Drafts filter — never the mixed All view.
await expect(draftRow).toHaveCount(0);
await page.getByTestId("inbox-options-trigger").click();
await page.getByRole("switch", { name: "Show unread only" }).click();
await expect(messageRow).toBeVisible();
await expect(reminderRow).toHaveCount(0);
await expect(draftRow).toHaveCount(0);
// The draft is still reachable under the Drafts filter.
await page.keyboard.press("Escape");
await page.getByTestId("inbox-filter-trigger").click();
await page.getByRole("menuitemradio", { name: "Drafts" }).click();
await expect(page.getByTestId("home-inbox-drafts")).toBeVisible();
});
test("Inbox merges a due reminder into its represented conversation", async ({
page,
}) => {
const messageId = "inbox-reminder-merge-message";
const reminderId = "inbox-reminder-merge";
await seedHomeInboxMention(page, messageId);
await page.evaluate(
async ({
authorPubkey,
channelId,
messageId: targetEventId,
pubkey,
reminderId: id,
}) => {
const now = Math.floor(Date.now() / 1_000);
window.__BUZZ_E2E_SEED_MOCK_REMINDERS__?.([
{
id,
pubkey,
created_at: now - 600,
kind: 30300,
tags: [
["d", id],
["not_before", String(now - 60)],
],
content: JSON.stringify({
target: {
eventId: targetEventId,
channelId,
preview: "Please review the home panel routing.",
authorPubkey,
},
status: "pending",
}),
sig: "mocksig".repeat(20).slice(0, 128),
},
]);
await window.__BUZZ_E2E_QUERY_CLIENT__?.invalidateQueries({
queryKey: ["reminders"],
});
},
{
authorPubkey: TEST_IDENTITIES.alice.pubkey,
channelId: GENERAL_CHANNEL_ID,
messageId,
pubkey: MOCK_IDENTITY_PUBKEY,
reminderId,
},
);
const conversationRow = page.getByTestId(`home-inbox-item-${messageId}`);
await expect(conversationRow.getByText("Reminder due")).toBeVisible();
await expect(
page.getByTestId(`home-all-reminders-${reminderId}`),
).toHaveCount(0);
await page.getByTestId("inbox-filter-trigger").click();
await page.getByRole("menuitemradio", { name: "Mentions" }).click();
await expect(conversationRow.getByText("Reminder due")).toBeVisible();
});
test("Inbox All keeps its filter when opening a due reminder", async ({
page,
}) => {
await page.goto("/");
await expect(page.getByTestId("home-inbox")).toBeVisible();
const reminderId = "inbox-stable-reminder";
await page.evaluate(
async ({ channelId, id, pubkey }) => {
const now = Math.floor(Date.now() / 1000);
window.__BUZZ_E2E_SEED_MOCK_REMINDERS__?.([
{
id,
pubkey,
created_at: now - 300,
kind: 30300,
tags: [
["d", id],
["not_before", String(now - 60)],
],
content: JSON.stringify({
target: {
eventId: "mock-general-alice",
channelId,
preview: "Review the Inbox behavior",
authorPubkey: pubkey,
},
status: "pending",
}),
sig: "mocksig".repeat(20).slice(0, 128),
},
]);
await window.__BUZZ_E2E_QUERY_CLIENT__?.invalidateQueries({
queryKey: ["reminders"],
});
},
{
channelId: GENERAL_CHANNEL_ID,
id: reminderId,
pubkey: MOCK_IDENTITY_PUBKEY,
},
);
const reminderRow = page.getByTestId(`home-all-reminders-${reminderId}`);
await expect(reminderRow).toBeVisible();
await reminderRow.click();
await expect(page.getByTestId("inbox-filter-trigger")).toContainText("All");
await expect(page.getByTestId("home-reminder-detail")).toBeVisible();
await expect(page.getByTestId("home-inbox-list")).toBeVisible();
});
test("Inbox reminder rows and detail identify DM context", async ({ page }) => {
await page.goto("/");
await expect(page.getByTestId("home-inbox")).toBeVisible();
const reminderId = "inbox-dm-reminder";
const dmChannelId = "f48efb06-0c93-5025-aac9-2e646bb6bfa8";
await page.evaluate(
async ({ authorPubkey, channelId, currentPubkey, reminderId }) => {
const now = Math.floor(Date.now() / 1_000);
window.__BUZZ_E2E_SEED_MOCK_REMINDERS__?.([
{
id: reminderId,
pubkey: currentPubkey,
created_at: now - 300,
kind: 30300,
tags: [
["d", reminderId],
["not_before", String(now - 60)],
],
content: JSON.stringify({
target: {
eventId: "mock-dm-alice",
channelId,
preview: "Follow up with Alice",
authorPubkey,
},
status: "pending",
}),
sig: "mocksig".repeat(20).slice(0, 128),
},
]);
await window.__BUZZ_E2E_QUERY_CLIENT__?.invalidateQueries({
queryKey: ["reminders"],
});
},
{
authorPubkey: TEST_IDENTITIES.alice.pubkey,
channelId: dmChannelId,
currentPubkey: MOCK_IDENTITY_PUBKEY,
reminderId,
},
);
await page.getByTestId("inbox-filter-trigger").click();
await page.getByRole("menuitemradio", { name: "Reminders" }).click();
const reminderRow = page.getByTestId(`home-reminder-item-${reminderId}`);
await expect(
reminderRow.getByText("DM with alice-tyler", { exact: true }),
).toBeVisible();
await reminderRow.getByRole("button").click();
const detail = page.getByTestId("home-reminder-detail");
await expect(
detail.getByText("DM with alice-tyler", { exact: true }),
).toBeVisible();
await expect(detail.getByText("#alice-tyler", { exact: true })).toHaveCount(
0,
);
});
test("Inbox detail title and source action navigate to the conversation", async ({
page,
}) => {
await seedHomeInboxMention(page, "mock-feed-home-channel-navigate");
const detail = page.getByTestId("home-inbox-detail");
await expect(detail.getByRole("heading")).toHaveText("Message in #general");
await expect(
detail.getByRole("button", { name: "Open in channel" }),
).toBeVisible();
await detail.getByTestId("home-inbox-context-title").click();
await expect(page).toHaveURL(
new RegExp(`#/channels/${GENERAL_CHANNEL_ID}\\?`),
);
await expect(page).toHaveURL(/messageId=mock-feed-home-channel-navigate/);
await expect(page).not.toHaveURL(/threadRootId=/);
await expect(page.getByTestId("message-timeline")).toBeVisible();
await expect(page.getByTestId("home-inbox-list")).toHaveCount(0);
});
test("home inbox thread reply mention carries threadRootId to the channel", async ({
page,
}) => {
const rootEventId = "mock-feed-home-thread-root";
await seedHomeInboxMention(page, "mock-feed-home-thread-navigate", [
["e", rootEventId, "", "root"],
["e", "mock-feed-home-thread-parent", "", "reply"],
["p", TEST_IDENTITIES.tyler.pubkey],
]);
const detail = page.getByTestId("home-inbox-detail");
await expect(detail.getByRole("heading")).toHaveText("Thread in #general");
await expect(detail.getByTestId("message-unread-divider")).toBeVisible();
await detail.getByRole("button", { name: "Open full thread" }).click();
await expect(page).toHaveURL(
new RegExp(`#/channels/${GENERAL_CHANNEL_ID}\\?`),
);
await expect(page).toHaveURL(/messageId=mock-feed-home-thread-navigate/);
await expect(page).toHaveURL(new RegExp(`threadRootId=${rootEventId}`));
await expect(page.getByTestId("message-timeline")).toBeVisible();
await expect(page.getByTestId("home-inbox-list")).toHaveCount(0);
});
test("Inbox filter changes preserve valid detail and directly select a replacement", async ({
page,
}) => {
const threadItemId = "inbox-filter-thread";
const actionItemId = "inbox-filter-action";
await seedHomeInboxMention(page, threadItemId, [
["e", "inbox-filter-root", "", "root"],
["e", "inbox-filter-parent", "", "reply"],
["p", TEST_IDENTITIES.tyler.pubkey],
]);
await page.evaluate(
({ actionId, channelId, senderPubkey }) => {
const pushFeedItem = (window as MockFeedWindow)
.__BUZZ_E2E_PUSH_MOCK_FEED_ITEM__;
if (!pushFeedItem) throw new Error("Mock feed helper is not installed.");
pushFeedItem({
category: "needs_action",
channel_id: channelId,
channel_name: "general",
channel_type: "stream",
content: "Approve the replacement selection",
created_at: Math.floor(Date.now() / 1_000) + 120,
id: actionId,
kind: 46010,
pubkey: senderPubkey,
tags: [["h", channelId]],
});
},
{
actionId: actionItemId,
channelId: GENERAL_CHANNEL_ID,
senderPubkey: TEST_IDENTITIES.alice.pubkey,
},
);
await page.getByTestId("inbox-filter-trigger").click();
await page.getByRole("menuitemradio", { name: "Threads" }).click();
await expect(
page.getByTestId(`home-inbox-item-${threadItemId}`),
).toHaveAttribute("aria-current", "true");
await expect(page.getByTestId("home-inbox-detail")).toContainText(
"Please review the home panel routing.",
);
await expect(
page.getByRole("menuitemradio", { name: "Threads" }),
).toHaveCount(0);
await page.getByTestId("inbox-filter-trigger").click();
await page.getByRole("menuitemradio", { name: "Needs action" }).click();
await expect(
page.getByTestId(`home-inbox-item-${actionItemId}`),
).toHaveAttribute("aria-current", "true");
await expect(page.getByTestId("home-inbox-detail")).toContainText(
"Approve the replacement selection",
);
});
test("Inbox keeps the unread boundary for replies from multiple agents", async ({
page,
}) => {
await page.goto("/");
await expect(page.getByTestId("home-inbox-list")).toBeVisible();
await page.waitForFunction(() => {
const win = window as MockFeedWindow;
return (
typeof win.__BUZZ_E2E_EMIT_MOCK_MESSAGE__ === "function" &&
typeof win.__BUZZ_E2E_PUSH_MOCK_FEED_ITEM__ === "function"
);
});
const replyIds = [
"inbox-agent-reply-first",
"inbox-agent-reply-second",
"inbox-agent-reply-third",
];
await page.evaluate(
({ agentPubkeys, channelId, currentPubkey, ids }) => {
const win = window as MockFeedWindow;
const emitMessage = win.__BUZZ_E2E_EMIT_MOCK_MESSAGE__;
const pushFeedItem = win.__BUZZ_E2E_PUSH_MOCK_FEED_ITEM__;
if (!emitMessage || !pushFeedItem) {
throw new Error("Mock bridge helpers are not installed.");
}
const createdAt = Math.floor(Date.now() / 1000) + 60;
const root = emitMessage({
channelName: "general",
content: "Agent collaboration thread",
createdAt: createdAt - 10,
id: "inbox-agent-thread-root",
pubkey: currentPubkey,
});
const contents = [
"First unread agent reply",
"Second unread agent reply",
"Third unread agent reply",
];
contents.forEach((content, index) => {
const event = emitMessage({
channelName: "general",
content,
createdAt: createdAt + index,
id: ids[index],
parentEventId: root.id,
pubkey: agentPubkeys[index % agentPubkeys.length],
});
pushFeedItem({
category: "activity",
channel_id: channelId,
channel_name: "general",
channel_type: "stream",
content: event.content,
created_at: event.created_at,
id: event.id,
kind: event.kind,
pubkey: event.pubkey,
tags: event.tags,
});
});
},
{
agentPubkeys: [
TEST_IDENTITIES.alice.pubkey,
TEST_IDENTITIES.charlie.pubkey,
],
channelId: GENERAL_CHANNEL_ID,
currentPubkey: MOCK_IDENTITY_PUBKEY,
ids: replyIds,
},
);
const firstUnreadRow = page.getByTestId(`home-inbox-item-${replyIds[0]}`);
await expect(firstUnreadRow).toBeVisible();
const inboxAvatar = page.getByTestId(`home-inbox-avatar-${replyIds[0]}`);
await expect(inboxAvatar).toBeVisible();
await inboxAvatar.focus();
await expect(inboxAvatar).toBeFocused();
await expect(inboxAvatar).toHaveCSS("clip-path", "none");
await expect(inboxAvatar).not.toHaveClass(/rounded-squircle/);
await expect(inboxAvatar.locator("[data-avatar-shape='squircle']")).toHaveCSS(
"clip-path",
/url\(["']?#rounded-squircle-clip["']?\)/,
);
await firstUnreadRow.click();
const detail = page.getByTestId("home-inbox-detail");
await expect(detail).toContainText("Agent collaboration thread");
await expect(detail).toContainText("First unread agent reply");
await expect(detail).toContainText("Second unread agent reply");
await expect(detail).toContainText("Third unread agent reply");
await expect(detail.getByTestId("message-unread-divider")).toBeVisible();
await expect(page.getByTestId("home-inbox-selected-message")).toContainText(
"First unread agent reply",
);
});
test("home inbox groups consecutive DMs and opens the full conversation", async ({
page,
}) => {
const dmChannelId = "f48efb06-0c93-5025-aac9-2e646bb6bfa8";
const dmIds = ["inbox-dm-first", "inbox-dm-second", "inbox-dm-third"];
await page.goto("/");
await expect(page.getByTestId("home-inbox-list")).toBeVisible();
await page.waitForFunction(() => {
const win = window as MockFeedWindow;
return (
typeof win.__BUZZ_E2E_EMIT_MOCK_MESSAGE__ === "function" &&
typeof win.__BUZZ_E2E_PUSH_MOCK_FEED_ITEM__ === "function"
);
});
await page.evaluate(
({ channelId, createdAt, ids, senderPubkey }) => {
const win = window as MockFeedWindow;
const emitMessage = win.__BUZZ_E2E_EMIT_MOCK_MESSAGE__;
const pushFeedItem = win.__BUZZ_E2E_PUSH_MOCK_FEED_ITEM__;
if (!emitMessage || !pushFeedItem) {
throw new Error("Mock bridge helpers are not installed.");
}
["First unread DM", "Second unread DM", "Third unread DM"].forEach(
(content, index) => {
const event = emitMessage({
channelName: "alice-tyler",
content,
createdAt: createdAt + index,
id: ids[index],
pubkey: senderPubkey,
});
pushFeedItem({
category: "activity",
channel_id: channelId,
channel_name: "alice-tyler",
channel_type: null,
content: event.content,
created_at: event.created_at,
id: event.id,
kind: event.kind,
pubkey: event.pubkey,
tags: event.tags,
});
},
);
},
{
channelId: dmChannelId,
createdAt: Math.floor(Date.now() / 1000),
ids: dmIds,
senderPubkey: TEST_IDENTITIES.alice.pubkey,
},
);
const firstDmRow = page.getByTestId(`home-inbox-item-${dmIds[0]}`);
await expect(firstDmRow).toBeVisible();
await expect(page.getByTestId(`home-inbox-item-${dmIds[1]}`)).toHaveCount(0);
await expect(page.getByTestId(`home-inbox-item-${dmIds[2]}`)).toHaveCount(0);
await expect(firstDmRow.getByTestId("home-inbox-unread-count")).toHaveText(
"3 unread",
);
await firstDmRow.click();
const detail = page.getByTestId("home-inbox-detail");
await expect(detail.getByRole("heading")).toHaveText("DM with alice");
await expect(detail).toContainText("First unread DM");
await expect(detail).toContainText("Second unread DM");
await expect(detail).toContainText("Third unread DM");
await expect(page.getByTestId("home-inbox-selected-message")).toContainText(
"First unread DM",
);
const unreadBoundary = page.getByTestId("message-unread-divider");
await expect(unreadBoundary).toBeVisible();
await expect(unreadBoundary).toContainText("New");
await expect(
detail.getByRole("button", { name: "Open conversation" }),
).toBeVisible();
});
test("home inbox manage affordance opens management without leaving home", async ({
page,
}) => {
await seedHomeInboxMention(page, "mock-feed-home-channel-panel");
await page
.getByTestId("home-inbox-detail")
.getByTestId("channel-management-trigger")
.click();
await expect(page.getByTestId("channel-management-sheet")).toBeVisible();
await expect(page.getByTestId("channel-management-name-row")).toContainText(
"general",
);
await expect(page.getByTestId("home-inbox-list")).toBeVisible();
const detailBox = await page.getByTestId("home-inbox-detail").boundingBox();
const sheetBox = await page
.getByTestId("channel-management-sheet")
.boundingBox();
if (!detailBox || !sheetBox) {
throw new Error("Expected home detail pane and channel management boxes.");
}
expect(detailBox.x + detailBox.width).toBeLessThanOrEqual(sheetBox.x + 1);
expect(sheetBox.width).toBeGreaterThanOrEqual(300);
const inboxList = page.getByTestId("home-inbox-list");
const inboxListBox = await inboxList.boundingBox();
if (!inboxListBox) {
throw new Error("Expected the home inbox list box.");
}
await inboxList.click({
position: { x: 24, y: inboxListBox.height - 24 },
});
await expect(page.getByTestId("channel-management-sheet")).toBeVisible();
await page.keyboard.press("Escape");
await expect(page.getByTestId("channel-management-sheet")).toBeVisible();
await page.setViewportSize({ height: 720, width: 820 });
await expect(page.getByTestId("home-inbox-list")).toHaveCount(0);
const narrowHomeBox = await page.getByTestId("home-inbox").boundingBox();
const narrowSheetBox = await page
.getByTestId("channel-management-sheet")
.boundingBox();
if (!narrowHomeBox || !narrowSheetBox) {
throw new Error("Expected narrow home and channel management boxes.");
}
expect(narrowSheetBox.width).toBeGreaterThanOrEqual(narrowHomeBox.width - 1);
await expect(page).not.toHaveURL(/#\/channels\//);
});
test("home channel settings keeps agent lifecycle actions scoped to the active community", async ({
page,
}) => {
await page.goto("/");
const agentPubkey = await addGenericAgent(
page,
"general",
`home-sidebar-agent-${Date.now()}`,
);
const baselineCommands = await readCommandLog(page);
const baselineRuntimeStopCount = commandCount(
baselineCommands,
"stop_managed_agent_runtime",
);
const baselineLegacyStopCount = commandCount(
baselineCommands,
"stop_managed_agent",
);
await page.getByRole("button", { exact: true, name: "Inbox" }).click();
await seedHomeInboxMention(
page,
"mock-feed-home-agent-lifecycle",
undefined,
{ navigate: false },
);
await page
.getByTestId("home-inbox-detail")
.getByTestId("channel-management-trigger")
.click();
await page.getByTestId("channel-management-member-count").click();
await expect(page.getByTestId("members-sidebar")).toBeVisible();
const agentStatus = page.getByTestId(
`sidebar-managed-agent-status-${agentPubkey}`,
);
const agentAction = page.getByTestId(`sidebar-agent-action-${agentPubkey}`);
await expect(agentStatus).toContainText("Here");
await openMemberMenu(page, agentPubkey);
await expect(agentAction).toContainText("Stop");
await agentAction.click();
await expect(agentStatus).toContainText("Unavailable");
const commands = await readCommandLog(page);
expect(commandCount(commands, "stop_managed_agent_runtime")).toBe(
baselineRuntimeStopCount + 1,
);
expect(commandCount(commands, "stop_managed_agent")).toBe(
baselineLegacyStopCount,
);
});
test("members sidebar virtualizes large channel rosters", async ({ page }) => {
await page.goto("/");
const channelId = await page
.getByTestId("channel-random")
.getAttribute("data-channel-id");
if (!channelId) {
throw new Error("Random channel id missing.");
}
const pubkeys = Array.from({ length: 500 }, (_, index) =>
(index + 1).toString(16).padStart(64, "0"),
);
await invokeMockCommand(page, "add_channel_members", {
channelId,
pubkeys,
role: "member",
});
await openMembersSidebar(page, "random");
const memberList = page.getByTestId("members-sidebar-people");
const memberRows = memberList.locator('[data-testid^="sidebar-member-"]');
await expect(memberRows.first()).toBeVisible();
expect(await memberRows.count()).toBeLessThan(50);
// Generated members have no display name, so the roster sorts them by
// their full canonical npub: these sequential pubkeys share an
// `npub1qqq…` head and diverge mid-key, while the compact label's
// checksum tail is display-only and decides nothing. Resolve
// a generated member from the rows the initial window actually rendered
// instead of assuming `pubkeys[0]` sorts into that window.
const generatedPubkeySet = new Set(pubkeys);
const renderedPubkeys = await memberRows.evaluateAll((rows) =>
rows.map(
(row) =>
(row as HTMLElement).dataset.testid?.slice("sidebar-member-".length) ??
"",
),
);
const firstRenderedGeneratedPubkey = renderedPubkeys.find((pubkey) =>
generatedPubkeySet.has(pubkey),
);
expect(firstRenderedGeneratedPubkey).toBeTruthy();
const firstGeneratedRow = memberList.getByTestId(
`sidebar-member-${firstRenderedGeneratedPubkey}`,
);
await expect
.poll(() =>
firstGeneratedRow.evaluate(
(row) =>
getComputedStyle(row.parentElement as HTMLElement).contentVisibility,
),
)
.toBe("visible");
const virtualizedList = memberList.locator(".overflow-y-auto");
await page.evaluate(() => document.fonts.ready);
const heights: number[] = [];
for (const ratio of [0, 0.1, 0.25, 0.5, 0.75, 1]) {
heights.push(
await virtualizedList.evaluate(async (element, ratio) => {
element.scrollTop =
(element.scrollHeight - element.clientHeight) * ratio;
element.dispatchEvent(new Event("scroll"));
await new Promise<void>((resolve) =>
requestAnimationFrame(() => requestAnimationFrame(() => resolve())),
);
return element.scrollHeight;
}, ratio),
);
}
expect(Math.max(...heights) - Math.min(...heights)).toBeLessThan(120);
await virtualizedList.evaluate((element) => {
element.scrollTop = element.scrollHeight;
element.dispatchEvent(new Event("scroll"));
});
// Fully scrolling must render the roster's true tail, and the tail
// endpoint must be known independently of whatever the virtual window
// happens to render. The sidebar's own roster accounting — the
// "Members · N" header — must read exactly the fixture-known total
// ("random" seeds alice, the mock identity, and bob; this test adds the
// 500 generated pubkeys on top), so fixture or classification drift
// fails loudly here instead of silently weakening the tail check.
const rosterCount = 3 + pubkeys.length;
await expect(memberList.getByText(/^Members · \d+$/)).toHaveText(
`Members · ${rosterCount}`,
);
// VirtualizedList stamps each rendered row with its item index, so the
// final item's row is a fixed target that no window sample can pick in
// its place: a virtualizer clamped mid-roster never renders it.
await expect(
memberList.locator(
`[data-index="${rosterCount - 1}"] > [data-testid^="sidebar-member-"]`,
),
).toBeVisible();
});
test("members sidebar orders unnamed members by full canonical npub", async ({
page,
}) => {
await page.goto("/");
const channelId = await page
.getByTestId("channel-random")
.getAttribute("data-channel-id");
if (!channelId) {
throw new Error("Random channel id missing.");
}
// Added in the opposite of the expected order, so incoming membership
// order can never satisfy the assertion on its own.
await invokeMockCommand(page, "add_channel_members", {
channelId,
pubkeys: [UNNAMED_MEMBER_V5_PUBKEY, UNNAMED_MEMBER_V24_PUBKEY],
role: "member",
});
await openMembersSidebar(page, "random");
// "random" seeds alice, the mock identity, and bob, so the two unnamed
// fixtures round out a five-row roster that the initial virtual window
// renders entirely — both fixtures are visible without scrolling.
await expect(
page.getByTestId(`sidebar-member-${UNNAMED_MEMBER_V24_PUBKEY}`),
).toBeVisible();
await expect(
page.getByTestId(`sidebar-member-${UNNAMED_MEMBER_V5_PUBKEY}`),
).toBeVisible();
// The compact labels (`npub1qqq…2hcx` < `npub1qqq…2w5c`) would order V5
// first; the full canonical npubs disagree and order V24 first. The
// rendered roster must follow the full key, not the display label.
const renderedOrder = await page
.getByTestId("members-sidebar-people")
.locator('[data-index] > [data-testid^="sidebar-member-"]')
.evaluateAll((rows) =>
rows.map(
(row) =>
(row as HTMLElement).dataset.testid?.slice(
"sidebar-member-".length,
) ?? "",
),
);
const v24Position = renderedOrder.indexOf(UNNAMED_MEMBER_V24_PUBKEY);
const v5Position = renderedOrder.indexOf(UNNAMED_MEMBER_V5_PUBKEY);
expect(v24Position).toBeGreaterThanOrEqual(0);
expect(v5Position).toBeGreaterThanOrEqual(0);
expect(v24Position).toBeLessThan(v5Position);
});
test("opening a human-only members sidebar skips managed runtime discovery", async ({
page,
}) => {
await page.goto("/");
const baselineCommands = await readCommandLog(page);
const baselineRuntimeListCount = commandCount(
baselineCommands,
"list_managed_agent_runtimes",
);
await openMembersSidebar(page, "random");
await expect(page.getByTestId("members-sidebar-people")).toBeVisible();
const commands = await readCommandLog(page);
expect(commandCount(commands, "list_managed_agent_runtimes")).toBe(
baselineRuntimeListCount,
);
});
test("members sidebar can invite relay-authorized agents", async ({ page }) => {
await installMockBridge(page, {
relayAgents: [
{
pubkey: DM_RELAY_AGENT_PUBKEY,
name: "quinn",
respondTo: "allowlist",
respondToAllowlist: [MOCK_IDENTITY_PUBKEY],
},
],
});
await page.goto("/");
await openMembersSidebar(page, "general");
await page.getByTestId("channel-management-search-users").fill("quinn");
await expect(
page.getByTestId(`channel-user-search-result-${DM_RELAY_AGENT_PUBKEY}`),
).toBeVisible();
});
test("members sidebar hides relay agents that are not authorized", async ({
page,
}) => {
await installMockBridge(page, {
relayAgents: [
{
pubkey: DM_RELAY_AGENT_PUBKEY,
name: "quinn",
respondTo: "allowlist",
respondToAllowlist: [TEST_IDENTITIES.outsider.pubkey],
},
],
});
await page.goto("/");
await openMembersSidebar(page, "general");
await page.getByTestId("channel-management-search-users").fill("quinn");
await expect(
page.getByTestId(`channel-user-search-result-${DM_RELAY_AGENT_PUBKEY}`),
).toHaveCount(0);
});
test("members sidebar can invite and remove managed agents", async ({
page,
}) => {
await installMockBridge(page, {
managedAgents: [
{
pubkey: TEST_IDENTITIES.charlie.pubkey,
name: "charlie",
status: "stopped",
},
],
});
await page.goto("/");
await openMembersSidebar(page, "general");
const initialMemberCount = await readMembersTriggerCount(page);
await expect(page.getByTestId("channel-management-add-pubkeys")).toHaveCount(
0,
);
await expect(page.getByText(/Members · \d+/)).toBeVisible();
await page.getByTestId("channel-management-search-users").fill("a");
await expect(page.getByText("Members", { exact: true })).toBeVisible();
await expect(page.getByText(/Members · \d+/)).toHaveCount(0);
await expect(
page.getByText("Not in this channel", { exact: true }),
).toBeVisible();
await page.getByTestId("channel-management-search-users").fill("char");
await expect(
page.getByTestId(
`channel-user-search-result-${TEST_IDENTITIES.charlie.pubkey}`,
),
).toBeVisible();
await page
.getByTestId(`channel-user-search-result-${TEST_IDENTITIES.charlie.pubkey}`)
.click();
await expect(
page.getByTestId(`sidebar-member-${TEST_IDENTITIES.charlie.pubkey}`),
).toContainText("charlie");
await expect(
page.getByTestId(`selected-invitee-${TEST_IDENTITIES.charlie.pubkey}`),
).toHaveCount(0);
await expect(page.getByTestId("channel-management-add-members")).toHaveCount(
0,
);
await expect(page.getByTestId("channel-management-search-users")).toHaveValue(
"char",
);
await expectMembersTriggerCount(page, initialMemberCount + 1);
await openMemberMenu(page, TEST_IDENTITIES.charlie.pubkey);
await page
.getByTestId(`sidebar-remove-member-${TEST_IDENTITIES.charlie.pubkey}`)
.click();
await expect(
page.getByTestId(`sidebar-member-${TEST_IDENTITIES.charlie.pubkey}`),
).toHaveCount(0);
await expectMembersTriggerCount(page, initialMemberCount);
});
test("members sidebar pages add-member search beyond the first 50 people", async ({
page,
}) => {
const searchProfiles = Array.from({ length: 55 }, (_, index) => ({
pubkey: `${(index + 1).toString(16).padStart(64, "0")}`,
displayName: `Alex ${String(index + 1).padStart(2, "0")}`,
}));
await installMockBridge(page, { searchProfiles });
await page.goto("/");
await openMembersSidebar(page, "general");
await page.getByTestId("channel-management-search-users").fill("Alex");
const results = page.locator("[data-testid^='channel-user-search-result-']");
await expect(results).toHaveCount(50);
await page
.getByTestId("members-sidebar-people")
.evaluate((node) => node.scrollTo(0, node.scrollHeight));
await expect(results).toHaveCount(55);
await expect(page.getByText("Alex 55")).toBeVisible();
const searchCalls = (await readCommandPayloadLog(page)).filter(
(entry) => entry.command === "search_users",
);
expect(searchCalls).toEqual(
expect.arrayContaining([
expect.objectContaining({
payload: expect.objectContaining({ cursor: null, limit: 50 }),
}),
expect.objectContaining({
payload: expect.objectContaining({ cursor: "2", limit: 50 }),
}),
]),
);
});
test("shows loading skeletons without stale recipient results", async ({
page,
}) => {
await installMockBridge(page, {
managedAgents: [],
relayAgents: [],
userSearchDelayMs: 1_000,
});
await page.goto("/");
await openNewMessagePage(page);
const search = page.getByTestId("new-dm-search");
await search.fill("Alex");
await expect(page.getByTestId("new-dm-loading")).toBeVisible();
await expect(
page.locator("[data-testid^='new-dm-result-']:visible"),
).toHaveCount(0);
await expect(search).not.toHaveAttribute("aria-activedescendant");
await search.press("Enter");
await expect(
page.locator("button[data-testid^='new-dm-selected-']"),
).toHaveCount(0);
await expect(page.getByTestId("new-dm-loading")).toBeHidden();
await expect(page.locator("[data-testid^='new-dm-result-']")).toHaveCount(0);
await expect(page.getByTestId("new-dm-empty")).toContainText(
"No matching users.",
);
});
test("new DM picker pages people search beyond the first 50 results", async ({
page,
}) => {
const searchProfiles = Array.from({ length: 55 }, (_, index) => ({
pubkey: `${(index + 1).toString(16).padStart(64, "0")}`,
displayName: `Alex ${String(index + 1).padStart(2, "0")}`,
}));
await installMockBridge(page, { searchProfiles });
await page.goto("/");
await openNewMessagePage(page);
await expect(page.getByTestId("new-message-page")).toBeVisible();
await page.getByTestId("new-dm-search").fill("Alex");
const results = page.locator("[data-testid^='new-dm-result-']");
await expect(results).toHaveCount(50);
await page
.getByTestId("new-dm-results")
.evaluate((node) => node.scrollTo(0, node.scrollHeight));
await expect(results).toHaveCount(55);
await expect(page.getByText("Alex 55")).toBeVisible();
const searchCalls = (await readCommandPayloadLog(page)).filter(
(entry) => entry.command === "search_users",
);
expect(searchCalls).toEqual(
expect.arrayContaining([
expect.objectContaining({
payload: expect.objectContaining({ cursor: null, limit: 50 }),
}),
expect.objectContaining({
payload: expect.objectContaining({ cursor: "2", limit: 50 }),
}),
]),
);
});
test("member people search starts at two characters", async ({ page }) => {
const jmPubkey =
"abababababababababababababababababababababababababababababababab";
await installMockBridge(page, {
searchProfiles: [{ pubkey: jmPubkey, displayName: "jm" }],
});
await page.goto("/");
await openMembersSidebar(page, "general");
await page.getByTestId("channel-management-search-users").fill("j");
await expect(
page.getByTestId(`channel-user-search-result-${jmPubkey}`),
).toHaveCount(0);
expect(
(await readCommandPayloadLog(page)).filter(
(entry) =>
entry.command === "search_users" &&
(entry.payload as { query?: string }).query === "j",
),
).toHaveLength(0);
await page.getByTestId("channel-management-search-users").fill("jm");
await expect(
page.getByTestId(`channel-user-search-result-${jmPubkey}`),
).toContainText("jm");
const searchCalls = (await readCommandPayloadLog(page)).filter(
(entry) => entry.command === "search_users",
);
expect(searchCalls).toEqual(
expect.arrayContaining([
expect.objectContaining({
payload: expect.objectContaining({ query: "jm" }),
}),
]),
);
});
test("members modal does not show direct pubkey entry", async ({ page }) => {
await page.goto("/");
await openMembersSidebar(page, "general");
await expect(page.getByTestId("channel-management-add-pubkeys")).toHaveCount(
0,
);
await expect(
page.getByTestId("channel-management-toggle-direct-pubkeys"),
).toHaveCount(0);
await expect(
page.getByTestId("channel-management-search-users"),
).toHaveAttribute("placeholder", "Add people and agents");
});
test("channel header omits the add agent action", async ({ page }) => {
await page.goto("/");
await page.getByTestId("channel-random").click();
await expect(page.getByTestId("chat-title")).toHaveText("random");
await page.setViewportSize({ width: 1280, height: 420 });
await expect(page.getByTestId("channel-add-bot-trigger")).toHaveCount(0);
await expect(page.getByTestId("channel-members-trigger")).toBeVisible();
await expect(page.getByTestId("channel-start-huddle-trigger")).toBeVisible();
await expect(page.getByTestId("channel-management-trigger")).toBeVisible();
});
test("huddle rollback end event clears the active header action", async ({
page,
}) => {
await page.goto("/");
await page.getByTestId("channel-random").click();
await expect(page.getByTestId("chat-title")).toHaveText("random");
await waitForMockLiveSubscription(page, "random", KIND_HUDDLE_STARTED);
const ephemeralChannelId = "10000000-0000-4000-8000-000000000001";
const createdAt = Math.floor(Date.now() / 1000);
await page.evaluate(
({ createdAt, ephemeralChannelId, kind }) => {
window.__BUZZ_E2E_EMIT_MOCK_MESSAGE__?.({
channelName: "random",
content: JSON.stringify({
ephemeral_channel_id: ephemeralChannelId,
}),
createdAt,
id: "1".repeat(64),
kind,
});
},
{ createdAt, ephemeralChannelId, kind: KIND_HUDDLE_STARTED },
);
await expect(
page.getByRole("button", {
name: "Join active huddle (1 participant)",
}),
).toBeVisible();
await page.evaluate(
({ createdAt, ephemeralChannelId, kind }) => {
window.__BUZZ_E2E_EMIT_MOCK_MESSAGE__?.({
channelName: "random",
content: JSON.stringify({
ephemeral_channel_id: ephemeralChannelId,
}),
createdAt,
id: "2".repeat(64),
kind,
});
},
{ createdAt, ephemeralChannelId, kind: KIND_HUDDLE_ENDED },
);
await expect(
page.getByTestId("channel-start-huddle-trigger"),
).toHaveAttribute("aria-label", "Start huddle");
});
test("channel header actions show tooltips", async ({ page }) => {
await page.goto("/");
await page.getByTestId("channel-random").click();
await expect(page.getByTestId("chat-title")).toHaveText("random");
for (const [testId, label] of [
["channel-members-trigger", "Channel members"],
["channel-huddle-tooltip-trigger", "Huddle"],
["channel-management-trigger", "Channel settings"],
] as const) {
await page.getByTestId(testId).hover();
await expect(page.getByRole("tooltip", { name: label })).toBeVisible();
}
});
test("members sidebar retains distinct same-persona managed agents", async ({
page,
}) => {
const inChannelAgentPubkey =
"eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee";
const outOfChannelAgentPubkey =
"ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff";
await installMockBridge(page, {
managedAgents: [
{
pubkey: outOfChannelAgentPubkey,
name: "Pinky",
personaId: "builtin:fizz",
status: "stopped",
},
{
pubkey: inChannelAgentPubkey,
name: "Pinky",
personaId: "builtin:fizz",
status: "running",
channelNames: ["general"],
},
],
searchProfiles: [
{
pubkey: outOfChannelAgentPubkey,
displayName: "Pinky",
ownerPubkey: MOCK_IDENTITY_PUBKEY,
isAgent: true,
},
{
pubkey: inChannelAgentPubkey,
displayName: "Pinky",
ownerPubkey: MOCK_IDENTITY_PUBKEY,
isAgent: true,
},
],
userSearchDelayMs: 1_000,
});
await page.goto("/");
await openMembersSidebar(page, "general");
await page.getByTestId("channel-management-search-users").fill("pi");
await expect(
page.getByTestId(`channel-user-search-result-${inChannelAgentPubkey}`),
).toHaveCount(0);
await expect(
page.getByTestId(`channel-user-search-result-${outOfChannelAgentPubkey}`),
).toBeVisible();
await expect(page.getByText("Pinky", { exact: true })).toHaveCount(2);
});
test("private-channel members can add people and managed agents without admin", async ({
page,
}) => {
await installMockBridge(page, {
managedAgents: [
{
pubkey: TEST_IDENTITIES.charlie.pubkey,
name: "charlie",
status: "stopped",
},
],
});
await page.goto("/");
// secret-projects is a private (non-DM) channel where the current user is a
// plain member. Active members may add ordinary members and bots; only
// elevated-role grants and role changes require owner/admin authority.
await openMembersSidebar(page, "secret-projects");
await expect(page.getByTestId("members-sidebar-add-denied")).toHaveCount(0);
await expect(
page.getByTestId("channel-management-search-users"),
).toHaveAttribute("placeholder", "Add people and agents");
await page.getByTestId("channel-management-search-users").fill("char");
await page
.getByTestId(`channel-user-search-result-${TEST_IDENTITIES.charlie.pubkey}`)
.click();
await expect(
page.getByTestId(`sidebar-member-${TEST_IDENTITIES.charlie.pubkey}`),
).toContainText("charlie");
});
test("open-channel members can add people and managed agents without admin", async ({
page,
}) => {
await installMockBridge(page, {
managedAgents: [
{
pubkey: TEST_IDENTITIES.charlie.pubkey,
name: "charlie",
status: "stopped",
},
],
});
await page.goto("/");
// random is open and the current user is a plain member there, so the
// owner/admin requirement must not leak outside private channels.
await openMembersSidebar(page, "random");
// The invite card is shown to any member of an open channel, not just
// owners/admins.
await expect(
page.getByTestId("channel-management-search-users"),
).toBeVisible();
await expect(page.getByTestId("members-sidebar-add-denied")).toHaveCount(0);
await page.getByTestId("channel-management-search-users").fill("char");
await page
.getByTestId(`channel-user-search-result-${TEST_IDENTITIES.charlie.pubkey}`)
.click();
await expect(
page.getByTestId(`sidebar-member-${TEST_IDENTITIES.charlie.pubkey}`),
).toContainText("charlie");
// The modal no longer exposes member/guest/bot role choices or a staged
// submit button; selected people are added immediately as members and
// selected agents are added as bots.
await expect(page.getByTestId("channel-management-add-role")).toHaveCount(0);
await expect(page.getByTestId("channel-management-add-members")).toHaveCount(
0,
);
});
test("removing a channel-scoped agent preserves the managed agent record", async ({
page,
}) => {
const agentName = `cleanup-agent-${Date.now()}`;
await page.goto("/");
const agentPubkey = await addGenericAgent(page, "general", agentName);
await page.getByTestId("channel-general").click();
await openMembersSidebar(page, "general");
await openMemberMenu(page, agentPubkey);
await page.getByTestId(`sidebar-remove-member-${agentPubkey}`).click();
await expect(page.getByTestId(`sidebar-member-${agentPubkey}`)).toHaveCount(
0,
);
await page.keyboard.press("Escape");
await expect(page.getByTestId("members-sidebar")).not.toBeVisible();
await page.getByTestId("open-agents-view").click();
await expect(page.getByTestId(`managed-agent-${agentPubkey}`)).toHaveCount(1);
});
test("members sidebar can stop and start a managed bot in this community", async ({
page,
}) => {
const agentName = `sidebar-agent-${Date.now()}`;
await page.goto("/");
const agentPubkey = await addGenericAgent(page, "general", agentName);
const baselineCommands = await readCommandLog(page);
const baselineLegacyStartCount = commandCount(
baselineCommands,
"start_managed_agent",
);
const baselineLegacyStopCount = commandCount(
baselineCommands,
"stop_managed_agent",
);
const baselineRuntimeListCount = commandCount(
baselineCommands,
"list_managed_agent_runtimes",
);
await openMembersSidebar(page, "general");
await expect
.poll(async () =>
commandCount(await readCommandLog(page), "list_managed_agent_runtimes"),
)
.toBe(baselineRuntimeListCount + 1);
const agentStatus = page.getByTestId(
`sidebar-managed-agent-status-${agentPubkey}`,
);
const agentAction = page.getByTestId(`sidebar-agent-action-${agentPubkey}`);
// The badge is pair-scoped: it reports the agent+active-community runtime.
await expect(agentStatus).toContainText("Here");
await openMemberMenu(page, agentPubkey);
await expect(agentAction).toContainText("Stop");
await agentAction.click();
await expect(agentStatus).toContainText("Unavailable");
await openMemberMenu(page, agentPubkey);
await expect(agentAction).toContainText("Start");
await agentAction.click();
await expect(agentStatus).toContainText("Here");
await expect(
page
.locator("[data-sonner-toast]")
.filter({ hasText: `Started ${agentName} in this community.` }),
).toBeVisible();
// The lifecycle menu item must dispatch the pair-scoped runtime commands,
// never the legacy agent-wide start/stop that drains every community.
const commands = await readCommandLog(page);
expect(commandCount(commands, "stop_managed_agent_runtime")).toBe(1);
expect(commandCount(commands, "start_managed_agent_runtime")).toBe(1);
expect(commandCount(commands, "start_managed_agent")).toBe(
baselineLegacyStartCount,
);
expect(commandCount(commands, "stop_managed_agent")).toBe(
baselineLegacyStopCount,
);
});
test("stopping a managed bot in one community leaves its other communities running", async ({
page,
}) => {
const agentPubkey = TEST_IDENTITIES.charlie.pubkey;
// Must match the relay of the community seeded by the mock bridge
// (`seedDefaultCommunity` follows BUZZ_E2E_RELAY_URL); the agent also has a
// live runtime in a second community on another relay.
const activeRelayUrl = (
process.env.BUZZ_E2E_RELAY_URL ?? "http://localhost:3000"
).replace(/^http/, "ws");
const otherRelayUrl = "ws://other-community.example";
await installMockBridge(page, {
managedAgents: [
{
pubkey: agentPubkey,
name: "charlie",
status: "running",
channelNames: ["general"],
},
],
managedAgentRuntimes: [
{ pubkey: agentPubkey, relayUrl: activeRelayUrl },
{ pubkey: agentPubkey, relayUrl: otherRelayUrl },
],
});
await page.goto("/");
await openMembersSidebar(page, "general");
const agentStatus = page.getByTestId(
`sidebar-managed-agent-status-${agentPubkey}`,
);
const agentAction = page.getByTestId(`sidebar-agent-action-${agentPubkey}`);
await expect(agentStatus).toContainText("Here");
await openMemberMenu(page, agentPubkey);
await expect(agentAction).toContainText("Stop");
await agentAction.click();
await expect(agentStatus).toContainText("Unavailable");
await expect(
page
.locator("[data-sonner-toast]")
.filter({ hasText: "Stopped charlie in this community." }),
).toBeVisible();
// Stop(A) must leave the community-B runtime untouched.
const runtimes = await page.evaluate(async () => {
const invoke = (
window as Window & {
__BUZZ_E2E_INVOKE_MOCK_COMMAND__?: (
command: string,
) => Promise<Array<{ relayUrl: string; lifecycle: string }>>;
}
).__BUZZ_E2E_INVOKE_MOCK_COMMAND__;
if (!invoke) {
throw new Error("Mock bridge is not installed.");
}
return invoke("list_managed_agent_runtimes");
});
expect(
runtimes.find((runtime) => runtime.relayUrl === activeRelayUrl)?.lifecycle,
).toBe("stopped");
expect(
runtimes.find((runtime) => runtime.relayUrl === otherRelayUrl)?.lifecycle,
).toBe("ready");
// And it must never route through the legacy agent-wide stop.
const commands = await readCommandLog(page);
expect(commandCount(commands, "stop_managed_agent_runtime")).toBe(1);
expect(commandCount(commands, "stop_managed_agent")).toBe(0);
});
test("members sidebar omits bulk controls for managed bots", async ({
page,
}) => {
const firstAgentName = `sidebar-remove-a-${Date.now()}`;
const secondAgentName = `sidebar-remove-b-${Date.now()}`;
await page.goto("/");
const firstAgentPubkey = await addGenericAgent(
page,
"general",
firstAgentName,
);
const secondAgentPubkey = await addGenericAgent(
page,
"general",
secondAgentName,
);
await openMembersSidebar(page, "general");
await expect(
page.getByTestId(`sidebar-member-${firstAgentPubkey}`),
).toBeVisible();
await expect(
page.getByTestId(`sidebar-member-${secondAgentPubkey}`),
).toBeVisible();
await expect(page.getByTestId("members-sidebar-agent-controls")).toHaveCount(
0,
);
await expect(page.getByTestId("members-sidebar-remove-all")).toHaveCount(0);
await page.keyboard.press("Escape");
await expect(page.getByTestId("members-sidebar")).not.toBeVisible();
await page.getByTestId("open-agents-view").click();
await expect(
page.getByTestId(`managed-agent-${firstAgentPubkey}`),
).toHaveCount(1);
await expect(
page.getByTestId(`managed-agent-${secondAgentPubkey}`),
).toHaveCount(1);
const commands = await readCommandLog(page);
expect(
commands.filter((command) => command === "remove_channel_member"),
).toHaveLength(0);
});
test("removing a multi-channel managed bot preserves its record after removal from all channels", async ({
page,
}) => {
const agentName = `multi-channel-agent-${Date.now()}`;
const secondChannelName = `multi-home-${Date.now()}`;
await page.goto("/");
const agentPubkey = await addGenericAgent(page, "general", agentName);
await openCreateChannelDialog(page);
await page.getByTestId("create-channel-name").fill(secondChannelName);
await page
.getByTestId("create-channel-description")
.fill("Second home for managed bot cleanup coverage");
await page.getByTestId("create-channel-submit").click();
await expect(page.getByTestId("chat-title")).toHaveText(secondChannelName);
const secondChannelId = await page
.getByTestId(`channel-${secondChannelName}`)
.getAttribute("data-channel-id");
if (!secondChannelId) {
throw new Error("Second channel id missing.");
}
await invokeMockCommand(page, "add_channel_members", {
channelId: secondChannelId,
pubkeys: [agentPubkey],
role: "bot",
});
// Snapshot command counts before removals so assertions are relative.
const baseline = await readCommandLog(page);
const baselineRemoves = baseline.filter(
(c) => c === "remove_channel_member",
).length;
await openMembersSidebar(page, "general");
await openMemberMenu(page, agentPubkey);
await page.getByTestId(`sidebar-remove-member-${agentPubkey}`).click();
await expect(page.getByTestId(`sidebar-member-${agentPubkey}`)).toHaveCount(
0,
);
await page.keyboard.press("Escape");
await expect(page.getByTestId("members-sidebar")).not.toBeVisible();
await page.getByTestId("open-agents-view").click();
await expect(page.getByTestId(`managed-agent-${agentPubkey}`)).toHaveCount(1);
let commands = await readCommandLog(page);
// First removal: 1 remove_channel_member, agent record preserved.
expect(
commands.filter((c) => c === "remove_channel_member").length -
baselineRemoves,
).toBe(1);
await openMembersSidebar(page, secondChannelName);
await openMemberMenu(page, agentPubkey);
await page.getByTestId(`sidebar-remove-member-${agentPubkey}`).click();
await expect(page.getByTestId(`sidebar-member-${agentPubkey}`)).toHaveCount(
0,
);
await page.keyboard.press("Escape");
await expect(page.getByTestId("members-sidebar")).not.toBeVisible();
await page.getByTestId("open-agents-view").click();
await expect(page.getByTestId(`managed-agent-${agentPubkey}`)).toHaveCount(1);
commands = await readCommandLog(page);
// Second removal: agent is preserved even after removal from all channels.
expect(
commands.filter((c) => c === "remove_channel_member").length -
baselineRemoves,
).toBe(2);
});
test("bulk remove stays hidden when row-level remove is not allowed", async ({
page,
}) => {
const alicePubkey =
"953d3363262e86b770419834c53d2446409db6d918a57f8f339d495d54ab001f";
await page.goto("/");
// Join the "design" channel (unjoined by default) via the channel browser.
// The user becomes a regular member — not admin/owner.
await openChannelBrowser(page);
await expect(page.getByTestId("channel-browser-dialog")).toBeVisible();
await page
.getByTestId("browse-channel-design")
.getByRole("button", { name: "Join" })
.click();
await expect(page.getByTestId("chat-title")).toHaveText("design");
await openMembersSidebar(page, "design");
// Alice is a relay-observed bot in design (present in mockRelayAgents) that
// the user does not manage locally. Since there is no local managed agent
// for alice, hasActions is false and no 3-dot menu renders.
await expect(page.getByTestId(`sidebar-member-${alicePubkey}`)).toBeVisible();
await expect(
page.getByTestId(`sidebar-member-menu-${alicePubkey}`),
).toHaveCount(0);
// Bulk agent controls only render when hasControllableManagedBots is true.
// Since no bots in design have a local managed agent, the controls are absent.
await expect(page.getByTestId("members-sidebar-agent-controls")).toHaveCount(
0,
);
});
test("open channel management supports join and leave", async ({ page }) => {
await page.goto("/");
// Navigate to "design" (an unjoined channel) via the channel browser
await openChannelBrowser(page);
await expect(page.getByTestId("channel-browser-dialog")).toBeVisible();
await page
.getByTestId("browse-channel-design")
.getByRole("button", { name: "Join" })
.click();
await expect(page.getByTestId("chat-title")).toHaveText("design");
// Open members sidebar — should show current user after joining
await page.getByTestId("channel-members-trigger").click();
await expect(page.getByTestId("members-sidebar")).toBeVisible();
await expect(
page.getByTestId(`sidebar-member-${MOCK_IDENTITY_PUBKEY}`),
).toContainText("You");
await page.keyboard.press("Escape");
// Open channel management — should show Leave since we just joined
await page.getByTestId("channel-management-trigger").click();
await expect(page.getByTestId("channel-management-sheet")).toBeVisible();
await expect(page.getByTestId("channel-management-join")).toHaveCount(0);
await expect(page.getByTestId("channel-management-leave")).toBeVisible();
// Leave the channel
await page.getByTestId("channel-management-leave").click();
await expect(page.getByTestId("channel-management-sheet")).not.toBeVisible();
// After leaving, the app navigates away — re-open browser and find design
await openChannelBrowser(page);
await expect(page.getByTestId("channel-browser-dialog")).toBeVisible();
// "design" should be back in the unjoined section with a Join button
await expect(
page
.getByTestId("browse-channel-design")
.getByRole("button", { name: "Join" }),
).toBeVisible();
});
test("manage channel can archive and unarchive a stream", async ({ page }) => {
await page.goto("/");
await openChannelManagement(page, "general");
await page.getByTestId("channel-management-archive").click();
await expect(page.getByTestId("channel-management-unarchive")).toBeVisible();
await closeChannelManagement(page);
await expect(page.getByTestId("stream-list")).not.toContainText("general");
await expect(page.getByTestId("message-input")).toHaveAttribute(
"contenteditable",
"false",
);
await expect(page.getByTestId("send-message")).toBeDisabled();
await openChannelBrowser(page);
await expect(page.getByTestId("channel-browser-dialog")).toBeVisible();
await expect(page.getByTestId("browse-channel-general")).toContainText(
"archived",
);
await page.getByTestId("browse-channel-general").click();
await expect(page.getByTestId("channel-browser-dialog")).not.toBeVisible();
await expect(page.getByTestId("chat-title")).toHaveText("general");
await page.getByTestId("channel-management-trigger").click();
await expect(page.getByTestId("channel-management-sheet")).toBeVisible();
await page.getByTestId("channel-management-unarchive").click();
await expect(page.getByTestId("channel-management-archive")).toBeVisible();
await closeChannelManagement(page);
await expect(page.getByTestId("stream-list")).toContainText("general");
await expect(page.getByTestId("message-input")).toHaveAttribute(
"contenteditable",
"true",
);
});
test("manage channel can delete an owned stream", async ({ page }) => {
const channelName = `delete-me-${Date.now()}`;
await page.goto("/");
await openCreateChannelDialog(page);
await page.getByTestId("create-channel-name").fill(channelName);
await page.getByTestId("create-channel-submit").click();
await expect(page.getByTestId("chat-title")).toHaveText(channelName);
await page.getByTestId("channel-management-trigger").click();
await expect(page.getByTestId("channel-management-sheet")).toBeVisible();
await page.getByTestId("channel-management-delete").click();
await expect(
page.getByTestId("channel-delete-confirmation-dialog"),
).toBeVisible();
await page.getByTestId("channel-delete-confirm").click();
await expect(page.getByTestId("home-inbox-list")).toBeVisible();
await expect(page.getByTestId("stream-list")).not.toContainText(channelName);
});
test("canceling channel deletion keeps the owned stream", async ({ page }) => {
const channelName = `keep-me-${Date.now()}`;
await page.goto("/");
await openCreateChannelDialog(page);
await page.getByTestId("create-channel-name").fill(channelName);
await page.getByTestId("create-channel-submit").click();
await expect(page.getByTestId("chat-title")).toHaveText(channelName);
await page.getByTestId("channel-management-trigger").click();
await expect(page.getByTestId("channel-management-sheet")).toBeVisible();
await page.getByTestId("channel-management-delete").click();
await expect(
page.getByTestId("channel-delete-confirmation-dialog"),
).toBeVisible();
await page.getByTestId("channel-delete-cancel").click();
await expect(
page.getByTestId("channel-delete-confirmation-dialog"),
).not.toBeVisible();
await expect(page.getByTestId("chat-title")).toHaveText(channelName);
await expect(page.getByTestId("stream-list")).toContainText(channelName);
});