Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions desktop/playwright.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@ export default defineConfig({
"**/channel-shared-header-backdrop.spec.ts",
"**/auxiliary-pane-close-visibility.spec.ts",
"**/channel-composer-overflow.spec.ts",
"**/channel-autocomplete.spec.ts",
"**/badge.spec.ts",
"**/channel-browser.spec.ts",
"**/channel-add-screenshots.spec.ts",
Expand Down
10 changes: 10 additions & 0 deletions desktop/src/features/messages/lib/useChannelLinks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -124,6 +124,16 @@ export function useChannelLinks() {
clearTimeout(debounceTimerRef.current);
}

// A cleared edit must release Enter immediately for delete confirmation.
// Keep nonempty queries debounced: Enter can split a valid query's block
// before the composer's autocomplete handler accepts the suggestion.
if (value.length === 0) {
debounceTimerRef.current = null;
setChannelQuery(null);
setChannelSelectedIndex(0);
return;
}

debounceTimerRef.current = setTimeout(() => {
debounceTimerRef.current = null;
const channel = detectPrefixQuery(
Expand Down
111 changes: 111 additions & 0 deletions desktop/tests/e2e/channel-autocomplete.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,111 @@
import { expect, test, type Page } from "@playwright/test";

import { installMockBridge } from "../helpers/bridge";

// Keep the real-editor counterexamples from #7874 covered alongside #6733.
async function openComposer(
page: Page,
surface: "channel" | "thread" | "forum",
) {
await installMockBridge(page);
await page.goto("/");
await page.getByTestId("channel-general").click();
await expect(page.getByTestId("chat-title")).toHaveText("general");
await expect(page.getByTestId("message-input")).toBeFocused();
if (surface === "thread") {
await page.locator('[data-message-id="mock-general-alice"]').hover();
await page.getByTestId("reply-message-mock-general-alice").click();
return page
.getByTestId("message-thread-panel")
.getByTestId("message-input");
}
if (surface === "forum") {
await page.getByTestId("channel-watercooler").click();
await page.getByRole("button", { name: "Start a new post..." }).click();
}
return page.getByTestId("message-input");
}

function channelSuggestion(page: Page) {
return page.getByRole("button", { name: "#general stream", exact: true });
}

for (const surface of ["channel", "thread", "forum"] as const) {
test(`Enter selects a channel in the ${surface} composer before submitting`, async ({
page,
}) => {
const input = await openComposer(page, surface);
await input.pressSequentially("#gener");
await expect(channelSuggestion(page)).toBeVisible();
await input.press("Enter");

await expect.poll(() => input.textContent()).toBe("#general ");
await expect(input.locator("p")).toHaveCount(1);
await expect(input.locator("br")).toHaveCount(0);
await expect(input).toBeFocused();
await expect(channelSuggestion(page)).toBeHidden();

// The next Enter submits exactly once through the mock IPC boundary.
await page.keyboard.type("selected channel");
await input.press("Enter");
await expect
.poll(() =>
page.evaluate(() => {
const content = "#general selected channel";
const signed = (window.__BUZZ_E2E_SIGNED_EVENTS__ ?? []).filter(
(event) => event.content === content,
);
if (signed.length) return signed.length;
return (window.__BUZZ_E2E_COMMAND_LOG__ ?? []).filter(
(call) =>
call.command === "send_channel_message" &&
(call.payload as { content?: string }).content === content,
).length;
}),
)
.toBe(1);
});
}

for (const key of ["Tab", "Escape", "Shift+Enter"] as const) {
test(`channel autocomplete preserves ${key} behavior`, async ({ page }) => {
const input = await openComposer(page, "channel");
await input.fill("#gener");
await expect(channelSuggestion(page)).toBeVisible();
await input.press(key);
await expect
.poll(() => input.textContent())
.toBe(key === "Tab" ? "#general " : "#gener");
await expect(channelSuggestion(page)).toBeHidden();
await expect(input.locator("p")).toHaveCount(1);
if (key === "Shift+Enter") {
await page.keyboard.type("next line");
await expect(input).toHaveText("#genernext line");
await expect(input.locator("br")).toHaveCount(1);
} else {
await expect(input.locator("br")).toHaveCount(0);
}
});
}

for (const [prefix, expected] of [
["@ali", "@alice "],
[":joy", "😂 "],
] as const) {
test(`Enter still selects ${prefix} autocomplete without splitting`, async ({
page,
}) => {
const input = await openComposer(page, "channel");
const dropdown = page.getByTestId(
prefix === "@ali" ? "mention-autocomplete" : "emoji-autocomplete",
);
await input.fill(prefix);
await expect(dropdown).toBeVisible();
await input.press("Enter");
await expect(dropdown).toBeHidden();
await expect.poll(() => input.textContent()).toBe(expected);
await expect(input.locator("p")).toHaveCount(1);
await expect(input.locator("br")).toHaveCount(0);
await expect(input).toBeFocused();
});
}
21 changes: 18 additions & 3 deletions desktop/tests/e2e/empty-edit-delete.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,17 +38,32 @@ async function submitEmptyEdit(
const input = page.getByTestId("message-input");
await expect(input).not.toBeEmpty({ timeout: 5_000 });
await input.click();
await page.keyboard.press("ControlOrMeta+A");
await page.keyboard.press("Backspace");
await expect(
page.getByRole("button", { name: "#general stream", exact: true }),
).toBeVisible();
// Keep clear -> Enter inside the channel picker's dismissal debounce,
// regardless of the runner's speed. Drive the real editor, not its hooks.
await page.clock.pauseAt(await page.evaluate(() => Date.now() + 1_000));
try {
await page.keyboard.press("ControlOrMeta+A");
await page.keyboard.press("Backspace");
await expect(input).toBeEmpty();
await page.keyboard.press("Enter");
} finally {
await page.clock.resume();
}
await expect(page.getByRole("alertdialog")).toHaveCount(1);
await expect(input).toBeEmpty();
await page.keyboard.press("Enter");
}

test.beforeEach(async ({ page }) => {
await page.clock.install();
await installMockBridge(page);
await page.goto("/");
await page.getByTestId("channel-general").click();
await expect(page.getByTestId("chat-title")).toHaveText("general");
// Let the composer's initial autofocus settle before opening a row menu.
await expect(page.getByTestId("message-input")).toBeFocused();
await expect
.poll(() =>
page.evaluate(
Expand Down
Loading