Files
buzz/desktop/tests/e2e/channel-sort.spec.ts
Will Pfleger 52c4c08c9e fix(sidebar): converge stale-at-open state across devices (sections/sort/stars/mutes) (#7805)
Adds bounded-backoff recovery reads to all four sidebar sync hooks
(sections, sort, stars and mutes). A stale relay read at app open now
converges without a user edit or a reconnect.

## What this does

**Shared recovery hook (`useStaleReaderRecovery`).** All four hooks use
one retry effect. It reads immediately on mount, then at 5, 10, 30 and
60 s, staying at 60 s after that. A change of `visibilityState` to
`visible` triggers an immediate read. Reads are single-flight. The
effect restarts whenever `pubkey` or `relayUrl` changes, so a hook that
stays mounted while switching relays drops the old relay's in-flight
reads.

**Guards that stop a recovery read from rolling back a local edit:**

1. **Skip the read while an edit is pending.** If an edit is pending
when a tick starts, the tick skips its read and just schedules the next
one. Without this, the read could return the pre-edit blob after the
edit published and pending cleared. At that point neither apply-time
guard could reject it.
2. **Pending check inside the state updater.** `hasPending()` and the
effect lifetime are re-read inside `setStore(prev => ...)`. That way the
check runs when React executes the updater, not when the response
arrives.
3. **Local-mutation revision.** Each hook counts its local edits. The
count is snapshotted when a read starts and checked again at apply time.
If an edit landed while the read was in flight, the response is
discarded.
4. **Head advanced only after a successful cache write.** A failed
`write*Store()` leaves the same head retryable on the next tick.

**Ownership-aware pending clear.** Each sync manager's
`doPublish(store)` now clears `pendingStore` only while it still holds
that same `store`, on both the identical-payload early return and the
success path. Before, an older publish that finished after a newer edit
had replaced `pendingStore` wiped the newer edit's pending marker, so a
recovery read before the newer debounce could apply the older relay head
over it. `destroy()` still clears unconditionally.

## Scope

Reader-side recovery plus the ownership-aware pending clear. No
outboxes, arbitration or replay changes. It applies to
`useChannelSections`, `useChannelSortPreference`, `useChannelStars` and
`useChannelMutes`.

Related: [#7806](https://github.com/block/buzz/pull/7806)

---------

Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
Signed-off-by: Duncan <dcfd242e557282d7a1e2cf2e6877522682f1e5c6156dc92ca7d90eaedd3b0f95@buzz.block.builderlab.xyz>
Co-authored-by: Duncan <dcfd242e557282d7a1e2cf2e6877522682f1e5c6156dc92ca7d90eaedd3b0f95@buzz.block.builderlab.xyz>
2026-09-28 12:48:16 -06:00

255 lines
8.9 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { expect, test } from "@playwright/test";
import type { Page } from "@playwright/test";
import { waitForAnimations } from "../helpers/animations";
import { TEST_IDENTITIES, installMockBridge } from "../helpers/bridge";
const SHOTS = "test-results/channel-sort";
// Mock-mode current-user pubkey and relay (see e2eBridge DEFAULT_MOCK_PUBKEY /
// DEFAULT_RELAY_WS_URL). Sort preferences persist under the relay-scoped key
// buzz-channel-sort.v1:<pubkey>:<encoded-relay>.
const MOCK_PUBKEY = "deadbeef".repeat(8);
const MOCK_RELAY_ENCODED = encodeURIComponent("ws://localhost:3000");
const SORT_STORAGE_KEY = `buzz-channel-sort.v1:${MOCK_PUBKEY}:${MOCK_RELAY_ENCODED}`;
function seedSortState(page: Page, groups: Record<string, string>) {
return page.addInitScript(
({ key, groups }) => {
window.localStorage.setItem(key, JSON.stringify({ version: 1, groups }));
},
{ key: SORT_STORAGE_KEY, groups },
);
}
async function openApp(page: Page) {
await page.goto("/");
await page.getByTestId("channel-general").click();
await expect(page.getByTestId("chat-title")).toHaveText("general");
}
async function waitForMockLiveSubscription(page: Page, channelName: string) {
await expect
.poll(() =>
page.evaluate(
(name) =>
window.__BUZZ_E2E_HAS_MOCK_LIVE_SUBSCRIPTION__?.({
channelName: name,
}) ?? false,
channelName,
),
)
.toBe(true);
}
async function emitMockMessage(
page: Page,
channelName: string,
createdAt: number,
) {
await page.evaluate(
({ channelName, createdAt, pubkey }) => {
const emit = window.__BUZZ_E2E_EMIT_MOCK_MESSAGE__;
if (!emit) {
throw new Error("Mock message emitter is not installed");
}
emit({
channelName,
content: "Fresh activity for Recent ordering",
createdAt,
pubkey,
});
},
{ channelName, createdAt, pubkey: TEST_IDENTITIES.alice.pubkey },
);
}
function forumNames(page: Page) {
return page
.getByTestId("forum-list")
.locator("[data-testid^='channel-']")
.evaluateAll((nodes) =>
nodes
.map((n) => n.getAttribute("data-testid") ?? "")
.filter(
(id) =>
!id.startsWith("channel-unread") &&
!id.startsWith("channel-working"),
)
.map((id) => id.replace(/^channel-/, "")),
);
}
function streamNames(page: Page) {
return page
.getByTestId("stream-list")
.locator("[data-testid^='channel-']")
.evaluateAll((nodes) =>
nodes
.map((n) => n.getAttribute("data-testid") ?? "")
.filter(
(id) =>
!id.startsWith("channel-unread") &&
!id.startsWith("channel-working") &&
!id.startsWith("channel-dm-count"),
)
.map((id) => id.replace(/^channel-/, "")),
);
}
test.describe("per-group channel sort", () => {
test("01 — Channels group defaults to A–Z", async ({ page }) => {
await installMockBridge(page);
await openApp(page);
const names = await streamNames(page);
const sorted = [...names].sort((a, b) => a.localeCompare(b));
expect(names).toEqual(sorted);
});
test("02 — sort trigger switches Channels to Recent and persists", async ({
page,
}) => {
await installMockBridge(page);
await openApp(page);
// Prime a live subscription while A–Z keeps engineering rendered, then move
// away so the later message exercises sidebar recency rather than routing.
await page.getByTestId("channel-engineering").click();
await expect(page.getByTestId("chat-title")).toHaveText("engineering");
await waitForMockLiveSubscription(page, "engineering");
await page.getByTestId("channel-general").click();
await expect(page.getByTestId("chat-title")).toHaveText("general");
// Hover the Channels header to reveal the action cluster, then open the
// sort dropdown and choose Recent.
const streamList = page.getByTestId("stream-list");
await expect(streamList).toBeVisible();
await page.getByText("Channels", { exact: true }).hover();
const trigger = page.getByTestId("section-actions-channels");
await expect(trigger).toBeVisible();
await waitForAnimations(page);
await page.screenshot({ path: `${SHOTS}/01-channels-sort-ingress.png` });
await trigger.click();
// Sort is now a submenu flyout — open it before the radio items render.
await page.getByRole("menuitem", { name: "Sort" }).click();
await expect(
page.getByRole("menuitemradio", { name: "Recent" }),
).toBeVisible();
await waitForAnimations(page);
await page.screenshot({ path: `${SHOTS}/02-channels-sort-open.png` });
await page.getByRole("menuitemradio", { name: "Recent" }).click();
// Mock recency: all-replies (far future) > deep-history (1m) > general
// (5m) > agents (15m) > sales (30m) > engineering (42m) > design (120m),
// then no-activity channels alphabetically. The list is virtualized, so
// only assert on the rendered prefix.
await expect
.poll(async () => (await streamNames(page)).slice(0, 3))
.toEqual(["all-replies", "deep-history", "general"]);
const names = await streamNames(page);
const recencyOrder = [
"all-replies",
"deep-history",
"general",
"agents",
"sales",
"engineering",
"design",
];
const rendered = recencyOrder.filter((n) => names.includes(n));
expect(names.slice(0, rendered.length)).toEqual(rendered);
await waitForAnimations(page);
await page.screenshot({ path: `${SHOTS}/03-channels-recent.png` });
// A fresh message must advance the matching channel immediately. The mock
// fixture intentionally pins all-replies in 2999, so use 3000 to prove the
// live cache update outranks even that deterministic sentinel.
await emitMockMessage(
page,
"engineering",
Math.floor(Date.parse("3000-01-01T00:00:00.000Z") / 1_000),
);
await expect
.poll(async () => (await streamNames(page)).slice(0, 3))
.toEqual(["engineering", "all-replies", "deep-history"]);
// Persisted for this identity.
const stored = await page.evaluate((key) => {
return JSON.parse(window.localStorage.getItem(key) ?? "null");
}, SORT_STORAGE_KEY);
expect(stored).toMatchObject({
version: 1,
groups: { channels: "recent" },
});
// Survives reload.
await page.reload();
await expect(page.getByTestId("stream-list")).toBeVisible();
await expect
.poll(async () => (await streamNames(page)).slice(0, 2))
.toEqual(["all-replies", "deep-history"]);
});
test("03 — group preferences are independent (seeded Channels=recent leaves Forums A–Z)", async ({
page,
}) => {
await seedSortState(page, { channels: "recent" });
await installMockBridge(page);
await openApp(page);
// Channels reflects the seeded Recent order…
await expect
.poll(async () => (await streamNames(page)).slice(0, 2))
.toEqual(["all-replies", "deep-history"]);
// …while Forums (unset) stays alphabetical.
const forumOrder = await forumNames(page);
const sortedForums = [...forumOrder].sort((a, b) => a.localeCompare(b));
expect(forumOrder).toEqual(sortedForums);
await waitForAnimations(page);
await page.screenshot({ path: `${SHOTS}/04-independent-groups.png` });
});
test("04 — Forum Recent survives authoritative reload", async ({ page }) => {
await seedSortState(page, { forums: "recent" });
await installMockBridge(page);
await openApp(page);
await expect
.poll(async () => (await forumNames(page)).slice(0, 2))
.toEqual(["watercooler", "announcements"]);
await page.reload();
await expect(page.getByTestId("forum-list")).toBeVisible();
await expect
.poll(async () => (await forumNames(page)).slice(0, 2))
.toEqual(["watercooler", "announcements"]);
});
test("05 — DM group has its own sort trigger", async ({ page }) => {
await installMockBridge(page);
await openApp(page);
const dmList = page.getByTestId("dm-list");
await expect(dmList).toBeVisible();
await page.getByText("Direct messages", { exact: true }).hover();
const trigger = page.getByTestId("section-actions-dms");
await expect(trigger).toBeVisible();
await trigger.click();
// Sort is now a submenu flyout — open it before the radio items render.
await page.getByRole("menuitem", { name: "Sort" }).click();
await expect(
page.getByRole("menuitemradio", { name: "A–Z" }),
).toBeVisible();
await waitForAnimations(page);
await page.screenshot({ path: `${SHOTS}/05-dm-sort-open.png` });
await page.getByRole("menuitemradio", { name: "Recent" }).click();
const stored = await page.evaluate((key) => {
return JSON.parse(window.localStorage.getItem(key) ?? "null");
}, SORT_STORAGE_KEY);
expect(stored).toMatchObject({ version: 1, groups: { dms: "recent" } });
});
});