Files
buzz/desktop/tests/e2e/integration.spec.ts
Will PflegerandGitHub f35930104b fix(desktop): remove 0.5.9+ perf regressions, speed up get_channels (#5599)
Desktop input latency regressed sharply for users on v0.5.9 and worsened
on latest main: multi-second stalls when clicking back into the app,
slow fresh boots, intermittent lockups, and scroll/mouse degradation.
Reverting to `119a84897` (pre-0.5.9) was confirmed to resolve it,
isolating the regression to that range. Profiling a live production
renderer plus a commit-level audit of the range found three independent,
additive causes — fixed here — plus a long-standing `get_channels` cost
that made every remaining refetch expensive, also addressed here.

## 1. Focus-return refetch storm (`refetchOnWindowFocus`)

#5490 wired TanStack's `focusManager` to app focus and flipped ~20 query
sites to `refetchOnWindowFocus: true`. A focus return after >60s away
fires them all within milliseconds — and a click into an unfocused
window *is* a focus return, so the burst runs before the click is
processed. That is the "click into the composer, wait 5 seconds"
symptom, and it also explains why mouse input feels worse than keyboard
(clicks arrive with focus transitions; typing happens while already
focused). A 5-second `sample` of a live production renderer caught a
single window activity-state transition consuming ~1.25s of main-thread
time, dominated by `JSON.parse` in the focus listener's microtask drain.

#5535 already established the fix pattern but applied it to only two
families (channels, home-feed). This PR extends the same 5-minute
`staleTime` discipline to the remaining families: pulse (×5), workflows
(×4), agents (×4), forum (×2), presence, user-status, custom-emoji,
channel-templates, and the persona catalog. Polling cadences and
push-invalidation paths are untouched — interval refetches and
`invalidateQueries` both bypass `staleTime`, so live-update behavior is
unchanged. Each gated family exports its focus-refetch policy as an
options object that the production hook spreads into `useQuery`, and a
`focusRefetchPolicy.test.mjs` drives a `QueryObserver` with that same
production object — locking the policy behaviorally (fresh focus return
→ 0 fetches; stale → refetch) and failing if a hook's
`staleTime`/`refetchOnWindowFocus` wiring drifts.

Four families deliberately keep tighter freshness, all surfaces where
the 5-minute gate would suppress the only refresh path and none of which
feed the app-wide storm: `repo-sync-status` keeps its fresh focus
refetch (its inline comment documents the "committed in a terminal,
switched back to the app" flow as intended); the workflow-runs list
stale-gates at 10s because a remotely-started run has no push
invalidation and its conditional 1s poll is off while the cache shows no
active runs; the workflow list queries (`useChannelWorkflowsQuery` and
the all-channels aggregate) stale-gate at 10s because they have no poll
and no relay subscription, and mutation-driven invalidation only covers
this renderer — remote workflow creates/edits/deletes surface only via
focus refetch; and the managed-agent log stale-gates at one poll tick
(30s) so returning to a live agent log refreshes immediately. Run
approvals keep the 5-minute gate under
`RUN_APPROVALS_FOCUS_STALE_TIME_MS` — their focused 10s poll already
covers freshness.

## 2. Synchronous localStorage sweep on the boot/focus path

#5453's stale-cache sweep synchronously `getItem` + `JSON.parse`s every
whitelisted localStorage entry on the main thread (multi-MB on seasoned
profiles), scheduled with a `requestIdleCallback` timeout of 1.5s that
guaranteed it landed mid-boot, and re-armed on every hidden→visible
transition — stacking it onto the exact moment the focus storm fires.
#5454's `trimSelfProfileCaches()` additionally scanned every
localStorage key on every `writeSelfProfileCache()` call (which fires
per relay self-profile delivery at boot).

Now: the first sweep waits `BOOT_SWEEP_FLOOR_MS` (30s) after startup,
the scan is time-sliced across idle callbacks, and the visibility
trigger is removed — boot-delayed plus hourly still covers the 14-day
TTL contract. The sliced sweep re-checks staleness immediately before
each removal (a key rewritten fresh mid-sweep survives), isolates
per-key storage errors so one bad entry can't strand the rest of the
snapshot, defers oversized values once rather than parsing them on a
zero-budget slice, guarantees forward progress on timeout-fired
callbacks, and cancels its scheduled slice when stopped. The profile
trim keeps a lazily-initialized memoized key count so the common
under-cap write is O(1); the full parse scan runs only when the count
exceeds a cap, resyncs if external deletions made it stale, and a failed
scan skips the trim instead of aborting the write. Sweep semantics
(rules, TTLs, eviction) are unchanged, and tests cover the scheduling,
slice-progress, error-isolation, defer-once, and trim short-circuit
behaviors.

## 3. The macOS window was never opaque

#5478's glass appearance is correctly opt-in at the CSS layer, but the
compositor cost was baked in deeper than its native `on_webview_ready`
transparency call: the main window is declared `"transparent": true` in
`tauri.conf.json` (added for the original glass work in #1671), which
makes tao call `NSWindow.setOpaque(false)` at creation and resolve every
later `set_background_color(None)` to `clearColor` — and no runtime
`setOpaque(true)` path exists through tauri, while wry's runtime
background setter can only force the WKWebView's `drawsBackground` off,
never back on. So "restore the platform default" was unreachable: every
launch, glass or not, ran with a non-opaque NSWindow, defeating
WindowServer's opaque-window compositing fast path and forcing full
window compositing every frame — compounded by the existing
`backdrop-blur` chrome overlapping the scrolling timeline. This matches
the compositor-shaped symptoms (scroll and pointer input degrading
first).

The window is now created opaque (`"transparent": false`) and the
NSWindow layer is never made transparent at runtime. Glass never needed
a transparent window: behind-window `NSVisualEffectView` vibrancy
renders inside opaque windows (this is how Finder and Notes draw vibrant
sidebars); it only requires a transparent WKWebView canvas, which the
`set_window_vibrancy` enable path already establishes at runtime
(`macos-private-api` compiles that in independent of the window flag).
Enabling glass installs the vibrancy layer and then makes only the
webview canvas see-through; disabling clears the vibrancy layer — the
canvas may stay non-drawing afterwards (wry's flag is one-way at
runtime), which is harmless because glass-off CSS paints fully opaque
above an always-opaque NSWindow. The boot-path first-frame backing
writes touch only the NSWindow backing color and are therefore inert to
glass state regardless of how they order against the `ThemeProvider`'s
vibrancy call on a persisted-glass-on cold boot. Glass-off users (the
default) get an end-to-end opaque window from boot for the first time.

## 4. `get_channels`: serial round-trips and a multi-MB payload on every
refetch

The stale gates in (1) cut refetch frequency; this cuts the cost of the
refetches that legitimately remain (boot, and focus returns after more
than 5 minutes away — previously still a multi-second stall).
`get_channels` made ~8 fully serial relay round-trips (~3.2–3.6s at
1,100+ channels), then shipped the full `ChannelInfo` list — including
every channel's member pubkeys — across IPC, where the renderer's
`JSON.parse` of the multi-MB payload froze the main thread (the ~1.25s
stall captured in the live sample).

- **Concurrent stages**: the membership chain, the open-channel
directory scan, and the hidden-DM snapshot run concurrently, as do the
member-count and last-message queries that follow. The critical path
drops from ~8 sequential round-trips to 2 phases. Filters, limits,
pagination, and merge semantics are unchanged.
- **Not-modified short-circuit**: the command now takes a
client-supplied content hash (FNV-1a 64 over the channel list,
canonicalized by id and excluding `last_message_at`) and omits the
channel list from the response when nothing else changed. Last-message
timestamps — which change on nearly every message anywhere — ship as a
small separate map that the client overlays onto its cached list with
reference preservation, so React Query's structural sharing also skips
downstream re-renders. On a typical refocus the renderer parses
kilobytes instead of megabytes. The hash is stored in the query cache
itself, tying its lifecycle to the data it describes so a community
switch can never leak a stale hash.

The E2E mock bridge speaks the new payload shape — including the
complete `last_messages` map the client treats as authoritative — and
hash canonicalization plus overlay reference-preservation are
unit-tested on both sides.

---------

Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
2026-08-11 19:47:19 -04:00

572 lines
18 KiB
TypeScript

import { expect, test, type Browser } from "@playwright/test";
import {
installRelayBridge,
openChannelBrowser,
openCreateChannelDialog,
TEST_IDENTITIES,
} from "../helpers/bridge";
import { openSettings } from "../helpers/settings";
import { assertRelaySeeded } from "../helpers/seed";
const isCi = Boolean(process.env.CI);
const relaySeedHookTimeoutMs = isCi ? 90_000 : 30_000;
async function createStream(
page: import("@playwright/test").Page,
channelName: string,
description?: string,
) {
await openCreateChannelDialog(page);
await page.getByTestId("create-channel-name").fill(channelName);
if (description !== undefined) {
await page.getByTestId("create-channel-description").fill(description);
}
await page.getByTestId("create-channel-submit").click();
await expect(page.getByTestId("stream-list")).toContainText(channelName);
await expect(page.getByTestId("chat-title")).toHaveText(channelName);
}
async function openChannelManagement(page: import("@playwright/test").Page) {
await page.getByTestId("channel-management-trigger").click();
await expect(page.getByTestId("channel-management-sheet")).toBeVisible();
}
async function openChannelEditDialog(page: import("@playwright/test").Page) {
await page.getByTestId("channel-management-edit").click();
await expect(
page.getByRole("dialog", {
name: /Edit (?:public|private) channel/,
}),
).toBeVisible();
}
async function closeChannelManagement(page: import("@playwright/test").Page) {
await page.getByTestId("auxiliary-panel-close").click();
await expect(page.getByTestId("channel-management-sheet")).not.toBeVisible();
}
async function assertDesktopNotificationsEnabled(
page: import("@playwright/test").Page,
) {
await openSettings(page, "notifications");
await expect(page.getByTestId("settings-notifications")).toBeVisible();
await expect(page.getByTestId("notifications-desktop-state")).toContainText(
"On",
);
await page.getByTestId("settings-back-to-app").click();
}
async function sendChannelMessage(
page: import("@playwright/test").Page,
{
channelName,
content,
kind,
mentionPubkeys,
}: {
channelName: string;
content: string;
kind?: number | null;
mentionPubkeys?: string[];
},
) {
await page.evaluate(
async ({
channelName: targetChannelName,
content,
kind,
mentionPubkeys,
}) => {
const tauriWindow = window as Window & {
__TAURI_INTERNALS__?: {
invoke: (
command: string,
payload?: Record<string, unknown>,
) => Promise<unknown>;
};
};
const invoke = tauriWindow.__TAURI_INTERNALS__?.invoke;
if (!invoke) {
throw new Error("Tauri invoke bridge is unavailable.");
}
const payload = (await invoke("get_channels")) as {
channels: Array<{ id: string; name: string }> | null;
};
const channels = payload.channels ?? [];
const channel = channels.find(({ name }) => name === targetChannelName);
if (!channel) {
throw new Error(`Channel not found: ${targetChannelName}`);
}
await invoke("send_channel_message", {
channelId: channel.id,
content,
parentEventId: null,
mediaTags: null,
mentionPubkeys: mentionPubkeys ?? null,
kind: kind ?? null,
});
},
{ channelName, content, kind, mentionPubkeys },
);
}
async function joinChannel(
page: import("@playwright/test").Page,
channelName: string,
) {
await page.evaluate(async (targetChannelName) => {
const tauriWindow = window as Window & {
__TAURI_INTERNALS__?: {
invoke: (
command: string,
payload?: Record<string, unknown>,
) => Promise<unknown>;
};
};
const invoke = tauriWindow.__TAURI_INTERNALS__?.invoke;
if (!invoke) {
throw new Error("Tauri invoke bridge is unavailable.");
}
const payload = (await invoke("get_channels")) as {
channels: Array<{ id: string; name: string }> | null;
};
const channels = payload.channels ?? [];
const channel = channels.find(({ name }) => name === targetChannelName);
if (!channel) {
throw new Error(`Channel not found: ${targetChannelName}`);
}
await invoke("join_channel", {
channelId: channel.id,
});
}, channelName);
}
async function getLoggedNotifications(page: import("@playwright/test").Page) {
return page.evaluate(() => {
const win = window as Window & {
__BUZZ_E2E_NOTIFICATIONS__?: Array<{
body: string | null;
title: string;
}>;
};
return win.__BUZZ_E2E_NOTIFICATIONS__ ?? [];
});
}
async function getLoggedNotificationCount(
page: import("@playwright/test").Page,
) {
return (await getLoggedNotifications(page)).length;
}
async function expectLoggedNotifications(
page: import("@playwright/test").Page,
expected: Array<{ body: string | null; title: string }>,
) {
await expect.poll(() => getLoggedNotifications(page)).toEqual(expected);
}
test.beforeAll(async () => {
test.setTimeout(relaySeedHookTimeoutMs);
await assertRelaySeeded();
});
test("create channel and verify in sidebar", async ({ page }) => {
const channelName = `integration-e2e-${Date.now()}`;
await installRelayBridge(page, "tyler");
await page.goto("/");
await openCreateChannelDialog(page);
await page.getByTestId("create-channel-name").fill(channelName);
await page.getByTestId("create-channel-submit").click();
await expect(page.getByTestId("stream-list")).toContainText(channelName);
await expect(page.getByTestId("chat-title")).toHaveText(channelName);
});
test("two users see the same channel", async ({
browser,
}: {
browser: Browser;
}) => {
const channelName = `shared-channel-${Date.now()}`;
const contextOne = await browser.newContext();
const contextTwo = await browser.newContext();
const pageOne = await contextOne.newPage();
const pageTwo = await contextTwo.newPage();
try {
await installRelayBridge(pageOne, "tyler");
await installRelayBridge(pageTwo, "alice");
await pageOne.goto("/");
await openCreateChannelDialog(pageOne);
await pageOne.getByTestId("create-channel-name").fill(channelName);
await pageOne.getByTestId("create-channel-submit").click();
await expect(pageOne.getByTestId("stream-list")).toContainText(channelName);
await pageTwo.goto("/");
await openChannelBrowser(pageTwo);
await expect(pageTwo.getByTestId("channel-browser-dialog")).toBeVisible();
await pageTwo
.getByTestId(`browse-channel-${channelName}`)
.getByRole("button", { name: "Join" })
.click();
await expect(pageTwo.getByTestId("stream-list")).toContainText(channelName);
} finally {
await contextOne.close();
await contextTwo.close();
}
});
test("message delivery across users", async ({
browser,
}: {
browser: Browser;
}) => {
const message = `Cross-user message ${Date.now()}`;
const contextOne = await browser.newContext();
const contextTwo = await browser.newContext();
const pageOne = await contextOne.newPage();
const pageTwo = await contextTwo.newPage();
try {
await installRelayBridge(pageOne, "tyler");
await installRelayBridge(pageTwo, "alice");
await pageOne.goto("/");
await pageTwo.goto("/");
await pageOne.getByTestId("channel-general").click();
await pageTwo.getByTestId("channel-general").click();
await expect(pageOne.getByTestId("chat-title")).toHaveText("general");
await expect(pageTwo.getByTestId("chat-title")).toHaveText("general");
await pageOne.getByTestId("message-input").fill(message);
await pageOne.getByTestId("send-message").click();
await expect(pageTwo.getByTestId("message-timeline")).toContainText(
message,
);
} finally {
await contextOne.close();
await contextTwo.close();
}
});
test("live mentions refetch the home feed without waiting for polling", async ({
browser,
}: {
browser: Browser;
}) => {
const stamp = Date.now();
const targetContext = await browser.newContext();
const senderContext = await browser.newContext();
const targetPage = await targetContext.newPage();
const senderPage = await senderContext.newPage();
try {
await installRelayBridge(targetPage, "tyler");
await installRelayBridge(senderPage, "alice");
await targetPage.goto("/");
await senderPage.goto("/");
await assertDesktopNotificationsEnabled(targetPage);
await targetPage.getByTestId("channel-general").click();
await expect(targetPage.getByTestId("chat-title")).toHaveText("general");
const message = `Heads up @tyler live mention ${stamp}`;
await sendChannelMessage(senderPage, {
channelName: "general",
content: message,
mentionPubkeys: [TEST_IDENTITIES.tyler.pubkey],
});
await expect(targetPage.getByTestId("message-timeline")).toContainText(
message,
);
await expectLoggedNotifications(targetPage, [
{
body: message,
title: "alice mentioned you in #general",
},
]);
// The Inbox feed should have been refetched live (the original purpose
// of this test). The home badge stays at 0 while the user is actively
// reading #general — reading in-channel advances the NIP-RS marker past
// the new mention — so the assertion that the refetch happened is the
// Inbox-list content, not the badge.
await targetPage
.getByTestId("app-sidebar")
.getByRole("button", { name: "Inbox" })
.click();
await expect(targetPage.getByTestId("home-inbox-list")).toBeVisible();
await expect(targetPage.getByTestId("home-inbox-list")).toContainText(
message,
);
await expect(targetPage.getByTestId("sidebar-home-count")).toHaveCount(0);
await expect.poll(() => getLoggedNotificationCount(targetPage)).toBe(1);
} finally {
await targetContext.close();
await senderContext.close();
}
});
test("live forum mentions refetch the home feed without waiting for polling", async ({
browser,
}: {
browser: Browser;
}) => {
const stamp = Date.now();
const targetContext = await browser.newContext();
const senderContext = await browser.newContext();
const targetPage = await targetContext.newPage();
const senderPage = await senderContext.newPage();
try {
await installRelayBridge(targetPage, "tyler");
await installRelayBridge(senderPage, "alice");
await targetPage.goto("/");
await senderPage.goto("/");
await assertDesktopNotificationsEnabled(targetPage);
await targetPage.getByTestId("channel-general").click();
await expect(targetPage.getByTestId("chat-title")).toHaveText("general");
await joinChannel(senderPage, "watercooler");
const message = `Forum ping @tyler ${stamp}`;
await sendChannelMessage(senderPage, {
channelName: "watercooler",
content: message,
kind: 45001,
mentionPubkeys: [TEST_IDENTITIES.tyler.pubkey],
});
await expect(targetPage.getByTestId("sidebar-home-count")).toHaveText("1");
await expectLoggedNotifications(targetPage, [
{
body: message,
title: "alice mentioned you in #watercooler",
},
]);
await targetPage
.getByTestId("app-sidebar")
.getByRole("button", { name: "Inbox" })
.click();
await expect(targetPage.getByTestId("home-inbox-list")).toBeVisible();
await expect(targetPage.getByTestId("home-inbox-list")).toBeVisible();
await expect(targetPage.getByTestId("home-inbox-list")).toContainText(
message,
);
await expect(targetPage.getByTestId("sidebar-home-count")).toHaveCount(0);
await expect.poll(() => getLoggedNotificationCount(targetPage)).toBe(1);
} finally {
await targetContext.close();
await senderContext.close();
}
});
test("DM channel appears in sidebar", async ({ page }) => {
await installRelayBridge(page, "tyler");
await page.goto("/");
await expect(page.getByTestId("dm-list")).toContainText("alice-tyler");
});
test("send message to DM", async ({ page }) => {
const message = `DM message ${Date.now()}`;
await installRelayBridge(page, "tyler");
await page.goto("/");
await page.getByTestId("channel-alice-tyler").click();
await expect(page.getByTestId("chat-title")).toHaveText("alice-tyler");
await page.getByTestId("message-input").fill(message);
await page.getByTestId("send-message").click();
await expect(page.getByTestId("message-timeline")).toContainText(message);
});
test("forum channel appears in sidebar", async ({ page }) => {
await installRelayBridge(page, "tyler");
await page.goto("/");
await expect(page.getByTestId("forum-list")).toContainText("watercooler");
});
test("create channel with description", async ({ page }) => {
const channelName = `desc-channel-${Date.now()}`;
const description = `Description for ${channelName}`;
await installRelayBridge(page, "tyler");
await page.goto("/");
await createStream(page, channelName, description);
await expect(page.getByTestId("chat-title")).toHaveAttribute(
"title",
description,
);
});
test("multiple channels independent", async ({ page }) => {
const channelA = `channel-a-${Date.now()}`;
const channelB = `channel-b-${Date.now()}`;
const messageA = `Message in A ${Date.now()}`;
await installRelayBridge(page, "tyler");
await page.goto("/");
await openCreateChannelDialog(page);
await page.getByTestId("create-channel-name").fill(channelA);
await page.getByTestId("create-channel-submit").click();
await expect(page.getByTestId("chat-title")).toHaveText(channelA);
await openCreateChannelDialog(page);
await page.getByTestId("create-channel-name").fill(channelB);
await page.getByTestId("create-channel-submit").click();
await expect(page.getByTestId("chat-title")).toHaveText(channelB);
// Navigate to channel A and send a message
await page.getByTestId(`channel-${channelA}`).click();
await expect(page.getByTestId("chat-title")).toHaveText(channelA);
await page.getByTestId("message-input").fill(messageA);
await page.getByTestId("send-message").click();
await expect(page.getByTestId("message-timeline")).toContainText(messageA);
// Switch to channel B — message from A should not appear
await page.getByTestId(`channel-${channelB}`).click();
await expect(page.getByTestId("chat-title")).toHaveText(channelB);
await expect(page.getByTestId("message-timeline")).not.toContainText(
messageA,
);
});
test("manage sheet updates channel details through the relay", async ({
page,
}) => {
const stamp = Date.now();
const initialName = `manage-integration-${stamp}`;
const renamedChannel = `manage-renamed-${stamp}`;
const initialDescription = `Initial description ${stamp}`;
const updatedDescription = `Updated description ${stamp}`;
await installRelayBridge(page, "tyler");
await page.goto("/");
await createStream(page, initialName, initialDescription);
await openChannelManagement(page);
await openChannelEditDialog(page);
const editDialog = page.getByRole("dialog", {
name: /Edit (?:public|private) channel/,
});
await editDialog.getByTestId("channel-management-name").fill(renamedChannel);
await editDialog
.getByTestId("channel-management-description")
.fill(updatedDescription);
await expect(editDialog.getByTestId("channel-management-topic")).toHaveCount(
0,
);
await expect(
editDialog.getByTestId("channel-management-purpose"),
).toHaveCount(0);
await editDialog.getByTestId("channel-management-save-changes").click();
await expect(editDialog).toHaveCount(0);
await expect(page.getByTestId("chat-title")).toHaveText(renamedChannel);
await expect(page.getByTestId("stream-list")).toContainText(renamedChannel);
await closeChannelManagement(page);
await page.reload();
await page.getByTestId(`channel-${renamedChannel}`).click();
await expect(page.getByTestId("chat-title")).toHaveText(renamedChannel);
await expect(page.getByTestId("chat-title")).toHaveAttribute(
"title",
updatedDescription,
);
await openChannelManagement(page);
await openChannelEditDialog(page);
const reopenedEditDialog = page.getByRole("dialog", {
name: /Edit (?:public|private) channel/,
});
await expect(
reopenedEditDialog.getByTestId("channel-management-name"),
).toHaveValue(renamedChannel);
await expect(
reopenedEditDialog.getByTestId("channel-management-description"),
).toHaveValue(updatedDescription);
await expect(
reopenedEditDialog.getByTestId("channel-management-topic"),
).toHaveCount(0);
await expect(
reopenedEditDialog.getByTestId("channel-management-purpose"),
).toHaveCount(0);
});
test("manage sheet archive and unarchive survives a reload through the relay", async ({
page,
}) => {
const channelName = `archive-integration-${Date.now()}`;
await installRelayBridge(page, "tyler");
await page.goto("/");
await createStream(page, channelName, "Archive integration channel");
await openChannelManagement(page);
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(channelName);
await expect(page.getByTestId("message-input")).toHaveAttribute(
"contenteditable",
"false",
);
await expect(page.getByTestId("send-message")).toBeDisabled();
await page.reload();
await expect(page.getByTestId("stream-list")).not.toContainText(channelName);
await openChannelBrowser(page);
await expect(page.getByTestId("channel-browser-dialog")).toBeVisible();
await expect(page.getByTestId(`browse-channel-${channelName}`)).toContainText(
"archived",
);
await page.getByTestId(`browse-channel-${channelName}`).click();
await expect(page.getByTestId("channel-browser-dialog")).not.toBeVisible();
await expect(page.getByTestId("chat-title")).toHaveText(channelName);
await expect(page.getByTestId("message-input")).toHaveAttribute(
"contenteditable",
"false",
);
await openChannelManagement(page);
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(channelName);
await expect(page.getByTestId("message-input")).toHaveAttribute(
"contenteditable",
"true",
);
});