mirror of
https://github.com/block/buzz.git
synced 2026-08-18 06:50:31 +02:00
**Category:** improvement **User Impact:** Buzz channel, message, repository, pull request, and issue links now open reliably and display recognizable context in the desktop app. **Problem:** Buzz links could appear as raw or ambiguous URLs, and navigation links received during startup or community transitions could be dropped before the UI was ready. Repository and issue shares in particular required hover context to understand at a glance. **Solution:** Queue desktop channel/message navigation until the UI is ready, then render bare Buzz permalinks as icon-prefixed chips with concise entity context while preserving user-authored Markdown labels as ordinary links. <details> <summary>File changes</summary> **desktop/src-tauri/src/deep_link.rs** Adds validated channel-link parsing and a deduplicated, acknowledged queue so navigation survives frontend startup. **desktop/src-tauri/src/lib.rs** Registers the pending-navigation state and commands with the desktop application. **desktop/src/features/communities/useCommunityInit.ts** Resets queued navigation safely across community boundaries without leaking stale destinations. **desktop/src/features/messages/lib/channelLink.test.mjs** Covers valid, malformed, and canonical channel permalink forms. **desktop/src/features/messages/lib/channelLink.ts** Defines strict parsing and detection for `buzz://channel/<uuid>` links. **desktop/src/features/messages/lib/composerMessageLinkNode.test.mjs** Extends composer-node coverage for normalized Buzz link content. **desktop/src/features/messages/lib/composerMessageLinkNode.ts** Keeps composer link-node handling aligned with the expanded Buzz link surface. **desktop/src/features/messages/lib/remarkChannelDeepLinks.test.mjs** Verifies bare channel URLs become renderable deep-link nodes without touching code. **desktop/src/features/messages/lib/remarkChannelDeepLinks.ts** Transforms eligible bare channel links into dedicated Markdown nodes. **desktop/src/features/messages/lib/remarkEntityLinks.test.mjs** Covers bare repository, pull-request, and issue detection and code-span exclusions. **desktop/src/features/messages/lib/remarkEntityLinks.ts** Adds dedicated Markdown nodes for bare Buzz project entities. **desktop/src/shared/deep-link.test.mjs** Exercises queued navigation, acknowledgement, serialization, and community-switch behavior. **desktop/src/shared/deep-link.ts** Serializes pending deep-link drains and acknowledges destinations only after successful navigation. **desktop/src/shared/styles/globals/markdown.css** Aligns permalink icon geometry and spacing with agent mention chips. **desktop/src/shared/ui/markdown.test.mjs** Adds integration coverage for every permalink chip, authored labels, fallbacks, icons, and static rendering. **desktop/src/shared/ui/markdown.tsx** Routes channel and entity nodes through the shared presentation path while preserving authored link text. **desktop/src/shared/ui/markdown/BuzzLinkChip.tsx** Introduces the shared interactive/static permalink chip and authored-label inline-link components. **desktop/src/shared/ui/markdown/ChannelDeepLink.tsx** Renders channel shares and references with Hash icons, names, and shortened-ID fallbacks. **desktop/src/shared/ui/markdown/MessageLinkPill.tsx** Renders ordinary message shares with message icons and channel/message context while retaining sent-from-thread behavior. **desktop/src/shared/ui/markdown/entityLinks.tsx** Maps repositories, pull requests, and issues to Projects-aligned icons and contextual labels. **desktop/src/shared/ui/markdown/nodeCache.ts** Includes entity-link rendering in cached Markdown node handling. **desktop/src/shared/ui/markdown/utils.ts** Allows validated channel links through the Buzz URL transform. **desktop/src/shared/useMessageDeepLinks.ts** Drains queued navigation links safely and clears them during teardown. **desktop/src/testing/e2eBridge.ts** Extends the mock bridge with pending-navigation command behavior. **desktop/tests/e2e/community-rail.spec.ts** Verifies queued links do not cross community boundaries. **desktop/tests/e2e/navigation.spec.ts** Covers channel/message deep-link navigation during startup and active sessions. **desktop/tests/helpers/bridge.ts** Adds reusable deep-link mock state and acknowledgement helpers. </details> ## Reproduction steps 1. Run the desktop app and open a channel containing bare `buzz://channel`, `buzz://message`, `buzz://repo`, `buzz://pr`, and `buzz://issue` URLs. 2. Confirm each bare URL renders as one cohesive chip with a type icon, a useful name or shortened identifier, and no duplicated channel `#` character. 3. Add an authored Markdown link such as `[design discussion](buzz://issue?...)` and confirm the supplied label remains an ordinary link rather than becoming a chip. 4. Select channel and message links and confirm they navigate correctly in warm and cold-start states. ## Screenshots / demos Houston dark theme with custom purple accent (`#a855f7`), captured from rebased visual implementation `ad411cc06`; current head `0aafa144f` only adjusts E2E expectations for the visible mention-label behavior shown here. **Composer — channel, message, repository, pull request, and issue pills**  **Message list — channel, message, repository, pull request, and issue pills**  --------- Signed-off-by: Taylor Ho <taylorkmho@gmail.com> Signed-off-by: Carl <acda9e433d19dcd0e6b6840f7f4b98f3a56f1fab98049d444c087019e6d36560@buzz.block.builderlab.xyz> Co-authored-by: Carl <acda9e433d19dcd0e6b6840f7f4b98f3a56f1fab98049d444c087019e6d36560@buzz.block.builderlab.xyz>
895 lines
30 KiB
TypeScript
895 lines
30 KiB
TypeScript
import { expect, test } from "@playwright/test";
|
|
|
|
import { installMockBridge, openCreateChannelDialog } from "../helpers/bridge";
|
|
|
|
async function getTimelineMetrics(page: import("@playwright/test").Page) {
|
|
return page.getByTestId("message-timeline").evaluate((element) => {
|
|
const timeline = element as HTMLDivElement;
|
|
|
|
return {
|
|
clientHeight: timeline.clientHeight,
|
|
scrollHeight: timeline.scrollHeight,
|
|
scrollTop: timeline.scrollTop,
|
|
distanceFromBottom:
|
|
timeline.scrollHeight - timeline.clientHeight - timeline.scrollTop,
|
|
};
|
|
});
|
|
}
|
|
|
|
async function ensureTimelineScrollable(
|
|
page: import("@playwright/test").Page,
|
|
prefix: string,
|
|
) {
|
|
const input = page.getByTestId("message-input");
|
|
const sendButton = page.getByTestId("send-message");
|
|
|
|
for (let index = 0; index < 24; index += 1) {
|
|
const metrics = await getTimelineMetrics(page);
|
|
if (metrics.scrollHeight > metrics.clientHeight + 160) {
|
|
return;
|
|
}
|
|
|
|
const message = `${prefix} seed ${index}`;
|
|
|
|
await input.fill(message);
|
|
await sendButton.click();
|
|
await expect(page.getByTestId("message-timeline")).toContainText(message);
|
|
}
|
|
|
|
const metrics = await getTimelineMetrics(page);
|
|
expect(metrics.scrollHeight).toBeGreaterThan(metrics.clientHeight + 160);
|
|
}
|
|
|
|
async function focusSidebarSearchWithShortcut(
|
|
page: import("@playwright/test").Page,
|
|
) {
|
|
const openSearchButton = page.getByTestId("open-search");
|
|
|
|
await expect(openSearchButton).toBeVisible();
|
|
await page.evaluate(() => {
|
|
const isMac = /mac|iphone|ipad|ipod/i.test(navigator.platform);
|
|
window.dispatchEvent(
|
|
new KeyboardEvent("keydown", {
|
|
bubbles: true,
|
|
cancelable: true,
|
|
code: "KeyK",
|
|
ctrlKey: !isMac,
|
|
key: "k",
|
|
metaKey: isMac,
|
|
}),
|
|
);
|
|
});
|
|
await expect(page.getByTestId("search-results")).toBeVisible();
|
|
await expect(page.getByTestId("search-dialog-input")).toBeFocused();
|
|
}
|
|
|
|
async function expectHomeView(page: import("@playwright/test").Page) {
|
|
await expect(page.getByTestId("home-inbox-list")).toBeVisible();
|
|
}
|
|
|
|
async function selectHomeInboxFilter(
|
|
page: import("@playwright/test").Page,
|
|
label: "Agents",
|
|
) {
|
|
await page
|
|
.getByTestId("home-inbox")
|
|
.getByRole("button", {
|
|
name: /^Filter inbox:/,
|
|
})
|
|
.click();
|
|
await page.getByRole("menuitemradio", { name: label }).click();
|
|
}
|
|
|
|
test.beforeEach(async ({ page }) => {
|
|
await installMockBridge(page);
|
|
});
|
|
|
|
test("loads the app shell with mocked channels", async ({ page }) => {
|
|
await page.goto("/");
|
|
|
|
await expect(page.getByTestId("app-sidebar")).toBeVisible();
|
|
await expect(page.getByTestId("stream-list")).toContainText("general");
|
|
await expect(page.getByTestId("forum-list")).toContainText("watercooler");
|
|
await expect(page.getByTestId("dm-list")).toContainText("alice-tyler");
|
|
});
|
|
|
|
async function chooseSharedComputeProvider(
|
|
page: import("@playwright/test").Page,
|
|
) {
|
|
await page.getByRole("tab", { name: "Customize for this agent" }).click();
|
|
const provider = page.locator("#persona-llm-provider");
|
|
await expect(provider).toBeVisible({ timeout: 10_000 });
|
|
await provider.press("Enter");
|
|
await page
|
|
.getByRole("menuitemradio", {
|
|
exact: true,
|
|
name: "Buzz shared compute",
|
|
})
|
|
.click();
|
|
}
|
|
|
|
test("creates a new mocked stream", async ({ page }) => {
|
|
const channelName = `release-notes-${Date.now()}`;
|
|
|
|
await page.goto("/");
|
|
await openCreateChannelDialog(page);
|
|
await page.getByTestId("create-channel-name").fill(channelName);
|
|
await page
|
|
.getByTestId("create-channel-description")
|
|
.fill("Release coordination");
|
|
await page.getByTestId("create-channel-submit").click();
|
|
|
|
await expect(page.getByTestId("stream-list")).toContainText(channelName);
|
|
await expect(page.getByTestId("chat-title")).toContainText(channelName);
|
|
});
|
|
|
|
test("Buzz shared compute explains automatic model selection", async ({
|
|
page,
|
|
}) => {
|
|
await page.goto("/");
|
|
await page.evaluate(() => {
|
|
(
|
|
window as Window & {
|
|
__BUZZ_E2E_SET_MESH__?: (mesh: {
|
|
models?: Array<{ id: string; name: string | null }>;
|
|
}) => void;
|
|
}
|
|
).__BUZZ_E2E_SET_MESH__?.({ models: [] });
|
|
});
|
|
await page.getByTestId("open-agents-view").click();
|
|
await page.getByTestId("new-agent-card").click();
|
|
await chooseSharedComputeProvider(page);
|
|
|
|
await expect
|
|
.poll(() =>
|
|
page.evaluate(
|
|
() =>
|
|
(window as Window & { __BUZZ_E2E_COMMANDS__?: string[] })
|
|
.__BUZZ_E2E_COMMANDS__ ?? [],
|
|
),
|
|
)
|
|
.toContain("discover_agent_models");
|
|
await expect(page.locator("#persona-model")).toContainText("Automatic");
|
|
await expect(
|
|
page.getByText(
|
|
"Auto uses Mesh collective intelligence when two or more models stay available, otherwise it chooses one available model.",
|
|
),
|
|
).toBeVisible();
|
|
await expect(page.locator("#persona-custom-model")).toHaveCount(0);
|
|
});
|
|
|
|
test("create agent persists Buzz shared compute with auto model", async ({
|
|
page,
|
|
}) => {
|
|
const agentName = `Shared compute agent ${Date.now()}`;
|
|
|
|
await page.goto("/");
|
|
await page.getByTestId("open-agents-view").click();
|
|
await page.getByTestId("new-agent-card").click();
|
|
await page.locator("#persona-display-name").fill(agentName);
|
|
|
|
await chooseSharedComputeProvider(page);
|
|
|
|
const model = page.locator("#persona-model");
|
|
await expect(model).toContainText("Automatic");
|
|
await page.getByTestId("persona-dialog-submit").click();
|
|
const createdToast = page
|
|
.locator("[data-sonner-toast][data-removed='false']")
|
|
.filter({ hasText: "Agent created" });
|
|
await expect(createdToast).toBeVisible({ timeout: 10_000 });
|
|
await expect(createdToast).toHaveCount(1);
|
|
await expect(page.getByRole("dialog")).toHaveCount(0);
|
|
|
|
const createPayload = await page.evaluate((name) => {
|
|
const log = (
|
|
window as Window & {
|
|
__BUZZ_E2E_COMMAND_LOG__?: Array<{
|
|
command: string;
|
|
payload: unknown;
|
|
}>;
|
|
}
|
|
).__BUZZ_E2E_COMMAND_LOG__;
|
|
return log
|
|
?.filter((entry) => entry.command === "create_managed_agent")
|
|
.map((entry) => entry.payload as { input?: Record<string, unknown> })
|
|
.find((payload) => payload.input?.name === name)?.input;
|
|
}, agentName);
|
|
|
|
expect(createPayload).toMatchObject({
|
|
agentCommand: "buzz-agent",
|
|
model: "auto",
|
|
provider: "relay-mesh",
|
|
spawnAfterCreate: true,
|
|
startOnAppLaunch: true,
|
|
});
|
|
});
|
|
|
|
test("create agent supports parallelism and system prompt overrides", async ({
|
|
page,
|
|
}) => {
|
|
const agentName = `Parallel agent ${Date.now()}`;
|
|
|
|
await page.goto("/");
|
|
await page.getByTestId("open-agents-view").click();
|
|
await page.getByTestId("new-agent-card").click();
|
|
|
|
await page.locator("#persona-display-name").fill(agentName);
|
|
await page
|
|
.locator("#persona-system-prompt")
|
|
.fill("You are concise and parallelize independent work.");
|
|
|
|
// The buzz-agent runtime auto-selects once the ACP runtime catalog loads;
|
|
// Customize reveals the per-agent LLM provider and model fields.
|
|
await page.getByRole("tab", { name: "Customize for this agent" }).click();
|
|
const llmProvider = page.locator("#persona-llm-provider");
|
|
await expect(llmProvider).toBeVisible({ timeout: 10_000 });
|
|
await llmProvider.press("Enter");
|
|
await page
|
|
.getByRole("menuitemradio", { exact: true, name: "Anthropic" })
|
|
.click();
|
|
const model = page.locator("#persona-model");
|
|
await model.click();
|
|
await page
|
|
.getByRole("button", { name: "Custom model...", exact: true })
|
|
.click();
|
|
await page.getByLabel("Custom model ID").fill("claude-opus-4-5");
|
|
await page.getByLabel("Anthropic API Key").fill("sk-test-api-key-for-e2e");
|
|
|
|
const advancedToggle = page.getByRole("button", {
|
|
name: "Advanced",
|
|
exact: true,
|
|
});
|
|
await advancedToggle.click();
|
|
// Parallelism is above the env-vars editor in the Advanced section; filling
|
|
// the required API-key row may have scrolled the dialog past it. Scroll back.
|
|
await page
|
|
.locator("#persona-parallelism")
|
|
.evaluate((el) => el.scrollIntoView({ block: "nearest" }));
|
|
await expect(page.locator("#persona-parallelism")).toBeVisible();
|
|
await page.locator("#persona-parallelism").fill("3");
|
|
|
|
// Submitting mints a running instance whose behavioral quad resolves from
|
|
// the definition (agents always start after creation).
|
|
await page.getByTestId("persona-dialog-submit").click();
|
|
|
|
const createdToast = page
|
|
.locator("[data-sonner-toast][data-removed='false']")
|
|
.filter({ hasText: "Agent created" });
|
|
await expect(createdToast).toBeVisible({ timeout: 10_000 });
|
|
await expect(createdToast).toHaveCount(1);
|
|
await expect(page.getByRole("dialog")).toHaveCount(0);
|
|
|
|
await expect(page.getByTestId("agents-library-personas")).toContainText(
|
|
agentName,
|
|
);
|
|
|
|
// Logs now live in the profile sidebar (PR #1274), not an inline panel.
|
|
// Open the new agent's card to reveal the profile panel, then read the
|
|
// harness log from the diagnostics view.
|
|
await page
|
|
.getByRole("button", { name: `${agentName} agent profile` })
|
|
.click();
|
|
await expect(page.getByTestId("user-profile-panel")).toBeVisible();
|
|
|
|
await page.getByTestId("user-profile-tab-runtime").click();
|
|
await page.getByTestId("user-profile-diagnostics-ingress").click();
|
|
|
|
const log = page.getByTestId("managed-agent-log-content");
|
|
await expect(log).toContainText("parallelism=3");
|
|
await expect(log).toContainText("system prompt override configured");
|
|
});
|
|
|
|
test("opens a mocked channel from the inbox feed", async ({ page }) => {
|
|
const inboxList = page.getByTestId("home-inbox-list");
|
|
|
|
await page.goto("/");
|
|
|
|
await expectHomeView(page);
|
|
await expect(inboxList).toContainText("Please review the release checklist.");
|
|
|
|
const releaseRow = page.getByTestId("home-inbox-item-mock-feed-mention");
|
|
await releaseRow.hover();
|
|
await releaseRow.getByRole("button", { name: "Open in channel" }).click();
|
|
|
|
await expect(page).toHaveURL(
|
|
/#\/channels\/9a1657ac-f7aa-5db0-b632-d8bbeb6dfb50\?messageId=mock-feed-mention$/,
|
|
);
|
|
await expect(page.getByTestId("chat-title")).toHaveText("general");
|
|
});
|
|
|
|
test("Inbox excludes generic channel and unowned agent traffic", async ({
|
|
page,
|
|
}) => {
|
|
const inboxList = page.getByTestId("home-inbox-list");
|
|
|
|
await page.goto("/");
|
|
await expectHomeView(page);
|
|
|
|
await expect(inboxList).not.toContainText(
|
|
"Engineering shipped the desktop build.",
|
|
);
|
|
await expect(inboxList).not.toContainText(
|
|
"Agent progress: channel index complete.",
|
|
);
|
|
|
|
await selectHomeInboxFilter(page, "Agents");
|
|
await expect(inboxList).toContainText("No agent updates found");
|
|
});
|
|
|
|
test("inbox feed renders resolved author labels", async ({ page }) => {
|
|
await page.goto("/");
|
|
|
|
await expect(page.getByTestId("home-inbox-list")).toContainText("alice");
|
|
await expect(page.getByTestId("home-inbox-list")).not.toContainText("You");
|
|
});
|
|
|
|
test("opens sidebar search with the shortcut and loads the exact result", async ({
|
|
page,
|
|
}) => {
|
|
await page.goto("/");
|
|
|
|
await focusSidebarSearchWithShortcut(page);
|
|
|
|
await page.getByTestId("search-dialog-input").fill("shipped");
|
|
await expect(page.getByTestId("search-results")).toContainText(
|
|
"Engineering shipped the desktop build.",
|
|
);
|
|
|
|
await page.keyboard.press("Enter");
|
|
|
|
await expect(page).toHaveURL(
|
|
/#\/channels\/1c7e1c02-87bb-5e88-b2da-5a7a9432d0c9\?messageId=mock-engineering-shipped$/,
|
|
);
|
|
await expect(page.getByTestId("chat-title")).toHaveText("engineering");
|
|
await expect(page.getByTestId("message-timeline")).toContainText(
|
|
"Engineering shipped the desktop build.",
|
|
);
|
|
});
|
|
|
|
test("opens channel matches from search", async ({ page }) => {
|
|
await page.goto("/");
|
|
|
|
await focusSidebarSearchWithShortcut(page);
|
|
|
|
await page.getByTestId("search-dialog-input").fill("engineering");
|
|
const results = page.getByTestId("search-results");
|
|
|
|
await expect(results).toContainText("engineering");
|
|
await expect(results).toContainText("Engineering discussions");
|
|
await expect(results).toContainText(
|
|
"Design system and UX discussions with engineering partners",
|
|
);
|
|
await expect(
|
|
results.locator('[data-testid^="search-result-channel-"]').first(),
|
|
).toHaveAttribute(
|
|
"data-testid",
|
|
"search-result-channel-1c7e1c02-87bb-5e88-b2da-5a7a9432d0c9",
|
|
);
|
|
|
|
await expect(
|
|
results.getByTestId(
|
|
"search-result-channel-1c7e1c02-87bb-5e88-b2da-5a7a9432d0c9",
|
|
),
|
|
).toHaveAttribute("aria-selected", "true");
|
|
await page.keyboard.press("Enter");
|
|
|
|
await expect(page).toHaveURL(
|
|
/#\/channels\/1c7e1c02-87bb-5e88-b2da-5a7a9432d0c9$/,
|
|
);
|
|
await expect(page.getByTestId("chat-title")).toHaveText("engineering");
|
|
});
|
|
|
|
test("global search offers an optional current-channel scope", async ({
|
|
page,
|
|
}) => {
|
|
await page.goto("/");
|
|
await page.getByTestId("channel-general").click();
|
|
await expect(page.getByTestId("chat-title")).toHaveText("general");
|
|
await expect(page).toHaveURL(
|
|
/#\/channels\/9a1657ac-f7aa-5db0-b632-d8bbeb6dfb50$/,
|
|
);
|
|
|
|
await focusSidebarSearchWithShortcut(page);
|
|
|
|
const scopeControl = page.getByTestId("search-current-channel-control");
|
|
const input = page.getByTestId("search-dialog-input");
|
|
await expect
|
|
.poll(() =>
|
|
scopeControl
|
|
.getByTestId("search-current-scope-label")
|
|
.evaluate((element) => element.textContent),
|
|
)
|
|
.toBe("Search in #general");
|
|
await expect(scopeControl).toContainText("Search in");
|
|
await expect(scopeControl).toContainText("#general");
|
|
await expect(scopeControl).toContainText("Search messages in this channel");
|
|
await expect(page.getByTestId("search-dialog-input-row")).toHaveCSS(
|
|
"border-bottom-width",
|
|
"1px",
|
|
);
|
|
await expect(scopeControl.locator("..")).toHaveCSS(
|
|
"border-bottom-width",
|
|
"0px",
|
|
);
|
|
await expect(scopeControl.locator("..")).toHaveCSS("padding-top", "14px");
|
|
await expect(scopeControl.locator("..")).toHaveCSS("padding-bottom", "14px");
|
|
const [controlBox, dialogBox] = await Promise.all([
|
|
scopeControl.boundingBox(),
|
|
page.getByTestId("search-results").boundingBox(),
|
|
]);
|
|
expect(controlBox).not.toBeNull();
|
|
expect(dialogBox).not.toBeNull();
|
|
expect(controlBox?.width ?? 0).toBeGreaterThan((dialogBox?.width ?? 0) * 0.9);
|
|
expect(controlBox?.width ?? 0).toBeLessThan(dialogBox?.width ?? 0);
|
|
await expect(scopeControl).toHaveAttribute("aria-selected", "true");
|
|
const firstRecentResult = page.locator(".search-result-row").first();
|
|
await input.press("ArrowDown");
|
|
await expect(firstRecentResult).toHaveAttribute("aria-selected", "true");
|
|
await input.press("ArrowUp");
|
|
await expect(scopeControl).toHaveAttribute("aria-selected", "true");
|
|
await input.press("Enter");
|
|
|
|
const scopeChip = page.getByTestId("search-channel-scope-chip");
|
|
await expect(scopeChip).toHaveText(/#general/);
|
|
await expect(input).toBeFocused();
|
|
await input.fill("w");
|
|
const relevantHeader = page.getByText("Most relevant", { exact: true });
|
|
const firstScopedResult = page
|
|
.locator('[data-search-section="messages"] .search-result-row')
|
|
.first();
|
|
await expect(page.getByText("Welcome to general")).toBeVisible();
|
|
await expect(page.getByText(/Searching messages in/)).toHaveCount(0);
|
|
await expect(relevantHeader).toBeVisible();
|
|
await expect(firstScopedResult).toBeVisible();
|
|
const contentStart = (element: HTMLElement) => {
|
|
const styles = window.getComputedStyle(element);
|
|
return (
|
|
element.getBoundingClientRect().left +
|
|
Number.parseFloat(styles.paddingLeft)
|
|
);
|
|
};
|
|
const [inputStart, headerStart, resultStart] = await Promise.all([
|
|
page.getByTestId("search-dialog-input-row").evaluate(contentStart),
|
|
relevantHeader.evaluate(contentStart),
|
|
firstScopedResult.evaluate(contentStart),
|
|
]);
|
|
expect(Math.abs(inputStart - headerStart)).toBeLessThanOrEqual(1);
|
|
expect(Math.abs(inputStart - resultStart)).toBeLessThanOrEqual(1);
|
|
|
|
await input.fill("x");
|
|
await expect(page.getByTestId("search-results")).toContainText(
|
|
"No messages for x in #general.",
|
|
);
|
|
|
|
await scopeChip.click();
|
|
await expect(scopeChip).toHaveCount(0);
|
|
await expect(input).toBeFocused();
|
|
await input.fill("shipped");
|
|
await expect(page.getByTestId("search-results")).toContainText(
|
|
"Engineering shipped the desktop build.",
|
|
);
|
|
});
|
|
|
|
test("global search offers a conversation-specific scope in direct messages", async ({
|
|
page,
|
|
}) => {
|
|
const directMessageId = "f48efb06-0c93-5025-aac9-2e646bb6bfa8";
|
|
|
|
await page.goto("/");
|
|
await page.getByTestId("channel-alice-tyler").click();
|
|
await expect(page.getByTestId("chat-title")).toHaveText("alice-tyler");
|
|
await expect(page).toHaveURL(
|
|
new RegExp(`#\\/channels\\/${directMessageId}$`),
|
|
);
|
|
|
|
await focusSidebarSearchWithShortcut(page);
|
|
|
|
const scopeControl = page.getByTestId("search-current-channel-control");
|
|
await expect
|
|
.poll(() =>
|
|
scopeControl
|
|
.getByTestId("search-current-scope-label")
|
|
.evaluate((element) => element.textContent),
|
|
)
|
|
.toBe("Search conversation with alice");
|
|
await expect(scopeControl).toContainText("Search conversation with alice");
|
|
await expect(scopeControl).toContainText(
|
|
"Search messages in this conversation.",
|
|
);
|
|
await expect(scopeControl).not.toContainText("channel");
|
|
await expect(page.getByTestId("search-dialog-input-row")).toHaveCSS(
|
|
"border-bottom-width",
|
|
"1px",
|
|
);
|
|
await expect(scopeControl.locator("..")).toHaveCSS(
|
|
"border-bottom-width",
|
|
"0px",
|
|
);
|
|
await expect(scopeControl.locator("..")).toHaveCSS("padding-top", "14px");
|
|
await expect(scopeControl.locator("..")).toHaveCSS("padding-bottom", "14px");
|
|
|
|
await scopeControl.click();
|
|
|
|
const scopeChip = page.getByTestId("search-channel-scope-chip");
|
|
const input = page.getByTestId("search-dialog-input");
|
|
await expect(scopeChip).toHaveText(/^alice$/);
|
|
await expect(scopeChip).not.toContainText("#");
|
|
await expect(input).toBeFocused();
|
|
await input.fill("a");
|
|
await expect(page.getByTestId("search-results")).toContainText(
|
|
"No messages for a in alice.",
|
|
);
|
|
await expect
|
|
.poll(() =>
|
|
page.evaluate(() => {
|
|
const calls =
|
|
(
|
|
window as Window & {
|
|
__BUZZ_E2E_COMMAND_LOG__?: Array<{
|
|
command: string;
|
|
payload: unknown;
|
|
}>;
|
|
}
|
|
).__BUZZ_E2E_COMMAND_LOG__ ?? [];
|
|
|
|
return calls.findLast((entry) => entry.command === "search_messages")
|
|
?.payload;
|
|
}),
|
|
)
|
|
.toMatchObject({
|
|
channelId: directMessageId,
|
|
q: "a",
|
|
});
|
|
|
|
await page.keyboard.press("Escape");
|
|
await page.keyboard.press("ControlOrMeta+f");
|
|
await expect(page.getByTestId("search-results")).toBeVisible();
|
|
await expect(page.getByTestId("search-channel-scope-chip")).toHaveText(
|
|
/^alice$/,
|
|
);
|
|
await expect(page.getByTestId("search-current-channel-control")).toHaveCount(
|
|
0,
|
|
);
|
|
});
|
|
|
|
test("channel find shortcut opens unified search with scope selected", async ({
|
|
page,
|
|
}) => {
|
|
await page.goto("/");
|
|
await page.getByTestId("channel-general").click();
|
|
await expect(page).toHaveURL(
|
|
/#\/channels\/9a1657ac-f7aa-5db0-b632-d8bbeb6dfb50$/,
|
|
);
|
|
|
|
await page.keyboard.press("ControlOrMeta+f");
|
|
|
|
await expect(page.getByTestId("search-results")).toBeVisible();
|
|
await expect(page.getByTestId("search-channel-scope-chip")).toHaveText(
|
|
/#general/,
|
|
);
|
|
await expect(page.getByTestId("search-current-channel-control")).toHaveCount(
|
|
0,
|
|
);
|
|
await expect(page.getByTestId("search-dialog-input")).toBeFocused();
|
|
});
|
|
|
|
test("global search omits channel scoping when no channel is active", async ({
|
|
page,
|
|
}) => {
|
|
await page.goto("/");
|
|
await expectHomeView(page);
|
|
|
|
await focusSidebarSearchWithShortcut(page);
|
|
|
|
await expect(page.getByTestId("search-current-channel-control")).toHaveCount(
|
|
0,
|
|
);
|
|
await expect(page.getByTestId("search-channel-scope-chip")).toHaveCount(0);
|
|
});
|
|
|
|
test("global one-character search does not query the relay", async ({
|
|
page,
|
|
}) => {
|
|
await page.goto("/");
|
|
await focusSidebarSearchWithShortcut(page);
|
|
|
|
await page.getByTestId("search-dialog-input").fill("x");
|
|
await page.waitForTimeout(400);
|
|
|
|
const messageSearchCalls = await page.evaluate(() => {
|
|
const calls =
|
|
(
|
|
window as Window & {
|
|
__BUZZ_E2E_COMMAND_LOG__?: Array<{ command: string }>;
|
|
}
|
|
).__BUZZ_E2E_COMMAND_LOG__ ?? [];
|
|
return calls.filter((entry) => entry.command === "search_messages").length;
|
|
});
|
|
expect(messageSearchCalls).toBe(0);
|
|
});
|
|
|
|
test("global search tolerates small channel and people typos", async ({
|
|
page,
|
|
}) => {
|
|
await page.goto("/");
|
|
await focusSidebarSearchWithShortcut(page);
|
|
|
|
const input = page.getByTestId("search-dialog-input");
|
|
const results = page.getByTestId("search-results");
|
|
await input.fill("engneering");
|
|
await expect(results).toContainText("Engineering discussions");
|
|
|
|
await input.fill("alcie");
|
|
await expect(results).toContainText("alice");
|
|
});
|
|
|
|
test("global search exposes a larger scrollable result window", async ({
|
|
page,
|
|
}) => {
|
|
await page.goto("/");
|
|
await page.getByTestId("channel-deep-history").click();
|
|
await expect(page.getByTestId("chat-title")).toHaveText("deep-history");
|
|
await expect(
|
|
page.locator('[data-message-id^="mock-deep-history-"]').first(),
|
|
).toBeVisible();
|
|
|
|
await focusSidebarSearchWithShortcut(page);
|
|
await page.getByTestId("search-dialog-input").fill("deep history message");
|
|
|
|
const resultRows = page.locator(
|
|
'[data-search-section="messages"] .search-result-row',
|
|
);
|
|
await expect(resultRows).toHaveCount(40);
|
|
const resultList = page.getByTestId("search-results-list");
|
|
await expect(resultList).toBeVisible();
|
|
const scopeControl = page.getByTestId("search-current-channel-control");
|
|
await expect(
|
|
resultList.getByTestId("search-current-channel-control"),
|
|
).toBeVisible();
|
|
const dimensions = await resultList.evaluate((element) => ({
|
|
clientHeight: element.clientHeight,
|
|
scrollHeight: element.scrollHeight,
|
|
}));
|
|
expect(dimensions.scrollHeight).toBeGreaterThan(dimensions.clientHeight);
|
|
await resultList.evaluate((element) => {
|
|
element.scrollTop = element.scrollHeight;
|
|
});
|
|
await expect(scopeControl).not.toBeInViewport();
|
|
await resultList.evaluate((element) => {
|
|
element.scrollTop = 0;
|
|
});
|
|
|
|
for (let index = 0; index < 14; index += 1) {
|
|
await page.keyboard.press("ArrowDown");
|
|
}
|
|
await expect(resultRows.nth(13)).toHaveAttribute("aria-selected", "true");
|
|
await expect(resultRows.nth(13)).toBeInViewport();
|
|
});
|
|
|
|
test("closes sidebar search with Escape", async ({ page }) => {
|
|
await page.goto("/");
|
|
|
|
await focusSidebarSearchWithShortcut(page);
|
|
await page.getByTestId("search-dialog-input").fill("shipped");
|
|
|
|
await page.keyboard.press("Escape");
|
|
|
|
await expect(page.getByTestId("search-results")).toHaveCount(0);
|
|
await expect(page.getByTestId("open-search")).toBeFocused();
|
|
});
|
|
|
|
test("search shortcut opens search without disturbing the collapsed sidebar", async ({
|
|
page,
|
|
}) => {
|
|
await page.goto("/");
|
|
|
|
await expect(page.getByTestId("open-search")).toBeVisible();
|
|
|
|
const sidebarRoot = page.locator('[data-side="left"][data-state]');
|
|
await expect(sidebarRoot).toHaveAttribute("data-state", "expanded");
|
|
|
|
// Collapse the sidebar; its pinned-header search slides off-screen.
|
|
await page
|
|
.getByRole("button", { name: "Toggle Sidebar", exact: true })
|
|
.click();
|
|
await expect(sidebarRoot).toHaveAttribute("data-state", "collapsed");
|
|
|
|
await page.evaluate(() => {
|
|
const isMac = /mac|iphone|ipad|ipod/i.test(navigator.platform);
|
|
window.dispatchEvent(
|
|
new KeyboardEvent("keydown", {
|
|
bubbles: true,
|
|
cancelable: true,
|
|
code: "KeyK",
|
|
ctrlKey: !isMac,
|
|
key: "k",
|
|
metaKey: isMac,
|
|
}),
|
|
);
|
|
});
|
|
|
|
// Search opens in its portal dialog; the sidebar must not react.
|
|
await expect(page.getByTestId("search-dialog-input")).toBeFocused();
|
|
await expect(sidebarRoot).toHaveAttribute("data-state", "collapsed");
|
|
});
|
|
|
|
test("search results use your resolved profile label instead of You", async ({
|
|
page,
|
|
}) => {
|
|
await page.goto("/");
|
|
|
|
await focusSidebarSearchWithShortcut(page);
|
|
|
|
await page.getByTestId("search-dialog-input").fill("welcome");
|
|
const results = page.getByTestId("search-results");
|
|
|
|
await expect(results).toContainText("Welcome to #general");
|
|
await expect(results).toContainText("npub1mock...");
|
|
await expect(results).not.toContainText("You");
|
|
});
|
|
|
|
test("opens accessible unjoined channels from search in read-only mode", async ({
|
|
page,
|
|
}) => {
|
|
await page.goto("/");
|
|
|
|
await focusSidebarSearchWithShortcut(page);
|
|
|
|
await page.getByTestId("search-dialog-input").fill("critique");
|
|
const results = page.getByTestId("search-results");
|
|
|
|
await expect(results).toContainText(
|
|
"Design critique notes for the browse flow.",
|
|
);
|
|
await results.getByText("Design critique notes for the browse flow.").click();
|
|
|
|
await expect(page.getByTestId("chat-title")).toHaveText("design");
|
|
await expect(page.getByTestId("message-timeline")).toContainText(
|
|
"Design critique notes for the browse flow.",
|
|
);
|
|
await expect(page.getByTestId("join-banner")).toBeVisible();
|
|
});
|
|
|
|
test("replaces the channel pane when switching channels", 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",
|
|
);
|
|
|
|
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("message-timeline")).not.toContainText(
|
|
"Welcome to general",
|
|
);
|
|
await expect(page.getByTestId("message-timeline")).toHaveCount(1);
|
|
await expect(page.getByTestId("message-timeline-day-divider")).toHaveCount(0);
|
|
|
|
await page.getByTestId("channel-engineering").click();
|
|
await expect(page.getByTestId("chat-title")).toHaveText("engineering");
|
|
await expect(page.getByTestId("message-channel-intro")).toBeVisible();
|
|
await expect(page.getByTestId("message-timeline")).toHaveCount(1);
|
|
await expect(page.getByTestId("message-timeline-day-divider")).toHaveCount(0);
|
|
});
|
|
|
|
test("sends a mocked channel message", async ({ page }) => {
|
|
const message = `Smoke message ${Date.now()}`;
|
|
|
|
await page.goto("/");
|
|
await page.getByTestId("channel-general").click();
|
|
await expect(page.getByTestId("chat-title")).toHaveText("general");
|
|
await page.getByTestId("message-input").fill(message);
|
|
await page.getByTestId("send-message").click();
|
|
|
|
await expect(page.getByTestId("message-timeline")).toContainText(message);
|
|
await expect
|
|
.poll(() =>
|
|
page.evaluate(() => {
|
|
const row = Array.from(
|
|
document.querySelectorAll<HTMLElement>("[data-message-id]"),
|
|
).at(-1);
|
|
const composer = document.querySelector<HTMLElement>(
|
|
'[data-testid="message-composer"]',
|
|
);
|
|
if (!row || !composer) return Number.NEGATIVE_INFINITY;
|
|
return (
|
|
composer.getBoundingClientRect().top -
|
|
row.getBoundingClientRect().bottom
|
|
);
|
|
}),
|
|
)
|
|
.toBeGreaterThanOrEqual(0);
|
|
});
|
|
|
|
test("supports multiline drafts with Ctrl+Enter and sends with Enter", async ({
|
|
page,
|
|
}) => {
|
|
const firstLine = `Shortcut smoke line one ${Date.now()}`;
|
|
const restOfLines = [
|
|
"Shortcut smoke line two",
|
|
"Shortcut smoke line three",
|
|
"Shortcut smoke line four",
|
|
"Shortcut smoke line five",
|
|
];
|
|
const input = page.getByTestId("message-input");
|
|
|
|
await page.goto("/");
|
|
await page.getByTestId("channel-general").click();
|
|
await expect(page.getByTestId("chat-title")).toHaveText("general");
|
|
await expect(
|
|
page.getByRole("button", { name: "Send message" }),
|
|
).toBeVisible();
|
|
const initialInputHeight = await input.evaluate(
|
|
(element) => (element as HTMLElement).clientHeight,
|
|
);
|
|
expect(initialInputHeight).toBeLessThan(40);
|
|
await input.fill(firstLine);
|
|
for (const line of restOfLines) {
|
|
await input.press("Shift+Enter");
|
|
await input.pressSequentially(line);
|
|
}
|
|
for (const line of [firstLine, ...restOfLines]) {
|
|
await expect(input).toContainText(line);
|
|
}
|
|
const expandedInputHeight = await input.evaluate(
|
|
(element) => (element as HTMLElement).clientHeight,
|
|
);
|
|
expect(expandedInputHeight).toBeLessThanOrEqual(130);
|
|
await expect(page.getByTestId("message-timeline")).not.toContainText(
|
|
firstLine,
|
|
);
|
|
await input.press("Enter");
|
|
|
|
await expect(page.getByTestId("message-timeline")).toContainText(firstLine);
|
|
await expect(page.getByTestId("message-timeline")).toContainText(
|
|
restOfLines[restOfLines.length - 1],
|
|
);
|
|
});
|
|
|
|
test("does not shift the timeline when the composer grows", async ({
|
|
page,
|
|
}) => {
|
|
const input = page.getByTestId("message-input");
|
|
const prefix = `Composer growth ${Date.now()}`;
|
|
|
|
await page.goto("/");
|
|
await page.getByTestId("channel-general").click();
|
|
await expect(page.getByTestId("chat-title")).toHaveText("general");
|
|
|
|
await ensureTimelineScrollable(page, prefix);
|
|
await page.waitForTimeout(400);
|
|
await page.getByTestId("message-timeline").evaluate((element) => {
|
|
const timeline = element as HTMLDivElement;
|
|
// The raw position assignment sets up detached history, while wheel is the
|
|
// same ownership signal a real reader produces before composer reflow.
|
|
timeline.dispatchEvent(new WheelEvent("wheel", { deltaY: -100 }));
|
|
timeline.scrollTop = 0;
|
|
timeline.dispatchEvent(new Event("scroll"));
|
|
});
|
|
await expect
|
|
.poll(async () => (await getTimelineMetrics(page)).distanceFromBottom)
|
|
.toBeGreaterThan(160);
|
|
const before = await getTimelineMetrics(page);
|
|
|
|
await input.fill("Composer growth line one");
|
|
await input.press("Shift+Enter");
|
|
await input.pressSequentially("Composer growth line two");
|
|
await input.press("Shift+Enter");
|
|
await input.pressSequentially("Composer growth line three");
|
|
await input.press("Shift+Enter");
|
|
await input.pressSequentially("Composer growth line four");
|
|
|
|
await page.waitForTimeout(1200);
|
|
|
|
const after = await getTimelineMetrics(page);
|
|
expect(after.clientHeight).toBeLessThanOrEqual(before.clientHeight);
|
|
expect(Math.abs(after.scrollTop - before.scrollTop)).toBeLessThanOrEqual(2);
|
|
expect(after.distanceFromBottom).toBeGreaterThan(160);
|
|
});
|