mirror of
https://github.com/block/buzz.git
synced 2026-11-12 12:22:06 +00:00
## Summary Fixes the four desktop product races behind recurring E2E flakes in the 21–28 Oct 2026 CI audit. Each fix has a regression test: - In the channel browser, Enter acted on a stale deferred list. - A stale autocomplete swallowed the Enter that submits an empty edit. - Same-second messages swapped order on screen. - The unread pill read "Jump to latest" instead of "N new messages". Two E2E specs that failed for the same ordering reason also get test-side fixes. This is one of seven PRs split out of the `flakey-tests` branch (siblings below). ### Related issue None found for these. Draft #8559 does not fix the voice-note flake. Draft #8661 (Refs #6733) closes the `#channel` list synchronously when an edit is cleared. §11 below also covers that case, plus the `@mention` and `:emoji` lists, so the two PRs overlap in `useChannelLinks.ts` and `empty-edit-delete.spec.ts`. ### Flaky tests fixed | Test | Failing runs | Cause | Change | |---|---|---|---| | §5 `channel-browser.spec.ts:402` "Enter with no matches jumps to create" | 15 of 50 main runs, e.g. 40597484657, 40582387515, 40566841699, 40367495044 | **Product.** The list renders from `useDeferredValue(query)`. An Enter that landed before the deferred render read the old list, so it opened a stale match or skipped the create row. | While the deferred query lags, Enter filters the live query with the same `selectBrowserChannels` the list renders from. | | §11 `empty-edit-delete.spec.ts:88`, `:111`, `:132` | :88 in 3 jobs, :111 in 3, :132 in 2. :111 failed all 3 attempts on main 39822883395 and 39799090750 | **Product.** Clearing the draft went through the 120 ms query debounce. The `#channel` list stayed open, so Enter re-inserted the suggestion instead of submitting the empty edit. | `useChannelLinks`, `useEmojiAutocomplete` and `useMentions` (typed `@` only) close as soon as the trigger is gone. Typing a query still debounces. `ChannelAutocomplete` gains `data-testid="channel-autocomplete"`. | | §10 `video-attachment.spec.ts:1469` "right-click menus expose distinct selectors…" | 10 of 50 main runs, e.g. 40528171327 | **Product.** The live cache merge (`sortMessages`, ascending id within a second) and the channel-window projection (descending id) disagreed, so two same-second rows swapped on screen as each writer landed. | A new `shared/lib/eventOrder.ts` holds `compareRelayOrder` and its exact reverse, `compareChronologicalOrder`. `sortMessages` and the window and reconciliation sorts all use the window's order, so older-page paging is unchanged. `sortEvents` keeps ascending id. The spec gives its three messages distinct seconds. | | §16 `voice-note.spec.ts:906` "starting a duplicate voice-note player pauses the other instance" | 6 of 50 main runs. All 3 attempts on PRs 39622488737 and 40309414135 | Same-second messages plus index-based card lookup. | Distinct seconds, and each card is found through its own message row. | | §13 `stream.spec.ts:477` "keeps scroll position when new messages arrive above the fold" (integration) | 7 of 50 main runs. All 3 attempts on PR 39546556833 | **Product.** Frozen rows came back reordered (7 of 12 instrumented runs). The selector ended older history at the snapshot's first id, so moved rows were both prepended and kept in the frozen tail. The duplicates filled the row count, `pendingCount` fell to 0, and the pill read "Jump to latest". Deleting a frozen row also dropped the whole freeze. | `useBufferedTimelineMessages` keys rows by `renderKey`, which is stable across an optimistic send and its ack (`frozenRowKeys`). It drops only deleted frozen rows, and ends older history at the first frozen row of any key. `stream.spec` is unchanged. | New regression tests: - `channel-browser.spec`: two forced Enter variants. - `empty-edit-delete.spec`: a `#`/`@`/`:` autocomplete table. - `autocompleteTriggerClose.jsdom-test`. - `projectChannelWindow.test`: the cache merge and the window agree in both arrival orders. - `channel-dense-second-reach.spec`: a RAF sampler sees one order. - `useBufferedTimelineMessages.test`: deletion, reorder and ack rows. - `virtualization.spec`: deleting a row while reading keeps the next arrival counted. - A `useThreadReplies.test` row: the thread fetch and a live append agree on same-second order. Local before/after counts are from the original `flakey-tests` commits (238c08c7c, c66f411f3): | Test | Before | After | |---|---|---| | channel-browser | 0/20 | 20/20 | | autocomplete table | 0/30 | 30/30 | | jsdom | 0/2 | 2/2 | | dense-second sampler | 0/10 | 10/10 | | buffered-timeline unit | 4/7 | 7/7 | | virtualization deletion | 0/10 | 10/10 | | `stream:477` against an isolated relay | 25/30, all failures "Jump to latest" | 30/30, then 20/20 and 10/10 | ### Verification This branch was pushed without local re-verification, by the user's choice: local test runs were overloading the machine and failing for that reason. **Hosted CI is the check.** It was pushed with `git push --no-verify`, because the pre-push hook runs typecheck and the unit suites. #8613's flake-check lane will also repeat the changed specs and unit tests. - Every file except `empty-edit-delete.spec.ts` and `virtualization.spec.ts` is byte-identical to its final version on `flakey-tests` (67185df6a). Those two carry only this PR's hunks; see "Merge order". - Checked for this PR: `git diff --check`, biome on both partial specs (no changes, the same as on `main`), and a conflict-free merge with all six siblings onto `main`. ### Readiness These are **product fixes in the desktop app**. Under AGENTS.md "Before opening a PR", this needs an agent review, an agent run of the affected flows, and a **human in-app check** before `buzz-review-completed` is added. Suggested check, in `just dev` or a local build against any community: 1. **Channel browser:** open "Browse channels", type a name that matches nothing and press Enter straight away. The create-channel flow opens with that name. Then type an existing channel's exact name and press Enter straight away. That channel opens, not an earlier match. 2. **Empty-edit delete:** edit one of your own messages and type `#gen` so the channel list opens. Select all, delete, and press Enter at once. The empty edit is submitted (the delete-by-clearing flow) and no suggestion is re-inserted. Repeat with `@` (mention list) and `:sm` (emoji list). 3. **Same-second order:** send two short messages in quick succession, within one second. They appear in send order and never swap while they land. 4. **Unread pill:** in a busy channel, scroll up past the fold, and have a second client post one message. The pill reads "1 new message". While still scrolled up, delete one of your visible messages, then have the second client post again. The pill still counts the new message. ### Merge order No required order, but two files are shared: - `desktop/tests/e2e/empty-edit-delete.spec.ts` is also edited by **flakey-tests-desktop-e2e-actions**. This PR adds the autocomplete table after line 158. The sibling changes imports, helpers, `beforeEach` and line 143. - `desktop/tests/e2e/virtualization.spec.ts` is also edited by **flakey-tests-desktop-e2e-gates**. This PR adds one test after line 929. The sibling's hunks are all above line 551. The hunks don't touch, so each pair merges cleanly in either order, as checked with `git merge-tree`. Draft #8661 overlaps this PR in `useChannelLinks.ts` and `empty-edit-delete.spec.ts`; whichever lands second will need a rebase. ### Sibling PRs Split from `flakey-tests` (one PR per area): #8676 (PostgreSQL lease + lane), #8677 (relay E2E helper + Windows shell), #8678 (mobile), #8679 (Tauri apt cache + Windows Rust cap), #8681 (desktop E2E state gates), #8682 (desktop E2E composer + row actions). 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Signed-off-by: Matt Toohey <contact@matttoohey.com> Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com> Co-authored-by: Jitter <2b2e6415e748c35180847a37aadae06a11e30dddc76b784e7fd9354c4eb42e7a@buzz.block.builderlab.xyz>
609 lines
20 KiB
TypeScript
609 lines
20 KiB
TypeScript
import { expect, test, type Page } from "@playwright/test";
|
|
|
|
import { installMockBridge, openChannelBrowser } from "../helpers/bridge";
|
|
|
|
const MOCK_PUBKEY = "deadbeef".repeat(8);
|
|
const CUSTOM_SECTION = { id: "sec-projects", name: "Projects", order: 0 };
|
|
|
|
async function seedCustomSection(page: Page) {
|
|
await page.addInitScript(
|
|
({ pubkey, section }) => {
|
|
window.localStorage.setItem(
|
|
`buzz-channel-sections.v1:${pubkey}`,
|
|
JSON.stringify({ version: 1, sections: [section], assignments: {} }),
|
|
);
|
|
},
|
|
{ pubkey: MOCK_PUBKEY, section: CUSTOM_SECTION },
|
|
);
|
|
}
|
|
|
|
test.beforeEach(async ({ page }, testInfo) => {
|
|
await installMockBridge(
|
|
page,
|
|
testInfo.title.includes("failed section create")
|
|
? { createChannelErrors: ["Create failed"] }
|
|
: undefined,
|
|
);
|
|
});
|
|
|
|
test("keyboard shortcut opens the channel browser dialog", async ({ page }) => {
|
|
await page.goto("/");
|
|
await expect(page.getByTestId("app-sidebar")).toBeVisible();
|
|
|
|
const isMacBrowser = await page.evaluate(() =>
|
|
/mac|iphone|ipad|ipod/i.test(navigator.platform),
|
|
);
|
|
|
|
if (isMacBrowser) {
|
|
await page.evaluate(() => {
|
|
window.dispatchEvent(
|
|
new KeyboardEvent("keydown", {
|
|
bubbles: true,
|
|
cancelable: true,
|
|
key: "O",
|
|
metaKey: true,
|
|
shiftKey: true,
|
|
}),
|
|
);
|
|
});
|
|
} else {
|
|
await page.keyboard.press("Control+Shift+O");
|
|
}
|
|
await expect(page.getByTestId("channel-browser-dialog")).toBeVisible();
|
|
});
|
|
|
|
test("channel browser shows channels not yet joined", async ({ page }) => {
|
|
await page.goto("/");
|
|
|
|
await openChannelBrowser(page);
|
|
await expect(page.getByTestId("channel-browser-dialog")).toBeVisible();
|
|
await expect(
|
|
page.getByRole("heading", { name: "Browse channels" }),
|
|
).toBeVisible();
|
|
|
|
// "design" and "sales" are open channels the mock user is NOT a member of
|
|
await expect(page.getByTestId("browse-channel-design")).toBeVisible();
|
|
await expect(page.getByTestId("browse-channel-sales")).toBeVisible();
|
|
|
|
// "general" is a channel the mock user IS a member of — shown in "Joined" section
|
|
await expect(page.getByTestId("browse-channel-general")).toBeVisible();
|
|
});
|
|
|
|
test("channel browser sorts alphabetically or by member count", async ({
|
|
page,
|
|
}) => {
|
|
await page.goto("/");
|
|
|
|
await openChannelBrowser(page);
|
|
const rows = page.locator('[data-testid^="browse-channel-"]');
|
|
|
|
await expect(rows).toHaveText([
|
|
/#agents/,
|
|
/#all-replies/,
|
|
/#buzz/,
|
|
/#deep-history/,
|
|
/#design/,
|
|
/#engineering/,
|
|
/#general/,
|
|
/#random/,
|
|
/#sales/,
|
|
/#secret-projects/,
|
|
/#welcome-everyone/,
|
|
]);
|
|
|
|
await page.getByTestId("channel-browser-sort").click();
|
|
await page.getByTestId("channel-browser-sort-members").click();
|
|
|
|
await expect(rows).toHaveText([
|
|
/#general/,
|
|
/#agents/,
|
|
/#engineering/,
|
|
/#random/,
|
|
/#all-replies/,
|
|
/#deep-history/,
|
|
/#design/,
|
|
/#sales/,
|
|
/#secret-projects/,
|
|
/#buzz/,
|
|
/#welcome-everyone/,
|
|
]);
|
|
await expect(page.getByTestId("channel-browser-sort")).toHaveAttribute(
|
|
"aria-label",
|
|
"Sort channels: Most members",
|
|
);
|
|
});
|
|
|
|
test("channel browser sorts by recent activity", async ({ page }) => {
|
|
await page.goto("/");
|
|
|
|
await openChannelBrowser(page);
|
|
const rows = page.locator('[data-testid^="browse-channel-"]');
|
|
|
|
await page.getByTestId("channel-browser-sort").click();
|
|
await page.getByTestId("channel-browser-sort-recent").click();
|
|
|
|
await expect(rows).toHaveText([
|
|
/#all-replies/,
|
|
/#deep-history/,
|
|
/#general/,
|
|
/#agents/,
|
|
/#sales/,
|
|
/#engineering/,
|
|
/#design/,
|
|
/#buzz/,
|
|
/#random/,
|
|
/#secret-projects/,
|
|
/#welcome-everyone/,
|
|
]);
|
|
await expect(page.getByTestId("channel-browser-sort")).toHaveAttribute(
|
|
"aria-label",
|
|
"Sort channels: Recent",
|
|
);
|
|
});
|
|
|
|
test("channel browser search filters by name", async ({ page }) => {
|
|
await page.goto("/");
|
|
|
|
await openChannelBrowser(page);
|
|
await expect(page.getByTestId("channel-browser-dialog")).toBeVisible();
|
|
|
|
await page.getByTestId("channel-browser-search").fill("design");
|
|
|
|
await expect(page.getByTestId("browse-channel-design")).toBeVisible();
|
|
await expect(page.getByTestId("browse-channel-sales")).toHaveCount(0);
|
|
await expect(page.getByTestId("browse-channel-general")).toHaveCount(0);
|
|
});
|
|
|
|
test("channel browser search filters by description", async ({ page }) => {
|
|
await page.goto("/");
|
|
|
|
await openChannelBrowser(page);
|
|
await page.getByTestId("channel-browser-search").fill("pipeline");
|
|
|
|
// "sales" has "pipeline" in its description
|
|
await expect(page.getByTestId("browse-channel-sales")).toBeVisible();
|
|
await expect(page.getByTestId("browse-channel-design")).toHaveCount(0);
|
|
});
|
|
|
|
test("channel browser shows no results for unmatched search", async ({
|
|
page,
|
|
}) => {
|
|
await page.goto("/");
|
|
|
|
await openChannelBrowser(page);
|
|
await page.getByTestId("channel-browser-search").fill("zzz-nonexistent");
|
|
|
|
await expect(page.getByText("No channels match your search")).toBeVisible();
|
|
});
|
|
|
|
test("channel browser fuzzy-matches a subsequence", async ({ page }) => {
|
|
await page.goto("/");
|
|
|
|
await openChannelBrowser(page);
|
|
// "engr" is not a substring of "engineering", but it is an in-order
|
|
// subsequence — plain includes() would miss it, fuzzy matching finds it.
|
|
await page.getByTestId("channel-browser-search").fill("engr");
|
|
|
|
await expect(page.getByTestId("browse-channel-engineering")).toBeVisible();
|
|
await expect(page.getByTestId("browse-channel-general")).toHaveCount(0);
|
|
});
|
|
|
|
test("channel browser matches a scattered subsequence", async ({ page }) => {
|
|
await page.goto("/");
|
|
|
|
await openChannelBrowser(page);
|
|
// "sls" is neither a substring nor a prefix of "sales" — it only matches as
|
|
// an in-order subsequence (s·a·l·e·s). Proves fuzzy matching end-to-end.
|
|
await page.getByTestId("channel-browser-search").fill("sls");
|
|
|
|
await expect(page.getByTestId("browse-channel-sales")).toBeVisible();
|
|
await expect(page.getByTestId("browse-channel-general")).toHaveCount(0);
|
|
});
|
|
|
|
test("channel browser ranks the best match first", async ({ page }) => {
|
|
await page.goto("/");
|
|
|
|
await openChannelBrowser(page);
|
|
// "gen" is a prefix of "general" (strong match) but only a substring of
|
|
// "agents" and a subsequence of "engineering" (weaker). The prefix match
|
|
// should float to the top regardless of the alphabetical default sort.
|
|
await page.getByTestId("channel-browser-search").fill("gen");
|
|
|
|
const firstRow = page.getByTestId(/^browse-channel-/).first();
|
|
await expect(firstRow).toHaveAttribute(
|
|
"data-testid",
|
|
"browse-channel-general",
|
|
);
|
|
});
|
|
|
|
test("sidebar add-channel button creates without treating the click as a callback", async ({
|
|
page,
|
|
}) => {
|
|
await page.goto("/");
|
|
await expect(page.getByTestId("app-sidebar")).toBeVisible();
|
|
|
|
await page.getByTestId("section-actions-channels-quick-create").click();
|
|
|
|
await expect(page.getByTestId("channel-browser-dialog")).toBeVisible();
|
|
const channelName = `sidebar-created-${Date.now()}`;
|
|
await page.getByTestId("channel-browser-search").fill(channelName);
|
|
await page.getByTestId("channel-browser-create-row").click();
|
|
await page.getByTestId("create-channel-submit").click();
|
|
|
|
await expect(page.getByTestId("channel-browser-dialog")).not.toBeVisible();
|
|
await expect(page.getByTestId("stream-list")).toContainText(channelName);
|
|
});
|
|
|
|
test("custom section add button creates directly into that section", async ({
|
|
page,
|
|
}) => {
|
|
await seedCustomSection(page);
|
|
await page.goto("/");
|
|
|
|
const addButton = page.getByTestId(
|
|
`section-actions-${CUSTOM_SECTION.id}-quick-create`,
|
|
);
|
|
await expect(addButton).toHaveAccessibleName("Add channel to Projects");
|
|
await addButton.click();
|
|
await expect(page.getByTestId("channel-browser-dialog")).toBeVisible();
|
|
|
|
const channelName = `section-created-${Date.now()}`;
|
|
await page.getByTestId("channel-browser-search").fill(channelName);
|
|
await page.getByTestId("channel-browser-create-row").click();
|
|
await page.getByTestId("create-channel-submit").click();
|
|
|
|
await expect(page.getByTestId("channel-browser-dialog")).not.toBeVisible();
|
|
await expect(
|
|
page.getByTestId(`section-title-${CUSTOM_SECTION.id}`),
|
|
).toBeVisible();
|
|
await expect(page.getByTestId(`channel-${channelName}`)).toBeVisible();
|
|
await expect(page.getByTestId("stream-list")).not.toContainText(channelName);
|
|
});
|
|
|
|
test("canceling section create does not affect the next global create", async ({
|
|
page,
|
|
}) => {
|
|
await seedCustomSection(page);
|
|
await page.goto("/");
|
|
|
|
await page
|
|
.getByTestId(`section-actions-${CUSTOM_SECTION.id}-quick-create`)
|
|
.click();
|
|
// Gate on the dialog mounting before dismissing it. Escape sent before mount
|
|
// is dropped (no handler yet), and not.toBeVisible() then passes vacuously
|
|
// against a dialog that hasn't rendered — so the dialog opens *after* the
|
|
// assertion and its overlay swallows every later click.
|
|
await expect(page.getByTestId("channel-browser-dialog")).toBeVisible();
|
|
await page.keyboard.press("Escape");
|
|
await expect(page.getByTestId("channel-browser-dialog")).not.toBeVisible();
|
|
// The overlay outlives the content by one exit animation; wait for it to
|
|
// detach so it can't intercept the next click.
|
|
await expect(page.getByTestId("dialog-overlay")).toHaveCount(0);
|
|
|
|
await page.getByTestId("section-actions-channels-quick-create").click();
|
|
const channelName = `global-after-cancel-${Date.now()}`;
|
|
await page.getByTestId("channel-browser-search").fill(channelName);
|
|
await page.getByTestId("channel-browser-create-row").click();
|
|
await page.getByTestId("create-channel-submit").click();
|
|
|
|
await expect(page.getByTestId("stream-list")).toContainText(channelName);
|
|
});
|
|
|
|
test("failed section create retry still assigns to the section", async ({
|
|
page,
|
|
}) => {
|
|
await seedCustomSection(page);
|
|
await page.goto("/");
|
|
|
|
await page
|
|
.getByTestId(`section-actions-${CUSTOM_SECTION.id}-quick-create`)
|
|
.click();
|
|
const channelName = `section-retry-${Date.now()}`;
|
|
await page.getByTestId("channel-browser-search").fill(channelName);
|
|
await page.getByTestId("channel-browser-create-row").click();
|
|
await page.getByTestId("create-channel-submit").click();
|
|
await expect(page.getByText("Create failed")).toBeVisible();
|
|
|
|
await page.getByTestId("create-channel-submit").click();
|
|
|
|
await expect(page.getByTestId("channel-browser-dialog")).not.toBeVisible();
|
|
await expect(page.getByTestId(`channel-${channelName}`)).toBeVisible();
|
|
await expect(page.getByTestId("stream-list")).not.toContainText(channelName);
|
|
});
|
|
|
|
test("create affordance is visible on open before typing", async ({ page }) => {
|
|
await page.goto("/");
|
|
|
|
await openChannelBrowser(page);
|
|
|
|
// The create row is present from the get-go so it's clear you can browse OR
|
|
// create — not just after you start typing.
|
|
const createRow = page.getByTestId("channel-browser-create-row");
|
|
await expect(createRow).toBeVisible();
|
|
await expect(createRow).toContainText("Create a new channel");
|
|
});
|
|
|
|
test("typing a partial match surfaces a persistent create row", async ({
|
|
page,
|
|
}) => {
|
|
await page.goto("/");
|
|
|
|
await openChannelBrowser(page);
|
|
// "desig" matches "design" by substring but is not an exact channel name,
|
|
// so both the matching channel AND the create row are shown.
|
|
await page.getByTestId("channel-browser-search").fill("desig");
|
|
|
|
const createRow = page.getByTestId("channel-browser-create-row");
|
|
await expect(createRow).toBeVisible();
|
|
await expect(createRow).toContainText("desig");
|
|
await expect(page.getByTestId("browse-channel-design")).toBeVisible();
|
|
});
|
|
|
|
test("exact name match hides the create row", async ({ page }) => {
|
|
await page.goto("/");
|
|
|
|
await openChannelBrowser(page);
|
|
await page.getByTestId("channel-browser-search").fill("general");
|
|
|
|
await expect(page.getByTestId("browse-channel-general")).toBeVisible();
|
|
await expect(page.getByTestId("channel-browser-create-row")).toHaveCount(0);
|
|
});
|
|
|
|
test("no-match search pins a create row above the empty state", async ({
|
|
page,
|
|
}) => {
|
|
await page.goto("/");
|
|
|
|
await openChannelBrowser(page);
|
|
await page.getByTestId("channel-browser-search").fill("zzz-nonexistent");
|
|
|
|
await expect(page.getByText("No channels match your search")).toBeVisible();
|
|
const createRow = page.getByTestId("channel-browser-create-row");
|
|
await expect(createRow).toBeVisible();
|
|
await expect(createRow).toContainText("zzz-nonexistent");
|
|
});
|
|
|
|
test("create row leads to the prefilled create form", async ({ page }) => {
|
|
await page.goto("/");
|
|
|
|
await openChannelBrowser(page);
|
|
await page.getByTestId("channel-browser-search").fill("desig");
|
|
await page.getByTestId("channel-browser-create-row").click();
|
|
|
|
// Create mode reuses the shared form; the name is prefilled from the query.
|
|
await expect(page.getByTestId("create-channel-name")).toHaveValue("desig");
|
|
|
|
// Back returns to the search list without closing the dialog.
|
|
await page.getByTestId("channel-browser-create-back").click();
|
|
await expect(page.getByTestId("channel-browser-search")).toBeVisible();
|
|
});
|
|
|
|
test("creating from the browser adds the channel to the sidebar", async ({
|
|
page,
|
|
}) => {
|
|
const channelName = `browse-created-${Date.now()}`;
|
|
|
|
await page.goto("/");
|
|
|
|
await openChannelBrowser(page);
|
|
await page.getByTestId("channel-browser-search").fill(channelName);
|
|
await page.getByTestId("channel-browser-create-row").click();
|
|
|
|
await expect(page.getByTestId("create-channel-name")).toHaveValue(
|
|
channelName,
|
|
);
|
|
await page.getByTestId("create-channel-submit").click();
|
|
|
|
await expect(page.getByTestId("channel-browser-dialog")).not.toBeVisible();
|
|
await expect(page.getByTestId("stream-list")).toContainText(channelName);
|
|
await expect(page.getByTestId("chat-title")).toContainText(channelName);
|
|
});
|
|
|
|
test("Enter with no matches jumps to create", async ({ page }) => {
|
|
const channelName = `enter-created-${Date.now()}`;
|
|
|
|
await page.goto("/");
|
|
|
|
await openChannelBrowser(page);
|
|
await page.getByTestId("channel-browser-search").fill(channelName);
|
|
await page.keyboard.press("Enter");
|
|
|
|
await expect(page.getByTestId("create-channel-name")).toHaveValue(
|
|
channelName,
|
|
);
|
|
});
|
|
|
|
// Type and press Enter in one task. React commits the urgent render on the
|
|
// microtask queue, but the deferred list re-renders in a later task, so Enter
|
|
// lands while the list still shows the previous (unfiltered) results.
|
|
async function typeThenEnterBeforeDeferredRender(page: Page, value: string) {
|
|
await page
|
|
.getByTestId("channel-browser-search")
|
|
.evaluate(async (el, text) => {
|
|
const input = el as HTMLInputElement;
|
|
Object.getOwnPropertyDescriptor(
|
|
HTMLInputElement.prototype,
|
|
"value",
|
|
)?.set?.call(input, text);
|
|
input.dispatchEvent(new Event("input", { bubbles: true }));
|
|
for (let i = 0; i < 5; i += 1) await Promise.resolve();
|
|
input.dispatchEvent(
|
|
new KeyboardEvent("keydown", {
|
|
bubbles: true,
|
|
cancelable: true,
|
|
key: "Enter",
|
|
}),
|
|
);
|
|
}, value);
|
|
}
|
|
|
|
test("Enter before the list catches up still creates a new name", async ({
|
|
page,
|
|
}) => {
|
|
const channelName = `enter-fast-${Date.now()}`;
|
|
|
|
await page.goto("/");
|
|
|
|
await openChannelBrowser(page);
|
|
await expect(page.getByTestId("browse-channel-agents")).toBeVisible();
|
|
await typeThenEnterBeforeDeferredRender(page, channelName);
|
|
|
|
await expect(page.getByTestId("create-channel-name")).toHaveValue(
|
|
channelName,
|
|
);
|
|
});
|
|
|
|
test("Enter before the list catches up still opens the typed match", async ({
|
|
page,
|
|
}) => {
|
|
await page.goto("/");
|
|
|
|
await openChannelBrowser(page);
|
|
await expect(page.getByTestId("browse-channel-agents")).toBeVisible();
|
|
await typeThenEnterBeforeDeferredRender(page, "desig");
|
|
|
|
await expect(page.getByTestId("channel-browser-dialog")).not.toBeVisible();
|
|
await expect(page.getByTestId("chat-title")).toHaveText("design");
|
|
});
|
|
|
|
test("arrow keys reach the pinned create row and Enter activates it", async ({
|
|
page,
|
|
}) => {
|
|
await page.goto("/");
|
|
|
|
await openChannelBrowser(page);
|
|
// "desig" keeps a channel match (#design) AND the create row visible, so the
|
|
// create row is not the only actionable item — it must be reachable by
|
|
// keyboard, not just Tab.
|
|
await page.getByTestId("channel-browser-search").fill("desig");
|
|
|
|
const createRow = page.getByTestId("channel-browser-create-row");
|
|
await expect(createRow).toBeVisible();
|
|
|
|
// The create row is pinned at the top → first ArrowDown highlights it.
|
|
await page.keyboard.press("ArrowDown");
|
|
await expect(createRow).toHaveAttribute("data-selected", "true");
|
|
|
|
// Enter on the highlighted create row enters the prefilled create form.
|
|
await page.keyboard.press("Enter");
|
|
await expect(page.getByTestId("create-channel-name")).toHaveValue("desig");
|
|
});
|
|
|
|
test("Enter selects a channel when create row is not highlighted", async ({
|
|
page,
|
|
}) => {
|
|
await page.goto("/");
|
|
|
|
await openChannelBrowser(page);
|
|
// With the create row present but NOT highlighted, Enter should still select
|
|
// the first channel match rather than jumping to create.
|
|
await page.getByTestId("channel-browser-search").fill("desig");
|
|
await expect(page.getByTestId("browse-channel-design")).toBeVisible();
|
|
|
|
await page.keyboard.press("Enter");
|
|
|
|
await expect(page.getByTestId("channel-browser-dialog")).not.toBeVisible();
|
|
await expect(page.getByTestId("chat-title")).toHaveText("design");
|
|
});
|
|
|
|
test("joining a channel from browser adds it to the sidebar", async ({
|
|
page,
|
|
}) => {
|
|
await page.goto("/");
|
|
|
|
// Verify "design" is not in the sidebar
|
|
const streamList = page.getByTestId("stream-list");
|
|
await expect(streamList).not.toContainText("design");
|
|
|
|
// Open browser and join
|
|
await openChannelBrowser(page);
|
|
await expect(page.getByTestId("channel-browser-dialog")).toBeVisible();
|
|
await page
|
|
.getByTestId("browse-channel-design")
|
|
.getByRole("button", { name: "Join" })
|
|
.click();
|
|
|
|
// Dialog should close and navigate to the joined channel
|
|
await expect(page.getByTestId("channel-browser-dialog")).not.toBeVisible();
|
|
await expect(page).toHaveURL(/#\/channels\//);
|
|
await expect(page.getByTestId("chat-title")).toHaveText("design");
|
|
|
|
// Channel should now appear in the sidebar
|
|
await expect(streamList).toContainText("design");
|
|
});
|
|
|
|
test("clicking a joined channel in browser navigates to it", async ({
|
|
page,
|
|
}) => {
|
|
await page.goto("/");
|
|
|
|
await openChannelBrowser(page);
|
|
await expect(page.getByTestId("channel-browser-dialog")).toBeVisible();
|
|
|
|
// "general" is already joined — clicking should navigate without join
|
|
await page.getByTestId("browse-channel-general").click();
|
|
|
|
await expect(page.getByTestId("channel-browser-dialog")).not.toBeVisible();
|
|
await expect(page).toHaveURL(/#\/channels\//);
|
|
await expect(page.getByTestId("chat-title")).toHaveText("general");
|
|
});
|
|
|
|
test("channel browser does not show DM or private channels", async ({
|
|
page,
|
|
}) => {
|
|
await page.goto("/");
|
|
|
|
await openChannelBrowser(page);
|
|
await expect(page.getByTestId("channel-browser-dialog")).toBeVisible();
|
|
|
|
// DM channels should not appear
|
|
await expect(page.getByTestId("browse-channel-alice-tyler")).toHaveCount(0);
|
|
await expect(page.getByTestId("browse-channel-bob-tyler")).toHaveCount(0);
|
|
|
|
// Private forum should not appear
|
|
await expect(page.getByTestId("browse-channel-announcements")).toHaveCount(0);
|
|
});
|
|
|
|
test("channel browser closes on escape", async ({ page }) => {
|
|
await page.goto("/");
|
|
|
|
await openChannelBrowser(page);
|
|
await expect(page.getByTestId("channel-browser-dialog")).toBeVisible();
|
|
|
|
await page.keyboard.press("Escape");
|
|
await expect(page.getByTestId("channel-browser-dialog")).not.toBeVisible();
|
|
});
|
|
|
|
test("keyboard navigation works in channel browser", async ({ page }) => {
|
|
await page.goto("/");
|
|
|
|
await openChannelBrowser(page);
|
|
await expect(page.getByTestId("channel-browser-dialog")).toBeVisible();
|
|
|
|
// Filter to unjoined channels only to get a predictable list
|
|
await page.getByTestId("channel-browser-search").fill("design");
|
|
await expect(page.getByTestId("browse-channel-design")).toBeVisible();
|
|
|
|
// Press Enter to join the selected (first) channel
|
|
await page.keyboard.press("Enter");
|
|
|
|
await expect(page.getByTestId("channel-browser-dialog")).not.toBeVisible();
|
|
await expect(page.getByTestId("chat-title")).toHaveText("design");
|
|
});
|
|
|
|
test("sidebar only shows channels the user has joined", async ({ page }) => {
|
|
await page.goto("/");
|
|
|
|
const streamList = page.getByTestId("stream-list");
|
|
|
|
// Channels the mock user IS a member of
|
|
await expect(streamList).toContainText("general");
|
|
await expect(streamList).toContainText("random");
|
|
await expect(streamList).toContainText("engineering");
|
|
await expect(streamList).toContainText("agents");
|
|
|
|
// Channels the mock user is NOT a member of
|
|
await expect(streamList).not.toContainText("design");
|
|
await expect(streamList).not.toContainText("sales");
|
|
});
|