Files
buzz/desktop/tests/e2e/thread-head-stale-edit.spec.ts
Salman Mohammed eb8e97cfe7 fix(desktop): show edited head content in thread panel (#6887)
## Why

Fixes #6886. In the channel timeline an edited message shows its newest
body, but opening its thread panel showed the stale pre-edit body until
the async thread-aux backfill landed (and permanently if it raced or
missed).

## Root cause

`desktop/src/features/messages/lib/independentThreadPanel.ts` built
`events = [head, ...replyEvents]`, discarding the head's aux overlay
events (edits `kind:40003`, deletions, reactions) already present in the
channel window. `formatTimelineMessages` only applies an edit when the
edit event sits in the same array as its target, so the thread head
relied solely on `withThreadAux` to re-supply it.

## What

- Carry the head's non-content aux events (via
`!isTimelineContentEvent`) from the channel window into the thread
head's event array, deduped against `replyEvents` by id.
- Thread head is now byte-identical to the main timeline the instant the
thread opens, independent of the async aux fetch.
- Restricted to non-content kinds so reply content rows never leak in.

## Tests

- Unit regression `independentThreadPanel.test.mjs` (6 cases): red
pre-fix (`"two PRs"`), green post-fix (`"these PRs"`).
- e2e `thread-head-stale-edit.spec.ts`: delays thread-replies aux to
force the channel-window-vs-thread-aux divergence, then opens the thread
via the reply button and asserts the panel head shows the edited body.
Verified red pre-fix (stale `message-thread-panel` head), green
post-fix.
- Full desktop unit suite: **5562 pass / 0 fail**. `biome` + `tsc
--noEmit` clean.

