Files
buzz/desktop/tests/e2e/channels.spec.ts

1201 lines
40 KiB
TypeScript

import { expect, test } from "@playwright/test";
import { TEST_IDENTITIES, installMockBridge } from "../helpers/bridge";
const MOCK_IDENTITY_PUBKEY = "deadbeef".repeat(8);
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.keyboard.press("Escape");
await expect(page.getByTestId("channel-management-sheet")).not.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 waitForMockLiveSubscription(
page: import("@playwright/test").Page,
channelName: string,
) {
await expect
.poll(async () => {
return page.evaluate((currentChannelName) => {
return (
(
window as Window & {
__SPROUT_E2E_HAS_MOCK_LIVE_SUBSCRIPTION__?: (input: {
channelName: string;
}) => boolean;
}
).__SPROUT_E2E_HAS_MOCK_LIVE_SUBSCRIPTION__?.({
channelName: currentChannelName,
}) ?? false
);
}, channelName);
})
.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,
) {
await page.getByTestId(`channel-${channelName}`).click();
await expect(page.getByTestId("chat-title")).toHaveText(channelName);
await page.getByTestId("channel-add-bot-trigger").click();
await expect(page.getByRole("heading", { name: "Add agents" })).toBeVisible();
await page.getByRole("button", { name: "Generic" }).click();
await page.locator("#channel-generic-name").fill(agentName);
await page
.locator("#channel-generic-prompt")
.fill("Watch the channel and help when asked.");
await page.getByRole("button", { name: "Add agent" }).click();
await expect(page.getByRole("heading", { name: "Add agents" })).toHaveCount(
0,
);
}
async function getManagedAgentPubkey(
page: import("@playwright/test").Page,
agentName: string,
) {
await page.getByTestId("open-agents-view").click();
const managedAgentRow = page
.locator('[data-testid^="managed-agent-"]')
.filter({ hasText: agentName });
await expect(managedAgentRow).toHaveCount(1);
const managedAgentTestId = await managedAgentRow
.first()
.getAttribute("data-testid");
if (!managedAgentTestId) {
throw new Error("Managed agent row test id missing.");
}
return managedAgentTestId.replace("managed-agent-", "");
}
async function readCommandLog(page: import("@playwright/test").Page) {
return page.evaluate(() => {
return (
(
window as Window & {
__SPROUT_E2E_COMMANDS__?: string[];
}
).__SPROUT_E2E_COMMANDS__ ?? []
);
});
}
async function invokeMockCommand<T>(
page: import("@playwright/test").Page,
command: string,
payload?: Record<string, unknown>,
) {
await page.waitForFunction(() => {
return Boolean(
(
window as Window & {
__SPROUT_E2E_INVOKE_MOCK_COMMAND__?: unknown;
}
).__SPROUT_E2E_INVOKE_MOCK_COMMAND__,
);
});
return page.evaluate(
async ({
command,
payload,
}: {
command: string;
payload?: Record<string, unknown>;
}) => {
const invoke = (
window as Window & {
__SPROUT_E2E_INVOKE_MOCK_COMMAND__?: (
command: string,
payload?: Record<string, unknown>,
) => Promise<unknown>;
}
).__SPROUT_E2E_INVOKE_MOCK_COMMAND__;
if (!invoke) {
throw new Error("Mock bridge is not installed.");
}
return invoke(command, payload);
},
{ command, payload },
) as Promise<T>;
}
test.beforeEach(async ({ page }) => {
await installMockBridge(page);
});
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")).toHaveText("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 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",
"Offline",
);
await expect(page.getByTestId("channel-presence-alice-tyler")).toBeVisible();
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}`),
).toContainText("Online");
await expect(
page.getByTestId(`sidebar-member-presence-${TEST_IDENTITIES.bob.pubkey}`),
).toContainText("Away");
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 page.goto("/");
await page.getByTestId("new-dm-trigger").click();
await expect(page.getByTestId("new-dm-dialog")).toBeVisible();
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 page.getByTestId("new-dm-submit").click();
await expect(page.getByTestId("dm-list")).toContainText("charlie");
await expect(page.getByTestId("chat-title")).toHaveText("charlie");
});
test("create stream with name and description", async ({ page }) => {
const channelName = `my-new-stream-${Date.now()}`;
await page.goto("/");
await page.getByRole("button", { name: "Create a stream" }).click();
await page.getByTestId("create-stream-name").fill(channelName);
await page
.getByTestId("create-stream-description")
.fill("A stream for testing channel creation");
await page
.getByTestId("create-stream-form")
.getByRole("button", { name: "Create" })
.click();
await expect(page.getByTestId("stream-list")).toContainText(channelName);
await expect(page.getByTestId("chat-title")).toHaveText(channelName);
});
test("create ephemeral stream shows sidebar and header affordances", async ({
page,
}) => {
const channelName = `ephemeral-stream-${Date.now()}`;
await page.goto("/");
await page.getByRole("button", { name: "Create a stream" }).click();
await page.getByTestId("create-stream-name").fill(channelName);
await page
.getByTestId("create-stream-description")
.fill("Auto-cleaned test stream");
await page
.getByTestId("create-stream-form")
.getByLabel("Ephemeral — auto-archives after 1 day of inactivity")
.click();
await page
.getByTestId("create-stream-form")
.getByRole("button", { name: "Create" })
.click();
await expect(page.getByTestId("stream-list")).toContainText(channelName);
await expect(page.getByTestId("chat-title")).toHaveText(channelName);
await expect(
page.getByTestId(`channel-ephemeral-${channelName}`),
).toBeVisible();
await expect(page.getByTestId("chat-ephemeral-badge")).toHaveText(
/Ephemeral.+left/,
);
await page.getByRole("button", { name: "Toggle Sidebar" }).click();
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-09T02:00:00.000Z");
await page.clock.setFixedTime(initialTime);
await page.goto("/");
for (const channelName of [firstChannelName, secondChannelName]) {
await page.getByRole("button", { name: "Create a stream" }).click();
await page.getByTestId("create-stream-name").fill(channelName);
await page
.getByTestId("create-stream-description")
.fill("Auto-cleaned test stream");
await page
.getByTestId("create-stream-form")
.getByLabel("Ephemeral — auto-archives after 1 day of inactivity")
.click();
await page
.getByTestId("create-stream-form")
.getByRole("button", { name: "Create" })
.click();
await expect(page.getByTestId("chat-title")).toHaveText(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")).toHaveText(firstChannelName);
await expect(page.getByTestId("chat-ephemeral-badge")).toHaveText(
/Ephemeral.+22h left/,
);
});
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 & {
__SPROUT_E2E_INVOKE_MOCK_COMMAND__?: unknown;
}
).__SPROUT_E2E_INVOKE_MOCK_COMMAND__,
);
});
await page.evaluate(
async ({
archivedForumName,
archivedStreamName,
outsiderPubkey,
}: {
archivedForumName: string;
archivedStreamName: string;
outsiderPubkey: string;
}) => {
const invoke = (
window as Window & {
__SPROUT_E2E_INVOKE_MOCK_COMMAND__?: (
command: string,
payload?: Record<string, unknown>,
) => Promise<{ id: string }>;
}
).__SPROUT_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 page.getByRole("button", { name: "Create a stream" }).click();
await page.getByTestId("create-stream-name").fill(channelName);
await page
.getByTestId("create-stream-description")
.fill("Stream with spaces and hyphens");
await page
.getByTestId("create-stream-form")
.getByRole("button", { name: "Create" })
.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 empty state", async ({ page }) => {
await page.goto("/");
await page.getByTestId("channel-random").click();
await expect(page.getByTestId("chat-title")).toHaveText("random");
await expect(page.getByTestId("message-empty")).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-timeline")).toContainText(
"Welcome to #general",
);
});
test("shows and clears typing indicators for active channel bots", async ({
page,
}) => {
await page.goto("/");
await page.getByTestId("channel-agents").click();
await expect(page.getByTestId("chat-title")).toHaveText("agents");
await waitForMockLiveSubscription(page, "agents");
await page.evaluate((pubkey) => {
window.__SPROUT_E2E_EMIT_MOCK_TYPING__?.({
channelName: "agents",
pubkey,
});
}, TEST_IDENTITIES.charlie.pubkey);
await expect(page.getByTestId("message-typing-indicator")).toBeVisible();
await expect(
page.getByTestId("message-typing-indicator-label"),
).toContainText("charlie is typing");
await page.evaluate((pubkey) => {
window.__SPROUT_E2E_EMIT_MOCK_MESSAGE__?.({
channelName: "agents",
content: "Done.",
pubkey,
});
}, TEST_IDENTITIES.charlie.pubkey);
await expect(page.getByTestId("message-timeline")).toContainText("Done.");
await expect(page.getByTestId("message-typing-indicator")).toHaveCount(0);
await page.waitForTimeout(1_200);
await page.evaluate((pubkey) => {
window.__SPROUT_E2E_EMIT_MOCK_TYPING__?.({
channelName: "agents",
pubkey,
});
}, TEST_IDENTITIES.charlie.pubkey);
await expect(page.getByTestId("message-typing-indicator")).toHaveCount(0);
});
test("typing indicator shows avatars and maintains stable name order", async ({
page,
}) => {
await page.goto("/");
await page.getByTestId("channel-general").click();
await expect(page.getByTestId("chat-title")).toHaveText("general");
await waitForMockLiveSubscription(page, "general");
// Alice starts typing first
await page.evaluate((pubkey) => {
window.__SPROUT_E2E_EMIT_MOCK_TYPING__?.({
channelName: "general",
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.__SPROUT_E2E_EMIT_MOCK_TYPING__?.({
channelName: "general",
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.__SPROUT_E2E_EMIT_MOCK_TYPING__?.({
channelName: "general",
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.__SPROUT_E2E_EMIT_MOCK_TYPING__?.({
channelName: "general",
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 page.evaluate(() => {
window.__SPROUT_E2E_EMIT_MOCK_MESSAGE__?.({
channelName: "random",
content: "Unread update for #random",
});
});
await expect(page.getByTestId("channel-unread-random")).toBeVisible();
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 clears unread indicator after opening a DM", async ({ page }) => {
await page.goto("/");
await expect(page.getByTestId("channel-unread-alice-tyler")).toHaveCount(0);
await page.evaluate((pubkey) => {
window.__SPROUT_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-timeline")).toContainText(
"Unread update for the DM",
);
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 and context", async ({ page }) => {
const stamp = Date.now();
const newName = `release-hub-${stamp}`;
const newDescription = `Release coordination ${stamp}`;
const newTopic = `Launch plan ${stamp}`;
const newPurpose = `Track blockers and owners ${stamp}`;
await page.goto("/");
await openChannelManagement(page, "general");
await page.getByTestId("channel-management-name").fill(newName);
await page.getByTestId("channel-management-description").fill(newDescription);
await page.getByTestId("channel-management-save-details").click();
await expect(page.getByTestId("chat-title")).toHaveText(newName);
await expect(page.getByTestId("stream-list")).toContainText(newName);
const saveTopicButton = page.getByTestId("channel-management-save-topic");
const savePurposeButton = page.getByTestId("channel-management-save-purpose");
await page.getByTestId("channel-management-topic").fill(newTopic);
await saveTopicButton.click();
await expect(saveTopicButton).toHaveText("Save topic");
await expect(page.getByTestId("channel-management-topic")).toHaveValue(
newTopic,
);
await page.getByTestId("channel-management-purpose").fill(newPurpose);
await savePurposeButton.click();
await expect(savePurposeButton).toHaveText("Save purpose");
await expect(page.getByTestId("channel-management-purpose")).toHaveValue(
newPurpose,
);
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 expect(page.getByTestId("channel-management-name")).toHaveValue(
newName,
);
await expect(page.getByTestId("channel-management-description")).toHaveValue(
newDescription,
);
await expect(page.getByTestId("channel-management-topic")).toHaveValue(
newTopic,
);
await expect(page.getByTestId("channel-management-purpose")).toHaveValue(
newPurpose,
);
});
test("manage channel keeps canvas near the top of the sheet", async ({
page,
}) => {
await page.goto("/");
await openChannelManagement(page, "general");
const sectionHeadings = await page
.getByTestId("channel-management-sheet")
.locator("section h2")
.allTextContents();
expect(sectionHeadings).toEqual([
"Access",
"Canvas",
"Context",
"Details",
"Channel state",
"Danger zone",
]);
});
test("members sidebar can invite and remove members", async ({ page }) => {
await page.goto("/");
await openMembersSidebar(page, "general");
await expect(page.getByTestId("channel-members-trigger")).toContainText("3");
await expect(page.getByTestId("channel-management-add-pubkeys")).toHaveCount(
0,
);
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(`selected-invitee-${TEST_IDENTITIES.charlie.pubkey}`),
).toContainText("charlie");
await page.getByTestId("channel-management-add-role").selectOption("admin");
await page.getByTestId("channel-management-add-members").click();
await expect(
page.getByTestId(`selected-invitee-${TEST_IDENTITIES.charlie.pubkey}`),
).toHaveCount(0);
await expect(page.getByTestId("channel-management-search-users")).toHaveValue(
"",
);
await expect(
page.getByTestId(`sidebar-member-${TEST_IDENTITIES.charlie.pubkey}`),
).toContainText("charlie");
await expect(page.getByTestId("channel-members-trigger")).toContainText("4");
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 expect(page.getByTestId("channel-members-trigger")).toContainText("3");
});
test("members sidebar keeps direct pubkey entry behind a toggle", async ({
page,
}) => {
await page.goto("/");
await openMembersSidebar(page, "general");
await expect(page.getByTestId("channel-management-add-pubkeys")).toHaveCount(
0,
);
await page.getByTestId("channel-management-toggle-direct-pubkeys").click();
await expect(
page.getByTestId("channel-management-add-pubkeys"),
).toBeVisible();
await page
.getByTestId("channel-management-add-pubkeys")
.fill(TEST_IDENTITIES.outsider.pubkey);
await page.getByTestId("channel-management-add-members").click();
await expect(
page.getByTestId(`sidebar-member-${TEST_IDENTITIES.outsider.pubkey}`),
).toContainText("outsider");
await expect(page.getByTestId("channel-members-trigger")).toContainText("4");
});
test("open-channel members can add agents from the header", async ({
page,
}) => {
await page.setViewportSize({ width: 1280, height: 420 });
await page.goto("/");
await page.getByTestId("channel-random").click();
await expect(page.getByTestId("chat-title")).toHaveText("random");
const addAgentTrigger = page.getByTestId("channel-add-bot-trigger");
await expect(addAgentTrigger).toBeEnabled();
await addAgentTrigger.click();
await expect(page.getByRole("heading", { name: "Add agents" })).toBeVisible();
await expect(page.getByTestId("add-channel-bot-dialog-header")).toBeVisible();
await expect(
page.getByTestId("add-channel-bot-dialog-scroll-area"),
).toBeVisible();
await expect(
page.getByTestId("add-channel-bot-dialog-scroll-area"),
).toHaveCSS("overflow-y", "auto");
expect(
await page
.getByTestId("add-channel-bot-dialog-scroll-area")
.evaluate(
(element) =>
element.scrollHeight > element.clientHeight &&
element.clientHeight > 0,
),
).toBe(true);
await expect(page.getByTestId("add-channel-bot-dialog-footer")).toBeVisible();
});
test("removing a channel-scoped agent also cleans up the managed agent record", async ({
page,
}) => {
const agentName = `cleanup-agent-${Date.now()}`;
await page.goto("/");
await addGenericAgent(page, "general", agentName);
const agentPubkey = await getManagedAgentPubkey(page, 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(0);
const commands = await readCommandLog(page);
expect(commands).toContain("delete_managed_agent");
});
test("members sidebar can respawn a stopped managed bot", async ({ page }) => {
const agentName = `sidebar-agent-${Date.now()}`;
await page.goto("/");
await addGenericAgent(page, "general", agentName);
const agentPubkey = await getManagedAgentPubkey(page, agentName);
const baselineCommands = await readCommandLog(page);
const baselineStartCount = baselineCommands.filter(
(command) => command === "start_managed_agent",
).length;
const baselineStopCount = baselineCommands.filter(
(command) => command === "stop_managed_agent",
).length;
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("Running");
await openMemberMenu(page, agentPubkey);
await expect(agentAction).toContainText("Stop");
await agentAction.click();
await expect(agentStatus).toContainText("Stopped");
await openMemberMenu(page, agentPubkey);
await expect(agentAction).toContainText("Respawn");
await agentAction.click();
await expect(agentStatus).toContainText("Running");
await expect(page.getByTestId("members-sidebar-action-notice")).toContainText(
`Respawned ${agentName}.`,
);
const commands = await readCommandLog(page);
expect(
commands.filter((command) => command === "start_managed_agent").length,
).toBe(baselineStartCount + 1);
expect(
commands.filter((command) => command === "stop_managed_agent").length,
).toBe(baselineStopCount + 1);
});
test("members sidebar supports bulk remove for managed bots", async ({
page,
}) => {
const firstAgentName = `sidebar-remove-a-${Date.now()}`;
const secondAgentName = `sidebar-remove-b-${Date.now()}`;
await page.goto("/");
await addGenericAgent(page, "general", firstAgentName);
await addGenericAgent(page, "general", secondAgentName);
const firstAgentPubkey = await getManagedAgentPubkey(page, firstAgentName);
const secondAgentPubkey = await getManagedAgentPubkey(page, secondAgentName);
await openMembersSidebar(page, "general");
await expect(
page.getByTestId("members-sidebar-agent-controls"),
).toBeVisible();
await page.getByTestId("members-sidebar-agent-controls").click();
await page.getByTestId("members-sidebar-remove-all").click();
await expect(page.getByTestId("members-sidebar-action-notice")).toContainText(
"Removed 2 managed bots from this channel.",
);
await expect(
page.getByTestId(`sidebar-member-${firstAgentPubkey}`),
).toHaveCount(0);
await expect(
page.getByTestId(`sidebar-member-${secondAgentPubkey}`),
).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(0);
await expect(
page.getByTestId(`managed-agent-${secondAgentPubkey}`),
).toHaveCount(0);
const commands = await readCommandLog(page);
expect(
commands.filter((command) => command === "remove_channel_member"),
).toHaveLength(2);
expect(
commands.filter((command) => command === "delete_managed_agent"),
).toHaveLength(2);
});
test("removing a multi-channel managed bot keeps its record until it is orphaned", async ({
page,
}) => {
const agentName = `multi-channel-agent-${Date.now()}`;
const secondChannelName = `multi-home-${Date.now()}`;
await page.goto("/");
await addGenericAgent(page, "general", agentName);
const agentPubkey = await getManagedAgentPubkey(page, agentName);
await page.getByRole("button", { name: "Create a stream" }).click();
await page.getByTestId("create-stream-name").fill(secondChannelName);
await page
.getByTestId("create-stream-description")
.fill("Second home for managed bot cleanup coverage");
await page
.getByTestId("create-stream-form")
.getByRole("button", { name: "Create" })
.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;
const baselineDeletes = baseline.filter(
(c) => c === "delete_managed_agent",
).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, bot still in second channel
// so no delete_managed_agent yet.
expect(
commands.filter((c) => c === "remove_channel_member").length -
baselineRemoves,
).toBe(1);
expect(
commands.filter((c) => c === "delete_managed_agent").length -
baselineDeletes,
).toBe(0);
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(0);
commands = await readCommandLog(page);
// Second removal: bot is now orphaned, so cleanup deletes the managed agent.
expect(
commands.filter((c) => c === "remove_channel_member").length -
baselineRemoves,
).toBe(2);
expect(
commands.filter((c) => c === "delete_managed_agent").length -
baselineDeletes,
).toBe(1);
});
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 page.getByTestId("browse-channels").click();
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 page.getByTestId("browse-channels").click();
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 page.getByTestId("browse-channels").click();
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")).toBeDisabled();
await expect(page.getByTestId("send-message")).toBeDisabled();
await page.getByTestId("browse-channels").click();
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")).toBeEnabled();
});
test("manage channel can delete an owned stream", async ({ page }) => {
const channelName = `delete-me-${Date.now()}`;
await page.goto("/");
await page.getByRole("button", { name: "Create a stream" }).click();
await page.getByTestId("create-stream-name").fill(channelName);
await page
.getByTestId("create-stream-form")
.getByRole("button", { name: "Create" })
.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("chat-title")).toHaveText("Home");
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 page.getByRole("button", { name: "Create a stream" }).click();
await page.getByTestId("create-stream-name").fill(channelName);
await page
.getByTestId("create-stream-form")
.getByRole("button", { name: "Create" })
.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);
});