mirror of
https://github.com/block/buzz.git
synced 2026-11-05 00:41:28 +00:00
## Summary Test-side fixes for the two largest desktop E2E flake clusters in the 21–28 Oct 2026 CI audit: - **Composer `fill()` races the app's deferred focus-to-end** (§1). Text was appended to the old draft, or a clear did nothing. - **Clicks on the hover-only message action bar while the timeline re-pins** (§3/§8/§18). Reply and more-actions clicks timed out or were intercepted. Two shared helpers replace the per-spec workarounds, and §9 rides along because its spec now imports them. The PR changes test code only. It is one of seven PRs split out of the `flakey-tests` branch (siblings below). ### Related issue None found. #8559, #8622 and #8613 touch these specs or helpers, but had no coverage for these clusters. Draft #8661 edits `empty-edit-delete.spec.ts`'s `submitEmptyEdit` and `beforeEach`, the same lines as this PR. ### New helpers - `tests/helpers/composer.ts`: - `clearComposer` waits for the app's focus, then repeats select-all + Delete through the editor keymap until the editor is empty (250 ms per try, 5 s overall). - `replaceComposerText` clears, then fills. On an empty document, a late collapse to the end is a no-op. - `tests/helpers/channelHeadReady.ts`: - `GENERAL_CHANNEL_ID`. - `openGeneral` clicks `#general`, then calls `waitForMockChannelHeadReady`. - `pressRowAction(button)` does focus + Enter. The action bar also shows on focus-within, and more-actions is a Radix trigger that opens on Enter. ### Flaky tests fixed | Test | Failing runs | Cause | Change | |---|---|---|---| | §1 `inbox-edit.spec.ts:525` | 39641311877, 39821769576, 40333705511, 40348069929, 40388246283 | **Append:** entering edit mode, or closing the non-member mention dialog, focuses the end of the draft in deferred steps: RAF, a nested RAF, then ProseMirror's 20 ms selection resync. `fill()` selects and inserts in separate protocol steps, so a step that lands in between collapses the selection and the text is appended. **Clear does nothing:** `fill("")` presses Delete before ProseMirror handles `selectionchange`, so the editor blocks it. | `clearComposer` / `replaceComposerText` | | §1 `inbox-edit.spec.ts:560` | 39538411386, 39821769576 | Same as above. | Same | | §1 `mention-recipients.spec.ts:591` (both variants) | 39799090750, 40374286094, 39641311877 | Same | Same | | §1 `mention-recipients.spec.ts:691` (both variants) | 40348069929, 40556019361, 39601763602 | Same | Same | | §1 `messaging.spec.ts:3799` | 39827234118, 40445481983 | Same, plus a bare `#general` click and a `force: true` Reply | Same, plus `openGeneral` and `pressRowAction` | | §1 `messaging.spec.ts:4022` | 40389392495 | Same | Same | | §1 `messaging.spec.ts:4289` | 39822883395, 40557808800, 40566841699 | Same | Same | | §1 `messaging.spec.ts:4368` | 40574058918 | Same | Same | | §1 `remote-owned-mentions.spec.ts:463` (both variants) | 39618692657, 40374286094, 40382726903, 40389392495, 40582387515 | Same, after the Invite dialog closes | Same | | §3 `messaging.spec.ts` :3531, :3647, :4122, :4208, :4450 (root and reply) and :4633 (keyboard and button) | :3531 40583830861, 40595483966. :3647 40566841699, 40597484657. :4122 39641311877, 40368315739, 40368363357. :4208 39525092486, 39796569342, 40367495044. :4450 39796569342, 40348069929, 40373404267. :4633 39530486096, 40348069929, 40368315739, 40582387515, 40368363357 | The action bar is `pointer-events-none` until its row is hovered. Opening `#general` refreshes the head after the live subscription lands, and the timeline re-pins, so the row moves out from under the pointer. `force: true` only hid this. | `openGeneral`, then `pressRowAction` on Reply or more-actions. Six more messaging tests that used hover + `click({ force: true })` get the same change, and each first waits for its last row to contain the sent root. | | §18 `thread-follow.spec.ts:350` | 39529963092, 40348069929, 40583830861 (all 3), 40595483966; PR 39541263507 (all 3) | Same; the composer overlay intercepts the more-actions click. | Same | | §8 `thread-head-stale-edit.spec.ts:55` | 39525092486, 39618692657, 39796569342, 39822883395, 40011714848, 40281448810, 40368363357, 40389392495, 40445481983, 40574058918; PR 40031299575 (all 3) | Same (used `force: true`) | Same | | `file-attachment.spec.ts:165` | 1–2 CI failures (audit long tail) | A more-actions click on a moving timeline | Same | | `empty-edit-delete.spec.ts` (`openMoreActionsMenu`, `submitEmptyEdit`, the non-empty edit, `beforeEach`) | Covered under §11 in the desktop-races sibling | The same hover and `fill` races | The head-ready wait before emitting, `pressRowAction`, `clearComposer` and `replaceComposerText`. The autocomplete table rows keep their back-to-back keys, which is what they test. | | §9 `file-attachment.spec.ts:419` "upload progress floats above the dock and lifts Jump to latest" | 10 of 50 main jobs, including 40583830861, 40597484657, 40608383847 | A programmatic `scrollTop` keeps the bottom intent armed. The upload card resizes the content, the timeline re-pins, and the pill unmounts under `boundingBox()`. The fixed 250 ms wait read the card mid-slide. | `scrollTimelineUp()` leaves the bottom with a real wheel, and one `expect.poll` checks all three layout relations. | Local before/after counts are from the original `flakey-tests` commits (e286a6ae0, 816c7c35e, c66f411f3, 67185df6a), running `main`'s copy and the changed copy side by side under forcings: - **§1 at 4× CPU throttle:** 130/130 after; `main`'s copies ranged from 5/10 to 10/10. - **§1 with `selectionchange` delayed, a stretched resync, or a gap inside `fill()`:** `main`'s copies 0/5 to 4/5 and 0/20; after 65/65 and 20/20. - **§3 at 4× throttle:** `main` had 3 Reply-click timeouts at :4208 and 3 at :4633 (keyboard); after 120/120. - **`file-attachment:165`:** 0/3 before, 3/3 after. - **§9 unforced:** 4/5 before, 5/5 after. These were measured with the helper's first version, which waited two frames plus the 20 ms resync. The final state-based helper ran unforced only: 157/157 and then 196/196 over the touched specs. Its retry was not checked under a forced late collapse. :3531, :3647, :4122, :4450 root, :4633 button, `thread-head-stale-edit:55` and the six force-click tests were not force-reproduced; they get the fix that matches their CI signatures and reproduced siblings. Dropped: - `mention-recipients.spec.ts:933` (40608383847) fails on `flavors.handled`, which is unrelated. - `persistent-agent-audience.spec.ts:828` (`@Mor@Morg`) did not reproduce in 79 forced runs. ### Verification This branch was pushed without local re-verification, by the user's choice: local test runs were overloading the machine and failing for that reason. **Hosted CI is the check.** It was pushed with `git push --no-verify`, because the pre-push hook runs typecheck and the unit suites. #8613's flake-check lane will also repeat the changed specs. - Every file except `empty-edit-delete.spec.ts` is byte-identical to its final version on `flakey-tests` (67185df6a). That file carries only this PR's hunks; see "Merge order". - Checked for this PR: `git diff --check`, biome on the partial spec (no changes, the same as on `main`), and a conflict-free merge with all six siblings onto `main`. ### Readiness Test-only change. AGENTS.md's readiness steps (agent review, then human confirmation) are still outstanding, so `buzz-review-completed` is not set. ### Merge order No required order. `desktop/tests/e2e/empty-edit-delete.spec.ts` is also edited by **flakey-tests-desktop-races**, which adds the autocomplete table after line 158. This PR changes imports, helpers, `beforeEach` and line 143. The hunks don't touch, so the two merge cleanly in either order, as checked with `git merge-tree`. Draft #8661 edits the same `submitEmptyEdit`/`beforeEach` lines; whichever lands second will need a rebase. ### Sibling PRs Split from `flakey-tests` (one PR per area): #8676 (PostgreSQL lease + lane), #8677 (relay E2E helper + Windows shell), #8678 (mobile), #8679 (Tauri apt cache + Windows Rust cap), #8680 (desktop product races), #8681 (desktop E2E state gates). 🤖 Generated with [Claude Code](https://claude.com/claude-code) Signed-off-by: Matt Toohey <contact@matttoohey.com> Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
730 lines
24 KiB
TypeScript
730 lines
24 KiB
TypeScript
import { expect, test } from "@playwright/test";
|
|
|
|
import { waitForAnimations } from "../helpers/animations";
|
|
import { installMockBridge, TEST_IDENTITIES } from "../helpers/bridge";
|
|
import { clearComposer } from "../helpers/composer";
|
|
|
|
const GENERAL_CHANNEL_ID = "9a1657ac-f7aa-5db0-b632-d8bbeb6dfb50";
|
|
const CURRENT_PUBKEY = "deadbeef".repeat(8);
|
|
const OWN_MESSAGE_ID = "d1".repeat(32);
|
|
const FOREIGN_MESSAGE_ID = "e2".repeat(32);
|
|
const EMPTY_DELETE_ROOT_ID = "a1".repeat(32);
|
|
const EMPTY_DELETE_REPLY_ID = "b2".repeat(32);
|
|
const EMPTY_DELETE_ROOT_CONTENT = "Inbox empty-edit root message.";
|
|
const EMPTY_DELETE_REPLY_CONTENT =
|
|
"Selected Inbox reply remains after root deletion.";
|
|
const COLD_ROOT_ID = "c3".repeat(32);
|
|
const COLD_SELECTED_REPLY_ID = "f4".repeat(32);
|
|
const COLD_DELETED_EDIT_ID = "0a".repeat(32);
|
|
const COLD_SIBLING_REPLY_ID = "1b".repeat(32);
|
|
const COLD_SIBLING_EDIT_ID = "2c".repeat(32);
|
|
const COLD_ROOT_CONTENT = "Cold Inbox thread root.";
|
|
const COLD_SELECTED_ORIGINAL_CONTENT = "Cold Inbox reply original text.";
|
|
const COLD_SELECTED_RETRACTED_CONTENT = "Cold Inbox reply retracted edit text.";
|
|
const COLD_SIBLING_ORIGINAL_CONTENT = "Cold Inbox sibling reply original text.";
|
|
const COLD_SIBLING_EDITED_CONTENT = "Cold Inbox sibling reply edited text.";
|
|
const ATTACHMENT_URL = `https://mock.relay/media/${"a".repeat(64)}.pdf`;
|
|
const ATTACHMENT_FILENAME = "inbox-edit-proof.pdf";
|
|
const SHOTS = "test-results/inbox-edit";
|
|
|
|
type MockFeedItem = {
|
|
category: "mention";
|
|
channel_id: string;
|
|
channel_name: string;
|
|
content: string;
|
|
created_at: number;
|
|
id: string;
|
|
kind: number;
|
|
pubkey: string;
|
|
tags: string[][];
|
|
};
|
|
|
|
type MockWindow = Window & {
|
|
__BUZZ_E2E_COMMAND_PAYLOADS__?: Array<{
|
|
command: string;
|
|
payload: unknown;
|
|
}>;
|
|
__BUZZ_E2E_EMIT_MOCK_MESSAGE__?: (input: {
|
|
channelName: string;
|
|
content: string;
|
|
parentEventId?: string | null;
|
|
pubkey?: string;
|
|
mentionPubkeys?: string[];
|
|
id?: string;
|
|
kind?: number;
|
|
extraTags?: string[][];
|
|
}) => {
|
|
content: string;
|
|
created_at: number;
|
|
id: string;
|
|
kind: number;
|
|
pubkey: string;
|
|
tags: string[][];
|
|
};
|
|
__BUZZ_E2E_INVALIDATE_CHANNELS__?: () => Promise<void>;
|
|
__BUZZ_E2E_RELEASE_SEND_MESSAGE_LIVE_ECHO__?: () => number;
|
|
__BUZZ_E2E_INVOKE_MOCK_COMMAND__?: (
|
|
command: string,
|
|
payload?: Record<string, unknown>,
|
|
) => Promise<unknown>;
|
|
__BUZZ_E2E_PUSH_MOCK_FEED_ITEM__?: (item: MockFeedItem) => unknown;
|
|
};
|
|
|
|
async function openMoreActions(
|
|
page: import("@playwright/test").Page,
|
|
messageId: string,
|
|
) {
|
|
const row = page.locator(`[data-message-id="${messageId}"]`);
|
|
await row.hover();
|
|
await page.getByTestId(`more-actions-${messageId}`).click();
|
|
await expect(page.locator('[role="menuitem"]').first()).toBeVisible();
|
|
}
|
|
|
|
async function seedEmptyDeleteThread(page: import("@playwright/test").Page) {
|
|
await page.waitForFunction(() => {
|
|
const win = window as MockWindow;
|
|
return (
|
|
typeof win.__BUZZ_E2E_EMIT_MOCK_MESSAGE__ === "function" &&
|
|
typeof win.__BUZZ_E2E_PUSH_MOCK_FEED_ITEM__ === "function"
|
|
);
|
|
});
|
|
await page.evaluate(
|
|
({
|
|
channelId,
|
|
currentPubkey,
|
|
replyContent,
|
|
replyId,
|
|
rootContent,
|
|
rootId,
|
|
}) => {
|
|
const win = window as MockWindow;
|
|
const emit = win.__BUZZ_E2E_EMIT_MOCK_MESSAGE__;
|
|
const push = win.__BUZZ_E2E_PUSH_MOCK_FEED_ITEM__;
|
|
if (!emit || !push)
|
|
throw new Error("Mock bridge helpers are unavailable.");
|
|
const root = emit({
|
|
channelName: "general",
|
|
content: rootContent,
|
|
id: rootId,
|
|
pubkey: currentPubkey,
|
|
});
|
|
const reply = emit({
|
|
channelName: "general",
|
|
content: replyContent,
|
|
id: replyId,
|
|
mentionPubkeys: [currentPubkey],
|
|
parentEventId: root.id,
|
|
pubkey: currentPubkey,
|
|
});
|
|
push({
|
|
category: "mention",
|
|
channel_id: channelId,
|
|
channel_name: "general",
|
|
content: reply.content,
|
|
created_at: reply.created_at,
|
|
id: reply.id,
|
|
kind: reply.kind,
|
|
pubkey: reply.pubkey,
|
|
tags: reply.tags,
|
|
});
|
|
},
|
|
{
|
|
channelId: GENERAL_CHANNEL_ID,
|
|
currentPubkey: CURRENT_PUBKEY,
|
|
replyContent: EMPTY_DELETE_REPLY_CONTENT,
|
|
replyId: EMPTY_DELETE_REPLY_ID,
|
|
rootContent: EMPTY_DELETE_ROOT_CONTENT,
|
|
rootId: EMPTY_DELETE_ROOT_ID,
|
|
},
|
|
);
|
|
await page.getByTestId(`home-inbox-item-${EMPTY_DELETE_REPLY_ID}`).click();
|
|
const detail = page.getByTestId("home-inbox-detail");
|
|
const rootRow = detail.locator(`[data-message-id="${EMPTY_DELETE_ROOT_ID}"]`);
|
|
await expect(rootRow).toContainText(EMPTY_DELETE_ROOT_CONTENT);
|
|
await expect(
|
|
detail.locator(`[data-message-id="${EMPTY_DELETE_REPLY_ID}"]`),
|
|
).toContainText(EMPTY_DELETE_REPLY_CONTENT);
|
|
return { detail, rootRow };
|
|
}
|
|
|
|
async function submitEmptyEdit(
|
|
page: import("@playwright/test").Page,
|
|
detail: import("@playwright/test").Locator,
|
|
messageId: string,
|
|
) {
|
|
await openMoreActions(page, messageId);
|
|
const editAction = page.getByTestId(`edit-message-${messageId}`);
|
|
await expect(editAction).toBeVisible();
|
|
await editAction.click();
|
|
await expect(detail.getByTestId("edit-target")).toBeVisible();
|
|
const input = detail.getByTestId("message-input");
|
|
await expect(input).not.toBeEmpty();
|
|
await clearComposer(input);
|
|
await page.keyboard.press("Enter");
|
|
}
|
|
|
|
function editCommandCount(page: import("@playwright/test").Page) {
|
|
return page.evaluate(
|
|
() =>
|
|
((window as MockWindow).__BUZZ_E2E_COMMAND_PAYLOADS__ ?? []).filter(
|
|
(entry) => entry.command === "edit_message",
|
|
).length,
|
|
);
|
|
}
|
|
|
|
test("editing an immediate attachment reply preserves its media tags", async ({
|
|
page,
|
|
}) => {
|
|
await installMockBridge(page, {
|
|
deferSendMessageLiveEcho: true,
|
|
uploadDescriptors: [
|
|
{
|
|
url: ATTACHMENT_URL,
|
|
sha256: "a".repeat(64),
|
|
size: 12345,
|
|
type: "application/pdf",
|
|
uploaded: Math.floor(Date.now() / 1000),
|
|
filename: ATTACHMENT_FILENAME,
|
|
},
|
|
],
|
|
});
|
|
await page.goto("/");
|
|
await expect(page.getByTestId("home-inbox-list")).toBeVisible();
|
|
await page.waitForFunction(
|
|
() =>
|
|
typeof (window as MockWindow).__BUZZ_E2E_PUSH_MOCK_FEED_ITEM__ ===
|
|
"function",
|
|
);
|
|
|
|
await page.evaluate(
|
|
({ channelId, messageId, pubkey }) => {
|
|
const pushFeedItem = (window as MockWindow)
|
|
.__BUZZ_E2E_PUSH_MOCK_FEED_ITEM__;
|
|
if (!pushFeedItem) {
|
|
throw new Error("Mock feed helper is not installed.");
|
|
}
|
|
|
|
pushFeedItem({
|
|
category: "mention",
|
|
channel_id: channelId,
|
|
channel_name: "general",
|
|
content: "Inbox thread root.",
|
|
created_at: Math.floor(Date.now() / 1_000),
|
|
id: messageId,
|
|
kind: 9,
|
|
pubkey,
|
|
tags: [
|
|
["h", channelId],
|
|
["p", pubkey],
|
|
],
|
|
});
|
|
},
|
|
{
|
|
channelId: GENERAL_CHANNEL_ID,
|
|
messageId: OWN_MESSAGE_ID,
|
|
pubkey: CURRENT_PUBKEY,
|
|
},
|
|
);
|
|
|
|
await page.getByTestId(`home-inbox-item-${OWN_MESSAGE_ID}`).click();
|
|
const detail = page.getByTestId("home-inbox-detail");
|
|
await expect(detail).toContainText("Inbox thread root.");
|
|
|
|
await detail.getByRole("button", { name: "Attach file" }).click();
|
|
await expect(detail.getByTestId("message-composer")).toContainText(
|
|
ATTACHMENT_FILENAME,
|
|
);
|
|
const input = detail.getByTestId("message-input");
|
|
await input.fill("Attachment reply before editing.");
|
|
await detail.getByTestId("send-message").click();
|
|
await expect(detail.getByText("Sending")).toHaveCount(0);
|
|
|
|
const sendPayload = await page.evaluate(() => {
|
|
const payloads = (window as MockWindow).__BUZZ_E2E_COMMAND_PAYLOADS__ ?? [];
|
|
return payloads.findLast(
|
|
(entry) => entry.command === "send_channel_message",
|
|
)?.payload;
|
|
});
|
|
expect(sendPayload).toEqual(
|
|
expect.objectContaining({
|
|
mediaTags: [
|
|
[
|
|
"imeta",
|
|
`url ${ATTACHMENT_URL}`,
|
|
"m application/pdf",
|
|
`x ${"a".repeat(64)}`,
|
|
"size 12345",
|
|
`filename ${ATTACHMENT_FILENAME}`,
|
|
],
|
|
],
|
|
parentEventId: OWN_MESSAGE_ID,
|
|
}),
|
|
);
|
|
|
|
const reply = detail
|
|
.locator('[data-testid="home-inbox-context-message"]')
|
|
.filter({ hasText: "Attachment reply before editing." });
|
|
await expect(reply).toBeVisible();
|
|
await expect(reply.getByTestId("file-card")).toContainText(
|
|
ATTACHMENT_FILENAME,
|
|
);
|
|
const replyId = await reply.getAttribute("data-message-id");
|
|
expect(replyId).not.toBeNull();
|
|
const replyRow = detail.locator(`[data-message-id="${replyId}"]`);
|
|
|
|
await openMoreActions(page, replyId as string);
|
|
await page.getByTestId(`edit-message-${replyId}`).click();
|
|
await expect(detail.getByTestId("edit-target")).toBeVisible();
|
|
await expect(detail.getByTestId("message-composer")).toContainText(
|
|
ATTACHMENT_FILENAME,
|
|
);
|
|
|
|
await input.fill("Attachment reply after editing.");
|
|
await page.keyboard.press("Enter");
|
|
await expect(detail.getByTestId("edit-target")).toBeHidden();
|
|
|
|
const editPayload = await page.evaluate(() => {
|
|
const payloads = (window as MockWindow).__BUZZ_E2E_COMMAND_PAYLOADS__ ?? [];
|
|
return payloads.findLast((entry) => entry.command === "edit_message")
|
|
?.payload;
|
|
});
|
|
expect(editPayload).toEqual(
|
|
expect.objectContaining({
|
|
input: expect.objectContaining({
|
|
eventId: replyId,
|
|
mediaTags: [
|
|
[
|
|
"imeta",
|
|
`url ${ATTACHMENT_URL}`,
|
|
"m application/pdf",
|
|
`x ${"a".repeat(64)}`,
|
|
"size 12345",
|
|
`filename ${ATTACHMENT_FILENAME}`,
|
|
],
|
|
],
|
|
}),
|
|
}),
|
|
);
|
|
|
|
const releasedEchoes = await page.evaluate(() => {
|
|
const release = (window as MockWindow)
|
|
.__BUZZ_E2E_RELEASE_SEND_MESSAGE_LIVE_ECHO__;
|
|
if (!release) {
|
|
throw new Error("Mock send-echo release helper is not installed.");
|
|
}
|
|
return release();
|
|
});
|
|
expect(releasedEchoes).toBe(1);
|
|
|
|
await expect(replyRow).toContainText("Attachment reply after editing.");
|
|
await expect(replyRow.getByTestId("file-card")).toContainText(
|
|
ATTACHMENT_FILENAME,
|
|
);
|
|
});
|
|
|
|
test("Inbox offers Edit and Delete actions only for manageable messages", async ({
|
|
page,
|
|
}) => {
|
|
await installMockBridge(page);
|
|
await page.goto("/");
|
|
await expect(page.getByTestId("home-inbox-list")).toBeVisible();
|
|
await page.waitForFunction(
|
|
() =>
|
|
typeof (window as MockWindow).__BUZZ_E2E_PUSH_MOCK_FEED_ITEM__ ===
|
|
"function",
|
|
);
|
|
|
|
await page.evaluate(
|
|
({
|
|
channelId,
|
|
currentPubkey,
|
|
foreignPubkey,
|
|
foreignMessageId,
|
|
ownMessageId,
|
|
}) => {
|
|
const pushFeedItem = (window as MockWindow)
|
|
.__BUZZ_E2E_PUSH_MOCK_FEED_ITEM__;
|
|
if (!pushFeedItem) {
|
|
throw new Error("Mock feed helper is not installed.");
|
|
}
|
|
|
|
const createdAt = Math.floor(Date.now() / 1_000);
|
|
const messages = [
|
|
{
|
|
content: "My Inbox message before editing.",
|
|
createdAt,
|
|
id: ownMessageId,
|
|
pubkey: currentPubkey,
|
|
},
|
|
{
|
|
content: "Another person's Inbox message.",
|
|
createdAt: createdAt - 1,
|
|
id: foreignMessageId,
|
|
pubkey: foreignPubkey,
|
|
},
|
|
];
|
|
|
|
for (const message of messages) {
|
|
pushFeedItem({
|
|
category: "mention",
|
|
channel_id: channelId,
|
|
channel_name: "general",
|
|
content: message.content,
|
|
created_at: message.createdAt,
|
|
id: message.id,
|
|
kind: 9,
|
|
pubkey: message.pubkey,
|
|
tags: [
|
|
["h", channelId],
|
|
["p", currentPubkey],
|
|
],
|
|
});
|
|
}
|
|
},
|
|
{
|
|
channelId: GENERAL_CHANNEL_ID,
|
|
currentPubkey: CURRENT_PUBKEY,
|
|
foreignMessageId: FOREIGN_MESSAGE_ID,
|
|
foreignPubkey: TEST_IDENTITIES.alice.pubkey,
|
|
ownMessageId: OWN_MESSAGE_ID,
|
|
},
|
|
);
|
|
|
|
await page.getByTestId(`home-inbox-item-${OWN_MESSAGE_ID}`).click();
|
|
const detail = page.getByTestId("home-inbox-detail");
|
|
await expect(detail).toContainText("My Inbox message before editing.");
|
|
|
|
await openMoreActions(page, OWN_MESSAGE_ID);
|
|
await expect(
|
|
page.getByTestId(`edit-message-${OWN_MESSAGE_ID}`),
|
|
).toBeVisible();
|
|
await expect(
|
|
page.getByTestId(`delete-message-${OWN_MESSAGE_ID}`),
|
|
).toBeVisible();
|
|
await waitForAnimations(page);
|
|
await page.screenshot({ path: `${SHOTS}/01-edit-action.png` });
|
|
await page.getByTestId(`edit-message-${OWN_MESSAGE_ID}`).click();
|
|
await expect(detail.getByTestId("edit-target")).toBeVisible();
|
|
|
|
const input = detail.getByTestId("message-input");
|
|
await expect(input).not.toBeEmpty();
|
|
await input.click();
|
|
await page.keyboard.press("ControlOrMeta+A");
|
|
await page.keyboard.type("My Inbox message after editing.");
|
|
await page.keyboard.press("Enter");
|
|
|
|
await expect(detail.getByTestId("edit-target")).toBeHidden();
|
|
await expect(
|
|
detail.locator(`[data-message-id="${OWN_MESSAGE_ID}"]`),
|
|
).toContainText("My Inbox message after editing.");
|
|
await waitForAnimations(page);
|
|
await page.screenshot({ path: `${SHOTS}/02-edited-message.png` });
|
|
|
|
await page.getByTestId(`home-inbox-item-${FOREIGN_MESSAGE_ID}`).click();
|
|
await expect(detail).toContainText("Another person's Inbox message.");
|
|
await openMoreActions(page, FOREIGN_MESSAGE_ID);
|
|
await expect(
|
|
page.getByTestId(`edit-message-${FOREIGN_MESSAGE_ID}`),
|
|
).toHaveCount(0);
|
|
await expect(
|
|
page.getByTestId(`delete-message-${FOREIGN_MESSAGE_ID}`),
|
|
).toHaveCount(0);
|
|
await page.keyboard.press("Escape");
|
|
|
|
await page.evaluate(async (channelId) => {
|
|
const testWindow = window as MockWindow;
|
|
const invoke = testWindow.__BUZZ_E2E_INVOKE_MOCK_COMMAND__;
|
|
const invalidateChannels = testWindow.__BUZZ_E2E_INVALIDATE_CHANNELS__;
|
|
if (!invoke || !invalidateChannels) {
|
|
throw new Error("Mock channel helpers are not installed.");
|
|
}
|
|
|
|
await invoke("archive_channel", { channelId });
|
|
await invalidateChannels();
|
|
}, GENERAL_CHANNEL_ID);
|
|
|
|
await page.getByTestId(`home-inbox-item-${OWN_MESSAGE_ID}`).click();
|
|
await expect(detail).toContainText("My Inbox message after editing.");
|
|
await openMoreActions(page, OWN_MESSAGE_ID);
|
|
await expect(page.getByTestId(`edit-message-${OWN_MESSAGE_ID}`)).toHaveCount(
|
|
0,
|
|
);
|
|
await expect(
|
|
page.getByTestId(`delete-message-${OWN_MESSAGE_ID}`),
|
|
).toHaveCount(0);
|
|
});
|
|
|
|
test("cancelling explicit Inbox deletion preserves the message and selection", async ({
|
|
page,
|
|
}) => {
|
|
await installMockBridge(page);
|
|
await page.goto("/");
|
|
await expect(page.getByTestId("home-inbox-list")).toBeVisible();
|
|
const { detail, rootRow } = await seedEmptyDeleteThread(page);
|
|
const selectedReplyRow = detail.locator(
|
|
`[data-message-id="${EMPTY_DELETE_REPLY_ID}"]`,
|
|
);
|
|
|
|
await openMoreActions(page, EMPTY_DELETE_ROOT_ID);
|
|
await page.getByTestId(`delete-message-${EMPTY_DELETE_ROOT_ID}`).click();
|
|
const dialog = page.getByRole("alertdialog");
|
|
await expect(dialog).toContainText("Delete message?");
|
|
await dialog.getByRole("button", { name: "Cancel" }).click();
|
|
|
|
await expect(dialog).toBeHidden();
|
|
await expect(rootRow).toContainText(EMPTY_DELETE_ROOT_CONTENT);
|
|
await expect(selectedReplyRow).toContainText(EMPTY_DELETE_REPLY_CONTENT);
|
|
await expect(selectedReplyRow).toHaveAttribute(
|
|
"data-testid",
|
|
"home-inbox-selected-message",
|
|
);
|
|
expect(
|
|
await page.evaluate(
|
|
() =>
|
|
((window as MockWindow).__BUZZ_E2E_COMMAND_PAYLOADS__ ?? []).filter(
|
|
(entry) => entry.command === "delete_message",
|
|
).length,
|
|
),
|
|
).toBe(0);
|
|
});
|
|
|
|
test("explicit Inbox deletion targets the chosen non-selected message", async ({
|
|
page,
|
|
}) => {
|
|
await installMockBridge(page);
|
|
await page.goto("/");
|
|
await expect(page.getByTestId("home-inbox-list")).toBeVisible();
|
|
const { detail, rootRow } = await seedEmptyDeleteThread(page);
|
|
const selectedReplyRow = detail.locator(
|
|
`[data-message-id="${EMPTY_DELETE_REPLY_ID}"]`,
|
|
);
|
|
|
|
await openMoreActions(page, EMPTY_DELETE_ROOT_ID);
|
|
await page.getByTestId(`delete-message-${EMPTY_DELETE_ROOT_ID}`).click();
|
|
const dialog = page.getByRole("alertdialog");
|
|
await expect(dialog).toContainText("Delete message?");
|
|
await dialog.getByRole("button", { name: "Delete" }).click();
|
|
|
|
await expect(dialog).toBeHidden();
|
|
await expect(rootRow).toBeHidden();
|
|
await expect(selectedReplyRow).toContainText(EMPTY_DELETE_REPLY_CONTENT);
|
|
await expect(selectedReplyRow).toHaveAttribute(
|
|
"data-testid",
|
|
"home-inbox-selected-message",
|
|
);
|
|
const deletePayload = await page.evaluate(() => {
|
|
const payloads = (window as MockWindow).__BUZZ_E2E_COMMAND_PAYLOADS__ ?? [];
|
|
return payloads.findLast((entry) => entry.command === "delete_message")
|
|
?.payload;
|
|
});
|
|
expect(deletePayload).toEqual(
|
|
expect.objectContaining({ eventId: EMPTY_DELETE_ROOT_ID }),
|
|
);
|
|
});
|
|
|
|
test("empty edit confirms deletion of the edited non-selected Inbox row", async ({
|
|
page,
|
|
}) => {
|
|
await installMockBridge(page);
|
|
await page.goto("/");
|
|
await expect(page.getByTestId("home-inbox-list")).toBeVisible();
|
|
const { detail, rootRow } = await seedEmptyDeleteThread(page);
|
|
const selectedReplyRow = detail.locator(
|
|
`[data-message-id="${EMPTY_DELETE_REPLY_ID}"]`,
|
|
);
|
|
const editsBefore = await editCommandCount(page);
|
|
|
|
await submitEmptyEdit(page, detail, EMPTY_DELETE_ROOT_ID);
|
|
const dialog = page.getByRole("alertdialog");
|
|
await expect(dialog).toContainText("Delete message?");
|
|
await expect(detail.getByTestId("edit-target")).toBeVisible();
|
|
expect(await editCommandCount(page)).toBe(editsBefore);
|
|
|
|
await dialog.getByRole("button", { name: "Delete" }).click();
|
|
await expect(dialog).toBeHidden();
|
|
await expect(detail.getByTestId("edit-target")).toBeHidden();
|
|
await expect(rootRow).toBeHidden();
|
|
await expect(selectedReplyRow).toContainText(EMPTY_DELETE_REPLY_CONTENT);
|
|
expect(await editCommandCount(page)).toBe(editsBefore);
|
|
|
|
const deletePayload = await page.evaluate(() => {
|
|
const payloads = (window as MockWindow).__BUZZ_E2E_COMMAND_PAYLOADS__ ?? [];
|
|
return payloads.findLast((entry) => entry.command === "delete_message")
|
|
?.payload;
|
|
});
|
|
expect(deletePayload).toEqual(
|
|
expect.objectContaining({ eventId: EMPTY_DELETE_ROOT_ID }),
|
|
);
|
|
});
|
|
|
|
test("cancelling an empty Inbox edit deletion preserves content and edit mode", async ({
|
|
page,
|
|
}) => {
|
|
await installMockBridge(page);
|
|
await page.goto("/");
|
|
await expect(page.getByTestId("home-inbox-list")).toBeVisible();
|
|
const { detail, rootRow } = await seedEmptyDeleteThread(page);
|
|
const editsBefore = await editCommandCount(page);
|
|
|
|
await submitEmptyEdit(page, detail, EMPTY_DELETE_ROOT_ID);
|
|
const dialog = page.getByRole("alertdialog");
|
|
await expect(dialog).toContainText("Delete message?");
|
|
await expect(detail.getByTestId("edit-target")).toBeVisible();
|
|
expect(await editCommandCount(page)).toBe(editsBefore);
|
|
|
|
await dialog.getByRole("button", { name: "Cancel" }).click();
|
|
await expect(dialog).toBeHidden();
|
|
await expect(detail.getByTestId("edit-target")).toBeVisible();
|
|
await expect(rootRow).toContainText(EMPTY_DELETE_ROOT_CONTENT);
|
|
await expect(
|
|
detail.locator(`[data-message-id="${EMPTY_DELETE_REPLY_ID}"]`),
|
|
).toContainText(EMPTY_DELETE_REPLY_CONTENT);
|
|
expect(await editCommandCount(page)).toBe(editsBefore);
|
|
expect(
|
|
await page.evaluate(
|
|
() =>
|
|
((window as MockWindow).__BUZZ_E2E_COMMAND_PAYLOADS__ ?? []).filter(
|
|
(entry) => entry.command === "delete_message",
|
|
).length,
|
|
),
|
|
).toBe(0);
|
|
});
|
|
|
|
// Regression test for the cold Inbox hydration closure. A deletion can target
|
|
// an edit event instead of the original message, and `formatTimelineMessages`
|
|
// drops an edit only when the edit's own id is in the deletion set. A one-hop
|
|
// `#e` fetch over the context message ids returns the edit but not the deletion
|
|
// of that edit, so a cold Inbox open re-applies retracted content. The sibling
|
|
// reply is the positive control: its live edit is NOT deleted, so it must still
|
|
// render as edited after the same cold fetch.
|
|
test("cold Inbox open drops an edit that was itself deleted", async ({
|
|
page,
|
|
}) => {
|
|
await installMockBridge(page);
|
|
await page.goto("/");
|
|
await expect(page.getByTestId("home-inbox-list")).toBeVisible();
|
|
await page.waitForFunction(() => {
|
|
const win = window as MockWindow;
|
|
return (
|
|
typeof win.__BUZZ_E2E_EMIT_MOCK_MESSAGE__ === "function" &&
|
|
typeof win.__BUZZ_E2E_PUSH_MOCK_FEED_ITEM__ === "function"
|
|
);
|
|
});
|
|
|
|
await page.evaluate(
|
|
({
|
|
channelId,
|
|
currentPubkey,
|
|
deletedEditId,
|
|
rootContent,
|
|
rootId,
|
|
selectedOriginalContent,
|
|
selectedReplyId,
|
|
selectedRetractedContent,
|
|
siblingEditId,
|
|
siblingEditedContent,
|
|
siblingOriginalContent,
|
|
siblingReplyId,
|
|
}) => {
|
|
const win = window as MockWindow;
|
|
const emit = win.__BUZZ_E2E_EMIT_MOCK_MESSAGE__;
|
|
const push = win.__BUZZ_E2E_PUSH_MOCK_FEED_ITEM__;
|
|
if (!emit || !push) {
|
|
throw new Error("Mock bridge helpers are unavailable.");
|
|
}
|
|
|
|
const root = emit({
|
|
channelName: "general",
|
|
content: rootContent,
|
|
id: rootId,
|
|
pubkey: currentPubkey,
|
|
});
|
|
const selectedReply = emit({
|
|
channelName: "general",
|
|
content: selectedOriginalContent,
|
|
id: selectedReplyId,
|
|
mentionPubkeys: [currentPubkey],
|
|
parentEventId: root.id,
|
|
pubkey: currentPubkey,
|
|
});
|
|
const siblingReply = emit({
|
|
channelName: "general",
|
|
content: siblingOriginalContent,
|
|
id: siblingReplyId,
|
|
parentEventId: root.id,
|
|
pubkey: currentPubkey,
|
|
});
|
|
|
|
// The retracted edit: published, then deleted by a kind:5 that targets
|
|
// the EDIT event, never the reply itself.
|
|
const retractedEdit = emit({
|
|
channelName: "general",
|
|
content: selectedRetractedContent,
|
|
extraTags: [["e", selectedReply.id]],
|
|
id: deletedEditId,
|
|
kind: 40003,
|
|
pubkey: currentPubkey,
|
|
});
|
|
emit({
|
|
channelName: "general",
|
|
content: "",
|
|
extraTags: [["e", retractedEdit.id]],
|
|
kind: 5,
|
|
pubkey: currentPubkey,
|
|
});
|
|
|
|
// Positive control: a live edit with no deletion of its own.
|
|
emit({
|
|
channelName: "general",
|
|
content: siblingEditedContent,
|
|
extraTags: [["e", siblingReply.id]],
|
|
id: siblingEditId,
|
|
kind: 40003,
|
|
pubkey: currentPubkey,
|
|
});
|
|
|
|
push({
|
|
category: "mention",
|
|
channel_id: channelId,
|
|
channel_name: "general",
|
|
content: selectedReply.content,
|
|
created_at: selectedReply.created_at,
|
|
id: selectedReply.id,
|
|
kind: selectedReply.kind,
|
|
pubkey: selectedReply.pubkey,
|
|
tags: selectedReply.tags,
|
|
});
|
|
},
|
|
{
|
|
channelId: GENERAL_CHANNEL_ID,
|
|
currentPubkey: CURRENT_PUBKEY,
|
|
deletedEditId: COLD_DELETED_EDIT_ID,
|
|
rootContent: COLD_ROOT_CONTENT,
|
|
rootId: COLD_ROOT_ID,
|
|
selectedOriginalContent: COLD_SELECTED_ORIGINAL_CONTENT,
|
|
selectedReplyId: COLD_SELECTED_REPLY_ID,
|
|
selectedRetractedContent: COLD_SELECTED_RETRACTED_CONTENT,
|
|
siblingEditId: COLD_SIBLING_EDIT_ID,
|
|
siblingEditedContent: COLD_SIBLING_EDITED_CONTENT,
|
|
siblingOriginalContent: COLD_SIBLING_ORIGINAL_CONTENT,
|
|
siblingReplyId: COLD_SIBLING_REPLY_ID,
|
|
},
|
|
);
|
|
|
|
// First selection of this item: every edit and deletion must come from the
|
|
// hydration fetch, not from an optimistic local write.
|
|
await page.getByTestId(`home-inbox-item-${COLD_SELECTED_REPLY_ID}`).click();
|
|
const detail = page.getByTestId("home-inbox-detail");
|
|
const siblingRow = detail.locator(
|
|
`[data-message-id="${COLD_SIBLING_REPLY_ID}"]`,
|
|
);
|
|
await expect(siblingRow).toContainText(COLD_SIBLING_EDITED_CONTENT);
|
|
|
|
const selectedRow = detail.locator(
|
|
`[data-message-id="${COLD_SELECTED_REPLY_ID}"]`,
|
|
);
|
|
await expect(selectedRow).toContainText(COLD_SELECTED_ORIGINAL_CONTENT);
|
|
await expect(selectedRow).not.toContainText(COLD_SELECTED_RETRACTED_CONTENT);
|
|
await expect(detail).not.toContainText(COLD_SELECTED_RETRACTED_CONTENT);
|
|
});
|