mirror of
https://github.com/block/buzz.git
synced 2026-10-27 12:40:30 +00:00
## Summary
Lands the build-now items from the desktop latency plan
(#ui-performance-deep-dive) as one change. Every perceived-latency hot
path a user hits on launch, channel open, thread open, and reply send
drops one or more round trips.
**A1 — persisted channel heads (the big one).** Native WAL SQLite cache
(`desktop/src-tauri/src/channel_head_cache.rs`) keyed by `{pubkey,
relayUrl}` scope, 32 rows/scope LRU, 1 MiB per-row drop cap,
schema-version reset, corrupt-row tolerance, checkpointed on shutdown.
Three blocking-pool commands: `channel_head_cache_load` / `_store` /
`_clear`. On the renderer side, `CommunityQueryProvider` kicks off
hydration of up to 12 heads when it constructs the query client — the
app, splash and relay preconnect mount immediately; only
`useChannelMessagesQuery` awaits the seed (`channelHeadHydration`), then
consumes a one-shot hydrated gate so a hydrated channel pays **zero**
`get_channel_window` calls on mount and exactly **one** on the
post-subscription refresh, whose response replaces page zero wholesale.
That refresh fires whether live-subscription setup succeeds or fails,
and is sequenced behind hydration so it is always a distinct
authoritative fetch (see Review follow-ups). Bounds-only persisted heads
(zero rows) are not hydrated and take the cold loading path. The
timeline loading latch recognizes native-hydrated rows as restart-safe
so they paint immediately instead of holding a skeleton. The cache is a
paint accelerator only — the relay response is always authoritative.
Replaces the legacy localStorage `messageSnapshot.ts` (removed, -401
lines).
Kill switch: `VITE_BUZZ_CHANNEL_HEAD_CACHE=off` at build time or
`localStorage["buzz-channel-head-cache"] = "off"` at runtime. Cache is
cleared on community removal and scoped per identity, so a replaced
signer never sees the previous identity's rows.
**B1 — thread aux in one response.** Relay thread filters accept
`include_aux`; the bridge appends the same authorized two-hop
reactions/edits/deletions closure a channel window gets
(`build_aux_query` shared with the window path). Renderer
`useThreadReplies` drops its two follow-up aux fetches. `next_cursor` is
computed from reply-kind rows only since aux rows are unpaged.
Documented in `docs/bridge-channel-window.md`. Thread queries keep
`staleTime: 0` (`bcfe04e2f`): an earlier revision raised it to 30s,
which CI's `thread-unread.spec.ts` caught — once the user leaves a
channel, the live subscription stops feeding that thread's cache, so a
reopen must always take the (now single) authoritative read.
**B2 — cached root on reply send.** `send_channel_message` gains
`root_event_id`; when the renderer already holds the parent (channel or
thread cache) it passes the NIP-10 root, and native signs without the
relay round trip that `resolve_thread_ref` used to make. Strict hex
parse; `root_event_id` requires `parent_event_id`; absent root falls
back to the existing relay resolution. The renderer never sends a
guessed root.
**B4** general HTTP pool idle 10s→300s, max idle per host 1→2. **B5**
relay preconnect fires as soon as identity is ready instead of waiting
for `requestIdleCallback`. One e2e test (`relay-reconnect.spec.ts`
"service restart close resets accumulated backoff") had been relying on
the idle-callback batching to skip past its own seeded dial failures
before the channel list painted; `8133d70bb` makes it wait for the
connected state instead (test-only, still fails with the 1012 backoff
reset disabled). **B6** profile freshness 60s→10 min (both the in-memory
entry check and the query `staleTime`). Tradeoff: another user's
display-name/avatar edit can take up to 10 min to propagate to a client
that already holds their profile (relay reconnect refetches
`users-batch` but resolves from the still-fresh per-pubkey entry); your
own edits still evict the entry immediately (`evictUsersBatchEntries` in
`useUpdateProfileMutation`).
### Related issue
Follows #6456/#6457/#6459/#6460 (already merged). #6455 is the
measurement instrument and is intentionally not folded in. No duplicate
PR found.
### Review follow-ups
Addressing Carl's reviews
[5001114109](https://github.com/block/buzz/pull/6572#pullrequestreview-5001114109)
and
[5002596542](https://github.com/block/buzz/pull/6572#pullrequestreview-5002596542),
each pushed as new commits (no rebase):
- `4f06b7770` fix(desktop): mount app while channel heads hydrate;
always revalidate — provider no longer gates children on the cache load;
`refreshAfterSubscribe` runs on subscribe failure too; bounds-only heads
skipped at seed; seed merges into an existing window store. +3 tests.
- `35834cb31` fix(relay): drain aux closure hops across the page clamp —
`query_all_pages` walks the `(created_at, id)` keyset via
`until`/`before_id` until a short page (`AUX_PAGE_LIMIT` =
`DEFAULT_MAX_PAGE_LIMIT`, `AUX_MAX_PAGES` = 64 warn+truncate) so
one-shot `limit: 1000` newest-first no longer drops the oldest
edits/deletions. +3 tests; `docs/bridge-channel-window.md` updated.
- `db21b0531` merge of `origin/main` `e23632941` (#6558, #6312 — no
overlap).
- `5a5566c0f` fix(desktop): sequence post-subscribe refresh behind
channel head hydration — `refreshChannelWindowMessages` awaits
`channelHeadHydration()` and, for a hydration-seeded query (`data !==
undefined && dataUpdatedAt === 0`), the in-flight snapshot fetch before
invalidating. Without this, a subscription that settles before the
SQLite load invalidated a data-less in-flight query; TanStack dedupes
that onto the existing fetch (`query-core` `fetch()` only cancels when
`state.data` exists), which returned the seeded snapshot — 0
authoritative fetches. Regression test reproduces Carl's exact ordering
(fails at `35834cb31` with 0 calls), plus a cold-channel guard that the
fix does not double-fetch.
- `b129231c8` fix(desktop): let concurrent post-hydration refreshes
share one window fetch — found independently by Max and Wren reviewing
`5a5566c0f`: subscribe settlement + reconnect both wake on the same
snapshot promise and both invalidate; the second (default
`cancelRefetch: true`) cancelled and replaced the first authoritative
fetch (3 queryFn calls, not 2, and the cancelled Tauri invoke still hits
the relay). The seeded branch now invalidates with `cancelRefetch:
false` so a second waker joins the in-flight fetch; cold/warm keep the
default (`test_canceled_stale_fetch_cannot_overwrite_catch_up_window`
relies on it). Concurrent regression test fails at `5a5566c0f` with 3.
### Testing
At `b129231c8` (PR head; verified in one shell with `git rev-parse HEAD`
= `b129231c8`): `pnpm check`, `tsc --noEmit`, desktop unit 5,393 / 0,
Playwright `boot-splash` + `channel-head-restart` + `relay-reconnect` +
`relay-reconnect-affordance` + `thread-unread` 34 / 34 on a fresh
`build:e2e`, pre-push hooks green.
At `5a5566c0f`: `pnpm check`, `tsc --noEmit`, desktop unit 5,392 / 0,
Playwright `boot-splash` + `channel-head-restart` + `relay-reconnect` +
`relay-reconnect-affordance` + `thread-unread` 34 / 34 on a fresh
`build:e2e`, pre-push hooks green.
At `35834cb31`: desktop unit 5,390 / 0; `cargo test -p buzz-relay --lib`
910 / 0; fmt + clippy `-D warnings` clean; Playwright 32 / 32 (same
specs minus affordance); GitHub CI green on every job except Smoke (3)
(unrelated project-review row-count + messaging timing flake, per Carl)
and Unit Tests (sherpa cache skeleton, below).
Earlier, all at `8133d70bb` (this PR head is `0c492366d` = 8133d70bb + a
comments-only commit correcting two `profile/hooks.ts` freshness
comments from 60s to 10 min; pre-push desktop check/typecheck/test
5,387/0 re-ran at 0c492366d) in one shell; `origin/main` = `040b203f7`
at PR open, since moved to `4baccd539` (#6558, mobile only — zero file
overlap, `git merge-tree` clean):
- `just desktop-test` — 5,387 passed / 0 failed (includes new hook-level
call-count test: cold = 1, stale-prefetched = 1, hydrated = 0 on mount
then 1 on invalidate with wholesale replacement)
- Playwright smoke `relay-reconnect.spec.ts` + `thread-unread.spec.ts` +
`channel-head-restart.spec.ts` — 30/30 (thread-unread was 8/13 at
`7acbf951b`; relay-reconnect was 15/16 at `bcfe04e2f`). The restart spec
persists a head, reloads into a fresh mock relay with the head fetch
held 5s, asserts the persisted row paints within 2s, exactly one
`get_channel_window` after open, and the stale row is removed when the
authoritative page lands.
- `pnpm typecheck`, `pnpm check` — clean
At `7acbf951b` (everything except the two-line `useThreadReplies.ts`
staleTime revert and the test-only `relay-reconnect.spec.ts` change),
also green in one shell:
- `just desktop-tauri-test` — 2,859 passed / 0 failed across the
workspace (channel_head_cache: wire shape, LRU+caps, schema reset,
corrupt-row skip)
- `just test-unit` — 632 passed (buzz-core/auth); `cargo test -p
buzz-relay --lib` — 908 passed / 0 failed
- `just check` components: fmt-check, clippy, desktop-check,
desktop-typecheck, desktop-tauri-fmt-check, desktop-tauri-clippy,
web-check, mobile-check, file-size-check — all green
- `just desktop-build`, `web-build`, `desktop-tauri-check`,
`mobile-test` (1,661 passed) — all green
CI note: the "Unit Tests" job goes red on this PR and on `main` whenever
it hits a poisoned `rust-cache` entry (an empty-directory skeleton of
`target/sherpa-onnx-prebuilt` that `sherpa-onnx-sys` build.rs trusts),
surfacing as `could not find native static library sherpa-onnx-c-api` in
`buzz-voice` — a crate this PR doesn't touch. Deleting the cache entry
and rerunning turned the job green at `0c492366d` (28/28); it re-poisons
on the next `main` push until the workflow clears that directory after
cache restore.
Reviewed in-channel by Wren (9 / 9 / 9.5) and Eva (9 / 9 / 9), and
line-by-line by me before opening; the staleTime fix re-verified by Wren
and me independently; the relay-reconnect test fix bisected and verified
by me.
---------
Signed-off-by: Perci <5a968df9a7494b4e019b9ecf739e088ba61097b4312124e9a88ae5b42e3f5f3e@buzz.block.builderlab.xyz>
Signed-off-by: Max <d8473ee32b973aa31a21a65adddcc4b69cc2a8a4dee8121ecd51926e0cddbc02@buzz.block.builderlab.xyz>
Signed-off-by: Wren <5217c5c2f7bfb4333e46d17c98a9255a52dadee18dcd43a43536b95e6776dfa0@buzz.block.builderlab.xyz>
Signed-off-by: Meli <5aaa86bce934fc3445fc254aab560a40923f10252f92107e665073dede0e04d3@buzz.block.builderlab.xyz>
Co-authored-by: Perci <5a968df9a7494b4e019b9ecf739e088ba61097b4312124e9a88ae5b42e3f5f3e@buzz.block.builderlab.xyz>
Co-authored-by: Max <d8473ee32b973aa31a21a65adddcc4b69cc2a8a4dee8121ecd51926e0cddbc02@buzz.block.builderlab.xyz>
Co-authored-by: Wren <5217c5c2f7bfb4333e46d17c98a9255a52dadee18dcd43a43536b95e6776dfa0@buzz.block.builderlab.xyz>
Co-authored-by: Meli <5aaa86bce934fc3445fc254aab560a40923f10252f92107e665073dede0e04d3@buzz.block.builderlab.xyz>
711 lines
23 KiB
TypeScript
711 lines
23 KiB
TypeScript
import { expect, test } from "@playwright/test";
|
|
|
|
import { installMockBridge } from "../helpers/bridge";
|
|
|
|
async function setMockWebsocketSendsStalled(
|
|
page: import("@playwright/test").Page,
|
|
stall: boolean,
|
|
) {
|
|
await page.evaluate((shouldStall) => {
|
|
const setter = (
|
|
window as Window & {
|
|
__BUZZ_E2E_SET_STALL_WEBSOCKET_SENDS__?: (stall: boolean) => void;
|
|
}
|
|
).__BUZZ_E2E_SET_STALL_WEBSOCKET_SENDS__;
|
|
if (!setter) {
|
|
throw new Error("E2E websocket stall setter is not installed.");
|
|
}
|
|
setter(shouldStall);
|
|
}, stall);
|
|
}
|
|
|
|
async function disconnectMockWebsockets(page: import("@playwright/test").Page) {
|
|
const disconnected = await page.evaluate(() => {
|
|
const disconnect = (
|
|
window as Window & {
|
|
__BUZZ_E2E_DISCONNECT_MOCK_WEBSOCKETS__?: () => number;
|
|
}
|
|
).__BUZZ_E2E_DISCONNECT_MOCK_WEBSOCKETS__;
|
|
if (!disconnect) {
|
|
throw new Error("E2E mock websocket disconnect seam is not installed.");
|
|
}
|
|
return disconnect();
|
|
});
|
|
|
|
expect(disconnected).toBeGreaterThan(0);
|
|
}
|
|
|
|
async function restartMockWebsockets(page: import("@playwright/test").Page) {
|
|
const restarted = await page.evaluate(() => {
|
|
const restart = (
|
|
window as Window & {
|
|
__BUZZ_E2E_RESTART_MOCK_WEBSOCKETS__?: () => number;
|
|
}
|
|
).__BUZZ_E2E_RESTART_MOCK_WEBSOCKETS__;
|
|
if (!restart)
|
|
throw new Error("E2E websocket restart seam is not installed.");
|
|
return restart();
|
|
});
|
|
expect(restarted).toBeGreaterThan(0);
|
|
}
|
|
|
|
async function setMockWebsocketUnavailable(
|
|
page: import("@playwright/test").Page,
|
|
unavailable: boolean,
|
|
) {
|
|
await page.evaluate((value) => {
|
|
const setUnavailable = window.__BUZZ_E2E_SET_MOCK_WEBSOCKET_UNAVAILABLE__;
|
|
if (!setUnavailable) {
|
|
throw new Error("E2E websocket availability seam is not installed.");
|
|
}
|
|
setUnavailable(value);
|
|
}, unavailable);
|
|
}
|
|
|
|
async function activateRelayRateLimit(
|
|
page: import("@playwright/test").Page,
|
|
seconds: number,
|
|
) {
|
|
await page.evaluate((duration) => {
|
|
const activate = window.__BUZZ_E2E_ACTIVATE_RELAY_RATE_LIMIT__;
|
|
if (!activate) {
|
|
throw new Error("E2E relay rate-limit seam is not installed.");
|
|
}
|
|
activate(duration);
|
|
}, seconds);
|
|
}
|
|
|
|
async function getMockWebsocketConnectAttempts(
|
|
page: import("@playwright/test").Page,
|
|
) {
|
|
return page.evaluate(() => {
|
|
const getAttempts = window.__BUZZ_E2E_GET_WEBSOCKET_CONNECT_ATTEMPTS__;
|
|
return getAttempts?.() ?? [];
|
|
});
|
|
}
|
|
|
|
async function emitMockMessages(
|
|
page: import("@playwright/test").Page,
|
|
messages: Array<{ content: string; createdAt: number }>,
|
|
) {
|
|
await page.evaluate((items) => {
|
|
const emit = (
|
|
window as Window & {
|
|
__BUZZ_E2E_EMIT_MOCK_MESSAGE__?: (input: {
|
|
channelName: string;
|
|
content: string;
|
|
createdAt: number;
|
|
}) => unknown;
|
|
}
|
|
).__BUZZ_E2E_EMIT_MOCK_MESSAGE__;
|
|
if (!emit) {
|
|
throw new Error("E2E mock message emitter is not installed.");
|
|
}
|
|
|
|
for (const item of items) {
|
|
emit({ channelName: "general", ...item });
|
|
}
|
|
}, messages);
|
|
}
|
|
|
|
async function queueAuthResponses(
|
|
page: import("@playwright/test").Page,
|
|
responses: Array<{ success: boolean; message: string }>,
|
|
) {
|
|
await page.evaluate((queued) => {
|
|
const queue = window.__BUZZ_E2E_QUEUE_AUTH_RESPONSES__;
|
|
if (!queue) throw new Error("E2E AUTH response seam is not installed.");
|
|
queue(queued);
|
|
}, responses);
|
|
}
|
|
|
|
async function closeLiveSubscriptions(
|
|
page: import("@playwright/test").Page,
|
|
reason: string,
|
|
) {
|
|
const closed = await page.evaluate((message) => {
|
|
const close = window.__BUZZ_E2E_CLOSE_LIVE_SUBSCRIPTIONS__;
|
|
if (!close) throw new Error("E2E live CLOSED seam is not installed.");
|
|
return close(message);
|
|
}, reason);
|
|
expect(closed).toBeGreaterThan(0);
|
|
}
|
|
|
|
async function queueChannelHistoryCloses(
|
|
page: import("@playwright/test").Page,
|
|
reasons: string[],
|
|
) {
|
|
await page.evaluate((queued) => {
|
|
const queue = window.__BUZZ_E2E_QUEUE_CHANNEL_HISTORY_CLOSES__;
|
|
if (!queue) {
|
|
throw new Error("E2E channel history CLOSED seam is not installed.");
|
|
}
|
|
queue(queued);
|
|
}, reasons);
|
|
}
|
|
|
|
async function driveConnectionDegraded(
|
|
page: import("@playwright/test").Page,
|
|
state: "connected" | "reconnecting" | "stalled" | "disconnected",
|
|
) {
|
|
await page.evaluate((s) => {
|
|
const setter = (
|
|
window as Window & {
|
|
__BUZZ_E2E_SET_RELAY_CONNECTION_STATE__?: (state: string) => void;
|
|
}
|
|
).__BUZZ_E2E_SET_RELAY_CONNECTION_STATE__;
|
|
if (!setter) {
|
|
throw new Error("E2E relay state setter is not installed.");
|
|
}
|
|
setter(s);
|
|
}, state);
|
|
}
|
|
|
|
test.beforeEach(async ({ page }) => {
|
|
await installMockBridge(page);
|
|
});
|
|
|
|
test("stalled early AUTH signing times out and starts a replacement dial", async ({
|
|
page,
|
|
}) => {
|
|
test.setTimeout(40_000);
|
|
await installMockBridge(page, {
|
|
websocketAuthBeforeConnectResolves: true,
|
|
stallFirstAuthSigning: true,
|
|
});
|
|
await page.goto("/");
|
|
|
|
await expect
|
|
.poll(() => getMockWebsocketConnectAttempts(page), { timeout: 32_000 })
|
|
.toHaveLength(2);
|
|
await expect
|
|
.poll(
|
|
() =>
|
|
page.evaluate(() => window.__BUZZ_E2E_GET_RELAY_CONNECTION_STATE__?.()),
|
|
{ timeout: 5_000 },
|
|
)
|
|
.toBe("connected");
|
|
});
|
|
|
|
test("AUTH arriving before connect resolves does not lose the first send", async ({
|
|
page,
|
|
}) => {
|
|
await installMockBridge(page, {
|
|
websocketAuthBeforeConnectResolves: true,
|
|
});
|
|
await page.goto("/");
|
|
await expect
|
|
.poll(
|
|
() =>
|
|
page.evaluate(() => window.__BUZZ_E2E_GET_RELAY_CONNECTION_STATE__?.()),
|
|
{ timeout: 5_000 },
|
|
)
|
|
.toBe("connected");
|
|
await page.getByTestId("channel-general").click();
|
|
await expect(page.getByTestId("chat-title")).toHaveText("general");
|
|
|
|
const message = `first send after early auth ${Date.now()}`;
|
|
await page.getByTestId("message-input").fill(message);
|
|
await page.getByTestId("send-message").click();
|
|
|
|
await expect(page.getByTestId("message-timeline")).toContainText(message);
|
|
});
|
|
|
|
test("failed initial relay dial retries automatically", async ({ page }) => {
|
|
await installMockBridge(page, {
|
|
websocketConnectErrors: ["mock relay pod unavailable"],
|
|
});
|
|
await page.goto("/");
|
|
|
|
// App-shell preconnect owns a keep-alive request. The first native dial is
|
|
// rejected before a socket ID exists; the session must still enter its
|
|
// backoff loop and recover without a click, query, or reload.
|
|
await expect
|
|
.poll(
|
|
() =>
|
|
page.evaluate(() => {
|
|
const getState = (
|
|
window as Window & {
|
|
__BUZZ_E2E_GET_RELAY_CONNECTION_STATE__?: () => string;
|
|
}
|
|
).__BUZZ_E2E_GET_RELAY_CONNECTION_STATE__;
|
|
return getState?.() ?? null;
|
|
}),
|
|
{ timeout: 10_000 },
|
|
)
|
|
.toBe("connected");
|
|
await expect(page.getByTestId("channel-general")).toBeVisible();
|
|
});
|
|
|
|
test("routine traffic cannot bypass outage backoff and recovery stays automatic", async ({
|
|
page,
|
|
}) => {
|
|
await page.goto("/");
|
|
await expect(page.getByTestId("channel-general")).toBeVisible();
|
|
|
|
await setMockWebsocketUnavailable(page, true);
|
|
await disconnectMockWebsockets(page);
|
|
|
|
// Exercise the production query path throughout the outage. Before the
|
|
// coordinator fix, each rejected query called ensureConnected(), cancelled
|
|
// the scheduled timer, and dialed immediately. The fixed session keeps these
|
|
// callers behind its single jittered exponential-backoff attempt.
|
|
await page.evaluate(async () => {
|
|
const deadline = Date.now() + 4_200;
|
|
while (Date.now() < deadline) {
|
|
await window.__BUZZ_E2E_QUERY_CLIENT__?.invalidateQueries({
|
|
queryKey: ["channels"],
|
|
});
|
|
await new Promise((resolve) => window.setTimeout(resolve, 100));
|
|
}
|
|
});
|
|
|
|
const attempts = await getMockWebsocketConnectAttempts(page);
|
|
expect(attempts.length).toBeGreaterThanOrEqual(2);
|
|
expect(attempts.length).toBeLessThanOrEqual(3);
|
|
for (let index = 1; index < attempts.length; index += 1) {
|
|
expect(attempts[index] - attempts[index - 1]).toBeGreaterThanOrEqual(700);
|
|
}
|
|
|
|
await setMockWebsocketUnavailable(page, false);
|
|
await expect
|
|
.poll(
|
|
() =>
|
|
page.evaluate(() => window.__BUZZ_E2E_GET_RELAY_CONNECTION_STATE__?.()),
|
|
{ timeout: 10_000 },
|
|
)
|
|
.toBe("connected");
|
|
|
|
const afterRecovery = `automatic outage recovery ${Date.now()}`;
|
|
await emitMockMessages(page, [
|
|
{ content: afterRecovery, createdAt: Math.floor(Date.now() / 1_000) },
|
|
]);
|
|
await page.getByTestId("channel-general").click();
|
|
await expect(page.getByTestId("message-timeline")).toContainText(
|
|
afterRecovery,
|
|
);
|
|
});
|
|
|
|
test("authenticated reconnect reports connected while replay is rate-limited", async ({
|
|
page,
|
|
}) => {
|
|
await page.goto("/");
|
|
await expect(page.getByTestId("channel-general")).toBeVisible();
|
|
|
|
await activateRelayRateLimit(page, 5);
|
|
await disconnectMockWebsockets(page);
|
|
|
|
// Replay remains intentionally blocked behind admission control, but socket
|
|
// open + successful AUTH is already a healthy connection. The UI must not
|
|
// claim the relay is unreachable for the rest of the gate window.
|
|
await expect
|
|
.poll(
|
|
() =>
|
|
page.evaluate(() => window.__BUZZ_E2E_GET_RELAY_CONNECTION_STATE__?.()),
|
|
{ timeout: 3_000 },
|
|
)
|
|
.toBe("connected");
|
|
await expect(page.getByTestId("sidebar-relay-unreachable")).toHaveCount(0);
|
|
});
|
|
|
|
test("rate-limited reconnect backfill does not tear down the authenticated socket", async ({
|
|
page,
|
|
}) => {
|
|
await page.goto("/");
|
|
await page.getByTestId("channel-general").click();
|
|
await expect(page.getByTestId("chat-title")).toHaveText("general");
|
|
|
|
// Give the channel live subscription a replay cursor. On reconnect this
|
|
// causes a paged channel-history REQ in addition to restoring the live REQ.
|
|
await emitMockMessages(page, [
|
|
{
|
|
content: `replay cursor ${Date.now()}`,
|
|
createdAt: Math.floor(Date.now() / 1_000),
|
|
},
|
|
]);
|
|
const attemptsBeforeReconnect = (await getMockWebsocketConnectAttempts(page))
|
|
.length;
|
|
|
|
// Inject back-pressure from the history REQ itself. This differs from a
|
|
// pre-armed gate and from CLOSED on the live subscription: AUTH has already
|
|
// succeeded and the socket is healthy when replay backfill is rejected.
|
|
await queueChannelHistoryCloses(page, [
|
|
"rate-limited: quota exceeded; retry in 1s",
|
|
]);
|
|
await disconnectMockWebsockets(page);
|
|
|
|
await expect
|
|
.poll(
|
|
async () =>
|
|
(await getMockWebsocketConnectAttempts(page)).length -
|
|
attemptsBeforeReconnect,
|
|
{ timeout: 3_000 },
|
|
)
|
|
.toBe(1);
|
|
await expect
|
|
.poll(() =>
|
|
page.evaluate(() => window.__BUZZ_E2E_GET_RELAY_CONNECTION_STATE__?.()),
|
|
)
|
|
.toBe("connected");
|
|
|
|
// Hold through the rate-limit hint plus the next base-backoff window. The
|
|
// authenticated socket must remain the only reconnect attempt, rather than
|
|
// flashing connected and redialing after replay rejects.
|
|
await page.waitForTimeout(2_500);
|
|
expect(
|
|
(await getMockWebsocketConnectAttempts(page)).length -
|
|
attemptsBeforeReconnect,
|
|
).toBe(1);
|
|
expect(
|
|
await page.evaluate(() =>
|
|
window.__BUZZ_E2E_GET_RELAY_CONNECTION_STATE__?.(),
|
|
),
|
|
).toBe("connected");
|
|
});
|
|
|
|
test("service restart close resets accumulated backoff", async ({ page }) => {
|
|
await installMockBridge(page, {
|
|
websocketConnectErrors: ["down 1", "down 2", "down 3"],
|
|
});
|
|
await page.goto("/");
|
|
// Three rejected dials put the session deep in its backoff loop before it
|
|
// connects; wait for that connect rather than for the Tauri-backed channel
|
|
// list, which paints long before the websocket is up.
|
|
await expect
|
|
.poll(
|
|
() =>
|
|
page.evaluate(() => window.__BUZZ_E2E_GET_RELAY_CONNECTION_STATE__?.()),
|
|
{ timeout: 15_000 },
|
|
)
|
|
.toBe("connected");
|
|
|
|
const startedAt = Date.now();
|
|
await restartMockWebsockets(page);
|
|
await expect
|
|
.poll(
|
|
() =>
|
|
page.evaluate(() => window.__BUZZ_E2E_GET_RELAY_CONNECTION_STATE__?.()),
|
|
{ timeout: 2_500 },
|
|
)
|
|
.toBe("connected");
|
|
expect(Date.now() - startedAt).toBeLessThan(2_500);
|
|
});
|
|
|
|
test("passive relay watchdog does not write while the websocket is half-open", async ({
|
|
page,
|
|
}) => {
|
|
await page.goto("/");
|
|
await page.getByTestId("channel-general").click();
|
|
await expect(page.getByTestId("chat-title")).toHaveText("general");
|
|
await expect(page.getByTestId("message-timeline")).toContainText(
|
|
"Welcome to general",
|
|
);
|
|
|
|
await setMockWebsocketSendsStalled(page, true);
|
|
|
|
// Wait longer than the old active-probe interval. If the watchdog still
|
|
// writes probes, the mocked plugin send would never resolve and mark the
|
|
// mock plugin mutex as wedged. Future reconnects would then be unable to
|
|
// register, matching the tauri-plugin-websocket failure mode. The passive
|
|
// watchdog should perform no writes of its own during this window.
|
|
await page.waitForTimeout(22_000);
|
|
|
|
await setMockWebsocketSendsStalled(page, false);
|
|
const message = `recovered after passive idle ${Date.now()}`;
|
|
await page.getByTestId("message-input").fill(message);
|
|
await page.getByTestId("send-message").click();
|
|
await expect(page.getByTestId("message-timeline")).toContainText(message);
|
|
});
|
|
|
|
test("sidebar reconnect prompt flips on live relay degradation without a query error", async ({
|
|
page,
|
|
}) => {
|
|
await page.goto("/");
|
|
|
|
// Healthy boot: channels render from the mock bridge and the reconnect
|
|
// prompt is absent (no query error, connection healthy).
|
|
await expect(page.getByTestId("channel-general")).toBeVisible();
|
|
await expect(page.getByTestId("sidebar-relay-unreachable")).toHaveCount(0);
|
|
|
|
// Drive ONLY the live connection state degraded — no channelsQuery error is
|
|
// set. Pre-fix the block keyed off `channelsQuery.error` alone, so it stays
|
|
// absent here; post-fix the dual signal surfaces it.
|
|
await driveConnectionDegraded(page, "stalled");
|
|
|
|
// `stalled` is debounced before surfacing, then React needs a render tick.
|
|
await expect(page.getByTestId("sidebar-relay-unreachable")).toBeVisible({
|
|
timeout: 10_000,
|
|
});
|
|
await expect(page.getByTestId("sidebar-reconnect")).toBeVisible();
|
|
|
|
// The cached channel list stays visible alongside the prompt (layout intent:
|
|
// surface the affordance, don't yank context).
|
|
await expect(page.getByTestId("channel-general")).toBeVisible();
|
|
});
|
|
|
|
test("profile popover does not show relay reconnect controls", async ({
|
|
page,
|
|
}) => {
|
|
await page.goto("/");
|
|
|
|
await expect(page.getByTestId("channel-general")).toBeVisible();
|
|
await page.getByTestId("sidebar-profile-avatar-button").click();
|
|
await expect(page.getByTestId("profile-popover-settings")).toBeVisible();
|
|
await expect(page.getByTestId("profile-popover-reconnect")).toHaveCount(0);
|
|
|
|
// The sidebar owns the relay reconnect affordance; the profile popover stays
|
|
// focused on profile/settings/community actions even while the relay is down.
|
|
await driveConnectionDegraded(page, "stalled");
|
|
await expect(page.getByTestId("sidebar-relay-unreachable")).toBeVisible({
|
|
timeout: 10_000,
|
|
});
|
|
await expect(page.getByTestId("profile-popover-reconnect")).toHaveCount(0);
|
|
});
|
|
|
|
test("resume event short-circuits accumulated reconnect backoff", async ({
|
|
page,
|
|
}) => {
|
|
await page.goto("/");
|
|
await expect(page.getByTestId("channel-general")).toBeVisible();
|
|
|
|
await setMockWebsocketUnavailable(page, true);
|
|
await disconnectMockWebsockets(page);
|
|
await expect
|
|
.poll(() => getMockWebsocketConnectAttempts(page), { timeout: 10_000 })
|
|
.toHaveLength(3);
|
|
|
|
await setMockWebsocketUnavailable(page, false);
|
|
const resumedAt = Date.now();
|
|
await page.evaluate(() => window.dispatchEvent(new Event("online")));
|
|
await expect
|
|
.poll(
|
|
() =>
|
|
page.evaluate(() => window.__BUZZ_E2E_GET_RELAY_CONNECTION_STATE__?.()),
|
|
{
|
|
timeout: 2_000,
|
|
},
|
|
)
|
|
.toBe("connected");
|
|
expect(Date.now() - resumedAt).toBeLessThan(2_000);
|
|
});
|
|
|
|
test("resume events during repeated AUTH rejection cannot defeat the terminal cap", async ({
|
|
page,
|
|
}) => {
|
|
await page.goto("/");
|
|
await page.getByTestId("channel-general").click();
|
|
await expect(page.getByTestId("chat-title")).toHaveText("general");
|
|
|
|
// Three consecutive rejections must latch terminal even when resume
|
|
// events interleave with the handshakes: resume attempts bypass backoff
|
|
// but must preserve the AUTH rejection streak (review finding on
|
|
// PR #4737 — preconnect()'s streak reset previously made the cap
|
|
// unreachable under focus/online bursts).
|
|
//
|
|
// Queue MORE rejections than the cap: superseded connection attempts
|
|
// (resume racing the backoff timer) consume queue entries on handshakes
|
|
// whose frames the client drops as stale, exactly like a real relay that
|
|
// rejects every attempt. A queue of exactly 3 can be silently eaten and
|
|
// a default-success AUTH would then reset the streak.
|
|
await queueAuthResponses(
|
|
page,
|
|
Array.from({ length: 12 }, () => ({
|
|
success: false,
|
|
message: "auth-required: verification failed",
|
|
})),
|
|
);
|
|
await disconnectMockWebsockets(page);
|
|
|
|
// Fire resume events while the rejection sequence plays out. Repeated
|
|
// dispatch (post-rate-limit spacing is irrelevant here: each poll tick
|
|
// dispatches both events) guarantees at least one lands between
|
|
// handshakes.
|
|
await expect
|
|
.poll(
|
|
async () => {
|
|
await page.evaluate(() => {
|
|
window.dispatchEvent(new Event("online"));
|
|
window.dispatchEvent(new Event("focus"));
|
|
});
|
|
return page.evaluate(() =>
|
|
window.__BUZZ_E2E_GET_RELAY_CONNECTION_STATE__?.(),
|
|
);
|
|
},
|
|
{ intervals: [500], timeout: 20_000 },
|
|
)
|
|
.toBe("disconnected");
|
|
|
|
// Terminal is user-owned: further resume events must not revive the
|
|
// session.
|
|
await page.evaluate(() => {
|
|
window.dispatchEvent(new Event("online"));
|
|
window.dispatchEvent(new Event("focus"));
|
|
});
|
|
await page.waitForTimeout(1_000);
|
|
expect(
|
|
await page.evaluate(() =>
|
|
window.__BUZZ_E2E_GET_RELAY_CONNECTION_STATE__?.(),
|
|
),
|
|
).toBe("disconnected");
|
|
});
|
|
|
|
test("sub-2s degraded flap invalidates relay queries on recovery", async ({
|
|
page,
|
|
}) => {
|
|
await page.goto("/");
|
|
await expect(page.getByTestId("channel-general")).toBeVisible();
|
|
|
|
await page.evaluate(() => {
|
|
const queryClient = window.__BUZZ_E2E_QUERY_CLIENT__ as
|
|
| {
|
|
invalidateQueries: (...args: unknown[]) => unknown;
|
|
__rawHealInvalidations?: number;
|
|
}
|
|
| undefined;
|
|
if (!queryClient)
|
|
throw new Error("E2E query client seam is not installed.");
|
|
const original = queryClient.invalidateQueries.bind(queryClient);
|
|
queryClient.__rawHealInvalidations = 0;
|
|
queryClient.invalidateQueries = (...args: unknown[]) => {
|
|
queryClient.__rawHealInvalidations =
|
|
(queryClient.__rawHealInvalidations ?? 0) + 1;
|
|
return original(...args);
|
|
};
|
|
});
|
|
|
|
await driveConnectionDegraded(page, "reconnecting");
|
|
await page.waitForTimeout(100);
|
|
await driveConnectionDegraded(page, "connected");
|
|
|
|
await expect
|
|
.poll(() =>
|
|
page.evaluate(
|
|
() =>
|
|
(
|
|
window.__BUZZ_E2E_QUERY_CLIENT__ as unknown as {
|
|
__rawHealInvalidations?: number;
|
|
}
|
|
)?.__rawHealInvalidations ?? 0,
|
|
),
|
|
)
|
|
.toBeGreaterThan(0);
|
|
});
|
|
|
|
test("transient AUTH rejection reconnects and restores live traffic", async ({
|
|
page,
|
|
}) => {
|
|
await page.goto("/");
|
|
await page.getByTestId("channel-general").click();
|
|
await expect(page.getByTestId("chat-title")).toHaveText("general");
|
|
|
|
await queueAuthResponses(page, [
|
|
{ success: false, message: "auth-required: verification failed" },
|
|
{ success: true, message: "" },
|
|
]);
|
|
await disconnectMockWebsockets(page);
|
|
|
|
await expect
|
|
.poll(
|
|
() =>
|
|
page.evaluate(() => window.__BUZZ_E2E_GET_RELAY_CONNECTION_STATE__?.()),
|
|
{
|
|
timeout: 10_000,
|
|
},
|
|
)
|
|
.toBe("connected");
|
|
|
|
const recovered = `live after transient AUTH rejection ${Date.now()}`;
|
|
await emitMockMessages(page, [
|
|
{ content: recovered, createdAt: Math.floor(Date.now() / 1_000) },
|
|
]);
|
|
await expect(page.getByTestId("message-timeline")).toContainText(recovered);
|
|
|
|
const sent = `send after transient AUTH rejection ${Date.now()}`;
|
|
await page.getByTestId("message-input").fill(sent);
|
|
await page.getByTestId("send-message").click();
|
|
await expect(page.getByTestId("message-timeline")).toContainText(sent);
|
|
});
|
|
|
|
test("auth-required CLOSED restores the active live subscription", async ({
|
|
page,
|
|
}) => {
|
|
await page.goto("/");
|
|
await page.getByTestId("channel-general").click();
|
|
await expect(page.getByTestId("chat-title")).toHaveText("general");
|
|
|
|
await closeLiveSubscriptions(page, "auth-required: not authenticated");
|
|
|
|
const recovered = `live after auth-required CLOSED ${Date.now()}`;
|
|
await expect
|
|
.poll(
|
|
async () => {
|
|
await emitMockMessages(page, [
|
|
{ content: recovered, createdAt: Math.floor(Date.now() / 1_000) },
|
|
]);
|
|
return page
|
|
.getByTestId("message-timeline")
|
|
.evaluate(
|
|
(element, content) => (element.textContent ?? "").includes(content),
|
|
recovered,
|
|
);
|
|
},
|
|
{ intervals: [1_100], timeout: 5_000 },
|
|
)
|
|
.toBe(true);
|
|
});
|
|
|
|
test("reconnect backfills more missed channel messages than the live subscription limit", async ({
|
|
page,
|
|
}) => {
|
|
await page.goto("/");
|
|
await page.getByTestId("channel-general").click();
|
|
await expect(page.getByTestId("chat-title")).toHaveText("general");
|
|
|
|
const baseCreatedAt = Math.floor(Date.now() / 1_000) - 300;
|
|
const seenBeforeDisconnect = "reconnect e2e seen before disconnect";
|
|
await emitMockMessages(page, [
|
|
{ content: seenBeforeDisconnect, createdAt: baseCreatedAt },
|
|
]);
|
|
await expect(page.getByTestId("message-timeline")).toContainText(
|
|
seenBeforeDisconnect,
|
|
);
|
|
|
|
await disconnectMockWebsockets(page);
|
|
|
|
const missedMessages = Array.from({ length: 260 }, (_, index) => ({
|
|
content: `reconnect e2e missed ${String(index + 1).padStart(3, "0")}`,
|
|
createdAt: baseCreatedAt + index + 1,
|
|
}));
|
|
await emitMockMessages(page, missedMessages);
|
|
|
|
// The newest backfilled message renders at the bottom once the reconnect
|
|
// catch-up settles.
|
|
const timeline = page.getByTestId("message-timeline");
|
|
await expect(timeline).toContainText("reconnect e2e missed 260", {
|
|
timeout: 15_000,
|
|
});
|
|
|
|
// The virtualized timeline windows the oldest backfilled rows out of the DOM
|
|
// while the user sits at the bottom. Walk upward with real wheel input: the
|
|
// older-history sentinel arms on a genuine leave→enter transition, and each
|
|
// prepend restores the visible anchor away from the top. Reaching "missed
|
|
// 001" proves reconnect backfilled past the live-subscription limit.
|
|
await timeline.hover();
|
|
await expect
|
|
.poll(
|
|
async () => {
|
|
// Compact same-author rows can leave the restored viewport inside the
|
|
// sentinel band. Move down first so the next upward gesture creates the
|
|
// leave→enter transition that arms another bounded page.
|
|
await page.mouse.wheel(0, 1200);
|
|
await page.waitForTimeout(40);
|
|
await page.mouse.wheel(0, -6000);
|
|
return timeline.evaluate((element) =>
|
|
(element.textContent ?? "").includes("reconnect e2e missed 001"),
|
|
);
|
|
},
|
|
{ intervals: [100, 150, 250], timeout: 15_000 },
|
|
)
|
|
.toBe(true);
|
|
});
|