Files
buzz/desktop/tests/e2e/file-attachment.spec.ts
Matt Toohey 428f0dcd22 test(desktop): settle composer edits and press hover-only row actions by keyboard (#8682)
## 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>
2026-10-30 09:59:11 +11:00

875 lines
30 KiB
TypeScript

import { expect, test } from "@playwright/test";
import type { Locator, Page } from "@playwright/test";
import { waitForAnimations } from "../helpers/animations";
import { installMockBridge } from "../helpers/bridge";
import { openGeneral, pressRowAction } from "../helpers/channelHeadReady";
import { expectCornerRadiusPx, expectSmoothCorners } from "../helpers/css";
async function openMoreActionsMenu(page: Page, messageId: string) {
const row = page.locator(`[data-message-id="${messageId}"]`);
await pressRowAction(row.getByTestId(`more-actions-${messageId}`));
await expect(page.locator('[role="menuitem"]').first()).toBeVisible({
timeout: 5_000,
});
}
// Leave the bottom of the timeline with real wheel input. A programmatic
// scrollTop keeps the timeline's bottom intent armed, so the next content
// resize (the upload card mounting) pins it back down and unmounts the pill.
async function scrollTimelineUp(page: Page, timeline: Locator) {
await timeline.hover();
await page.mouse.wheel(0, -2_000);
}
// Exercises the generic file-attachment UI contract end-to-end through the
// mock Tauri bridge: paperclip upload → composer chip → send → FileCard in the
// timeline. This guards the frontend wiring (the riskiest, previously
// untested path). It does NOT prove the real relay store/serve round-trip —
// that lives in the Rust media + relay tests.
test.beforeEach(async ({ page }) => {
await installMockBridge(page, {
deferredComposerUploads: true,
uploadDescriptors: [
{
url: `https://mock.relay/media/${"a".repeat(64)}.pdf`,
sha256: "a".repeat(64),
size: 12345,
type: "application/pdf",
uploaded: Math.floor(Date.now() / 1000),
filename: "quarterly-report.pdf",
},
],
});
});
async function chooseQuarterlyReport(page: Page) {
const [chooser] = await Promise.all([
page.waitForEvent("filechooser"),
page.getByRole("button", { name: "Attach file" }).click(),
]);
await chooser.setFiles({
buffer: Buffer.from("quarterly report"),
mimeType: "application/pdf",
name: "quarterly-report.pdf",
});
}
async function chooseLargeVideo(page: Page) {
const [chooser] = await Promise.all([
page.waitForEvent("filechooser"),
page.getByRole("button", { name: "Attach file" }).click(),
]);
await chooser.setFiles({
buffer: Buffer.alloc(16 * 1024 * 1024, 1),
mimeType: "video/mp4",
name: "large-video.mp4",
});
}
async function choosePhoto(page: Page) {
const [chooser] = await Promise.all([
page.waitForEvent("filechooser"),
page.getByRole("button", { name: "Attach file" }).click(),
]);
await chooser.setFiles({
buffer: Buffer.from("photo"),
mimeType: "image/png",
name: "photo.png",
});
}
const PHOTO_FILE = {
buffer: Buffer.from("photo"),
mimeType: "image/png",
name: "photo.png",
};
async function uploadCommandCount(page: Page) {
return page.evaluate(
() =>
(
(window as Window & { __BUZZ_E2E_COMMANDS__?: string[] })
.__BUZZ_E2E_COMMANDS__ ?? []
).filter((command) => command === "upload_media_bytes_raw").length,
);
}
test("picker survives cancel, same-file retry, and multiple selection", async ({
page,
}) => {
await page.goto("/");
await page.getByTestId("channel-general").click();
const attach = page.getByRole("button", { name: "Attach file" });
// Model cancel/no selection, then immediately reopen. The composer must
// reuse its one mounted input rather than creating competing detached ones.
const [canceledChooser] = await Promise.all([
page.waitForEvent("filechooser"),
attach.click(),
]);
await canceledChooser.setFiles([]);
await choosePhoto(page);
await expect.poll(() => uploadCommandCount(page)).toBe(1);
// Reset-before-open is load-bearing: without it browsers suppress `change`
// when the same path remains selected.
await choosePhoto(page);
await expect.poll(() => uploadCommandCount(page)).toBe(2);
const [multipleChooser] = await Promise.all([
page.waitForEvent("filechooser"),
attach.click(),
]);
await multipleChooser.setFiles([
PHOTO_FILE,
{ ...PHOTO_FILE, buffer: Buffer.from("second photo"), name: "other.png" },
]);
await expect.poll(() => uploadCommandCount(page)).toBe(4);
});
test("photos upload before Send without a queued spoiler control", async ({
page,
}) => {
await page.goto("/");
await page.evaluate(() => {
const e2e = (
window as Window & {
__BUZZ_E2E__?: { mock?: { uploadDelayMs?: number } };
}
).__BUZZ_E2E__;
if (e2e?.mock) e2e.mock.uploadDelayMs = 1_000;
});
await page.getByTestId("channel-general").click();
await choosePhoto(page);
await expect(page.getByTestId("upload-progress")).toBeVisible();
await expect(page.getByTestId("composer-queued-video-spoiler")).toHaveCount(
0,
);
// Photos upload immediately, so they are in neither `pendingImeta` nor the
// queued list until the upload lands: Send stays blocked so the message
// cannot publish without the attachment.
await expect(page.getByTestId("send-message")).toBeDisabled();
await expect(page.getByTestId("upload-progress")).toHaveCount(0, {
timeout: 5_000,
});
await expect(page.getByTestId("composer-upload-progress")).toHaveCount(0);
await expect(page.getByTestId("send-message")).toBeEnabled();
await expect
.poll(() =>
page.evaluate(
() =>
(window as Window & { __BUZZ_E2E_COMMANDS__?: string[] })
.__BUZZ_E2E_COMMANDS__ ?? [],
),
)
.toContain("upload_media_bytes_raw");
});
test("opening edit during an immediate photo upload preserves the draft", async ({
page,
}) => {
await page.goto("/");
// Open the menu only after history and its head refresh settle, so the row
// is not replaced under the keyboard focus.
await openGeneral(page);
await expect(page.getByTestId("chat-title")).toHaveText("general");
// Hold the upload at the IPC boundary until the edit assertion completes.
// A fixed delay races menu interaction and CI scheduling, so it can test
// an already-completed upload instead of the in-flight draft guard.
await page.evaluate(() => {
const w = window as Window & {
__RELEASE_PHOTO_UPLOAD__?: () => void;
__TAURI_INTERNALS__: {
invoke: (
command: string,
payload: unknown,
options: unknown,
) => Promise<unknown>;
};
};
const original = w.__TAURI_INTERNALS__.invoke.bind(w.__TAURI_INTERNALS__);
const gate = new Promise<void>((resolve) => {
w.__RELEASE_PHOTO_UPLOAD__ = resolve;
});
w.__TAURI_INTERNALS__.invoke = async (command, payload, options) => {
if (command === "upload_media_bytes_raw") await gate;
return original(command, payload, options);
};
});
await choosePhoto(page);
await expect(page.getByTestId("upload-progress")).toBeVisible();
await openMoreActionsMenu(page, "mock-general-welcome");
await page.getByTestId("edit-message-mock-general-welcome").click();
// Edit is dispatched by onCloseAutoFocus after Radix unmounts the menu,
// not by the click itself. Keep the upload held through that handoff.
await expect(page.getByRole("menu")).toHaveCount(0);
// Edit entry is rejected while the compacted draft cannot represent the
// reserved upload slot. The upload remains current and lands in the draft.
await expect(page.getByTestId("edit-target")).toHaveCount(0);
await expect(page.getByTestId("upload-progress")).toBeVisible();
await page.evaluate(() => {
(
window as Window & { __RELEASE_PHOTO_UPLOAD__: () => void }
).__RELEASE_PHOTO_UPLOAD__();
});
await expect(page.getByTestId("upload-progress")).toHaveCount(0, {
timeout: 5_000,
});
await expect(page.getByTestId("message-composer")).toContainText(
"quarterly-report.pdf",
);
// Once settled, the same edit action enters edit mode normally.
await openMoreActionsMenu(page, "mock-general-welcome");
await page.getByTestId("edit-message-mock-general-welcome").click();
await expect(page.getByTestId("edit-target")).toBeVisible();
});
test("upload a file and see a FileCard in the timeline", async ({ page }) => {
await page.goto("/");
await page.getByTestId("channel-general").click();
await expect(page.getByTestId("chat-title")).toHaveText("general");
// Non-video files keep the established immediate-upload behavior.
await chooseQuarterlyReport(page);
// The composer shows a chip with the original filename.
await expect(page.getByTestId("message-composer")).toContainText(
"quarterly-report.pdf",
);
// Send the (attachment-only) message.
await page.getByTestId("send-message").click();
await expect(page.getByText("Sending")).toHaveCount(0);
// A FileCard renders in the timeline: a button carrying the filename. It
// downloads via the native `download_file` command (HTTP inside the app's
// tunnel + save dialog), NOT a plain `<a download>` link — a bare link
// escapes the webview to the OS browser and hits a corporate CDN page.
const card = page.getByTestId("file-card").last();
await expect(card).toBeVisible();
await expectCornerRadiusPx(card, 16);
await expectSmoothCorners(card);
await expect(card).toContainText("quarterly-report.pdf");
await card.click();
await expect
.poll(() =>
page.evaluate(
() =>
(window as Window & { __BUZZ_E2E_COMMANDS__?: string[] })
.__BUZZ_E2E_COMMANDS__ ?? [],
),
)
.toContain("download_file");
});
test("sends immediately and keeps upload progress across channels", async ({
page,
}) => {
await page.goto("/");
await page.evaluate(() => {
const e2e = (
window as Window & {
__BUZZ_E2E__?: { mock?: { uploadDelayMs?: number } };
}
).__BUZZ_E2E__;
if (e2e?.mock) e2e.mock.uploadDelayMs = 1_000;
});
await page.getByTestId("channel-general").click();
await chooseLargeVideo(page);
await expect(page.getByTestId("composer-upload-progress")).toHaveCount(0);
await expect(page.getByTestId("composer-video-spoiler")).toHaveCount(0);
const queuedSpoiler = page.getByTestId("composer-queued-video-spoiler");
// Revealed on hover rather than removed from the DOM: the control stays
// focusable so keyboard users can reach it, but is transparent and
// click-through until the thumbnail is hovered or focused.
await expect(queuedSpoiler).toHaveCSS("opacity", "0");
await expect(queuedSpoiler).toHaveCSS("pointer-events", "none");
await page.getByTestId("composer-queued-media-attachment").hover();
await expect(queuedSpoiler).toBeVisible();
await expect(queuedSpoiler).toHaveCSS("opacity", "1");
await page.getByTestId("send-message").click();
await expect(page.getByTestId("message-composer")).not.toContainText(
"large-video.mp4",
);
await expect(page.getByTestId("composer-upload-progress")).toBeVisible();
await page.getByTestId("channel-random").click();
await expect(page.getByTestId("chat-title")).toHaveText("random");
await expect(page.getByTestId("composer-upload-progress")).toBeVisible();
await expect(page.getByTestId("composer-upload-progress")).toHaveCount(0, {
timeout: 5_000,
});
await page.getByTestId("channel-general").click();
await expect(page.getByTestId("file-card").last()).toContainText(
"quarterly-report.pdf",
);
});
test("shows upload feedback before transferring a large file", async ({
page,
}) => {
await page.goto("/");
await page.evaluate(() => {
const e2e = (
window as Window & {
__BUZZ_E2E__?: { mock?: { uploadDelayMs?: number } };
}
).__BUZZ_E2E__;
if (e2e?.mock) e2e.mock.uploadDelayMs = 5_000;
});
await page.getByTestId("channel-general").click();
await chooseLargeVideo(page);
const progress = page.getByTestId("composer-upload-progress");
await Promise.all([
page.getByTestId("send-message").click(),
expect(progress).toBeVisible({ timeout: 800 }),
]);
await expect(progress).toHaveAttribute("aria-label", "Preparing");
await expect(page.getByTestId("composer-upload-spinner")).toBeVisible();
await expect(page.getByTestId("composer-upload-percentage")).toHaveCount(0);
await expect
.poll(() =>
page.evaluate(
() =>
(
window as Window & {
__BUZZ_E2E_COMMAND_PAYLOADS__?: Array<{
command: string;
payload: { rawByteLength?: number } | null;
}>;
}
).__BUZZ_E2E_COMMAND_PAYLOADS__ ?? [],
),
)
.toContainEqual({
command: "upload_media_bytes_raw",
payload: { rawByteLength: 16 * 1024 * 1024 },
});
const uploadId = "background-media-upload-0-0";
await page.evaluate(async (id) => {
await window.__BUZZ_E2E_EMIT_MEDIA_UPLOAD_PHASE__?.({
id,
phase: "processing-video",
});
}, uploadId);
await expect(progress).toHaveAttribute("aria-label", "Processing");
await waitForAnimations(page);
const processingPhaseBox = await page
.getByTestId("composer-upload-phase")
.boundingBox();
const processingStatusBox = await page
.getByTestId("composer-upload-status")
.boundingBox();
expect(processingPhaseBox).not.toBeNull();
expect(processingStatusBox).not.toBeNull();
expect(
(processingStatusBox?.x ?? 0) -
((processingPhaseBox?.x ?? 0) + (processingPhaseBox?.width ?? 0)),
).toBeGreaterThanOrEqual(3);
await expect(page.getByTestId("composer-upload-spinner")).toBeVisible();
await expect(page.getByTestId("composer-upload-percentage")).toHaveCount(0);
await page.evaluate(async (id) => {
await window.__BUZZ_E2E_EMIT_MEDIA_UPLOAD_PHASE__?.({
id,
phase: "uploading",
});
await window.__BUZZ_E2E_EMIT_MEDIA_UPLOAD_PROGRESS__?.({
id,
sent: 42,
total: 100,
});
}, uploadId);
await expect(progress).toHaveAttribute("aria-label", "Uploading 42%");
await waitForAnimations(page);
await expect(page.getByTestId("composer-upload-spinner")).toHaveCount(0);
await expect(page.getByTestId("composer-upload-percentage")).toHaveText(
"42%",
);
await page.getByTestId("composer-upload-cancel").click();
});
test("canceling a background upload prevents the message from publishing", async ({
page,
}) => {
await page.goto("/");
await page.evaluate(() => {
const e2e = (
window as Window & {
__BUZZ_E2E__?: { mock?: { uploadDelayMs?: number } };
}
).__BUZZ_E2E__;
if (e2e?.mock) e2e.mock.uploadDelayMs = 1_000;
});
await page.getByTestId("channel-general").click();
await chooseLargeVideo(page);
await page.getByTestId("send-message").click();
await page.getByTestId("composer-upload-cancel").click();
await expect(page.getByTestId("composer-upload-progress")).toHaveCount(0);
await page.waitForTimeout(1_100);
await expect(page.getByTestId("file-card")).toHaveCount(0);
});
test("upload progress floats above the dock and lifts Jump to latest", async ({
page,
}) => {
await page.goto("/");
await page.evaluate(() => {
const e2e = (
window as Window & {
__BUZZ_E2E__?: { mock?: { uploadDelayMs?: number } };
}
).__BUZZ_E2E__;
if (e2e?.mock) e2e.mock.uploadDelayMs = 2_000;
});
await page.getByTestId("channel-deep-history").click();
const timeline = page.getByTestId("message-timeline");
await expect(timeline.locator("[data-message-id]").first()).toBeVisible();
await scrollTimelineUp(page, timeline);
const jumpToLatest = page.getByTestId("message-scroll-to-latest");
await expect(jumpToLatest).toBeVisible();
const restingBox = await jumpToLatest.boundingBox();
await chooseLargeVideo(page);
await page.getByTestId("send-message").click();
const uploadMotion = page.getByTestId("composer-upload-progress-motion");
await expect(uploadMotion).toBeVisible();
await scrollTimelineUp(page, timeline);
await expect(jumpToLatest).toBeVisible();
// The upload card slides in and the pill lifts above it, both animated.
// Poll the settled layout instead of reading it after a fixed 250 ms.
expect(restingBox).not.toBeNull();
await expect
.poll(async () => {
const [uploadBox, dockBackdropBox, liftedBox] = await Promise.all([
uploadMotion.boundingBox(),
page.getByTestId("composer-dock-backdrop").boundingBox(),
jumpToLatest.boundingBox(),
]);
if (!uploadBox || !dockBackdropBox || !liftedBox || !restingBox) {
return "a box is missing";
}
return {
uploadAboveDockBackdrop:
dockBackdropBox.y + 1 >= uploadBox.y + uploadBox.height,
pillAboveUpload: liftedBox.y + liftedBox.height <= uploadBox.y,
pillLifted: liftedBox.y < restingBox.y - 10,
};
})
.toEqual({
uploadAboveDockBackdrop: true,
pillAboveUpload: true,
pillLifted: true,
});
await page.getByTestId("composer-upload-cancel").click();
});
test("dropping a file on the channel column attaches it to the composer", async ({
page,
}) => {
await page.goto("/");
await page.getByTestId("channel-general").click();
await expect(page.getByTestId("chat-title")).toHaveText("general");
const dataTransfer = await page.evaluateHandle(() => {
const transfer = new DataTransfer();
transfer.items.add(
new File(["quarterly report"], "quarterly-report.pdf", {
type: "application/pdf",
}),
);
return transfer;
});
const dropZone = page.getByTestId("channel-drop-zone");
await dropZone.dispatchEvent("dragenter", { dataTransfer });
const overlay = dropZone.getByTestId("drop-zone-overlay");
const label = dropZone.getByTestId("drop-zone-label");
await expect(overlay).toBeVisible();
await expect(label).toContainText("Drop files to upload");
const [dropZoneBox, overlayBox, overlayStyles, stacking] = await Promise.all([
dropZone.boundingBox(),
overlay.boundingBox(),
page.evaluate(() => {
const overlayElement = document.querySelector<HTMLElement>(
'[data-testid="drop-zone-overlay"]',
);
const contentSurface = document.querySelector<HTMLElement>(
"[data-buzz-content-surface]",
);
if (!(overlayElement && contentSurface)) return null;
const overlayStyle = getComputedStyle(overlayElement);
return {
backdropFilter: overlayStyle.backdropFilter,
containerRadius: getComputedStyle(contentSurface).borderRadius,
overlayRadius: overlayStyle.borderRadius,
};
}),
page.evaluate(() => {
const overlayElement = document.querySelector<HTMLElement>(
'[data-testid="drop-zone-overlay"]',
);
const composerOverlayElement = document.querySelector<HTMLElement>(
'[data-testid="channel-composer-overlay"]',
);
if (!(overlayElement && composerOverlayElement)) return null;
return {
composer: Number.parseInt(
getComputedStyle(composerOverlayElement).zIndex,
10,
),
dropZone: Number.parseInt(getComputedStyle(overlayElement).zIndex, 10),
};
}),
]);
expect(overlayBox).toEqual(dropZoneBox);
expect(overlayStyles).not.toBeNull();
expect(overlayStyles?.overlayRadius).toBe(overlayStyles?.containerRadius);
expect(overlayStyles?.backdropFilter).toContain("blur");
expect(stacking).not.toBeNull();
expect(stacking?.dropZone).toBeGreaterThan(stacking?.composer ?? 0);
await dropZone.dispatchEvent("drop", { dataTransfer });
await expect(page.getByTestId("message-composer")).toContainText(
"quarterly-report.pdf",
);
});
for (const theme of ["buzz", "buzz-dark", "github-light", "github-dark"]) {
test(`drop prompt has accessible text contrast in ${theme}`, async ({
page,
}) => {
await page.goto("/");
await page.evaluate((selectedTheme) => {
window.localStorage.setItem("buzz-theme", selectedTheme);
}, theme);
await page.reload();
await page.getByTestId("channel-general").click();
const dataTransfer = await page.evaluateHandle(() => {
const transfer = new DataTransfer();
transfer.items.add(
new File(["contrast check"], "contrast-check.txt", {
type: "text/plain",
}),
);
return transfer;
});
const dropZone = page.getByTestId("channel-drop-zone");
await dropZone.dispatchEvent("dragenter", { dataTransfer });
const contrastRatio = await dropZone
.getByTestId("drop-zone-label")
.evaluate((element) => {
const parseRgb = (value: string) =>
(value.match(/[\d.]+/g) ?? []).slice(0, 3).map(Number);
const luminance = (color: number[]) =>
color
.map((channel) => {
const value = channel / 255;
return value <= 0.04045
? value / 12.92
: ((value + 0.055) / 1.055) ** 2.4;
})
.reduce(
(sum, channel, index) =>
sum + channel * [0.2126, 0.7152, 0.0722][index],
0,
);
const style = getComputedStyle(element);
const foreground = luminance(parseRgb(style.color));
const background = luminance(parseRgb(style.backgroundColor));
return (
(Math.max(foreground, background) + 0.05) /
(Math.min(foreground, background) + 0.05)
);
});
expect(contrastRatio).toBeGreaterThanOrEqual(4.5);
});
}
test("forum posts emit a FileCard for generic attachments, not a broken image", async ({
page,
}) => {
// Regression guard for the ForumComposer bug: it used to hand-build content
// as `![image](url)` for every non-video attachment (and omit the `filename`
// imeta tag), so a PDF posted in a forum rendered as a broken inline image
// and lost its label. The fix routes forum/notes posts through the same
// `buildOutgoingMessage` builder as chat. This test would fail (no FileCard)
// if ForumComposer ever drifts back to hand-building media markdown.
await page.goto("/");
// "watercooler" is a seeded forum the mock identity is a member of.
await page.getByTestId("channel-watercooler").click();
// Open the new-post composer ("Start a new post...").
await page.getByRole("button", { name: "Start a new post..." }).click();
// Paperclip → mocked pick_and_upload_media returns the PDF descriptor.
await page.getByRole("button", { name: "Attach file" }).click();
// Submit the (attachment-only) forum post.
await page.getByTestId("send-message").click();
// The post renders through the shared Markdown component as a FileCard —
// a button carrying the filename that downloads via the native
// `download_file` command — NOT an inline image and NOT a bare link.
const card = page.getByTestId("file-card");
await expect(card).toBeVisible();
await expect(card).toContainText("quarterly-report.pdf");
await card.click();
await expect
.poll(() =>
page.evaluate(
() =>
(window as Window & { __BUZZ_E2E_COMMANDS__?: string[] })
.__BUZZ_E2E_COMMANDS__ ?? [],
),
)
.toContain("download_file");
});
test("a queued attachment can be removed without a mouse", async ({ page }) => {
// Regression: the queued remove badge is revealed on hover, but hiding it
// with `display: none` made it unfocusable, leaving keyboard-only users no
// way to drop a queued video before sending.
await page.goto("/");
await page.getByTestId("channel-general").click();
await chooseLargeVideo(page);
const queued = page.getByTestId("composer-queued-media-attachment");
await expect(queued).toBeVisible();
const remove = queued.getByRole("button", { name: "Remove attachment" });
// Focusable while transparent — this is what `display: none` prevented.
await remove.focus();
await expect(remove).toBeFocused();
// Focus reveals it, so the user can see what they are about to activate.
await expect(remove).toHaveCSS("opacity", "1");
await page.keyboard.press("Enter");
await expect(queued).toHaveCount(0);
await expect(page.getByTestId("message-composer")).not.toContainText(
"large-video.mp4",
);
});
test("an uploaded attachment's remove button is named and keyboard-operable", async ({
page,
}) => {
// Companion to the queued case: these badges are now in the tab order, so
// every icon-only remove button needs an accessible name a screen reader can
// read. Images and non-media files render through different branches, so
// both are checked here.
await installMockBridge(page, {
deferredComposerUploads: true,
uploadDescriptors: [
{
url: `https://mock.relay/media/${"b".repeat(64)}.png`,
sha256: "b".repeat(64),
size: 2048,
type: "image/png",
uploaded: Math.floor(Date.now() / 1000),
dim: "320x200",
filename: "photo.png",
},
],
});
await page.goto("/");
await page.getByTestId("channel-general").click();
const composer = page.getByTestId("message-composer");
const remove = composer.getByRole("button", { name: "Remove attachment" });
// Image attachment (MediaAttachmentItem).
await choosePhoto(page);
await expect(composer.getByTestId("composer-media-attachment")).toBeVisible();
await expect(remove).toHaveCount(1);
await remove.focus();
await expect(remove).toBeFocused();
await expect(remove).toHaveCSS("opacity", "1");
await page.keyboard.press("Enter");
await expect(composer.getByTestId("composer-media-attachment")).toHaveCount(
0,
);
});
test("a non-media attachment's remove button is named and keyboard-operable", async ({
page,
}) => {
// The file-card branch renders its own remove badge, so it needs the same
// accessible name as the image and queued ones.
await page.goto("/");
await page.getByTestId("channel-general").click();
await chooseQuarterlyReport(page);
const composer = page.getByTestId("message-composer");
await expect(composer).toContainText("quarterly-report.pdf");
const remove = composer.getByRole("button", { name: "Remove attachment" });
await expect(remove).toHaveCount(1);
await remove.focus();
await expect(remove).toBeFocused();
await expect(remove).toHaveCSS("opacity", "1");
await page.keyboard.press("Enter");
await expect(composer).not.toContainText("quarterly-report.pdf");
});
// Shared by the two FileCard width-cap tests below. FileCard is an inline-flex
// (shrink-to-fit) box whose min-content width is set by its `truncate`
// (nowrap) filename, so a long name is what drives the card to its cap.
const LONG_ATTACHMENT_FILENAME =
"Running onboarding-flow first run with a temporary identity and wiped communities.txt";
// 24rem at the default 16px root — the 24rem half of FileCard's
// `max-w-[min(24rem,100%)]` cap.
const FILE_CARD_CAP_PX = 384;
async function seedLongNamedAttachment(page: Page) {
const sha256 = "b".repeat(64);
const url = `https://mock.relay/media/${sha256}.txt`;
await page.goto("/");
await page.waitForFunction(
() =>
typeof (window as Window & { __BUZZ_E2E_INVOKE_MOCK_COMMAND__?: unknown })
.__BUZZ_E2E_INVOKE_MOCK_COMMAND__ === "function",
null,
{ timeout: 5_000 },
);
await page.evaluate(
async ({ content, mediaTags }) => {
const invoke = (
window as Window & {
__BUZZ_E2E_INVOKE_MOCK_COMMAND__: (
command: string,
payload: Record<string, unknown>,
) => Promise<unknown>;
}
).__BUZZ_E2E_INVOKE_MOCK_COMMAND__;
await invoke("send_channel_message", {
channelId: "9a1657ac-f7aa-5db0-b632-d8bbeb6dfb50",
content,
mediaTags,
});
},
{
content: `[${LONG_ATTACHMENT_FILENAME}](${url})`,
mediaTags: [
[
"imeta",
`url ${url}`,
"m text/plain",
`x ${sha256}`,
"size 13312",
`filename ${LONG_ATTACHMENT_FILENAME}`,
],
],
},
);
await page.getByTestId("channel-general").click();
const timelineRow = page
.getByTestId("message-timeline")
.getByTestId("message-row")
.filter({ has: page.getByTestId("file-card") })
.last();
await expect(timelineRow).toBeVisible();
return timelineRow;
}
async function expectFilenameEllipsised(card: Locator) {
await expect
.poll(() =>
card
.getByText(LONG_ATTACHMENT_FILENAME)
.evaluate((el) => el.scrollWidth > el.clientWidth),
)
.toBe(true);
}
test("a long filename stays inside the narrow thread pane row", async ({
page,
}) => {
// Regression: with only the 24rem cap the card grew past a ~300px
// thread-pane row and clipped the download icon. The cap is
// min(24rem, 100%) so the card shrinks and the filename ellipsises instead.
const timelineRow = await seedLongNamedAttachment(page);
await timelineRow.hover();
await timelineRow.getByRole("button", { name: "Reply" }).click();
// The thread head renders the same message in the default 380px pane.
const threadRow = page
.getByTestId("message-thread-panel")
.getByTestId("message-thread-head")
.getByTestId("message-row");
const card = threadRow.getByTestId("file-card");
await expect(card).toBeVisible();
await expect(card).toContainText("13 KB");
const [rowBox, cardBox, iconBox] = await Promise.all([
threadRow.boundingBox(),
card.boundingBox(),
card.locator("svg").last().boundingBox(),
]);
if (!rowBox || !cardBox || !iconBox) {
throw new Error("Expected the thread row, card, and icon to lay out.");
}
// The pane is narrower than the card's 24rem cap, so the filename must be
// ellipsised — otherwise this test would not be exercising the overflow.
expect(rowBox.width).toBeLessThan(FILE_CARD_CAP_PX);
await expectFilenameEllipsised(card);
expect(cardBox.x + cardBox.width).toBeLessThanOrEqual(
rowBox.x + rowBox.width + 0.5,
);
expect(iconBox.x + iconBox.width).toBeLessThanOrEqual(
rowBox.x + rowBox.width + 0.5,
);
});
test("a long filename keeps the 24rem cap in a wide timeline row", async ({
page,
}) => {
// Regression: `max-w-full` alone (without the 24rem cap) let a long
// filename stretch the card across the whole main-timeline row. The cap
// must still bind where the row is wider than 24rem.
const timelineRow = await seedLongNamedAttachment(page);
const card = timelineRow.getByTestId("file-card");
await expect(card).toBeVisible();
await expect(card).toContainText("13 KB");
const [rowBox, cardBox] = await Promise.all([
timelineRow.boundingBox(),
card.boundingBox(),
]);
if (!rowBox || !cardBox) {
throw new Error("Expected the timeline row and card to lay out.");
}
// The row must be comfortably wider than the cap, otherwise `100%` would
// bind first and this test would not be exercising the 24rem limit.
expect(rowBox.width).toBeGreaterThan(FILE_CARD_CAP_PX + 100);
expect(cardBox.width).toBeLessThanOrEqual(FILE_CARD_CAP_PX + 0.5);
await expectFilenameEllipsised(card);
});