Files
buzz/desktop/tests/e2e/huddle-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

183 lines
6.9 KiB
TypeScript

import { expect, test, type Page } from "@playwright/test";
import { installMockBridge, TEST_IDENTITIES } from "../helpers/bridge";
/**
* Consumer-level guard for the false-empty thread-load bug on the Huddle
* transcript (PR #6447, Carl r3). The transcript flattens summarized reply
* subtrees into the chat timeline via `useThreadRepliesForRoots`, whose combine
* now reports an aggregate `isError`/`refetch`. `useHuddleChannelMessages` used
* to read only `.events` and drop that state, so one failed subtree left the
* partial transcript presenting as complete with no warning or recovery.
*
* The combine unit test proves the hook REPORTS failure; it cannot catch a
* consumer discarding it. This drives the REAL Huddle wiring
* (useHuddleChannelMessages -> ChannelScreen -> ChannelPane) through the mock
* bridge: two summarized roots, fail only ONE subtree's fetch at the IPC
* boundary, assert the surviving root's reply still renders AND the retry alert
* appears, then Retry recovers the failed subtree. Dropping the propagation
* turns this red.
*/
const HUDDLE_CHANNEL_ID = "11111111-1111-4111-8111-111111111111";
const HUDDLE_PARENT_ID = "9a1657ac-f7aa-5db0-b632-d8bbeb6dfb50";
const ROOT_A_CONTENT = "Huddle root A";
const REPLY_A_CONTENT = "Huddle reply A survives";
const ROOT_B_CONTENT = "Huddle root B";
const REPLY_B_CONTENT = "Huddle reply B recovered";
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);
}
// Fail get_thread_replies for exactly one root while the flag names it, letting
// the other subtree and the eventual retry succeed. Wrapping the real
// __TAURI_INTERNALS__.invoke exercises the whole per-root query path with no
// source seam to bypass — the aggregate error and the failed-only refetch are
// production behavior, not a test stub.
async function installPerRootThreadFailureSwitch(page: Page) {
await page.evaluate(() => {
const w = window as typeof window & {
__FAIL_THREAD_ROOT__?: string | null;
__TAURI_INTERNALS__: {
invoke: (
command: string,
payload: unknown,
options: unknown,
) => Promise<unknown>;
};
};
w.__FAIL_THREAD_ROOT__ = null;
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_ROOT__ != null &&
(payload as { rootEventId?: string })?.rootEventId ===
w.__FAIL_THREAD_ROOT__
) {
throw new Error("relay unreachable: request timed out");
}
return original(command, payload, options);
};
});
}
async function setFailingThreadRoot(page: Page, rootId: string | null) {
await page.evaluate((rootId) => {
(
window as typeof window & { __FAIL_THREAD_ROOT__?: string | null }
).__FAIL_THREAD_ROOT__ = rootId;
}, rootId);
}
test.describe("huddle thread load failure", () => {
test("a failed reply subtree shows the retry alert beside surviving rows; Retry recovers", async ({
page,
}) => {
await installMockBridge(page, {
windowLabel: `huddle-${HUDDLE_CHANNEL_ID}`,
huddle: {
parentChannelId: HUDDLE_PARENT_ID,
ephemeralChannelId: HUDDLE_CHANNEL_ID,
members: [
{ pubkey: TEST_IDENTITIES.tyler.pubkey, role: "member" },
{ pubkey: TEST_IDENTITIES.alice.pubkey, role: "bot" },
],
transcriptionEnabled: true,
},
});
await page.goto("/");
await expect(page.getByTestId("huddle-transcript-intro")).toBeVisible();
await installPerRootThreadFailureSwitch(page);
await waitForMockLiveSubscription(page, "huddle");
// Seed two summarized roots. Each threaded reply emits a live thread
// summary (descendant_count > 0), so both roots enter the transcript's
// useThreadRepliesForRoots fan-out and each gets its own subtree fetch. Fail
// root B's fetch BEFORE seeding so its first fan-out fetch reaches the
// terminal error while root A resolves — the aggregate reports failure with
// A's reply already merged, exactly the partial-transcript case.
const rootB = "b".repeat(64);
await setFailingThreadRoot(page, rootB);
const seeded = await page.evaluate(
({
agentPubkey,
rootAContent,
replyAContent,
rootBContent,
replyBContent,
rootBId,
}) => {
const emit = window.__BUZZ_E2E_EMIT_MOCK_MESSAGE__;
if (!emit) throw new Error("Mock message emitter is not installed.");
const rootA = emit({ channelName: "huddle", content: rootAContent });
emit({
channelName: "huddle",
content: replyAContent,
parentEventId: rootA.id,
pubkey: agentPubkey,
});
emit({ channelName: "huddle", content: rootBContent, id: rootBId });
emit({
channelName: "huddle",
content: replyBContent,
parentEventId: rootBId,
pubkey: agentPubkey,
});
return { rootA: rootA.id };
},
{
agentPubkey: TEST_IDENTITIES.alice.pubkey,
rootAContent: ROOT_A_CONTENT,
replyAContent: REPLY_A_CONTENT,
rootBContent: ROOT_B_CONTENT,
replyBContent: REPLY_B_CONTENT,
rootBId: rootB,
},
);
expect(seeded.rootA).toBeTruthy();
// The load-bearing assertion: root A's reply still renders (non-destructive
// — a failed subtree does not blank the surviving rows) AND root B's failed
// fan-out surfaces the retry alert. That alert is rendered ONLY by the
// Huddle transcript's `huddleThreadRepliesError` propagation; without it the
// partial transcript would present as complete. (Reply rows themselves flow
// through the live channel window, so their presence is not the signal — the
// alert is.)
await expect(
page.getByTestId("message-row").filter({ hasText: REPLY_A_CONTENT }),
).toBeVisible({ timeout: 15_000 });
await expect(
page.getByTestId("message-thread-replies-error"),
).toContainText("Couldn't load replies", { timeout: 15_000 });
// Retry with fetches succeeding: the failed-only refetch recovers root B's
// subtree, the aggregate error clears, and the alert is dismissed while both
// replies stay visible.
await setFailingThreadRoot(page, null);
await page.getByTestId("message-thread-replies-retry").click();
await expect(page.getByTestId("message-thread-replies-error")).toHaveCount(
0,
);
await expect(
page.getByTestId("message-row").filter({ hasText: REPLY_B_CONTENT }),
).toBeVisible();
await expect(
page.getByTestId("message-row").filter({ hasText: REPLY_A_CONTENT }),
).toBeVisible();
});
});