mirror of
https://github.com/block/buzz.git
synced 2026-08-18 06:50:31 +02:00
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>
572 lines
18 KiB
TypeScript
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",
|
|
);
|
|
});
|