Files
buzz/desktop/tests/e2e/thread-load-failure.spec.ts
Will Pfleger f6e6617a9d fix(desktop): bound thread /query and surface load errors, not false-empty (#6447)
Long threads in the desktop app sometimes never load (stuck on a
skeleton until you close and reopen the panel), and a failed load
silently renders as "No replies in this branch yet" — presenting a
broken fetch as an authoritative empty thread with no way to recover.
This fixes both, the two IMPORTANT findings from the thread-load
investigation.

## Defect 1 — unbounded `/query` request

The shared `reqwest::Client` in `relay.rs` sets no timeout, and neither
`/query` request builder set a per-request `.timeout(...)`. A stalled or
half-open connection (headers or body never arrive) leaves the request
pending forever, so a thread-history load hangs on the skeleton
indefinitely.

Fix: a 30s per-request deadline on both `/query` builders, funnelled
through one `send_query_request` helper so the timeout can never be
applied to one builder and dropped from the other. Scoped per-request
rather than client-level because the same client also serves STT/TTS
model downloads, builderlab auth, and the media proxy — a client-level
timeout would cut those off. The deadline sits above the 25s WS
`HISTORY_TIMEOUT_MS` so a slow-but-live relay isn't cut off before the
WebSocket path would be. A timeout surfaces through
`classify_request_error` as the stable `"relay unreachable: request
timed out"` string.

`send()` resolves as soon as response headers arrive, so a relay that
returns headers and then stalls the body trips the deadline during body
consumption, not at `send()` — and that consumption happens on two
paths: `parse_json_response` for 2xx, and `relay_error_message` for a
non-success status (500/429/…). Both paths route their body-consumption
error through one shared `classify_body_timeout` helper so they can't
drift: a stalled body surfaces the stable `"relay unreachable: request
timed out"` string on either path rather than the malformed-response
bucket (2xx) or a bare `"relay returned 500"` status label (non-2xx). A
genuinely non-stalled error still keeps its status classification.

## Defect 2 — terminal error painted as empty

`ChannelScreen` consumed only `isPending`/`data` from the thread-replies
query. Once React Query exhausted its one retry, `isPending` was false
and the zero-length data fell through `selectDeferredListRenderState` to
the `"empty"` state — indistinguishable from a genuinely empty branch,
with no retry affordance.

Fix: plumb `isError` + `refetch` through `ChannelScreen` → `ChannelPane`
→ `MessageThreadPanel`. A pure `selectThreadRepliesSurface` helper
decides the paint in strict precedence — the load-bearing invariant is
that a terminal error **never** resolves to `"empty"`, and cached
replies stay visible non-destructively under a later error (the error
card only surfaces when there is nothing to show). The panel renders an
explicit "Couldn't load replies" + Retry card (testids
`message-thread-replies-error` / `message-thread-replies-retry`).

`ProjectConversationPanel` is a second producer of the same shared panel
and used to hard-code `threadRepliesPending={false}` with no
error/retry, so a failed load in a Projects conversation still painted
the false-empty. It now propagates the same
`isPending`/`isError`/`refetch` from its `useThreadReplies` query.

The multi-root `useThreadRepliesForRoots` hook (the Huddle transcript
and Projects-agent conversation surfaces) had the same gap in its
`useQueries` `combine`: it returned only `{ events, isPending }`, so a
failed reply subtree contributed zero rows and vanished. The combine is
now a pure, unit-testable `combineThreadRepliesResults` that exposes
aggregate `isError`/`error` plus a `refetch` that re-runs only the
failed subtrees. Both multi-root consumers render the shared "Couldn't
load replies" + Retry card when a subtree fails: the Projects-agent
conversation after its transcript, and the Huddle transcript as a
non-destructive banner above the timeline. `useHuddleChannelMessages`
used to read only `.events` and discard the aggregate state, so one
summarized root failing left the flattened transcript presenting as
complete; it now propagates `threadRepliesError`/`onRetryThreadReplies`
through `ChannelScreen` into `ChannelPane`, where successful rows stay
visible and `onRetry` re-runs only the failed subtrees.

The error card carries `role="alert"` so its asynchronous appearance is
announced to assistive tech — without a live region a screen-reader user
parked in the composer never learns the load failed or that Retry became
available.

## Tests

- `stalled_query_request_times_out_with_classified_error` — a loopback
server that never responds; asserts the stable classified timeout
string.
- `stalled_response_body_times_out_with_classified_error` — a loopback
server that writes valid 2xx JSON headers then stalls the body past the
deadline; asserts the classified timeout string, not the malformed
bucket.
- `stalled_error_response_body_times_out_with_classified_error` — a
loopback that writes `500` headers promising a body it never sends;
asserts the classified timeout string rather than the `500` status
label.
- `non_stalled_error_response_yields_status_message` — a promptly-served
`500` still surfaces `"relay returned 500 Internal Server Error"`,
pinning that timeout preservation is scoped to actual timeouts.
- `selectThreadRepliesSurface` — pending→skeleton, terminal error→error
(never empty), page-2 failure never empty, cached rows stay visible
under error, successful-empty→empty, retry-success→list,
streaming→pending, and huddle-transcript collapse.
- `MessageThreadReplyState` mounted test — terminal error renders the
error card (asserting `role="alert"`), never the empty card.
- `combineThreadRepliesResults` — multi-root aggregation/order, a failed
subtree surfaces the aggregate error and never drops rows, aggregate
pending, refetch re-runs only failed queries, all-success yields no
error.
- `thread-load-failure.spec.ts` (smoke E2E) — binds the real
channel-thread panel wiring: forces a terminal `get_thread_replies`
failure at the IPC boundary, asserts the error card renders (never the
false-empty) and Retry recovers.
- `project-conversation-load-failure.spec.ts` (smoke E2E) — the same
guard for the Projects conversation producer, driven through the
Projects Channels-tab row.
- `huddle-thread-load-failure.spec.ts` (smoke E2E) — the consumer-level
guard the combine unit test can't provide: drives the real Huddle wiring
(`useHuddleChannelMessages` → `ChannelScreen` → `ChannelPane`) with two
summarized roots, fails one subtree's fetch at the IPC boundary, asserts
the surviving root's reply stays visible while the retry alert surfaces,
then Retry recovers the failed subtree and clears the alert.

## Structure

To stay under the desktop file-size ratchet, `relay.rs`'s inline test
module moved to `relay/tests.rs`, and two pure pieces were extracted
from the panel: the empty/error reply cards (`MessageThreadReplyState`)
and the per-row branch-highlight derivation
(`selectThreadRowHighlight`).

---------

Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
Co-authored-by: Duncan <dcfd242e557282d7a1e2cf2e6877522682f1e5c6156dc92ca7d90eaedd3b0f95@buzz.block.builderlab.xyz>
2026-08-24 16:13:03 -04:00

162 lines
6.6 KiB
TypeScript

import { expect, test, type Page } from "@playwright/test";
import { TEST_IDENTITIES, installMockBridge } from "../helpers/bridge";
/**
* End-to-end guard for the false-empty thread-load bug (PR #6447). A terminal
* thread-replies fetch failure must paint the error/Retry card and NEVER the
* "No replies in this branch yet" empty card — the two look similar but a failed
* load presented as an authoritative empty is the user-visible defect.
*
* Unit tests cover `ThreadReplyRegion` in isolation, but they cannot mount the
* full panel (Tiptap/React-Query), so they never observe the production
* panel→region handoff. This spec drives the REAL panel wiring through the mock
* bridge, so any regression at that seam (e.g. a static `isError={false}` at the
* call site) ships a visible false-empty and turns this case red.
*/
// Fail every get_thread_replies fetch at the IPC boundary while the flag is on,
// then let the real mock handler answer once it is cleared. Wrapping
// __TAURI_INTERNALS__.invoke (installed by the mock bridge) exercises the whole
// thread-load path — query hook, retry, panel, region — exactly as production
// does, with no source seam to bypass. A boolean gate (rather than a failure
// countdown) is deterministic: stray thread-reply prefetches during channel
// open can't drain it, so the panel's own load reliably reaches the terminal
// error state, and clearing the flag makes the Retry fetch reliably succeed.
async function installThreadFailureSwitch(page: Page) {
await page.evaluate(() => {
const w = window as typeof window & {
__FAIL_THREAD_REPLIES__?: boolean;
__TAURI_INTERNALS__: {
invoke: (
command: string,
payload: unknown,
options: unknown,
) => Promise<unknown>;
};
};
w.__FAIL_THREAD_REPLIES__ = false;
const original = w.__TAURI_INTERNALS__.invoke.bind(w.__TAURI_INTERNALS__);
w.__TAURI_INTERNALS__.invoke = async (command, payload, options) => {
if (command === "get_thread_replies" && w.__FAIL_THREAD_REPLIES__) {
throw new Error("relay unreachable: request timed out");
}
return original(command, payload, options);
};
});
}
async function setThreadRepliesFailing(page: Page, failing: boolean) {
await page.evaluate((failing) => {
(
window as typeof window & { __FAIL_THREAD_REPLIES__?: boolean }
).__FAIL_THREAD_REPLIES__ = failing;
}, failing);
}
// Open the welcome thread deterministically: prefer its summary row, but fall
// back to hovering the root message and clicking Reply. The summary row depends
// on the channel-window query having materialized the seeded reply, which can
// lag; the root Reply affordance opens the same thread panel without that race,
// so the panel is reliably open before the error-card assertions run.
async function openWelcomeThread(page: Page) {
const summary = page.locator(
'[data-testid="message-thread-summary"][data-thread-head-id="mock-general-welcome"]',
);
if (await summary.count()) {
await summary.first().click();
} else {
const root = page.locator(
'[data-testid="message-row"][data-message-id="mock-general-welcome"]',
);
await root.hover();
await root.getByRole("button", { name: "Reply" }).click();
}
await expect(page.getByTestId("message-thread-panel")).toBeVisible();
}
test.describe("thread load failure", () => {
test("terminal fetch failure shows error card, never false-empty; Retry recovers", async ({
page,
}) => {
await installMockBridge(page);
await page.goto("/");
// Seed a reply into the mock store BEFORE opening general, i.e. before its
// live subscription exists. The reply lands in the channel window (so the
// "1 reply" thread summary renders) but is never live-pushed into the
// thread-replies cache — so the thread opens with an empty reply cache and
// the failed get_thread_replies has nothing to fall back to. If it were
// emitted while general was open, the live handler would seed the thread
// cache and the panel would render the list branch, masking the error card.
//
// Wait for the emitter to be installed first: emitting before the app has
// booted is a silent no-op (the helper is undefined), which would leave the
// store empty and the recovery assertion with nothing to render.
await expect
.poll(() =>
page.evaluate(
() =>
typeof (
window as typeof window & {
__BUZZ_E2E_EMIT_MOCK_MESSAGE__?: unknown;
}
).__BUZZ_E2E_EMIT_MOCK_MESSAGE__ === "function",
),
)
.toBe(true);
await page.evaluate((pubkey) => {
(
window as typeof window & {
__BUZZ_E2E_EMIT_MOCK_MESSAGE__?: (input: {
channelName: string;
content: string;
parentEventId?: string;
pubkey?: string;
createdAt?: number;
}) => unknown;
}
).__BUZZ_E2E_EMIT_MOCK_MESSAGE__?.({
channelName: "general",
content: "First reply to welcome",
parentEventId: "mock-general-welcome",
pubkey,
createdAt: Math.floor(Date.now() / 1000) - 10,
});
}, TEST_IDENTITIES.alice.pubkey);
await page.getByTestId("channel-general").click();
await expect(page.getByTestId("chat-title")).toHaveText("general");
await installThreadFailureSwitch(page);
// Fail every thread-replies fetch, then open the thread: the panel query and
// its retry both fail, driving the terminal error state.
await setThreadRepliesFailing(page, true);
await openWelcomeThread(page);
// The load-bearing assertion: a terminal failure paints the error/Retry
// card and NEVER the false-empty "No replies in this branch yet" state.
await expect(
page.getByTestId("message-thread-replies-error"),
).toContainText("Couldn't load replies", { timeout: 15_000 });
await expect(page.getByTestId("message-thread-replies-retry")).toHaveText(
"Retry",
);
await expect(page.getByText("No replies in this branch yet")).toHaveCount(
0,
);
// Retry with fetches succeeding again: the reply loads and renders — the
// error card is gone and no false-empty appears.
await setThreadRepliesFailing(page, false);
await page.getByTestId("message-thread-replies-retry").click();
await expect(page.getByTestId("message-thread-replies-error")).toHaveCount(
0,
);
await expect(page.getByText("First reply to welcome")).toBeVisible();
await expect(page.getByText("No replies in this branch yet")).toHaveCount(
0,
);
});
});