Origin: Buzz channel `fix-reply-thread-old-view-bug`
(#464b982f-fc05-42c8-918f-409e1f05a1df).

---------

Signed-off-by: Leo <5faf251baee50ee6bcde338aef6acdd70bb3e60115664c2cd490d94a55dfc488@buzz.block.builderlab.xyz>
Co-authored-by: Leo <5faf251baee50ee6bcde338aef6acdd70bb3e60115664c2cd490d94a55dfc488@buzz.block.builderlab.xyz>
2026-08-26 20:57:19 +00:00

170 lines
6.3 KiB
TypeScript

import { expect, test } from "@playwright/test";
import { installMockBridge, TEST_IDENTITIES } from "../helpers/bridge";
import { waitForAnimations } from "../helpers/animations";
// Regression coverage for the reported bug: the top-level timeline shows the
// EDITED message body, but opening its thread panel shows the STALE, un-edited
// original. The thread head is taken from the channel window (which carries the
// edit) but its overlay was previously dropped, relying solely on the async
// thread-reply aux backfill — so before that fetch lands the head renders stale.
//
// We reproduce the divergence deterministically by *gating* the thread-replies
// fetch (`deferThreadReplies`): the channel window already holds the edit, but
// the thread-aux response is held open — it provably cannot land until the test
// releases it. This is stronger than a timed delay: a 4s timer self-heals
// inside Playwright's auto-retry window, so on the buggy code the delayed aux
// backfill could arrive mid-assertion and false-green. A held gate cannot.
const CHANNEL = "general";
const SHOT = "test-results/thread-head-stale-edit";
type MockMessageWindow = Window & {
__BUZZ_E2E_EMIT_MOCK_MESSAGE__?: (input: {
channelName: string;
content: string;
parentEventId?: string | null;
pubkey?: string;
kind?: number;
extraTags?: string[][];
}) => { id: string; created_at: number; pubkey: string } | undefined;
};
async function waitForMockLiveSubscription(
page: import("@playwright/test").Page,
channelName: string,
) {
await expect
.poll(() =>
page.evaluate(
(ch) =>
(
window as Window & {
__BUZZ_E2E_HAS_MOCK_LIVE_SUBSCRIPTION__?: (input: {
channelName: string;
}) => boolean;
}
).__BUZZ_E2E_HAS_MOCK_LIVE_SUBSCRIPTION__?.({ channelName: ch }) ??
false,
channelName,
),
)
.toBe(true);
}
test("thread head reflects the channel-window edit even before thread aux loads", async ({
page,
}) => {
// Hold the thread-replies (and its aux edit backfill) response open so it
// provably cannot land until we release it after asserting the head.
await installMockBridge(page, { deferThreadReplies: true });
await page.goto("/");
await page.getByTestId(`channel-${CHANNEL}`).click();
await expect(page.getByTestId("chat-title")).toHaveText(CHANNEL);
await waitForMockLiveSubscription(page, CHANNEL);
const ORIGINAL = "can i get a review on these two PRs? (pr 6706, pr 6701)";
const EDITED = "can i get a review on these PRs? (pr 6706, pr 6701, pr 6503)";
// 1. Post a top-level message, then edit it — both land in the channel window.
// The message MUST be authored by the active identity (tyler): an edit is
// only overlaid onto a message when the edit's signer matches the target's
// author (formatTimelineMessages authorization), and the mock `edit_message`
// command signs with the active identity.
const root = await page.evaluate(
({ channelName, content, pubkey }) =>
(window as MockMessageWindow).__BUZZ_E2E_EMIT_MOCK_MESSAGE__?.({
channelName,
content,
pubkey,
}) ?? null,
{
channelName: CHANNEL,
content: ORIGINAL,
pubkey: TEST_IDENTITIES.tyler.pubkey,
},
);
expect(root?.id).toBeTruthy();
const rootId = root?.id;
if (!rootId) throw new Error("mock message emit did not return an id");
await page.evaluate(
({ channelName, rootId, content, pubkey, editKind }) =>
(window as MockMessageWindow).__BUZZ_E2E_EMIT_MOCK_MESSAGE__?.({
channelName,
content,
pubkey,
kind: editKind,
// kind:40003 edit event targeting the root — the same shape the relay
// delivers into the channel-window subscription.
extraTags: [["e", rootId]],
}),
{
channelName: CHANNEL,
rootId,
content: EDITED,
pubkey: TEST_IDENTITIES.tyler.pubkey,
editKind: 40003,
},
);
// 2. The main timeline row shows the edited body (baseline: edit is applied
// in the channel window).
const timelineRow = page.locator(
`[data-testid="message-row"][data-message-id="${rootId}"]`,
);
await expect(timelineRow.getByTestId("message-body")).toContainText(
"these PRs?",
);
await expect(timelineRow.getByTestId("message-body")).not.toContainText(
"these two PRs?",
);
// 3. Open the thread via the reply action (the flow in the bug report).
const replyButton = page.getByTestId(`reply-message-${rootId}`);
await replyButton.click({ force: true });
const threadPanel = page.getByTestId("message-thread-panel");
await expect(threadPanel).toBeVisible();
// 4. The thread head must show the EDITED body immediately — while the
// thread-aux backfill is still gated (provably not yet delivered). Pre-fix
// this rendered the stale original ("these two PRs?"), and because the gate
// is held (not merely delayed) no backfill can arrive to heal it.
const headBody = threadPanel
.locator(`[data-testid="message-row"][data-message-id="${rootId}"]`)
.getByTestId("message-body");
await expect(headBody).toContainText("these PRs?");
await expect(headBody).not.toContainText("these two PRs?");
// The thread-aux fetch must actually be held: this proves the edited head
// above came purely from the channel-window overlay, not from a backfill that
// slipped in. (Guards against the fetch never having been dispatched.)
await expect
.poll(() =>
page.evaluate(
() =>
(
window as Window & {
__BUZZ_E2E_THREAD_REPLIES_PENDING__?: () => number;
}
).__BUZZ_E2E_THREAD_REPLIES_PENDING__?.() ?? 0,
),
)
.toBeGreaterThan(0);
await waitForAnimations(page);
await page.screenshot({ path: `${SHOT}/thread-head-edited.png` });
// 5. Release the gate; the held thread-aux backfill now lands and the head
// stays edited (dedup against relay-provided aux, no regression).
await page.evaluate(() =>
(
window as Window & {
__BUZZ_E2E_RELEASE_THREAD_REPLIES__?: () => number;
}
).__BUZZ_E2E_RELEASE_THREAD_REPLIES__?.(),
);
await expect(headBody).toContainText("these PRs?");
await expect(headBody).not.toContainText("these two PRs?");
});