mirror of
https://github.com/block/buzz.git
synced 2026-10-28 21:11:48 +00:00
Prepared by Brain on Wes’s behalf. ## Summary Reduce startup request amplification and let quota-refused background work recover without changing relay quotas or bypassing the existing Send gate. - Coalesce nest regeneration into one worker plus the latest pending generation. Keep the stale-write fence; bound the archive read so an old workspace cannot strand newer work. - Claim pending channel subscriptions before awaiting setup; cancel removed/unmounted entries and preserve still-owned subscriptions across reconnect handoff. - Recheck extended cooldown deadlines before retry dispatch; distinguish an explicit zero-second hint from a missing hint. - Recover native finite/unread queries within their original caller deadline and a three-retry budget, preserving verified unique events, complete filters and terminal errors. - Pace renderer initial/live-CLOSED REQs at one per 250ms: visible channel and explicitly interactive TTS/read-state/observer owners first, then live-only auxiliaries, then background history. Publication and finite queries bypass this queue. Reconnect replay now promotes explicit interactive owners alongside the visible channel with stable registration-order ties; its existing capped batches and cooldown remain intact. **Scope at `867495426`:** 39 source/test files, including 15 production files (+665/-235) and 24 test/fixture files. The diagnostic recorder, HTTP instrumentation, trace launcher and raw captures are deliberately excluded. No profile reset, grouping, quota increase or new global scheduler. ## Before / after evidence These are observations from instrumented, human-operated development Tauri/Vite runs against a production relay, plus explicitly identified controlled regressions—not a packaged-release startup benchmark. | Measurement | Before | After | Evidence boundary | | --- | --- | --- | --- | | Archive snapshot requests (kind 13535) | 290 | 3 | Recorder-only baseline → nest-only intermediate candidate; unequal 67.436s / 39.273s captures and hydration differences | | Archive responses: HTTP 200 / 429 | 277 / 13 | 3 / 0 | Same baseline/nest-only captures; not a claim that all quota pressure disappeared | | Successful archive responses’ advertised content-length sum | 21,208,505 bytes | 229,695 bytes | Header values, not actual transferred bytes | | 154-channel StrictMode replay | 308 REQs | 154 REQs | Controlled real React hook → real session → mocked Tauri transport regression | | Add one channel while 154 setups are pending | 309 REQs | 155 REQs | Same production-boundary regression; ordinary mount remains 154 → 154 | | Broad renderer request burst / spacing | 110 broad REQs within 10.766ms in pre-drain capture | 250.061ms minimum / 252.131ms median between broad REQ writes in final longer capture | Different attended runs; pacing rather than a universal request-rate cap | | Native unread query outcomes | Baseline: 8 EOSE / 146 quota CLOSED out of 154 queries | Final longer run: all 156 observed queries reach EOSE; eight refused queries each recover after two refusals | Different runs/workloads; query completion, not badge rendering | ### Timing metrics | Observation | Measured value | | --- | ---: | | Baseline’s two accepted chat EVENT socket writes → positive OK | 173.340ms / 85.556ms | | Nest-only intermediate candidate’s two accepted writes → positive OK | 277.854ms / 78.775ms | | Final candidate, short run, accepted chat writes → positive OK | 80.636ms / 77.506ms | | Final candidate, longer run, accepted chat writes → positive OK | 120.712ms / 86.895ms | | Last initial EOSE among 163 observed broad channel registrations, longer run | 64.550s after trace start | | Last EOSE among 156 observed native unread queries, longer run | 73.972s after trace start | | Native retry waits after 3s then 0s hints, longer run | 3000.7–3002.8ms, then 2001.5–2002.6ms | | Renderer finite-query retry after a 2s hint, longer run | 2061.365ms | Both Sends in each final run were accepted, with no recorded negative OK. Every quota-refused query in the longer run subsequently completed the same query. Its two Sends happened early (17.878s and 21.655s), not near the end. These small samples do not support a percentage latency improvement. **Tradeoffs and limits** - 296 background subscriptions imply about 74s of paced registration before extra cooldowns/high-priority work. The longer capture observed 163 registrations; it did not independently enumerate the expected membership set or record a queue-empty marker. - This is not a global 4-REQ/s cap: finite queries, native requests and reconnect replay are outside the drain and still share relay quota. Quota refusals recover rather than disappear. - Socket-write → OK starts after composer submission and any publisher gate wait. No click-to-visible, launch-to-usable or badge-paint latency is claimed. - Native EOSE is not proof of command return, classification, React state application or visible unread badges. Existing per-channel kinds, since floors, limits, h-less routing, unread lease/scope guard and eight-query concurrency remain intact. - The attended measurements belong to the instrumented candidate, not a fresh release run of this fixes-only commit. At the initial packaging checkpoint (`1bf3d060`), eighteen of twenty files were byte-identical; the other two removed diagnostic gate calls and isolated the nest test’s temporary paths. The subsequent interactive priority/reconnect and readiness fixes have controlled hook/session/browser evidence, not another attended voice/performance run. ### Evidence provenance (captures retained locally, not uploaded) | Capture | SHA-256 | | --- | --- | | Baseline `STARTUP_20260921T194814Z_98811.jsonl` | `f4f0a89ad9aea91084bd6a7432510488166de4cbc906600d88c26d557f380994` | | Nest-only `STARTUP_20260921T202610Z_28642.jsonl` | `ce517b02a3f717c36d96b36bb8616e8fa80b03b12b356bacfd40475925ea3f3e` | | Final short `STARTUP_20260922T013628Z_13506.jsonl` | `bfea6f3cf80cd7b7e4e676632a3df2f16271fade5bfa0edec72b7bcbbe7839ff` | | Final longer `STARTUP_20260922T014637Z_15846.jsonl` | `f4abece613d108c4c2fe3cb808e633a0486bdc9993ca92323822dc00c3fae6ec` | Final instrumented build fingerprint: `b0b4fc27d793c3e643f4e10289b5afdb4620ee2e13a8b33fa0595dd3417fd5ca`. All observed dropped-record counters in these final captures were zero. Source manifests and offline correlation analyses are retained locally; no raw captures, profile data or agent sessions are part of this PR. ## Validation Current head: `86749542657a60abdfe82eed5cc576d3e162fe5d`, based on `77729abfb692b25a0f4ec4a69add86af2e32c0dd`. Review follow-ups: - `8842dbd8`: explicit interactive TTS admission using the existing main-window owner, independent of companion/timeline selection. Real-hook/session/IPC coverage with 296 cold owners proves initial and CLOSED priority, unchanged filters/cooldown/pacing, background progress and deduplicated speech. - `8cb088106`: reconnect preserves interactive priority even with no visible channel. Stable ordering among foreground ties; real hook/session reconnect regressions cover cooldown, capped batching, all 297 owners, deduplication and unmount cancellation. Independent reviewer mutation restored the prior replay implementation, failed both reconnect rows, then passed the restored candidate. - `68e76d7c`: read-state and observer reuse interactive priority. Stop and ModelPicker wait on the existing observer owner inside send callbacks, after installing result listeners/deadlines. No new owner, timer or scheduler; inherited CLOSED/fallback/error semantics remain, rather than a new strict EOSE guarantee. The real Stop panel browser probe holds observer IPC across the click and checks observer-before-control order and correlated outcome. ModelPicker component ordering is source-reviewed only. - Mock live admission now replays matching stored history with per-filter time/limit constraints, deduplication, untagged auxiliary channel fallback and authoritative explicit h-tags. Kind-specific fixture admission waits and retry-resolving hover actions fix startup-dependent test setup without weakening event/badge assertions. - `48e9c0dad` and `867495426`: steady-state scroll-prepend setup observes live admission, subsequent head dispatch, successful/idle query completion and initial bottom geometry. Phase-2 genuine-growth and ≤32px anchor assertions are unchanged. This isolates steady-state prepending: it **does not fix or certify production early-scroll/head-reset behavior**. Current validation: - Normal push hooks at `867495426`: **6,582/6,582 renderer tests**, zero failed/skipped/cancelled; lint/static checks, typecheck and differential file-size gate passed. Native source is unchanged from the earlier checkpoint; no new native run is claimed for these renderer/test follow-ups. - E2E build passed at `48e9c0dad` plus the exact subsequently committed test-only delta (spec blob `469be2eed868c677ba80b859cc871208000fee60`). The completed affected-smoke run passed **153/153** across agent-control, badge, entity links, messaging, profile status and scroll history; the full scroll file additionally passed twice (**36/36**). No full CI-smoke pass is claimed from this six-file local run. - Pinky independently reviewed production and test follow-ups, including the final scroll spec blob `469be2eed868c677ba80b859cc871208000fee60`, with no blocking finding. Review is source evidence, not an independent test run. - **Not merge-ready:** new-head required CI must complete, exact-range security review requires authorization, and GitHub currently reports REVIEW_REQUIRED; fresh exact-head reviewer approval is still needed. No approval, dismissal or merge performed. No new packaged/native audio, launch-to-usable or badge-paint observation is claimed. Initial fixes-only checkpoint validation (`1bf3d06024d284a63326fdd353baced0842aeb81`): - Pre-commit formatting hooks passed; worktree clean. - Renderer package hooks: 6,570 tests passed, zero failed/skipped/cancelled; lint/static checks, TypeScript and differential file-size gate passed. - Native package hook passed: default and mesh-enabled all-target Clippy, then the complete native workspace test suite. App library: 3,222 passed / 0 failed / 19 ignored; terminal/integration suites passed. The real nest trigger/stalled-body test passed on macOS. - Independent whole-change source review: no blocking findings at this commit; all 20 file hashes matched the committed blobs and review/validation manifests. - Regression coverage includes real hook/session cancellation, StrictMode/membership re-entry, reconnect ownership, extended/zero cooldowns, burst pacing, auxiliary readiness (including EOSE before IPC settles), native socket refusal/retry/deadline/cancellation, unread fetch/classification and real nest trigger/HTTP/file persistence. - Earlier isolated renderer mutations that started readiness before enqueue or removed auxiliary priority each failed the real presence regression; restored controls passed. No native mutation claim. - Nest fixture uses explicit app-data/nest paths instead of assuming Windows known-folder APIs obey HOME/APPDATA. Windows runtime execution is not claimed; CI/build results remain separate. Originating conversation: buzz://message?channel=46ced308-71df-495d-a144-4ebf08334703&id=bc43c3422a3b0b2141268d0fa149b898421c824992b662aed8b73c4eee50404f No approval or merge recommendation is implied by opening this PR. --------- Signed-off-by: Brain <1a02c72794dcd0f07058a353bc3a81f4028b8c77c92c87fce6d5c8b85970a20b@buzz.block.builderlab.xyz> Co-authored-by: Brain <1a02c72794dcd0f07058a353bc3a81f4028b8c77c92c87fce6d5c8b85970a20b@buzz.block.builderlab.xyz>
4870 lines
178 KiB
TypeScript
4870 lines
178 KiB
TypeScript
import { readFileSync } from "node:fs";
|
|
|
|
import { expect, test, type Locator } from "@playwright/test";
|
|
|
|
import { waitForAnimations } from "../helpers/animations";
|
|
import { installMockBridge, TEST_IDENTITIES } from "../helpers/bridge";
|
|
import { expectCornerRadiusPx, expectSmoothCorners } from "../helpers/css";
|
|
import { openSettings } from "../helpers/settings";
|
|
|
|
const LINK_PREVIEW_IMAGE = readFileSync(
|
|
new URL("../fixtures/github-pr-5629-og.png", import.meta.url),
|
|
);
|
|
const LINK_PREVIEW_IMAGE_DATA_URL = `data:image/png;base64,${LINK_PREVIEW_IMAGE.toString("base64")}`;
|
|
|
|
async function waitForReadyComposerSnapshots(
|
|
page: import("@playwright/test").Page,
|
|
count = 1,
|
|
) {
|
|
await expect(page.locator("[data-composer-link-previews]")).toHaveAttribute(
|
|
"data-ready-snapshot-count",
|
|
String(count),
|
|
);
|
|
}
|
|
|
|
// Edit starts only when Radix finishes closing. An enabled reply input is
|
|
// not evidence that edit content/focus (and the navigation guard) are ready.
|
|
async function expectReplyEditReady(threadPanel: Locator, content: string) {
|
|
await expect(threadPanel.getByTestId("edit-target")).toBeVisible();
|
|
const input = threadPanel.getByTestId("message-input");
|
|
await expect(input).toHaveText(content);
|
|
await expect(input).toBeFocused();
|
|
}
|
|
|
|
async function expectThreadReplyUnobscured(row: Locator) {
|
|
await expect
|
|
.poll(async () =>
|
|
row.evaluate((element) => {
|
|
const threadBody = element.closest(
|
|
'[data-testid="message-thread-body"]',
|
|
) as HTMLElement | null;
|
|
const threadPanel = element.closest(
|
|
'[data-testid="message-thread-panel"]',
|
|
) as HTMLElement | null;
|
|
const composer = threadPanel?.querySelector<HTMLElement>(
|
|
'[data-testid="message-input"]',
|
|
);
|
|
if (!threadBody || !composer) return false;
|
|
|
|
const rowRect = element.getBoundingClientRect();
|
|
const bodyRect = threadBody.getBoundingClientRect();
|
|
const composerRect = composer.getBoundingClientRect();
|
|
const visibleBottom = Math.min(bodyRect.bottom, composerRect.top);
|
|
return (
|
|
rowRect.top >= bodyRect.top - 1 && rowRect.bottom <= visibleBottom + 1
|
|
);
|
|
}),
|
|
)
|
|
.toBe(true);
|
|
}
|
|
|
|
async function measureThreadSummaryGeometry(summaryRow: Locator) {
|
|
return summaryRow.evaluate((summaryButton) => {
|
|
const summaryWrapper = summaryButton.parentElement;
|
|
const container = summaryWrapper?.parentElement;
|
|
const messageRow = container?.querySelector<HTMLElement>(
|
|
'[data-testid="message-row"]',
|
|
);
|
|
const messageMarkdown =
|
|
messageRow?.querySelector<HTMLElement>(".message-markdown");
|
|
const messageAuthor = messageRow?.querySelector<HTMLElement>(
|
|
'[data-testid="message-author"]',
|
|
);
|
|
const firstParticipant = summaryButton.querySelector<HTMLElement>(
|
|
'[data-testid="message-thread-summary-participant"]',
|
|
);
|
|
const summarySurface = summaryButton.querySelector<HTMLElement>(
|
|
'[data-testid="message-thread-summary-surface"]',
|
|
);
|
|
const firstAvatar = firstParticipant?.querySelector<HTMLElement>(
|
|
'[data-testid^="message-thread-summary-avatar-"]',
|
|
);
|
|
|
|
if (
|
|
!summaryWrapper ||
|
|
!container ||
|
|
!messageRow ||
|
|
!messageAuthor ||
|
|
!messageMarkdown ||
|
|
!summarySurface ||
|
|
!(firstAvatar instanceof HTMLElement)
|
|
) {
|
|
throw new Error("Expected measurable thread summary geometry.");
|
|
}
|
|
|
|
const containerRect = container.getBoundingClientRect();
|
|
const messageRowRect = messageRow.getBoundingClientRect();
|
|
const messageAuthorRect = messageAuthor.getBoundingClientRect();
|
|
const messageMarkdownRect = messageMarkdown.getBoundingClientRect();
|
|
const summaryButtonRect = summaryButton.getBoundingClientRect();
|
|
const summaryButtonStyle = getComputedStyle(summaryButton);
|
|
const summaryButtonPaddingLeft = Number.parseFloat(
|
|
summaryButtonStyle.paddingLeft,
|
|
);
|
|
const summaryWrapperRect = summaryWrapper.getBoundingClientRect();
|
|
const firstAvatarRect = firstAvatar.getBoundingClientRect();
|
|
const summarySurfaceRect = summarySurface.getBoundingClientRect();
|
|
|
|
return {
|
|
authorLeft: messageAuthorRect.left,
|
|
avatarLeft: firstAvatarRect.left,
|
|
bodyLeft: messageMarkdownRect.left,
|
|
bottomPadding: containerRect.bottom - summaryWrapperRect.bottom,
|
|
messageRowLeft: messageRowRect.left,
|
|
summaryButtonContentLeft:
|
|
summaryButtonRect.left + summaryButtonPaddingLeft,
|
|
summaryButtonLeft: summaryButtonRect.left,
|
|
summaryButtonPaddingLeft,
|
|
summarySurfaceLeft: summarySurfaceRect.left,
|
|
topPadding: messageRowRect.top - containerRect.top,
|
|
};
|
|
});
|
|
}
|
|
|
|
test.beforeEach(async ({ page }, testInfo) => {
|
|
const baseMock = testInfo.title.includes("agent owner label")
|
|
? {
|
|
searchProfiles: [
|
|
{
|
|
pubkey: TEST_IDENTITIES.alice.pubkey,
|
|
displayName: "alice",
|
|
ownerPubkey: TEST_IDENTITIES.bob.pubkey,
|
|
isAgent: true,
|
|
},
|
|
{
|
|
pubkey: TEST_IDENTITIES.bob.pubkey,
|
|
displayName: "bob",
|
|
},
|
|
],
|
|
}
|
|
: testInfo.title.includes("cardless short tweet preview")
|
|
? {
|
|
linkPreviewMetadata: {
|
|
title: "jack (@jack) on X",
|
|
siteName: "X (formerly Twitter)",
|
|
description: "just setting up my twttr",
|
|
imageDataUrl: null,
|
|
imageDomain: null,
|
|
},
|
|
}
|
|
: testInfo.title.includes("cardless tweet preview")
|
|
? {
|
|
linkPreviewMetadata: {
|
|
title: "Buzz (@buzz) on X",
|
|
siteName: "X (formerly Twitter)",
|
|
description:
|
|
"This is a real tweet-style description long enough to wrap across several lines while preserving the message-like treatment. It keeps going with enough distinct words to exceed five rendered lines at the preview width, proving that the overflow-aware control appears only when the content is genuinely clipped rather than relying on a brittle character-count guess.",
|
|
imageDataUrl:
|
|
"data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='1200' height='675'%3E%3Crect width='1200' height='675' fill='%231d9bf0'/%3E%3C/svg%3E",
|
|
imageDomain: "pbs.twimg.com",
|
|
},
|
|
}
|
|
: testInfo.title.includes("fragment link previews")
|
|
? {
|
|
// Metadata is keyed by the canonical, fragment-less URL — the
|
|
// shape a real OpenGraph/HTML fetch resolves against. A resolver
|
|
// that fetches with the raw `#fragment` attached would miss these
|
|
// keys and drop the card, which is exactly the bug under test.
|
|
linkPreviewMetadataByHref: {
|
|
"https://github.com/block/buzz/pull/3767": {
|
|
title: "Buzz pull request 3767",
|
|
siteName: "GitHub",
|
|
description: "Fragment-bearing PR link.",
|
|
imageDataUrl: null,
|
|
imageDomain: null,
|
|
},
|
|
"https://github.com/block/buzz/pull/3867": {
|
|
title: "Buzz pull request 3867",
|
|
siteName: "GitHub",
|
|
description: "Plain PR link.",
|
|
imageDataUrl: null,
|
|
imageDomain: null,
|
|
},
|
|
},
|
|
}
|
|
: testInfo.title.includes("mixed link preview image outcomes")
|
|
? {
|
|
linkPreviewMetadataByHref: {
|
|
"https://github.com/block/buzz/pull/4001": {
|
|
title: "Loaded preview image",
|
|
siteName: "GitHub",
|
|
description: "The image request completed.",
|
|
imageDataUrl:
|
|
"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII=",
|
|
imageDomain: "opengraph.githubassets.com",
|
|
imageFetchState: "image",
|
|
imageRetryAfterMs: null,
|
|
},
|
|
"https://github.com/block/buzz/pull/4002": {
|
|
title: "Rate-limited preview image",
|
|
siteName: "GitHub",
|
|
description: "Metadata remains available during cooldown.",
|
|
imageDataUrl: null,
|
|
imageDomain: null,
|
|
imageFetchState: "transient_failure",
|
|
imageRetryAfterMs: 900_000,
|
|
},
|
|
},
|
|
}
|
|
: testInfo.title.includes("link preview browser image error")
|
|
? {
|
|
linkPreviewMetadata: {
|
|
title: "Invalid decoded preview image",
|
|
siteName: "GitHub",
|
|
description: "The browser should replace this image.",
|
|
imageDataUrl: null,
|
|
imageDomain: null,
|
|
imageFetchState: "rejected",
|
|
imageRetryAfterMs: null,
|
|
},
|
|
}
|
|
: testInfo.title.includes("link preview image geometry")
|
|
? {
|
|
linkPreviewMetadata: {
|
|
title:
|
|
"Ship a wider horizontal preview with a two-line title that wraps cleanly",
|
|
siteName: "GitHub",
|
|
description:
|
|
"A polished, stable preview for shared links.",
|
|
imageDataUrl: LINK_PREVIEW_IMAGE_DATA_URL,
|
|
imageDomain: "opengraph.githubassets.com",
|
|
faviconDataUrl:
|
|
"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII=",
|
|
},
|
|
linkPreviewMetadataDelayMs: 800,
|
|
}
|
|
: testInfo.title.includes("link preview no-image layout") ||
|
|
testInfo.title.includes("composer no-image link embeds")
|
|
? {
|
|
linkPreviewMetadata: {
|
|
title: "Buzz",
|
|
siteName: "GitHub",
|
|
description:
|
|
"Open-source collaboration for the Buzz app.",
|
|
imageDataUrl: null,
|
|
imageDomain: null,
|
|
faviconDataUrl:
|
|
"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII=",
|
|
},
|
|
linkPreviewMetadataDelayMs: 2_000,
|
|
}
|
|
: testInfo.title.includes(
|
|
"rich link preview preserves description newlines",
|
|
)
|
|
? {
|
|
linkPreviewMetadata: {
|
|
title: "Buzz pull request",
|
|
siteName: "GitHub",
|
|
description:
|
|
"First paragraph line one.\nFirst paragraph line two.\n\nSecond paragraph.",
|
|
imageDataUrl: null,
|
|
imageDomain: null,
|
|
},
|
|
}
|
|
: testInfo.title.includes(
|
|
"Enter during an in-flight snapshot upload",
|
|
) ||
|
|
testInfo.title.includes("Skip wins the upload race") ||
|
|
testInfo.title.includes(
|
|
"async metadata beyond old cutoff",
|
|
) ||
|
|
testInfo.title.includes(
|
|
"async upload beyond metadata budget",
|
|
) ||
|
|
testInfo.title.includes(
|
|
"immediately pressing Enter prepares",
|
|
)
|
|
? {
|
|
linkPreviewMetadata: {
|
|
title: "Buzz pull request",
|
|
siteName: "GitHub",
|
|
description: "A sender-authored preview snapshot.",
|
|
imageDataUrl:
|
|
"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII=",
|
|
imageDomain: "opengraph.githubassets.com",
|
|
},
|
|
linkPreviewMetadataDelayMs: testInfo.title.includes(
|
|
"async metadata beyond old cutoff",
|
|
)
|
|
? 4_000
|
|
: 300,
|
|
linkPreviewUploadDelayMs: testInfo.title.includes(
|
|
"async upload beyond metadata budget",
|
|
)
|
|
? 4_000
|
|
: 1_200,
|
|
}
|
|
: testInfo.title.includes(
|
|
"snapshot thumbnail upload failure",
|
|
) ||
|
|
testInfo.title.includes(
|
|
"snapshot media upload failure",
|
|
)
|
|
? {
|
|
linkPreviewMetadata: {
|
|
title: "Buzz pull request",
|
|
siteName: "GitHub",
|
|
description:
|
|
"A sender-authored preview snapshot.",
|
|
imageDataUrl:
|
|
"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII=",
|
|
imageDomain: "opengraph.githubassets.com",
|
|
faviconDataUrl:
|
|
"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII=",
|
|
},
|
|
// Fail only the thumbnail upload; the favicon survives,
|
|
// so the snapshot degrades to a favicon-only preview.
|
|
linkPreviewUploadErrorFilenames: [
|
|
"link-preview-image",
|
|
],
|
|
}
|
|
: testInfo.title.includes(
|
|
"sent link preview media uses",
|
|
)
|
|
? {
|
|
mediaProxyInitiallyUnavailable: true,
|
|
linkPreviewMetadata: {
|
|
title: "Buzz pull request",
|
|
siteName: "GitHub",
|
|
description:
|
|
"A sender-authored preview snapshot.",
|
|
imageDataUrl:
|
|
"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII=",
|
|
imageDomain: "opengraph.githubassets.com",
|
|
faviconDataUrl:
|
|
"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII=",
|
|
},
|
|
}
|
|
: testInfo.title.includes("link preview") ||
|
|
testInfo.title.includes("supported Compact")
|
|
? {
|
|
linkPreviewMetadata: {
|
|
title: "Buzz pull request",
|
|
siteName: "GitHub",
|
|
description:
|
|
"A sender-authored preview snapshot.",
|
|
imageDataUrl: null,
|
|
imageDomain: null,
|
|
},
|
|
linkPreviewMetadataDelayMs:
|
|
testInfo.title.includes(
|
|
"loading card before cold resolver work",
|
|
)
|
|
? 10_000
|
|
: testInfo.title.includes(
|
|
"explicit cancellation suppresses a pending",
|
|
) ||
|
|
testInfo.title.includes(
|
|
"settled-empty promoted link preview",
|
|
)
|
|
? 10_000
|
|
: testInfo.title.includes(
|
|
"draft auto-send",
|
|
)
|
|
? 500
|
|
: testInfo.title.includes(
|
|
"style defaults",
|
|
) ||
|
|
testInfo.title.includes(
|
|
"attachment-sized",
|
|
)
|
|
? 1_500
|
|
: undefined,
|
|
linkPreviewMetadataStartBlockMs:
|
|
testInfo.title.includes(
|
|
"loading card before cold resolver work",
|
|
)
|
|
? 150
|
|
: undefined,
|
|
}
|
|
: undefined;
|
|
const mock = testInfo.title.includes("unresolvable preview")
|
|
? { linkPreviewMetadata: null, linkPreviewMetadataDelayMs: 800 }
|
|
: {
|
|
...baseMock,
|
|
...(testInfo.title.includes("clears Sending after")
|
|
? { sendMessageDelayMs: 800 }
|
|
: {}),
|
|
};
|
|
await installMockBridge(page, mock);
|
|
});
|
|
|
|
test("agent avatars use the one normalized SVG clip path", async ({ page }) => {
|
|
await page.goto("/");
|
|
await page.getByTestId("channel-general").click();
|
|
|
|
const agentMessage = page
|
|
.getByTestId("message-row")
|
|
.filter({ hasText: "Hey team — checking in." });
|
|
const avatar = agentMessage.getByTestId("message-avatar");
|
|
await expect(avatar).toHaveClass(/rounded-squircle/);
|
|
await expect(avatar).toHaveCSS("border-radius", "0px");
|
|
await expect(avatar).toHaveCSS("clip-path", /rounded-squircle-clip/);
|
|
await expect(page.locator("#rounded-squircle-clip")).toHaveCount(1);
|
|
|
|
const avatarButton = avatar.locator("xpath=ancestor::button[1]");
|
|
await page.keyboard.press("Tab");
|
|
await avatarButton.focus();
|
|
await expect(avatarButton).toBeFocused();
|
|
await expect(avatarButton).toHaveCSS("clip-path", "none");
|
|
await expect(avatarButton).not.toHaveCSS("box-shadow", "none");
|
|
|
|
const avatarBox = await avatar.boundingBox();
|
|
expect(avatarBox).toMatchObject({
|
|
width: expect.any(Number),
|
|
height: expect.any(Number),
|
|
});
|
|
if (avatarBox === null) {
|
|
throw new Error("agent message avatar has no rendered bounds");
|
|
}
|
|
expect(avatarBox.width).toBeGreaterThanOrEqual(24);
|
|
expect(avatarBox.height).toBeGreaterThanOrEqual(24);
|
|
expect(Math.abs(avatarBox.width - avatarBox.height)).toBeLessThanOrEqual(1);
|
|
|
|
await agentMessage.getByRole("button", { name: "A" }).first().click();
|
|
const profileAvatar = page
|
|
.getByTestId("user-profile-panel")
|
|
.locator(".rounded-squircle")
|
|
.first();
|
|
await expect(profileAvatar).toBeVisible();
|
|
await expect(profileAvatar).toHaveCSS("border-radius", "0px");
|
|
await expect(profileAvatar).toHaveCSS("clip-path", /rounded-squircle-clip/);
|
|
await expect
|
|
.poll(() =>
|
|
profileAvatar.evaluate((element) => {
|
|
const rect = element.getBoundingClientRect();
|
|
return rect.width >= 80 && rect.height >= 80;
|
|
}),
|
|
)
|
|
.toBe(true);
|
|
});
|
|
|
|
test("agent owner label identifies the agent and owner", async ({ page }) => {
|
|
await page.goto("/");
|
|
await page.getByTestId("channel-general").click();
|
|
|
|
const aliceMessage = page
|
|
.getByTestId("message-row")
|
|
.filter({ hasText: "Hey team — checking in." });
|
|
const ownerTreatment = aliceMessage.getByTestId("message-agent-owner");
|
|
|
|
await expect(ownerTreatment.locator("svg")).toBeVisible();
|
|
await expect(
|
|
ownerTreatment.getByText("managed by", { exact: true }),
|
|
).toBeVisible();
|
|
await expect(ownerTreatment.locator(".font-semibold")).toHaveText("bob");
|
|
await expect(ownerTreatment.getByRole("button")).toHaveAccessibleName("bob");
|
|
await expect(ownerTreatment.locator(".sr-only")).toHaveText(
|
|
"Agent managed by",
|
|
);
|
|
});
|
|
|
|
test("send a message and see it in timeline", async ({ page }) => {
|
|
const message = `Hello timeline ${Date.now()}`;
|
|
|
|
await page.goto("/");
|
|
await page.getByTestId("channel-general").click();
|
|
await expect(page.getByTestId("chat-title")).toHaveText("general");
|
|
|
|
await page.getByTestId("message-input").fill(message);
|
|
await page.getByTestId("send-message").click();
|
|
|
|
await expect(page.getByTestId("message-timeline")).toContainText(message);
|
|
await expect(page.getByTestId("message-row").last()).toContainText(
|
|
"npub1mock...",
|
|
);
|
|
});
|
|
|
|
test("long autolink wraps without widening the timeline", async ({ page }) => {
|
|
await page.setViewportSize({ width: 800, height: 600 });
|
|
|
|
await page.goto("/");
|
|
await page.getByTestId("channel-general").click();
|
|
await expect(page.getByTestId("chat-title")).toHaveText("general");
|
|
|
|
const longUrl = `https://blocked.teams.cloudflare.com/?${"dependencyconfusionnpm".repeat(18)}`;
|
|
const message = `Step "adapter" failed: npm error invalid json response body at <${longUrl}> reason: Unexpected token '<'`;
|
|
|
|
await page.getByTestId("message-input").fill(message);
|
|
await page.getByTestId("send-message").click();
|
|
|
|
const timeline = page.getByTestId("message-timeline");
|
|
await expect(timeline).toContainText('Step "adapter" failed');
|
|
await expect
|
|
.poll(() =>
|
|
timeline.evaluate((element) => element.scrollWidth - element.clientWidth),
|
|
)
|
|
.toBeLessThanOrEqual(1);
|
|
|
|
const row = page.getByTestId("message-row").last();
|
|
await row.hover();
|
|
|
|
const actionBar = page.locator('[data-testid^="message-action-bar-"]').last();
|
|
await expect(actionBar).toHaveCSS("opacity", "1");
|
|
await expect
|
|
.poll(async () => {
|
|
const [barBox, timelineBox] = await Promise.all([
|
|
actionBar.boundingBox(),
|
|
timeline.boundingBox(),
|
|
]);
|
|
if (!barBox || !timelineBox) {
|
|
return Number.POSITIVE_INFINITY;
|
|
}
|
|
return barBox.x + barBox.width - (timelineBox.x + timelineBox.width);
|
|
})
|
|
.toBeLessThanOrEqual(0);
|
|
});
|
|
|
|
test("markdown tables wrap long prose and fill the message when narrow", async ({
|
|
page,
|
|
}) => {
|
|
await page.setViewportSize({ width: 900, height: 600 });
|
|
await page.goto("/");
|
|
await page.getByTestId("channel-general").click();
|
|
await expect(page.getByTestId("chat-title")).toHaveText("general");
|
|
await page.waitForFunction(
|
|
() => typeof window.__BUZZ_E2E_EMIT_MOCK_MESSAGE__ === "function",
|
|
);
|
|
|
|
const longCell = "WIDE TABLE COLUMN VALUE ".repeat(8);
|
|
await page.evaluate(
|
|
({ wide, narrow }) => {
|
|
const createdAt = Math.floor(Date.now() / 1000);
|
|
window.__BUZZ_E2E_EMIT_MOCK_MESSAGE__?.({
|
|
channelName: "general",
|
|
content: wide,
|
|
createdAt,
|
|
});
|
|
window.__BUZZ_E2E_EMIT_MOCK_MESSAGE__?.({
|
|
channelName: "general",
|
|
content: narrow,
|
|
createdAt: createdAt + 1,
|
|
});
|
|
},
|
|
{
|
|
wide: `| ${longCell} | ${longCell} | ${longCell} |\n| --- | --- | --- |\n| ${longCell} | ${longCell} | ${longCell} |`,
|
|
narrow: "| NARROW TABLE | VALUE |\n| --- | --- |\n| alpha | beta |",
|
|
},
|
|
);
|
|
|
|
const wideTable = page
|
|
.getByTestId("message-row")
|
|
.filter({ hasText: "WIDE TABLE COLUMN VALUE" })
|
|
.locator("[data-table-block]");
|
|
const narrowTable = page
|
|
.getByTestId("message-row")
|
|
.filter({ hasText: "NARROW TABLE" })
|
|
.locator("[data-table-block]");
|
|
await expect(wideTable).toBeVisible();
|
|
await expect(narrowTable).toBeVisible();
|
|
|
|
// Long prose should wrap, not force horizontal scrolling. Unavoidable
|
|
// many-column overflow is covered separately in markdown-tables.spec.ts.
|
|
await expect
|
|
.poll(() =>
|
|
wideTable.evaluate(
|
|
(element) => element.scrollWidth - element.clientWidth,
|
|
),
|
|
)
|
|
.toBeLessThanOrEqual(1);
|
|
await expect
|
|
.poll(() =>
|
|
narrowTable.evaluate((element) => {
|
|
const table = element.querySelector("table");
|
|
return table
|
|
? Math.abs(table.getBoundingClientRect().width - element.clientWidth)
|
|
: Number.POSITIVE_INFINITY;
|
|
}),
|
|
)
|
|
.toBeLessThanOrEqual(1);
|
|
});
|
|
|
|
test("sent link preview media uses the authenticated proxy in compact and rich cards", async ({
|
|
page,
|
|
}) => {
|
|
const previewUrl = "https://github.com/block/buzz/pull/3246?proxy=1";
|
|
const fallbackMediaPattern =
|
|
/^buzz-media:\/\/localhost\/media\/[\da-f]{64}\.png$/;
|
|
const proxyMediaPattern =
|
|
/^http:\/\/127\.0\.0\.1:54321\/media\/[\da-f]{64}\.png$/;
|
|
await page.route("http://127.0.0.1:54321/media/**", (route) =>
|
|
route.fulfill({
|
|
body: '<svg xmlns="http://www.w3.org/2000/svg" width="40" height="20"><rect width="40" height="20" fill="#22c55e"/></svg>',
|
|
contentType: "image/svg+xml",
|
|
}),
|
|
);
|
|
|
|
await page.goto("/");
|
|
await page.getByTestId("channel-general").click();
|
|
await page.getByTestId("message-input").fill(previewUrl);
|
|
await waitForReadyComposerSnapshots(page);
|
|
await page.getByTestId("send-message").click();
|
|
|
|
const row = page.getByTestId("message-row").last();
|
|
const compactPreview = row.locator(
|
|
'[data-link-preview="github-pull-request"]',
|
|
);
|
|
const compactThumbnail = compactPreview
|
|
.locator("[data-link-preview-thumbnail] img")
|
|
.first();
|
|
const compactFavicon = compactPreview.locator(
|
|
"img[data-link-preview-hostname-favicon]",
|
|
);
|
|
await expect(compactThumbnail).toHaveAttribute("src", fallbackMediaPattern);
|
|
await expect(compactFavicon).toHaveAttribute("src", fallbackMediaPattern);
|
|
|
|
const releasedPort = await page.evaluate(() =>
|
|
window.__BUZZ_E2E_RELEASE_MEDIA_PROXY__?.(),
|
|
);
|
|
expect(releasedPort).toBe(54321);
|
|
await expect(compactThumbnail).toHaveAttribute("src", proxyMediaPattern);
|
|
await expect(compactFavicon).toHaveAttribute("src", proxyMediaPattern);
|
|
await expect
|
|
.poll(() => compactThumbnail.evaluate((image) => image.naturalWidth))
|
|
.toBe(40);
|
|
|
|
// The compact thumbnail sits flush against the card shell's left edge, so the
|
|
// shell's smooth-corner clip carves those corners. It must therefore carry
|
|
// the same treatment itself, or its right corners render as a plain arc
|
|
// against the shell's smoothed left corners. See the invariant in
|
|
// `shared/ui/smoothCorners.ts`.
|
|
const compactThumbnailFrame = compactPreview
|
|
.locator("[data-link-preview-thumbnail]")
|
|
.first();
|
|
await expectCornerRadiusPx(compactPreview, 16);
|
|
await expectCornerRadiusPx(compactThumbnailFrame, 16);
|
|
await expectSmoothCorners(compactThumbnailFrame);
|
|
|
|
await openSettings(page, "appearance");
|
|
await page.getByTestId("link-preview-style-rich").click();
|
|
await expect(page.getByTestId("link-preview-style-rich")).toHaveAttribute(
|
|
"aria-pressed",
|
|
"true",
|
|
);
|
|
await page.getByTestId("settings-back-to-app").click();
|
|
|
|
const richPreview = row.locator(
|
|
'[data-link-preview="github-pull-request"][data-link-preview-inline]',
|
|
);
|
|
const richThumbnail = richPreview
|
|
.locator("[data-link-preview-thumbnail] img")
|
|
.first();
|
|
const richFavicon = richPreview.locator("img[data-link-preview-favicon]");
|
|
await expect(richThumbnail).toHaveAttribute("src", proxyMediaPattern);
|
|
await expect(richFavicon).toHaveAttribute("src", proxyMediaPattern);
|
|
await expect
|
|
.poll(() => richThumbnail.evaluate((image) => image.naturalWidth))
|
|
.toBe(40);
|
|
});
|
|
|
|
test("link preview style defaults to compact and Rich unfurls descriptions", async ({
|
|
page,
|
|
}) => {
|
|
const previewUrl = "https://github.com/block/buzz/pull/3246?inline=1";
|
|
await page.setViewportSize({ width: 800, height: 900 });
|
|
await page.goto("/");
|
|
await page.getByTestId("channel-general").click();
|
|
await page.getByTestId("message-input").fill(previewUrl);
|
|
const composerPreview = page
|
|
.locator("[data-composer-link-previews]")
|
|
.locator('[data-link-preview="github-pull-request"]');
|
|
await expect(composerPreview).toHaveAttribute("data-image-state", "pending");
|
|
if (process.env.BUZZ_LINK_PREVIEW_SCREENSHOTS_DIR) {
|
|
await waitForAnimations(page);
|
|
await page.screenshot({
|
|
animations: "disabled",
|
|
path: `${process.env.BUZZ_LINK_PREVIEW_SCREENSHOTS_DIR}/compact-composer-loading.png`,
|
|
});
|
|
}
|
|
await waitForReadyComposerSnapshots(page);
|
|
await expect(composerPreview).toHaveAttribute("data-image-state", "none");
|
|
if (process.env.BUZZ_LINK_PREVIEW_SCREENSHOTS_DIR) {
|
|
await waitForAnimations(page);
|
|
await page.screenshot({
|
|
animations: "disabled",
|
|
path: `${process.env.BUZZ_LINK_PREVIEW_SCREENSHOTS_DIR}/compact-composer-ready.png`,
|
|
});
|
|
}
|
|
await page.getByTestId("send-message").click();
|
|
|
|
const row = page.getByTestId("message-row").last();
|
|
const compactPreview = row.locator(
|
|
'[data-link-preview="github-pull-request"]',
|
|
);
|
|
await expect(compactPreview).toHaveCSS("border-top-left-radius", "0px");
|
|
await expect(compactPreview).toHaveCSS("border-left-width", "3px");
|
|
if (process.env.BUZZ_LINK_PREVIEW_SCREENSHOTS_DIR) {
|
|
await waitForAnimations(page);
|
|
await page.screenshot({
|
|
animations: "disabled",
|
|
path: `${process.env.BUZZ_LINK_PREVIEW_SCREENSHOTS_DIR}/recipient-compact.png`,
|
|
});
|
|
}
|
|
|
|
await openSettings(page, "appearance");
|
|
await expect(page.getByTestId("link-preview-style-compact")).toHaveAttribute(
|
|
"aria-pressed",
|
|
"true",
|
|
);
|
|
await page.getByTestId("link-preview-style-rich").click();
|
|
await expect(page.getByTestId("link-preview-style-rich")).toHaveAttribute(
|
|
"aria-pressed",
|
|
"true",
|
|
);
|
|
await expect
|
|
.poll(() =>
|
|
page.evaluate(() =>
|
|
localStorage.getItem("buzz.appearance.linkPreviewStyle"),
|
|
),
|
|
)
|
|
.toBe("rich");
|
|
|
|
await page.getByTestId("settings-back-to-app").click();
|
|
const richPreview = row.locator(
|
|
'[data-link-preview="github-pull-request"][data-link-preview-inline]',
|
|
);
|
|
await expect(richPreview).toBeVisible();
|
|
const richHostname = richPreview.locator("[data-link-preview-hostname]");
|
|
await expect(richHostname).toHaveText("github.com");
|
|
await expect(richHostname).toHaveAttribute("href", previewUrl);
|
|
if (process.env.BUZZ_LINK_PREVIEW_SCREENSHOTS_DIR) {
|
|
await waitForAnimations(page);
|
|
await page.screenshot({
|
|
animations: "disabled",
|
|
path: `${process.env.BUZZ_LINK_PREVIEW_SCREENSHOTS_DIR}/recipient-rich.png`,
|
|
});
|
|
const richComposerUrl = `${previewUrl}&composer=rich`;
|
|
await page.getByTestId("message-input").fill(richComposerUrl);
|
|
const richComposerPreview = page
|
|
.locator("[data-composer-link-previews]")
|
|
.locator('[data-link-preview="github-pull-request"]');
|
|
await expect(richComposerPreview).toHaveAttribute(
|
|
"data-image-state",
|
|
"pending",
|
|
);
|
|
await waitForAnimations(page);
|
|
await page.screenshot({
|
|
animations: "disabled",
|
|
path: `${process.env.BUZZ_LINK_PREVIEW_SCREENSHOTS_DIR}/rich-composer-loading.png`,
|
|
});
|
|
await waitForReadyComposerSnapshots(page);
|
|
await expect(richComposerPreview).toHaveAttribute(
|
|
"data-image-state",
|
|
"none",
|
|
);
|
|
await waitForAnimations(page);
|
|
await page.screenshot({
|
|
animations: "disabled",
|
|
path: `${process.env.BUZZ_LINK_PREVIEW_SCREENSHOTS_DIR}/rich-composer-ready.png`,
|
|
});
|
|
await page.getByTestId("message-input").fill("");
|
|
}
|
|
|
|
await openSettings(page, "appearance");
|
|
await page.getByTestId("link-preview-style-compact").click();
|
|
await expect(page.getByTestId("link-preview-style-compact")).toHaveAttribute(
|
|
"aria-pressed",
|
|
"true",
|
|
);
|
|
});
|
|
|
|
for (const [pasteShape, wrapUrl] of [
|
|
["bare", (url: string) => url],
|
|
["angle-bracket", (url: string) => `<${url}>`],
|
|
] as const) {
|
|
test(`${pasteShape} link preview paste paints before cold resolver work`, async ({
|
|
page,
|
|
}) => {
|
|
const previewUrl = `https://github.com/block/buzz/pull/3246?paste=${pasteShape}`;
|
|
await page.goto("/");
|
|
await page.getByTestId("channel-general").click();
|
|
const input = page.getByTestId("message-input");
|
|
await input.focus();
|
|
|
|
const pasteText = wrapUrl(previewUrl);
|
|
const firstPaint = await input.evaluate(async (element, pasteText) => {
|
|
const clipboardData = new DataTransfer();
|
|
clipboardData.setData("text/plain", pasteText);
|
|
const startedAt = performance.now();
|
|
element.dispatchEvent(
|
|
new ClipboardEvent("paste", {
|
|
bubbles: true,
|
|
cancelable: true,
|
|
clipboardData,
|
|
}),
|
|
);
|
|
await new Promise<void>((resolve) =>
|
|
requestAnimationFrame(() => resolve()),
|
|
);
|
|
return {
|
|
elapsedMs: performance.now() - startedAt,
|
|
resolverStarted: (window.__BUZZ_E2E_COMMAND_PAYLOADS__ ?? []).some(
|
|
(entry) => entry.command === "fetch_link_preview_metadata",
|
|
),
|
|
text: element.textContent,
|
|
};
|
|
}, pasteText);
|
|
expect(firstPaint.text).toContain(previewUrl);
|
|
expect(firstPaint.resolverStarted).toBe(false);
|
|
expect(firstPaint.elapsedMs).toBeLessThan(100);
|
|
const composerPreview = page
|
|
.locator("[data-composer-link-previews]")
|
|
.locator('[data-link-preview="github-pull-request"]');
|
|
await expect(input).toContainText(previewUrl, { timeout: 1_000 });
|
|
await expect(
|
|
composerPreview.locator('[data-slot="attachment"]'),
|
|
).toHaveAttribute("data-state", /^(processing|done)$/, { timeout: 1_000 });
|
|
await expect(page.getByTestId("send-message")).toBeEnabled();
|
|
});
|
|
}
|
|
|
|
test("display-text link preview produces and sends its preview", async ({
|
|
page,
|
|
}) => {
|
|
const previewUrl = "https://github.com/block/buzz/pull/3246?display=text";
|
|
await page.goto("/");
|
|
await page.getByTestId("channel-general").click();
|
|
|
|
const input = page.getByTestId("message-input");
|
|
await input.click();
|
|
await input.pressSequentially("review the pull request");
|
|
await page.keyboard.press("ControlOrMeta+a");
|
|
await page.keyboard.press("ControlOrMeta+k");
|
|
const dialog = page.getByRole("dialog", { name: "Add link" });
|
|
await dialog.getByLabel("URL").fill(previewUrl);
|
|
await dialog.getByRole("button", { name: "Save" }).click();
|
|
|
|
await expect(input.locator(`a[href="${previewUrl}"]`)).toHaveText(
|
|
"review the pull request",
|
|
);
|
|
await expect(page.locator("[data-composer-link-previews]")).toBeVisible();
|
|
await waitForReadyComposerSnapshots(page);
|
|
await page.getByTestId("send-message").click();
|
|
|
|
const row = page.getByTestId("message-row").last();
|
|
await expect(
|
|
row.getByRole("link", { name: "review the pull request", exact: true }),
|
|
).toHaveAttribute("href", previewUrl);
|
|
await expect(row.locator("[data-link-preview]")).toBeVisible();
|
|
const linkPreviewTags = await page.evaluate(() => {
|
|
const call = [...(window.__BUZZ_E2E_COMMAND_PAYLOADS__ ?? [])]
|
|
.reverse()
|
|
.find((entry) => entry.command === "send_channel_message");
|
|
return (
|
|
call?.payload as { linkPreviewTags?: string[][] | null } | undefined
|
|
)?.linkPreviewTags;
|
|
});
|
|
expect(linkPreviewTags?.map((tag) => tag[3])).toEqual([previewUrl]);
|
|
});
|
|
|
|
test("rich link preview preserves description newlines after sending", async ({
|
|
page,
|
|
}) => {
|
|
const previewUrl = "https://github.com/block/buzz/pull/3246?newlines=1";
|
|
await page.addInitScript(() =>
|
|
localStorage.setItem("buzz.appearance.linkPreviewStyle", "rich"),
|
|
);
|
|
await page.goto("/");
|
|
await page.getByTestId("channel-general").click();
|
|
await page.getByTestId("message-input").fill(previewUrl);
|
|
await waitForReadyComposerSnapshots(page);
|
|
await page.getByTestId("send-message").click();
|
|
|
|
const description = page
|
|
.getByTestId("message-row")
|
|
.last()
|
|
.locator('[data-link-preview-inline] [data-slot="attachment-description"]');
|
|
await expect(description.locator("p")).toHaveCount(2);
|
|
await expect(description).toHaveText(
|
|
"First paragraph line one.\nFirst paragraph line two.\n\nSecond paragraph.",
|
|
{ useInnerText: true },
|
|
);
|
|
});
|
|
|
|
test("completed link previews normalize a trailing-fragment URL and still send", async ({
|
|
page,
|
|
}) => {
|
|
// The third URL carries a trailing `#` (empty fragment). It is normalized to
|
|
// its fragmentless canonical form for the preview and snapshot tag, so it now
|
|
// gets a card like the others; the message body keeps the original URL.
|
|
const previewUrls = [
|
|
"https://twitter.com/tellaho",
|
|
"https://github.com/block/buzz/pull/3246",
|
|
"https://x.com/tellaho/status/1884289176381841506#",
|
|
];
|
|
const canonicalUrls = [
|
|
"https://twitter.com/tellaho",
|
|
"https://github.com/block/buzz/pull/3246",
|
|
"https://x.com/tellaho/status/1884289176381841506",
|
|
];
|
|
const pastedText = previewUrls.join("\n");
|
|
await page.goto("/");
|
|
await page.getByTestId("channel-general").click();
|
|
const input = page.getByTestId("message-input");
|
|
await input.focus();
|
|
await input.evaluate((element, text) => {
|
|
const clipboardData = new DataTransfer();
|
|
clipboardData.setData("text/plain", text);
|
|
element.dispatchEvent(
|
|
new ClipboardEvent("paste", {
|
|
bubbles: true,
|
|
cancelable: true,
|
|
clipboardData,
|
|
}),
|
|
);
|
|
}, pastedText);
|
|
const composerPreviewCards = page.locator(
|
|
"[data-link-preview-composer-card]",
|
|
);
|
|
await expect(composerPreviewCards).toHaveCount(3);
|
|
await waitForReadyComposerSnapshots(page, 3);
|
|
|
|
const send = page.getByTestId("send-message");
|
|
await expect(send).toBeEnabled();
|
|
await send.click();
|
|
await expect(page.getByTestId("message-input")).toHaveText("");
|
|
await expect(page.locator("[data-composer-link-previews]")).toHaveCount(0);
|
|
await page.waitForTimeout(250);
|
|
await expect(page.getByTestId("message-input")).toHaveText("");
|
|
|
|
const calls = await page.evaluate(() =>
|
|
(window.__BUZZ_E2E_COMMAND_PAYLOADS__ ?? []).filter(
|
|
(entry) => entry.command === "send_channel_message",
|
|
),
|
|
);
|
|
expect(calls).toHaveLength(1);
|
|
expect(
|
|
(
|
|
calls[0]?.payload as { linkPreviewTags?: string[][] | null } | undefined
|
|
)?.linkPreviewTags?.map((tag) => tag[3]),
|
|
).toEqual(canonicalUrls);
|
|
});
|
|
|
|
test("unresolvable preview disappears after the terminal miss", async ({
|
|
page,
|
|
}) => {
|
|
const previewUrl = "https://x.com/tellaho/status";
|
|
await page.goto("/");
|
|
await page.getByTestId("channel-general").click();
|
|
await page.getByTestId("message-input").fill(previewUrl);
|
|
|
|
const composerPreviews = page.locator("[data-composer-link-previews]");
|
|
await expect(composerPreviews).toBeVisible();
|
|
await expect(
|
|
composerPreviews.locator("[data-link-preview-composer-card]"),
|
|
).toHaveAttribute("data-image-state", "pending");
|
|
await expect(composerPreviews).toHaveCount(0);
|
|
|
|
await page.getByTestId("send-message").click();
|
|
const row = page.getByTestId("message-row").last();
|
|
await expect(row).toContainText(previewUrl);
|
|
await expect(row.locator("[data-link-preview]")).toHaveCount(0);
|
|
});
|
|
|
|
test("explicit cancellation suppresses a pending link preview and sends without it", async ({
|
|
page,
|
|
}) => {
|
|
const previewUrl = "https://github.com/block/buzz/pull/3246?send=pending";
|
|
await page.goto("/");
|
|
await page.getByTestId("channel-general").click();
|
|
await page.getByTestId("message-input").fill(previewUrl);
|
|
|
|
const composerPreviews = page.locator("[data-composer-link-previews]");
|
|
const card = composerPreviews.locator(
|
|
'[data-link-preview="github-pull-request"]',
|
|
);
|
|
const send = page.getByTestId("send-message");
|
|
const cancel = card.getByTestId("composer-hide-link-previews");
|
|
const progress = card.getByTestId("link-preview-progress");
|
|
const textPlaceholder = card.getByTestId("link-preview-text-placeholder");
|
|
await expect(composerPreviews).toHaveAttribute(
|
|
"data-ready-snapshot-count",
|
|
"0",
|
|
);
|
|
await expect(card).toHaveAttribute("data-image-state", "pending");
|
|
await expect(cancel).toHaveAttribute(
|
|
"aria-label",
|
|
"Send without link previews",
|
|
);
|
|
await expect(cancel).toHaveAttribute("title", "Send without link previews");
|
|
await expect(progress).toBeVisible();
|
|
await expect(progress).not.toHaveAttribute("aria-valuenow");
|
|
await expect(textPlaceholder).toBeVisible();
|
|
await expect(card.locator("[data-link-preview-hostname]")).toHaveCount(0);
|
|
await expect(card.getByText("github.com", { exact: true })).toHaveCount(0);
|
|
await expect(cancel).toHaveCSS("opacity", "0");
|
|
await card.hover();
|
|
await expect(cancel).toHaveCSS("opacity", "1");
|
|
await expect(card).toHaveCSS("overflow", "visible");
|
|
await expect(card.locator('[data-slot="attachment"]')).toHaveCSS(
|
|
"overflow",
|
|
"hidden",
|
|
);
|
|
|
|
// The mock metadata takes ten seconds. Submit is intentionally available:
|
|
// the pending work is promoted into the floating background preparation UI.
|
|
await expect(send).toBeEnabled();
|
|
await page.waitForTimeout(2_200);
|
|
await expect(composerPreviews).toHaveAttribute(
|
|
"data-has-pending-snapshots",
|
|
"true",
|
|
);
|
|
await expect(card).toHaveAttribute("data-image-state", "pending");
|
|
await expect(send).toBeEnabled();
|
|
|
|
// The explicit escape suppresses all previews, preserves the draft link, and
|
|
// makes the durable no-preview intent sendable immediately.
|
|
await cancel.click();
|
|
await expect(composerPreviews).toHaveCount(0);
|
|
await expect(page.getByTestId("message-input")).toContainText(previewUrl);
|
|
await expect(send).toBeEnabled();
|
|
await send.click();
|
|
|
|
const row = page.getByTestId("message-row").last();
|
|
await expect(row).toContainText(previewUrl);
|
|
await expect(row.locator("[data-link-preview]")).toHaveCount(0);
|
|
|
|
const linkPreviewTags = await page.evaluate(() => {
|
|
const call = [...(window.__BUZZ_E2E_COMMAND_PAYLOADS__ ?? [])]
|
|
.reverse()
|
|
.find((entry) => entry.command === "send_channel_message");
|
|
return (
|
|
call?.payload as { linkPreviewTags?: string[][] | null } | undefined
|
|
)?.linkPreviewTags;
|
|
});
|
|
expect(linkPreviewTags).toEqual([["link-preview", "none"]]);
|
|
});
|
|
|
|
test("Enter during an in-flight snapshot upload hands off and sends once", async ({
|
|
page,
|
|
}) => {
|
|
const previewUrl = "https://github.com/block/buzz/pull/3246";
|
|
await page.goto("/");
|
|
await page.getByTestId("channel-general").click();
|
|
const input = page.getByTestId("message-input");
|
|
await input.fill(previewUrl);
|
|
|
|
const card = page.locator("[data-link-preview-composer-card]");
|
|
await expect(card).toHaveAttribute("data-image-state", "image");
|
|
await expect(card).toHaveAttribute("data-snapshot-tag-ready", "false");
|
|
|
|
await input.press("Enter");
|
|
await expect(input).toHaveText("");
|
|
const progress = page.getByTestId("composer-upload-progress");
|
|
await expect(progress).toHaveAccessibleName("Preparing link preview");
|
|
await expect(page.getByTestId("composer-upload-cancel")).toHaveText("Skip");
|
|
|
|
const sendsDuringUpload = await page.evaluate(
|
|
() =>
|
|
(window.__BUZZ_E2E_COMMAND_PAYLOADS__ ?? []).filter(
|
|
(entry) => entry.command === "send_channel_message",
|
|
).length,
|
|
);
|
|
expect(sendsDuringUpload).toBe(0);
|
|
|
|
const row = page.getByTestId("message-row").last();
|
|
await expect(row).toContainText(previewUrl);
|
|
await expect(row.locator("[data-link-preview]")).toBeVisible();
|
|
await expect(progress).toHaveCount(0);
|
|
const sends = await page.evaluate(
|
|
() =>
|
|
(window.__BUZZ_E2E_COMMAND_PAYLOADS__ ?? []).filter(
|
|
(entry) => entry.command === "send_channel_message",
|
|
).length,
|
|
);
|
|
expect(sends).toBe(1);
|
|
});
|
|
|
|
test("async metadata beyond old cutoff still produces preview image", async ({
|
|
page,
|
|
}) => {
|
|
const previewUrl = "https://github.com/block/buzz/pull/3246?slow=metadata";
|
|
await page.goto("/");
|
|
await page.getByTestId("channel-general").click();
|
|
const input = page.getByTestId("message-input");
|
|
await input.fill(previewUrl);
|
|
await input.press("Enter");
|
|
|
|
const progress = page.getByTestId("composer-upload-progress");
|
|
await expect(progress).toHaveAccessibleName("Preparing link preview");
|
|
await page.waitForTimeout(3_200);
|
|
await expect(progress).toBeVisible();
|
|
|
|
const row = page.getByTestId("message-row").last();
|
|
await expect(row).toContainText(previewUrl);
|
|
await expect(row.locator("[data-link-preview]")).toHaveAttribute(
|
|
"data-image-state",
|
|
"image",
|
|
);
|
|
});
|
|
|
|
test("async upload beyond metadata budget retains preview image", async ({
|
|
page,
|
|
}) => {
|
|
const previewUrl = "https://github.com/block/buzz/pull/3246?slow=image";
|
|
await page.goto("/");
|
|
await page.getByTestId("channel-general").click();
|
|
const input = page.getByTestId("message-input");
|
|
await input.fill(previewUrl);
|
|
await input.press("Enter");
|
|
|
|
const progress = page.getByTestId("composer-upload-progress");
|
|
await expect(progress).toHaveAccessibleName("Preparing link preview");
|
|
await page.waitForTimeout(3_200);
|
|
await expect(progress).toBeVisible();
|
|
|
|
const row = page.getByTestId("message-row").last();
|
|
await expect(row).toContainText(previewUrl);
|
|
await expect(row.locator("[data-link-preview]")).toHaveAttribute(
|
|
"data-image-state",
|
|
"image",
|
|
);
|
|
const tags = await page.evaluate(() => {
|
|
const call = [...(window.__BUZZ_E2E_COMMAND_PAYLOADS__ ?? [])]
|
|
.reverse()
|
|
.find((entry) => entry.command === "send_channel_message");
|
|
return (call?.payload as { linkPreviewTags?: string[][] }).linkPreviewTags;
|
|
});
|
|
expect(tags?.[0]?.[7]).toContain("/media/");
|
|
expect(tags?.[0]?.[8]).not.toBe("");
|
|
});
|
|
|
|
test("Skip wins the upload race and sends without preview", async ({
|
|
page,
|
|
}) => {
|
|
const previewUrl = "https://github.com/block/buzz/pull/3246?skip=1";
|
|
await page.goto("/");
|
|
await page.getByTestId("channel-general").click();
|
|
const input = page.getByTestId("message-input");
|
|
await input.fill(previewUrl);
|
|
await expect(
|
|
page.locator("[data-link-preview-composer-card]"),
|
|
).toHaveAttribute("data-snapshot-tag-ready", "false");
|
|
|
|
await input.press("Enter");
|
|
const skip = page.getByTestId("composer-upload-cancel");
|
|
await expect(skip).toHaveText("Skip");
|
|
await skip.click();
|
|
|
|
const row = page.getByTestId("message-row").last();
|
|
await expect(row).toContainText(previewUrl);
|
|
await expect(row.locator("[data-link-preview]")).toHaveCount(0);
|
|
await page.waitForTimeout(1_500);
|
|
const calls = await page.evaluate(() =>
|
|
(window.__BUZZ_E2E_COMMAND_PAYLOADS__ ?? []).filter(
|
|
(entry) => entry.command === "send_channel_message",
|
|
),
|
|
);
|
|
expect(calls).toHaveLength(1);
|
|
expect(
|
|
(calls[0]?.payload as { linkPreviewTags?: string[][] }).linkPreviewTags,
|
|
).toEqual([]);
|
|
});
|
|
|
|
test("promoted link preview send clears Sending after REST publication", async ({
|
|
page,
|
|
}) => {
|
|
const previewUrl = "https://github.com/block/buzz/pull/3246?pending=preview";
|
|
await page.goto("/");
|
|
await page.getByTestId("channel-general").click();
|
|
const input = page.getByTestId("message-input");
|
|
await input.fill(previewUrl);
|
|
await input.press("Enter");
|
|
|
|
const row = page.getByTestId("message-row").last();
|
|
await expect(row).toContainText(previewUrl);
|
|
await expect(row).toContainText("Sending…");
|
|
await expect(row.locator("[data-link-preview]")).toBeVisible();
|
|
await expect(row).not.toContainText("Sending…");
|
|
|
|
const restCalls = await page.evaluate(() =>
|
|
(window.__BUZZ_E2E_COMMAND_PAYLOADS__ ?? []).filter(
|
|
(entry) => entry.command === "send_channel_message",
|
|
),
|
|
);
|
|
expect(restCalls).toHaveLength(1);
|
|
expect(
|
|
(restCalls[0]?.payload as { linkPreviewTags?: string[][] }).linkPreviewTags,
|
|
).toHaveLength(1);
|
|
});
|
|
|
|
test("settled-empty promoted link preview send uses REST and clears Sending after Skip", async ({
|
|
page,
|
|
}) => {
|
|
const previewUrl = "https://github.com/block/buzz/pull/3246?pending=empty";
|
|
await page.goto("/");
|
|
await page.getByTestId("channel-general").click();
|
|
const input = page.getByTestId("message-input");
|
|
await input.fill(previewUrl);
|
|
await input.press("Enter");
|
|
await page.getByTestId("composer-upload-cancel").click();
|
|
|
|
const row = page.getByTestId("message-row").last();
|
|
await expect(row).toContainText(previewUrl);
|
|
await expect(row).toContainText("Sending…");
|
|
await expect(row).not.toContainText("Sending…");
|
|
await expect(row.locator("[data-link-preview]")).toHaveCount(0);
|
|
|
|
const result = await page.evaluate(() => ({
|
|
restCalls: (window.__BUZZ_E2E_COMMAND_PAYLOADS__ ?? []).filter(
|
|
(entry) => entry.command === "send_channel_message",
|
|
),
|
|
websocketSends: (window.__BUZZ_E2E_SIGNED_EVENTS__ ?? []).filter(
|
|
(event) => event.kind === 9,
|
|
),
|
|
}));
|
|
expect(result.restCalls).toHaveLength(1);
|
|
expect(
|
|
(result.restCalls[0]?.payload as { linkPreviewTags?: string[][] })
|
|
.linkPreviewTags,
|
|
).toEqual([]);
|
|
expect(result.websocketSends).toHaveLength(0);
|
|
});
|
|
|
|
test("draft auto-send promotes link preview preparation and sends exactly once", async ({
|
|
page,
|
|
}) => {
|
|
// A confirmed Drafts-panel send must fire once immediately, promote preview
|
|
// preparation into the background flow, and eventually publish one enriched
|
|
// event rather than consuming the one-shot trigger while the hook debounces.
|
|
const previewUrl = "https://github.com/block/buzz/pull/3246?draft=autosend";
|
|
const channelId = "9a1657ac-f7aa-5db0-b632-d8bbeb6dfb50";
|
|
|
|
// Seed a channel draft under the legacy store key (migrated on startup). The
|
|
// main composer keys its draft off the bare channel id, and the Drafts panel
|
|
// navigates with ?autoSend=<that key>, so seeding under the bare id mirrors
|
|
// the real "Send message" target exactly.
|
|
await page.addInitScript(
|
|
({ storeKey, draftKey, content, channel }) => {
|
|
const timestamp = new Date().toISOString();
|
|
window.localStorage.setItem(
|
|
storeKey,
|
|
JSON.stringify({
|
|
[draftKey]: {
|
|
channelId: channel,
|
|
content,
|
|
createdAt: timestamp,
|
|
pendingImeta: [],
|
|
selectionEnd: content.length,
|
|
selectionStart: content.length,
|
|
spoileredAttachmentUrls: [],
|
|
status: "active",
|
|
updatedAt: timestamp,
|
|
},
|
|
}),
|
|
);
|
|
},
|
|
{
|
|
storeKey: `buzz-drafts.v1:${"deadbeef".repeat(8)}`,
|
|
draftKey: channelId,
|
|
content: previewUrl,
|
|
channel: channelId,
|
|
},
|
|
);
|
|
|
|
// Drive the real Drafts-panel "Send message" confirm flow. This does an
|
|
// in-app client navigation to the channel with ?autoSend=<draftKey>, arming
|
|
// the main composer's auto-submit effect — the exact production path.
|
|
await page.goto("/", { waitUntil: "domcontentloaded" });
|
|
await expect(page.getByTestId("home-inbox")).toBeVisible({ timeout: 10_000 });
|
|
await page.getByTestId("inbox-filter-trigger").click();
|
|
await page.getByRole("menuitemradio", { name: "Drafts" }).click();
|
|
await page.keyboard.press("Escape");
|
|
|
|
const draftRow = page.locator(`[data-testid='home-draft-item-${channelId}']`);
|
|
await expect(draftRow).toBeVisible({ timeout: 8_000 });
|
|
await draftRow.hover();
|
|
await draftRow
|
|
.getByRole("button", { name: "Send message", exact: true })
|
|
.click();
|
|
const dialog = page.getByRole("alertdialog");
|
|
await expect(dialog).toBeVisible({ timeout: 4_000 });
|
|
await dialog.getByRole("button", { name: "Send", exact: true }).click();
|
|
|
|
// Exactly one publish eventually fires and carries the promoted snapshot.
|
|
await expect
|
|
.poll(async () =>
|
|
page.evaluate(
|
|
() =>
|
|
(window.__BUZZ_E2E_COMMAND_PAYLOADS__ ?? []).filter(
|
|
(entry) => entry.command === "send_channel_message",
|
|
).length,
|
|
),
|
|
)
|
|
.toBe(1);
|
|
|
|
const linkPreviewTags = await page.evaluate(() => {
|
|
const call = [...(window.__BUZZ_E2E_COMMAND_PAYLOADS__ ?? [])]
|
|
.reverse()
|
|
.find((entry) => entry.command === "send_channel_message");
|
|
return (
|
|
call?.payload as { linkPreviewTags?: string[][] | null } | undefined
|
|
)?.linkPreviewTags;
|
|
});
|
|
expect(linkPreviewTags?.map((tag) => tag[3])).toEqual([previewUrl]);
|
|
});
|
|
|
|
test("rapid Enter presses on a ready link preview send exactly once", async ({
|
|
page,
|
|
}) => {
|
|
const previewUrl = "https://github.com/block/buzz/pull/3246?rapid=1";
|
|
await page.goto("/");
|
|
await page.getByTestId("channel-general").click();
|
|
const input = page.getByTestId("message-input");
|
|
await input.fill(previewUrl);
|
|
|
|
// Wait until the snapshot is fully ready and Send is enabled, so the only
|
|
// thing under test is the composer-local send lock — not preview settling.
|
|
await waitForReadyComposerSnapshots(page);
|
|
await expect(page.getByTestId("send-message")).toBeEnabled();
|
|
|
|
// Mash Enter. The synchronous submit lock (isSubmitLockedRef), acquired before
|
|
// any await, must collapse these into exactly one send_channel_message so a
|
|
// duplicate cannot clear shared prep/hydration state mid-send.
|
|
await input.press("Enter");
|
|
await input.press("Enter");
|
|
await input.press("Enter");
|
|
|
|
const row = page.getByTestId("message-row").last();
|
|
await expect(row).toContainText(previewUrl);
|
|
await expect(row.locator("[data-link-preview]")).toBeVisible();
|
|
|
|
await expect
|
|
.poll(async () =>
|
|
page.evaluate(
|
|
() =>
|
|
(window.__BUZZ_E2E_COMMAND_PAYLOADS__ ?? []).filter(
|
|
(entry) => entry.command === "send_channel_message",
|
|
).length,
|
|
),
|
|
)
|
|
.toBe(1);
|
|
});
|
|
|
|
test("pasting a link and immediately pressing Enter prepares it after submit", async ({
|
|
page,
|
|
}) => {
|
|
const previewUrl = "https://github.com/block/buzz/pull/3246?fast=send";
|
|
await page.goto("/");
|
|
await page.getByTestId("channel-general").click();
|
|
const input = page.getByTestId("message-input");
|
|
|
|
await input.fill(previewUrl);
|
|
await input.press("Enter");
|
|
await expect(input).toHaveText("");
|
|
await expect(page.getByTestId("composer-upload-cancel")).toHaveText("Skip");
|
|
|
|
const row = page.getByTestId("message-row").last();
|
|
await expect(row).toContainText(previewUrl);
|
|
await expect(row.locator("[data-link-preview]")).toBeVisible();
|
|
const calls = await page.evaluate(() =>
|
|
(window.__BUZZ_E2E_COMMAND_PAYLOADS__ ?? []).filter(
|
|
(entry) => entry.command === "send_channel_message",
|
|
),
|
|
);
|
|
expect(calls).toHaveLength(1);
|
|
});
|
|
|
|
test("a snapshot media upload failure preserves a metadata-only preview", async ({
|
|
page,
|
|
}) => {
|
|
const previewUrl = "https://github.com/block/buzz/pull/3246?upload=fail";
|
|
await page.goto("/");
|
|
await page.getByTestId("channel-general").click();
|
|
const input = page.getByTestId("message-input");
|
|
await input.fill(previewUrl);
|
|
await input.press("Enter");
|
|
|
|
const row = page.getByTestId("message-row").last();
|
|
await expect(row).toContainText(previewUrl);
|
|
await expect(row.locator("[data-link-preview]")).toBeVisible();
|
|
const tags = await page.evaluate(() => {
|
|
const call = [...(window.__BUZZ_E2E_COMMAND_PAYLOADS__ ?? [])]
|
|
.reverse()
|
|
.find((entry) => entry.command === "send_channel_message");
|
|
return (call?.payload as { linkPreviewTags?: string[][] }).linkPreviewTags;
|
|
});
|
|
expect(tags).toHaveLength(1);
|
|
expect(tags?.[0]?.slice(0, 7)).toEqual([
|
|
"link-preview",
|
|
"snapshot",
|
|
"1",
|
|
previewUrl,
|
|
"Buzz pull request",
|
|
"GitHub",
|
|
"A sender-authored preview snapshot.",
|
|
]);
|
|
expect(tags?.[0]?.slice(7)).toEqual(["", "", "", ""]);
|
|
});
|
|
|
|
test("editing a message excludes link previews entirely", async ({ page }) => {
|
|
const message = `Edit-me ${Date.now()}`;
|
|
const previewUrl = "https://github.com/block/buzz/pull/3246?edit=1";
|
|
await page.goto("/");
|
|
await page.getByTestId("channel-general").click();
|
|
const input = page.getByTestId("message-input");
|
|
|
|
// Send a plain message with no link, then edit it to add a supported URL.
|
|
await input.fill(message);
|
|
await input.press("Enter");
|
|
await expect(page.getByTestId("message-timeline")).toContainText(message);
|
|
|
|
await expect(input).toBeFocused();
|
|
await page.keyboard.press("ArrowUp");
|
|
await expect(page.getByTestId("edit-target")).toBeVisible();
|
|
|
|
// Adding a link while editing must NOT resolve, upload, gate Save, or render a
|
|
// composer preview card — edit mode does not persist snapshots (decision A).
|
|
await input.fill(`${message} ${previewUrl}`);
|
|
await expect(page.locator("[data-composer-link-previews]")).toHaveCount(0);
|
|
// No snapshot upload was attempted for the edited link.
|
|
const uploadedPreviewMedia = await page.evaluate(
|
|
() =>
|
|
(window.__BUZZ_E2E_COMMAND_PAYLOADS__ ?? []).filter(
|
|
(entry) =>
|
|
entry.command === "upload_media_bytes" &&
|
|
typeof (entry.payload as { filename?: string })?.filename ===
|
|
"string" &&
|
|
(entry.payload as { filename: string }).filename.startsWith(
|
|
"link-preview-",
|
|
),
|
|
).length,
|
|
);
|
|
expect(uploadedPreviewMedia).toBe(0);
|
|
// Save is not blocked waiting on a snapshot the edit will never emit.
|
|
await expect(page.getByTestId("send-message")).toBeEnabled();
|
|
});
|
|
|
|
test("hiding composer link previews suppresses the whole draft and emits the blanket marker", async ({
|
|
page,
|
|
}) => {
|
|
const firstUrl = "https://github.com/block/buzz/pull/3246?hide=all";
|
|
const secondUrl = "https://linear.app/acme/issue/ABC-123/hidden-too";
|
|
await page.goto("/");
|
|
await page.getByTestId("channel-general").click();
|
|
await page.getByTestId("message-input").fill(firstUrl);
|
|
await expect(page.locator("[data-composer-link-previews]")).toBeVisible();
|
|
await waitForReadyComposerSnapshots(page);
|
|
|
|
await page.getByTestId("composer-hide-link-previews").click();
|
|
await expect(page.locator("[data-composer-link-previews]")).toHaveCount(0);
|
|
await page.getByTestId("message-input").fill(`${firstUrl} ${secondUrl}`);
|
|
await expect(page.locator("[data-composer-link-previews]")).toHaveCount(0);
|
|
await page.getByTestId("send-message").click();
|
|
|
|
const row = page.getByTestId("message-row").last();
|
|
await expect(row).toContainText(firstUrl);
|
|
await expect(row).toContainText(secondUrl);
|
|
await expect(row.locator("[data-link-preview]")).toHaveCount(0);
|
|
const linkPreviewTags = await page.evaluate(() => {
|
|
const call = [...(window.__BUZZ_E2E_COMMAND_PAYLOADS__ ?? [])]
|
|
.reverse()
|
|
.find((entry) => entry.command === "send_channel_message");
|
|
return (
|
|
call?.payload as { linkPreviewTags?: string[][] | null } | undefined
|
|
)?.linkPreviewTags;
|
|
});
|
|
expect(linkPreviewTags).toEqual([["link-preview", "none"]]);
|
|
|
|
await page.getByTestId("message-input").fill(firstUrl);
|
|
await expect(page.locator("[data-composer-link-previews]")).toBeVisible();
|
|
});
|
|
|
|
test("composer link preview embeds stay attachment-sized while loading and ready", async ({
|
|
page,
|
|
}) => {
|
|
const previewUrl = "https://github.com/block/buzz/pull/3246";
|
|
|
|
for (const width of [800, 420]) {
|
|
await page.setViewportSize({ width: 800, height: 700 });
|
|
await page.goto("/");
|
|
await page.getByTestId("channel-general").click();
|
|
await page.setViewportSize({ width, height: 700 });
|
|
await page
|
|
.getByTestId("message-input")
|
|
.fill(`${previewUrl}?viewport=${width}`);
|
|
const card = page
|
|
.locator("[data-composer-link-previews]")
|
|
.locator('[data-link-preview="github-pull-request"]');
|
|
await expect(card).toBeVisible();
|
|
const initial = await card.evaluate((element) => ({
|
|
height: element.getBoundingClientRect().height,
|
|
width: element.getBoundingClientRect().width,
|
|
thumbnailHeight: element
|
|
.querySelector("[data-link-preview-thumbnail]")
|
|
?.getBoundingClientRect().height,
|
|
thumbnailWidth: element
|
|
.querySelector("[data-link-preview-thumbnail]")
|
|
?.getBoundingClientRect().width,
|
|
}));
|
|
if (process.env.BUZZ_LINK_PREVIEW_SCREENSHOTS_DIR) {
|
|
await waitForAnimations(page);
|
|
await page.screenshot({
|
|
animations: "disabled",
|
|
path: `${process.env.BUZZ_LINK_PREVIEW_SCREENSHOTS_DIR}/composer-${width}-loading.png`,
|
|
});
|
|
}
|
|
|
|
await expect(card.locator('[data-slot="attachment"]')).toHaveAttribute(
|
|
"data-state",
|
|
"done",
|
|
);
|
|
const ready = await card.evaluate((element) => ({
|
|
height: element.getBoundingClientRect().height,
|
|
width: element.getBoundingClientRect().width,
|
|
thumbnailHeight: element
|
|
.querySelector("[data-link-preview-thumbnail]")
|
|
?.getBoundingClientRect().height,
|
|
thumbnailWidth: element
|
|
.querySelector("[data-link-preview-thumbnail]")
|
|
?.getBoundingClientRect().width,
|
|
}));
|
|
if (process.env.BUZZ_LINK_PREVIEW_SCREENSHOTS_DIR) {
|
|
await waitForAnimations(page);
|
|
await page.screenshot({
|
|
animations: "disabled",
|
|
path: `${process.env.BUZZ_LINK_PREVIEW_SCREENSHOTS_DIR}/composer-${width}-ready.png`,
|
|
});
|
|
}
|
|
|
|
expect(initial.height).toBe(55);
|
|
expect(initial.width).toBe(320);
|
|
expect(initial.thumbnailHeight).toBe(55);
|
|
expect(initial.thumbnailWidth).toBe(55);
|
|
expect(ready).toEqual(initial);
|
|
}
|
|
});
|
|
|
|
test("compact link preview image geometry truncates long titles to one line", async ({
|
|
page,
|
|
}) => {
|
|
const previewUrl = "https://github.com/block/buzz/pull/3246?geometry=1";
|
|
// Sent snapshot media is relay-hosted and rewritten through the
|
|
// authenticated media proxy (#5627), so serve the fixture from the mock
|
|
// proxy origin rather than the raw relay origin.
|
|
await page.route("http://127.0.0.1:54321/media/**", (route) =>
|
|
route.fulfill({
|
|
body: LINK_PREVIEW_IMAGE,
|
|
contentType: "image/png",
|
|
}),
|
|
);
|
|
await page.setViewportSize({ width: 800, height: 700 });
|
|
await page.goto("/");
|
|
await page.getByTestId("channel-general").click();
|
|
await page.getByTestId("message-input").fill(previewUrl);
|
|
await waitForReadyComposerSnapshots(page);
|
|
await page.getByTestId("send-message").click();
|
|
|
|
const row = page.getByTestId("message-row").last();
|
|
const card = row.locator('[data-link-preview="github-pull-request"]');
|
|
const thumbnail = card.locator("[data-link-preview-thumbnail]");
|
|
const title = card.locator('[data-slot="attachment-title"]');
|
|
const image = thumbnail.locator("img");
|
|
await expect(card).toHaveAttribute("data-image-state", "image");
|
|
await expect(image).toHaveJSProperty("complete", true);
|
|
await expect(card).toHaveCSS("height", "64px");
|
|
await expect(thumbnail).toHaveCSS("height", "64px");
|
|
await expect(thumbnail).toHaveCSS("width", "104px");
|
|
await expect(title).toHaveText(
|
|
"Ship a wider horizontal preview with a two-line title that wraps cleanly",
|
|
);
|
|
await expect(title).toHaveCSS("white-space", "nowrap");
|
|
await expect
|
|
.poll(() =>
|
|
title.evaluate((element) => element.scrollWidth - element.clientWidth),
|
|
)
|
|
.toBeGreaterThan(1);
|
|
|
|
if (process.env.BUZZ_LINK_PREVIEW_SCREENSHOTS_DIR) {
|
|
await waitForAnimations(page);
|
|
await row.screenshot({
|
|
animations: "disabled",
|
|
path: `${process.env.BUZZ_LINK_PREVIEW_SCREENSHOTS_DIR}/recipient-compact-long-title.png`,
|
|
});
|
|
}
|
|
});
|
|
|
|
test("composer no-image link embeds keep the attachment footprint", async ({
|
|
page,
|
|
}) => {
|
|
const previewUrl = "https://github.com/block/buzz/pull/3246?inline=none";
|
|
await page.goto("/");
|
|
await page.getByTestId("channel-general").click();
|
|
await page.getByTestId("message-input").fill(previewUrl);
|
|
const card = page
|
|
.locator("[data-composer-link-previews]")
|
|
.locator('[data-link-preview="github-pull-request"]');
|
|
await expect(card).toHaveAttribute("data-image-state", "none");
|
|
await expect(card.locator("[data-link-preview-thumbnail]")).toBeVisible();
|
|
await expect(card.locator('[data-slot="attachment-title"]')).toContainText(
|
|
/github\.com|Buzz/,
|
|
);
|
|
await expect(card).toHaveCSS("height", "55px");
|
|
});
|
|
|
|
test("mixed link preview image outcomes keep Compact and Rich fallbacks stable", async ({
|
|
page,
|
|
}) => {
|
|
const loadedUrl = "https://github.com/block/buzz/pull/4001";
|
|
const rateLimitedUrl = "https://github.com/block/buzz/pull/4002";
|
|
await page.goto("/");
|
|
await page.getByTestId("channel-general").click();
|
|
await page
|
|
.getByTestId("message-input")
|
|
.fill(`${loadedUrl}\n${rateLimitedUrl}`);
|
|
await waitForReadyComposerSnapshots(page, 2);
|
|
await page.getByTestId("send-message").click();
|
|
|
|
const row = page.getByTestId("message-row").last();
|
|
const compactCards = row.locator('[data-link-preview="github-pull-request"]');
|
|
await expect(compactCards).toHaveCount(2);
|
|
await expect(compactCards.nth(0)).toHaveAttribute(
|
|
"data-image-state",
|
|
"image",
|
|
);
|
|
await expect(
|
|
compactCards.nth(0).locator("[data-link-preview-thumbnail]"),
|
|
).toBeVisible();
|
|
await expect(compactCards.nth(1)).toHaveAttribute("data-image-state", "none");
|
|
await expect(
|
|
compactCards.nth(1).locator("[data-link-preview-image-fallback]"),
|
|
).toHaveCount(0);
|
|
|
|
await openSettings(page, "appearance");
|
|
await page.getByTestId("link-preview-style-rich").click();
|
|
await expect(page.getByTestId("link-preview-style-rich")).toHaveAttribute(
|
|
"aria-pressed",
|
|
"true",
|
|
);
|
|
await page.getByTestId("settings-back-to-app").click();
|
|
|
|
const richCards = row.locator(
|
|
'[data-link-preview="github-pull-request"][data-link-preview-inline]',
|
|
);
|
|
await expect(richCards).toHaveCount(2);
|
|
await expect(
|
|
richCards.nth(1).locator("[data-link-preview-image-fallback]"),
|
|
).toHaveCount(0);
|
|
});
|
|
|
|
test("fragment link previews render a card per canonical URL", async ({
|
|
page,
|
|
}) => {
|
|
// Two links into the SAME page differing only by `#fragment`, plus a link
|
|
// to a second page. The fragment variants collapse to one card (the preview
|
|
// is of the page, not the anchor); the second page adds a second card — two
|
|
// cards total. A resolver that keys previews on the raw fragment-bearing URL
|
|
// drops the fragment cards entirely (the reported bug).
|
|
const fragmentUrlA =
|
|
"https://github.com/block/buzz/pull/3767#pullrequestreview-4857569498";
|
|
const fragmentUrlB = "https://github.com/block/buzz/pull/3767#issuecomment-1";
|
|
const plainUrl = "https://github.com/block/buzz/pull/3867";
|
|
await page.goto("/");
|
|
await page.getByTestId("channel-general").click();
|
|
await page
|
|
.getByTestId("message-input")
|
|
.fill(`${fragmentUrlA}\n${fragmentUrlB}\n${plainUrl}`);
|
|
|
|
const composerCards = page
|
|
.locator("[data-composer-link-previews]")
|
|
.locator('[data-link-preview="github-pull-request"]');
|
|
await expect(composerCards).toHaveCount(2);
|
|
|
|
await waitForReadyComposerSnapshots(page, 2);
|
|
await page.getByTestId("send-message").click();
|
|
|
|
const row = page.getByTestId("message-row").last();
|
|
// Two preview cards: the fragment variants collapsed to the 3767 page, plus
|
|
// the 3867 page.
|
|
await expect(
|
|
row.locator('[data-link-preview="github-pull-request"]'),
|
|
).toHaveCount(2);
|
|
// Both original fragment-bearing prose links survive intact and clickable —
|
|
// the fragment is a navigation anchor, only the preview is normalized.
|
|
await expect(row.locator(`a[href="${fragmentUrlA}"]`)).toBeVisible();
|
|
await expect(row.locator(`a[href="${fragmentUrlB}"]`)).toBeVisible();
|
|
});
|
|
|
|
test("link preview browser image errors render a fallback", async ({
|
|
page,
|
|
}) => {
|
|
await page.goto("/");
|
|
await page.getByTestId("channel-general").click();
|
|
await page
|
|
.getByTestId("message-input")
|
|
.fill("https://github.com/block/buzz/pull/4003");
|
|
await waitForReadyComposerSnapshots(page);
|
|
await page.getByTestId("send-message").click();
|
|
|
|
const row = page.getByTestId("message-row").last();
|
|
const compactCard = row.locator('[data-link-preview="github-pull-request"]');
|
|
await expect(compactCard).toHaveAttribute("data-image-state", "none");
|
|
await expect(
|
|
compactCard.locator("[data-link-preview-image-fallback]"),
|
|
).toHaveCount(0);
|
|
|
|
await openSettings(page, "appearance");
|
|
await page.getByTestId("link-preview-style-rich").click();
|
|
await expect(page.getByTestId("link-preview-style-rich")).toHaveAttribute(
|
|
"aria-pressed",
|
|
"true",
|
|
);
|
|
await page.getByTestId("settings-back-to-app").click();
|
|
|
|
const richCard = row.locator(
|
|
'[data-link-preview="github-pull-request"][data-link-preview-inline]',
|
|
);
|
|
await expect(
|
|
richCard.locator("[data-link-preview-image-fallback]"),
|
|
).toHaveCount(0);
|
|
});
|
|
|
|
test("supported Compact link previews keep the message link visible with square outer corners", async ({
|
|
page,
|
|
}) => {
|
|
const previewUrl = "https://github.com/block/sprout/pull/1334";
|
|
|
|
await page.goto("/");
|
|
await page.getByTestId("channel-general").click();
|
|
await expect(page.getByTestId("chat-title")).toHaveText("general");
|
|
|
|
await page.getByTestId("message-input").fill(previewUrl);
|
|
await waitForReadyComposerSnapshots(page);
|
|
await page.getByTestId("send-message").click();
|
|
|
|
const row = page.getByTestId("message-row").last();
|
|
await expect(
|
|
row.getByRole("link", { exact: true, name: previewUrl }),
|
|
).toBeVisible();
|
|
const previewCard = row.locator('[data-link-preview="github-pull-request"]');
|
|
await expect(previewCard).toBeVisible();
|
|
await expectCornerRadiusPx(previewCard, 0);
|
|
});
|
|
|
|
test("send multiple messages in sequence", async ({ page }) => {
|
|
const ts = Date.now();
|
|
const messages = [
|
|
`First message ${ts}`,
|
|
`Second message ${ts}`,
|
|
`Third message ${ts}`,
|
|
];
|
|
const input = page.getByTestId("message-input");
|
|
const sendButton = page.getByTestId("send-message");
|
|
|
|
await page.goto("/");
|
|
await page.getByTestId("channel-general").click();
|
|
await expect(page.getByTestId("chat-title")).toHaveText("general");
|
|
|
|
for (const message of messages) {
|
|
await input.fill(message);
|
|
await sendButton.click();
|
|
await expect(page.getByTestId("message-timeline")).toContainText(message);
|
|
}
|
|
|
|
const timeline = page.getByTestId("message-timeline");
|
|
for (const message of messages) {
|
|
await expect(timeline).toContainText(message);
|
|
}
|
|
});
|
|
|
|
test("copy a rendered code block and paste it back as code", async ({
|
|
page,
|
|
}) => {
|
|
await page.context().grantPermissions(["clipboard-read", "clipboard-write"], {
|
|
origin: "http://127.0.0.1:4173",
|
|
});
|
|
|
|
const code = "# not a heading\nconst answer = 42;\n indented();";
|
|
|
|
await page.goto("/");
|
|
await page.getByTestId("channel-general").click();
|
|
await expect(page.getByTestId("chat-title")).toHaveText("general");
|
|
|
|
const input = page.getByTestId("message-input");
|
|
await page.evaluate(
|
|
(text) => navigator.clipboard.writeText(text),
|
|
`\`\`\`ts\n${code}\n\`\`\``,
|
|
);
|
|
await input.click();
|
|
await page.keyboard.press("ControlOrMeta+V");
|
|
await page.getByTestId("send-message").click();
|
|
|
|
const codeBlock = page.locator("[data-code-block]");
|
|
await expect(codeBlock).toHaveCount(1);
|
|
await expectCornerRadiusPx(codeBlock.locator("pre"), 16);
|
|
await expectSmoothCorners(codeBlock.locator("pre"));
|
|
|
|
const copyButton = page.getByLabel("Copy code block");
|
|
await expect(copyButton).toHaveCSS("opacity", "0");
|
|
await codeBlock.hover();
|
|
await expect(copyButton).toHaveCSS("opacity", "1");
|
|
await copyButton.click();
|
|
await expect
|
|
.poll(() => page.evaluate(() => navigator.clipboard.readText()))
|
|
.toBe(code);
|
|
|
|
await input.click();
|
|
await page.keyboard.press("ControlOrMeta+V");
|
|
await input.press("Enter");
|
|
|
|
await expect(codeBlock).toHaveCount(2);
|
|
});
|
|
|
|
test("pasting a long copied code block scrolls composer to cursor", async ({
|
|
page,
|
|
}) => {
|
|
await page.context().grantPermissions(["clipboard-read", "clipboard-write"], {
|
|
origin: "http://127.0.0.1:4173",
|
|
});
|
|
|
|
const longCode = Array.from(
|
|
{ length: 48 },
|
|
(_, index) => `const line${index} = ${index};`,
|
|
).join("\n");
|
|
|
|
await page.goto("/");
|
|
await page.getByTestId("channel-general").click();
|
|
await expect(page.getByTestId("chat-title")).toHaveText("general");
|
|
|
|
const input = page.getByTestId("message-input");
|
|
await page.evaluate(
|
|
(text) => navigator.clipboard.writeText(text),
|
|
`\`\`\`ts\n${longCode}\n\`\`\``,
|
|
);
|
|
await input.click();
|
|
await page.keyboard.press("ControlOrMeta+V");
|
|
await page.getByTestId("send-message").click();
|
|
|
|
const copiedCodeBlock = page.locator("[data-code-block]");
|
|
await expect(copiedCodeBlock).toHaveCount(1);
|
|
await copiedCodeBlock.hover();
|
|
await page.getByLabel("Copy code block").click();
|
|
|
|
await input.fill("typed before paste");
|
|
await page.keyboard.press("ControlOrMeta+V");
|
|
|
|
const scrollContainer = page.getByTestId("message-input-scroll");
|
|
await expect
|
|
.poll(() =>
|
|
scrollContainer.evaluate(
|
|
(element) =>
|
|
element.scrollHeight - element.clientHeight - element.scrollTop,
|
|
),
|
|
)
|
|
.toBeLessThanOrEqual(1);
|
|
});
|
|
|
|
test("code block shows language label when language is specified", async ({
|
|
page,
|
|
}) => {
|
|
await page.context().grantPermissions(["clipboard-read", "clipboard-write"], {
|
|
origin: "http://127.0.0.1:4173",
|
|
});
|
|
|
|
await page.goto("/");
|
|
await page.getByTestId("channel-general").click();
|
|
await expect(page.getByTestId("chat-title")).toHaveText("general");
|
|
|
|
const input = page.getByTestId("message-input");
|
|
await page.evaluate(
|
|
(text) => navigator.clipboard.writeText(text),
|
|
"```typescript\nconst x = 1;\n```",
|
|
);
|
|
|
|
await input.click();
|
|
await page.keyboard.press("ControlOrMeta+V");
|
|
await page.getByTestId("send-message").click();
|
|
|
|
const codeBlock = page.locator("[data-code-block]");
|
|
await expect(codeBlock).toBeVisible();
|
|
await expect(codeBlock.getByText("typescript")).toBeVisible();
|
|
});
|
|
|
|
test("typing triple backticks and Enter creates a code block in composer", async ({
|
|
page,
|
|
}) => {
|
|
await page.goto("/");
|
|
await page.getByTestId("channel-general").click();
|
|
await expect(page.getByTestId("chat-title")).toHaveText("general");
|
|
|
|
const input = page.getByTestId("message-input");
|
|
await input.click();
|
|
await page.keyboard.type("```");
|
|
await page.keyboard.press("Enter");
|
|
|
|
// A <pre> code block should appear inside the ProseMirror editor
|
|
const editorPre = input.locator("pre");
|
|
await expect(editorPre).toBeVisible();
|
|
|
|
// The literal backticks should be consumed (not visible as text)
|
|
await expect(input).not.toContainText("```");
|
|
});
|
|
|
|
test("message input clears after send", async ({ page }) => {
|
|
const message = `Clear after send ${Date.now()}`;
|
|
const input = page.getByTestId("message-input");
|
|
|
|
await page.goto("/");
|
|
await page.getByTestId("channel-general").click();
|
|
await expect(page.getByTestId("chat-title")).toHaveText("general");
|
|
|
|
await input.fill(message);
|
|
await expect(input).toHaveText(message);
|
|
await page.getByTestId("send-message").click();
|
|
|
|
await expect(page.getByTestId("message-timeline")).toContainText(message);
|
|
await expect(input).toHaveText("");
|
|
});
|
|
|
|
test("emoji picker inserts emoji into the draft and keeps focus in the composer", async ({
|
|
page,
|
|
}) => {
|
|
await page.goto("/");
|
|
await page.getByTestId("channel-general").click();
|
|
await expect(page.getByTestId("chat-title")).toHaveText("general");
|
|
|
|
const input = page.getByTestId("message-input");
|
|
await input.fill("Ship");
|
|
|
|
await page.getByTestId("composer-emoji-button").click();
|
|
|
|
// emoji-mart renders inside a Shadow DOM web component — use the search
|
|
// input to find the rocket emoji, then click it.
|
|
const pickerEl = page.locator("em-emoji-picker");
|
|
const searchInput = pickerEl.locator("input[type='search']");
|
|
await searchInput.fill("rocket");
|
|
await pickerEl.locator("button[aria-label='🚀']").first().click();
|
|
|
|
await expect(input).toHaveText("Ship🚀");
|
|
await expect(input).toBeFocused();
|
|
|
|
await input.pressSequentially(" now");
|
|
await expect(input).toHaveText("Ship🚀 now");
|
|
});
|
|
|
|
test("relay GIF capability gates the composer picker", async ({ page }) => {
|
|
await page.route("http://localhost:3000/info", (route) =>
|
|
route.fulfill({
|
|
body: JSON.stringify({ supported_extensions: [] }),
|
|
contentType: "application/nostr+json",
|
|
}),
|
|
);
|
|
|
|
await page.goto("/");
|
|
await page.getByTestId("channel-general").click();
|
|
const pickerButton = page.getByTestId("composer-emoji-button");
|
|
await expect(pickerButton).toHaveAccessibleName("Insert emoji");
|
|
await pickerButton.click();
|
|
await expect(page.getByRole("tab", { name: "GIFs" })).toHaveCount(0);
|
|
});
|
|
|
|
test("relay GIF search selects content-only media and reports the share", async ({
|
|
page,
|
|
}) => {
|
|
const searchBodies: Array<{
|
|
customer_id: string;
|
|
locale: string;
|
|
query: string;
|
|
}> = [];
|
|
const shareBodies: Array<{ customer_id: string; slug: string }> = [];
|
|
const gifUrl = "https://static.klipy.com/e2e-ship-it.gif";
|
|
const previewUrl = "https://static.klipy.com/e2e-ship-it.webp";
|
|
|
|
await page.route("http://localhost:3000/info", (route) =>
|
|
route.fulfill({
|
|
body: JSON.stringify({
|
|
gif: {
|
|
provider: "klipy",
|
|
search: "/gifs/search",
|
|
share: "/gifs/share",
|
|
},
|
|
supported_extensions: ["buzz-gif"],
|
|
}),
|
|
contentType: "application/nostr+json",
|
|
}),
|
|
);
|
|
await page.route("http://localhost:3000/gifs/search", async (route) => {
|
|
searchBodies.push(JSON.parse(route.request().postData() ?? "{}"));
|
|
await route.fulfill({
|
|
body: JSON.stringify({
|
|
result: true,
|
|
data: {
|
|
data: [
|
|
{
|
|
id: null,
|
|
file: {
|
|
md: {
|
|
gif: { height: 180, size: 42, url: gifUrl, width: 320 },
|
|
},
|
|
sm: {
|
|
webp: {
|
|
height: 90,
|
|
size: 12,
|
|
url: previewUrl,
|
|
width: 160,
|
|
},
|
|
},
|
|
},
|
|
slug: "e2e-ship-it",
|
|
title: "Ship it",
|
|
type: "gif",
|
|
},
|
|
],
|
|
},
|
|
}),
|
|
contentType: "application/json",
|
|
});
|
|
});
|
|
await page.route("http://localhost:3000/gifs/share", async (route) => {
|
|
shareBodies.push(JSON.parse(route.request().postData() ?? "{}"));
|
|
await route.fulfill({ status: 204 });
|
|
});
|
|
await page.route("https://static.klipy.com/e2e-ship-it.*", (route) =>
|
|
route.fulfill({
|
|
body: '<svg xmlns="http://www.w3.org/2000/svg" width="320" height="180"><rect width="320" height="180" fill="#7c3aed"/></svg>',
|
|
contentType: "image/svg+xml",
|
|
}),
|
|
);
|
|
|
|
await page.goto("/");
|
|
await page.getByTestId("channel-general").click();
|
|
const pickerButton = page.getByTestId("composer-emoji-button");
|
|
await expect(pickerButton).toHaveAccessibleName("Insert emoji or GIF");
|
|
await pickerButton.click();
|
|
await page.getByRole("tab", { name: "GIFs" }).click();
|
|
|
|
await expect.poll(() => searchBodies.map(({ query }) => query)).toContain("");
|
|
await expect(
|
|
page.getByRole("button", { name: "Choose Ship it" }),
|
|
).toBeVisible();
|
|
|
|
await page.getByRole("searchbox", { name: "Search KLIPY" }).fill("celebrate");
|
|
await expect
|
|
.poll(() => searchBodies.map(({ query }) => query))
|
|
.toContain("celebrate");
|
|
await page.getByRole("button", { name: "Choose Ship it" }).click();
|
|
|
|
await expect(page.getByTestId("composer-media-attachment")).toBeVisible();
|
|
await expect
|
|
.poll(() => shareBodies)
|
|
.toEqual([
|
|
{
|
|
customer_id: searchBodies.at(-1)?.customer_id,
|
|
slug: "e2e-ship-it",
|
|
},
|
|
]);
|
|
|
|
await page.getByTestId("send-message").click();
|
|
await expect(page.getByTestId("composer-media-attachment")).toHaveCount(0);
|
|
await expect
|
|
.poll(() =>
|
|
page.evaluate((expectedUrl) => {
|
|
return Boolean(
|
|
(
|
|
window as Window & {
|
|
__BUZZ_E2E_SIGNED_EVENTS__?: Array<{
|
|
content?: string;
|
|
kind?: number;
|
|
}>;
|
|
}
|
|
).__BUZZ_E2E_SIGNED_EVENTS__?.some(
|
|
(event) => event.kind === 9 && event.content?.includes(expectedUrl),
|
|
),
|
|
);
|
|
}, gifUrl),
|
|
)
|
|
.toBe(true);
|
|
const matchingEvent = await page.evaluate((expectedUrl) => {
|
|
return (
|
|
window as Window & {
|
|
__BUZZ_E2E_SIGNED_EVENTS__?: Array<{
|
|
content?: string;
|
|
kind?: number;
|
|
tags?: string[][];
|
|
}>;
|
|
}
|
|
).__BUZZ_E2E_SIGNED_EVENTS__?.find(
|
|
(event) => event.kind === 9 && event.content?.includes(expectedUrl),
|
|
);
|
|
}, gifUrl);
|
|
expect(matchingEvent?.content).toBe(``);
|
|
expect(matchingEvent?.tags?.some((tag) => tag[0] === "imeta")).toBe(false);
|
|
});
|
|
|
|
async function routeGifMocks(page: import("@playwright/test").Page) {
|
|
const gifs = [
|
|
{
|
|
animated: "https://static.klipy.com/e2e-party.gif",
|
|
poster: "https://static.klipy.com/e2e-party.jpg",
|
|
slug: "e2e-party",
|
|
title: "Party parrot",
|
|
},
|
|
{
|
|
animated: "https://static.klipy.com/e2e-thumbs-up.gif",
|
|
poster: "https://static.klipy.com/e2e-thumbs-up.jpg",
|
|
slug: "e2e-thumbs-up",
|
|
title: "Thumbs up",
|
|
},
|
|
];
|
|
|
|
await page.route("http://localhost:3000/info", (route) =>
|
|
route.fulfill({
|
|
body: JSON.stringify({
|
|
gif: {
|
|
provider: "klipy",
|
|
search: "/gifs/search",
|
|
share: "/gifs/share",
|
|
},
|
|
supported_extensions: ["buzz-gif"],
|
|
}),
|
|
contentType: "application/nostr+json",
|
|
}),
|
|
);
|
|
await page.route("http://localhost:3000/gifs/search", (route) =>
|
|
route.fulfill({
|
|
body: JSON.stringify({
|
|
result: true,
|
|
data: {
|
|
data: gifs.map((gif) => ({
|
|
id: null,
|
|
file: {
|
|
md: {
|
|
gif: { height: 180, size: 42, url: gif.animated, width: 320 },
|
|
},
|
|
sm: {
|
|
jpg: { height: 90, size: 8, url: gif.poster, width: 160 },
|
|
webp: {
|
|
height: 90,
|
|
size: 12,
|
|
url: `${gif.animated.replace(/\.gif$/, ".webp")}`,
|
|
width: 160,
|
|
},
|
|
},
|
|
},
|
|
slug: gif.slug,
|
|
title: gif.title,
|
|
type: "gif",
|
|
})),
|
|
},
|
|
}),
|
|
contentType: "application/json",
|
|
}),
|
|
);
|
|
await page.route("http://localhost:3000/gifs/share", (route) =>
|
|
route.fulfill({ status: 204 }),
|
|
);
|
|
await page.route("https://static.klipy.com/e2e-*", (route) =>
|
|
route.fulfill({
|
|
body: '<svg xmlns="http://www.w3.org/2000/svg" width="320" height="180"><rect width="320" height="180" fill="#7c3aed"/></svg>',
|
|
contentType: "image/svg+xml",
|
|
}),
|
|
);
|
|
|
|
return gifs;
|
|
}
|
|
|
|
async function openGifGrid(page: import("@playwright/test").Page) {
|
|
await page.goto("/");
|
|
await page.getByTestId("channel-general").click();
|
|
await page.getByTestId("composer-emoji-button").click();
|
|
await page.getByRole("tab", { name: "GIFs" }).click();
|
|
await expect(page.getByTestId("klipy-gif-grid")).toBeVisible();
|
|
}
|
|
|
|
test("reduced-motion GIF grid renders static posters, not animated previews", async ({
|
|
page,
|
|
}) => {
|
|
await page.emulateMedia({ reducedMotion: "reduce" });
|
|
const gifs = await routeGifMocks(page);
|
|
await openGifGrid(page);
|
|
|
|
const grid = page.getByTestId("klipy-gif-grid");
|
|
// Each GIF stays identifiable and selectable by its accessible name.
|
|
for (const gif of gifs) {
|
|
await expect(
|
|
grid.getByRole("button", { name: `Choose ${gif.title}` }),
|
|
).toBeVisible();
|
|
}
|
|
|
|
const sources = await grid
|
|
.locator("img")
|
|
.evaluateAll((imgs) =>
|
|
imgs.map((img) => (img as HTMLImageElement).getAttribute("src")),
|
|
);
|
|
// The static poster is shown; no animated preview URL enters the DOM.
|
|
expect(sources).toEqual(gifs.map((gif) => gif.poster));
|
|
for (const gif of gifs) {
|
|
expect(sources).not.toContain(gif.animated);
|
|
expect(sources.some((src) => src?.endsWith(".webp"))).toBe(false);
|
|
}
|
|
});
|
|
|
|
test("reduced-motion GIF grid falls back to a named static placeholder", async ({
|
|
page,
|
|
}) => {
|
|
await page.emulateMedia({ reducedMotion: "reduce" });
|
|
await page.route("http://localhost:3000/info", (route) =>
|
|
route.fulfill({
|
|
body: JSON.stringify({
|
|
gif: {
|
|
provider: "klipy",
|
|
search: "/gifs/search",
|
|
share: "/gifs/share",
|
|
},
|
|
supported_extensions: ["buzz-gif"],
|
|
}),
|
|
contentType: "application/nostr+json",
|
|
}),
|
|
);
|
|
// A GIF with no jpg asset: reduced motion must not fall back to animation.
|
|
await page.route("http://localhost:3000/gifs/search", (route) =>
|
|
route.fulfill({
|
|
body: JSON.stringify({
|
|
result: true,
|
|
data: {
|
|
data: [
|
|
{
|
|
id: null,
|
|
file: {
|
|
md: {
|
|
gif: {
|
|
height: 180,
|
|
size: 42,
|
|
url: "https://static.klipy.com/e2e-no-poster.gif",
|
|
width: 320,
|
|
},
|
|
},
|
|
sm: {
|
|
webp: {
|
|
height: 90,
|
|
size: 12,
|
|
url: "https://static.klipy.com/e2e-no-poster.webp",
|
|
width: 160,
|
|
},
|
|
},
|
|
},
|
|
slug: "e2e-no-poster",
|
|
title: "No poster clip",
|
|
type: "gif",
|
|
},
|
|
],
|
|
},
|
|
}),
|
|
contentType: "application/json",
|
|
}),
|
|
);
|
|
await page.route("http://localhost:3000/gifs/share", (route) =>
|
|
route.fulfill({ status: 204 }),
|
|
);
|
|
|
|
await openGifGrid(page);
|
|
|
|
const grid = page.getByTestId("klipy-gif-grid");
|
|
await expect(
|
|
grid.getByRole("button", { name: "Choose No poster clip" }),
|
|
).toBeVisible();
|
|
await expect(grid.getByTestId("klipy-gif-static-placeholder")).toHaveText(
|
|
"No poster clip",
|
|
);
|
|
await expect(grid.locator("img")).toHaveCount(0);
|
|
});
|
|
|
|
test("normal-motion GIF grid renders animated previews", async ({ page }) => {
|
|
await page.emulateMedia({ reducedMotion: "no-preference" });
|
|
const gifs = await routeGifMocks(page);
|
|
await openGifGrid(page);
|
|
|
|
const sources = await page
|
|
.getByTestId("klipy-gif-grid")
|
|
.locator("img")
|
|
.evaluateAll((imgs) =>
|
|
imgs.map((img) => (img as HTMLImageElement).getAttribute("src")),
|
|
);
|
|
// Animated `.webp` previews are used; static jpg posters stay out of the DOM.
|
|
expect(sources.every((src) => src?.endsWith(".webp"))).toBe(true);
|
|
for (const gif of gifs) {
|
|
expect(sources).not.toContain(gif.poster);
|
|
}
|
|
});
|
|
|
|
test("selected GIFs keep distinct accessible names in the composer and lightbox", async ({
|
|
page,
|
|
}) => {
|
|
const gifs = await routeGifMocks(page);
|
|
await page.goto("/");
|
|
await page.getByTestId("channel-general").click();
|
|
|
|
// Select two differently titled GIFs.
|
|
for (const gif of gifs) {
|
|
await page.getByTestId("composer-emoji-button").click();
|
|
await page.getByRole("tab", { name: "GIFs" }).click();
|
|
await page
|
|
.getByTestId("klipy-gif-grid")
|
|
.getByRole("button", { name: `Choose ${gif.title}` })
|
|
.click();
|
|
}
|
|
|
|
const thumbnails = page.getByTestId("composer-media-attachment");
|
|
await expect(thumbnails).toHaveCount(2);
|
|
|
|
// Composer thumbnails carry distinct accessible names from the GIF titles.
|
|
for (const gif of gifs) {
|
|
await expect(
|
|
thumbnails.getByRole("button", { name: gif.title, exact: true }),
|
|
).toBeVisible();
|
|
await expect(
|
|
page.getByRole("button", { name: `Remove ${gif.title}` }),
|
|
).toHaveCount(1);
|
|
}
|
|
|
|
// Each lightbox dialog is titled by the same GIF name.
|
|
for (const gif of gifs) {
|
|
await thumbnails
|
|
.getByRole("button", { name: gif.title, exact: true })
|
|
.click();
|
|
await expect(
|
|
page.getByRole("dialog", { name: `${gif.title} preview` }),
|
|
).toBeVisible();
|
|
await page.getByRole("button", { name: "Close", exact: true }).click();
|
|
await expect(page.getByRole("dialog")).toHaveCount(0);
|
|
}
|
|
});
|
|
|
|
test("empty message cannot be sent", async ({ page }) => {
|
|
await page.goto("/");
|
|
await page.getByTestId("channel-general").click();
|
|
await expect(page.getByTestId("chat-title")).toHaveText("general");
|
|
|
|
const sendButton = page.getByTestId("send-message");
|
|
await expect(sendButton).toBeDisabled();
|
|
});
|
|
|
|
test("send message with Enter key", async ({ page }) => {
|
|
const message = `Enter key send ${Date.now()}`;
|
|
const input = page.getByTestId("message-input");
|
|
|
|
await page.goto("/");
|
|
await page.getByTestId("channel-general").click();
|
|
await expect(page.getByTestId("chat-title")).toHaveText("general");
|
|
|
|
await input.fill(message);
|
|
await input.press("Enter");
|
|
|
|
await expect(page.getByTestId("message-timeline")).toContainText(message);
|
|
});
|
|
|
|
test("messages persist across channel switches", async ({ page }) => {
|
|
const message = `Persist across switch ${Date.now()}`;
|
|
|
|
await page.goto("/");
|
|
await page.getByTestId("channel-general").click();
|
|
await expect(page.getByTestId("chat-title")).toHaveText("general");
|
|
|
|
await page.getByTestId("message-input").fill(message);
|
|
await page.getByTestId("send-message").click();
|
|
await expect(page.getByTestId("message-timeline")).toContainText(message);
|
|
|
|
await page.getByTestId("channel-random").click();
|
|
await expect(page.getByTestId("chat-title")).toHaveText("random");
|
|
|
|
await page.getByTestId("channel-general").click();
|
|
await expect(page.getByTestId("chat-title")).toHaveText("general");
|
|
await expect(page.getByTestId("message-timeline")).toContainText(message);
|
|
});
|
|
|
|
test("draft is preserved when switching channels", async ({ page }) => {
|
|
const draft = `Unsent draft ${Date.now()}`;
|
|
const input = page.getByTestId("message-input");
|
|
|
|
await page.goto("/");
|
|
await page.getByTestId("channel-general").click();
|
|
await expect(page.getByTestId("chat-title")).toHaveText("general");
|
|
|
|
// Type a draft but do not send it
|
|
await input.fill(draft);
|
|
await expect(input).toHaveText(draft);
|
|
|
|
// Switch to another channel — composer should be empty
|
|
await page.getByTestId("channel-random").click();
|
|
await expect(page.getByTestId("chat-title")).toHaveText("random");
|
|
await expect(input).toHaveText("");
|
|
|
|
// Switch back — the draft should still be there
|
|
await page.getByTestId("channel-general").click();
|
|
await expect(page.getByTestId("chat-title")).toHaveText("general");
|
|
await expect(input).toHaveText(draft);
|
|
});
|
|
|
|
test("sending a message clears the draft", async ({ page }) => {
|
|
const message = `Sent message ${Date.now()}`;
|
|
const input = page.getByTestId("message-input");
|
|
|
|
await page.goto("/");
|
|
await page.getByTestId("channel-general").click();
|
|
await expect(page.getByTestId("chat-title")).toHaveText("general");
|
|
|
|
// Type and send a message
|
|
await input.fill(message);
|
|
await page.getByTestId("send-message").click();
|
|
await expect(page.getByTestId("message-timeline")).toContainText(message);
|
|
|
|
// Switch away and back — composer should be empty, not restored from draft
|
|
await page.getByTestId("channel-random").click();
|
|
await expect(page.getByTestId("chat-title")).toHaveText("random");
|
|
await page.getByTestId("channel-general").click();
|
|
await expect(page.getByTestId("chat-title")).toHaveText("general");
|
|
await expect(input).toHaveText("");
|
|
});
|
|
|
|
test("different channels have independent messages", async ({ page }) => {
|
|
const ts = Date.now();
|
|
const generalMessage = `General only ${ts}`;
|
|
const randomMessage = `Random only ${ts}`;
|
|
|
|
await page.goto("/");
|
|
await page.getByTestId("channel-general").click();
|
|
await expect(page.getByTestId("chat-title")).toHaveText("general");
|
|
await page.getByTestId("message-input").fill(generalMessage);
|
|
await page.getByTestId("send-message").click();
|
|
await expect(page.getByTestId("message-timeline")).toContainText(
|
|
generalMessage,
|
|
);
|
|
|
|
await page.getByTestId("channel-random").click();
|
|
await expect(page.getByTestId("chat-title")).toHaveText("random");
|
|
await expect(page.getByTestId("message-timeline")).not.toContainText(
|
|
generalMessage,
|
|
);
|
|
|
|
await page.getByTestId("message-input").fill(randomMessage);
|
|
await page.getByTestId("send-message").click();
|
|
await expect(page.getByTestId("message-timeline")).toContainText(
|
|
randomMessage,
|
|
);
|
|
|
|
await page.getByTestId("channel-general").click();
|
|
await expect(page.getByTestId("chat-title")).toHaveText("general");
|
|
await expect(page.getByTestId("message-timeline")).toContainText(
|
|
generalMessage,
|
|
);
|
|
await expect(page.getByTestId("message-timeline")).not.toContainText(
|
|
randomMessage,
|
|
);
|
|
});
|
|
|
|
test("day divider appears in timeline", async ({ page }) => {
|
|
await page.goto("/");
|
|
await page.getByTestId("channel-general").click();
|
|
await expect(page.getByTestId("chat-title")).toHaveText("general");
|
|
|
|
await expect(page.getByTestId("message-timeline")).toContainText(
|
|
"Welcome to general",
|
|
);
|
|
// `.first()`: the seeds are backdated by up to 120s, so a run that straddles
|
|
// midnight UTC legitimately renders two dividers (Yesterday + Today) and a
|
|
// bare locator fails Playwright strict mode. The intent is "a divider
|
|
// appears", which the first divider proves on both sides of midnight.
|
|
await expect(
|
|
page.getByTestId("message-timeline-day-divider").first(),
|
|
).toBeVisible();
|
|
});
|
|
|
|
test("send message to DM channel p-tags the recipient", async ({ page }) => {
|
|
const message = `DM message ${Date.now()}`;
|
|
|
|
await page.goto("/");
|
|
await page.getByTestId("channel-alice-tyler").click();
|
|
await expect(page.getByTestId("chat-title")).toHaveText("alice-tyler");
|
|
|
|
await page.getByTestId("message-input").fill(message);
|
|
await page.getByTestId("send-message").click();
|
|
|
|
await expect(page.getByTestId("message-timeline")).toContainText(message);
|
|
await expect
|
|
.poll(() =>
|
|
page.evaluate((content) => {
|
|
const events = (
|
|
window as Window & {
|
|
__BUZZ_E2E_SIGNED_EVENTS__?: Array<{
|
|
content: string;
|
|
tags: string[][];
|
|
}>;
|
|
}
|
|
).__BUZZ_E2E_SIGNED_EVENTS__;
|
|
return events?.find((event) => event.content === content)?.tags ?? [];
|
|
}, message),
|
|
)
|
|
.toContainEqual(["p", TEST_IDENTITIES.alice.pubkey]);
|
|
});
|
|
|
|
test("sends a thread message to its parent channel with a root-thread link", async ({
|
|
page,
|
|
}) => {
|
|
const timestamp = Date.now();
|
|
const rootContent = `🧵 Share source thread ${timestamp}`;
|
|
const priorChannelMessage = `Prior channel message ${timestamp}`;
|
|
const replySummary = `Share this reply ${timestamp}`;
|
|
const attachmentSha = "d".repeat(64);
|
|
const attachmentUrl = `http://localhost:3000/media/${attachmentSha}.txt`;
|
|
const customEmojiUrl = "https://example.com/send-to-channel-party.svg";
|
|
const previewUrl = "https://github.com/block/buzz/pull/5305";
|
|
const ownReplyContent = [
|
|
`${replySummary} with @alice :party:`,
|
|
`[launch-notes.txt](${attachmentUrl})`,
|
|
previewUrl,
|
|
].join("\n\n");
|
|
const imetaTag = [
|
|
"imeta",
|
|
`url ${attachmentUrl}`,
|
|
"m text/plain",
|
|
`x ${attachmentSha}`,
|
|
"size 42",
|
|
"filename launch-notes.txt",
|
|
];
|
|
const emojiTag = ["emoji", "party", customEmojiUrl];
|
|
const mentionTag = ["mention", TEST_IDENTITIES.alice.pubkey];
|
|
const linkPreviewTag = [
|
|
"link-preview",
|
|
"snapshot",
|
|
"1",
|
|
previewUrl,
|
|
"Add Send to channel for thread messages",
|
|
"GitHub",
|
|
"A shared link preview preserved from the source thread message.",
|
|
"",
|
|
"",
|
|
"",
|
|
"",
|
|
];
|
|
|
|
await page.route(customEmojiUrl, (route) =>
|
|
route.fulfill({
|
|
body: '<svg xmlns="http://www.w3.org/2000/svg" width="32" height="32"><circle cx="16" cy="16" r="15" fill="#a78bfa"/><path d="M8 18l5 5 11-13" fill="none" stroke="white" stroke-width="3"/></svg>',
|
|
contentType: "image/svg+xml",
|
|
}),
|
|
);
|
|
|
|
await page.goto("/");
|
|
await page.getByTestId("channel-general").click();
|
|
await expect(page.getByTestId("chat-title")).toHaveText("general");
|
|
await page.waitForFunction(
|
|
() =>
|
|
typeof window.__BUZZ_E2E_EMIT_MOCK_MESSAGE__ === "function" &&
|
|
(window.__BUZZ_E2E_HAS_MOCK_LIVE_SUBSCRIPTION__?.({
|
|
channelName: "general",
|
|
}) ??
|
|
false),
|
|
);
|
|
|
|
const { ownReplyId, rootId } = await page.evaluate(
|
|
({ alicePubkey, ownReply, root, semanticTags }) => {
|
|
const emit = window.__BUZZ_E2E_EMIT_MOCK_MESSAGE__;
|
|
if (!emit) throw new Error("Mock message emitter is unavailable.");
|
|
const rootEvent = emit({
|
|
channelName: "general",
|
|
content: root,
|
|
pubkey: alicePubkey,
|
|
});
|
|
const ownReplyEvent = emit({
|
|
channelName: "general",
|
|
content: ownReply,
|
|
extraTags: semanticTags,
|
|
mentionPubkeys: [alicePubkey],
|
|
parentEventId: rootEvent.id,
|
|
});
|
|
return {
|
|
ownReplyId: ownReplyEvent.id,
|
|
rootId: rootEvent.id,
|
|
};
|
|
},
|
|
{
|
|
alicePubkey: TEST_IDENTITIES.alice.pubkey,
|
|
ownReply: ownReplyContent,
|
|
root: rootContent,
|
|
semanticTags: [imetaTag, emojiTag, mentionTag, linkPreviewTag],
|
|
},
|
|
);
|
|
|
|
const timeline = page.getByTestId("message-timeline");
|
|
const rootRow = timeline.locator(`[data-message-id="${rootId}"]`);
|
|
await expect(rootRow).toContainText(rootContent);
|
|
|
|
await page.getByTestId("message-input").fill(priorChannelMessage);
|
|
await page.getByTestId("send-message").click();
|
|
const priorChannelRow = timeline
|
|
.getByTestId("message-row")
|
|
.filter({ hasText: priorChannelMessage });
|
|
await expect(priorChannelRow).toBeVisible();
|
|
await expect(priorChannelRow.getByTestId("message-send-status")).toHaveCount(
|
|
0,
|
|
);
|
|
|
|
await timeline
|
|
.locator(
|
|
`[data-testid="message-thread-summary"][data-thread-head-id="${rootId}"]`,
|
|
)
|
|
.click();
|
|
const threadPanel = page.getByTestId("message-thread-panel");
|
|
const threadRootRow = threadPanel.locator(`[data-message-id="${rootId}"]`);
|
|
const rootMoreActions = threadRootRow.getByTestId(`more-actions-${rootId}`);
|
|
await rootMoreActions.click({ force: true });
|
|
await expect(page.getByRole("menu")).toBeVisible();
|
|
await expect(
|
|
page.getByRole("menuitem", { name: "Send to channel" }),
|
|
).toHaveCount(0);
|
|
await page.keyboard.press("Escape");
|
|
await expect(page.getByRole("menu")).toHaveCount(0);
|
|
|
|
const ownReplyRow = threadPanel.locator(`[data-message-id="${ownReplyId}"]`);
|
|
await expect(ownReplyRow).toContainText(replySummary);
|
|
await ownReplyRow
|
|
.getByTestId(`more-actions-${ownReplyId}`)
|
|
.click({ force: true });
|
|
const sendToChannelItem = page.getByRole("menuitem", {
|
|
name: "Send to channel",
|
|
});
|
|
const sendToChannelIcon = sendToChannelItem.getByTestId(
|
|
"send-to-channel-icon",
|
|
);
|
|
await expect(sendToChannelIcon).toBeVisible();
|
|
await expect(sendToChannelIcon).toHaveAttribute("aria-hidden", "true");
|
|
await expect(sendToChannelIcon).toHaveClass(/lucide-hash-arrow-in/);
|
|
await expect
|
|
.poll(async () => {
|
|
const box = await sendToChannelIcon.boundingBox();
|
|
return box ? [box.width, box.height] : null;
|
|
})
|
|
.toEqual([16, 16]);
|
|
await sendToChannelItem.click();
|
|
|
|
await expect(
|
|
page.locator("[data-sonner-toast]").filter({ hasText: "Sent to channel" }),
|
|
).toBeVisible();
|
|
await expect
|
|
.poll(() =>
|
|
page.evaluate((content) => {
|
|
return Boolean(
|
|
(window.__BUZZ_E2E_COMMAND_LOG__ ?? []).findLast(
|
|
(entry) =>
|
|
entry.command === "send_channel_message" &&
|
|
(entry.payload as { content?: string } | undefined)?.content ===
|
|
content,
|
|
),
|
|
);
|
|
}, ownReplyContent),
|
|
)
|
|
.toBe(true);
|
|
const sentPayload = await page.evaluate(
|
|
(content) =>
|
|
(window.__BUZZ_E2E_COMMAND_LOG__ ?? []).findLast(
|
|
(entry) =>
|
|
entry.command === "send_channel_message" &&
|
|
(entry.payload as { content?: string } | undefined)?.content ===
|
|
content,
|
|
)?.payload as Record<string, unknown> | undefined,
|
|
ownReplyContent,
|
|
);
|
|
expect(sentPayload).toMatchObject({
|
|
channelId: "9a1657ac-f7aa-5db0-b632-d8bbeb6dfb50",
|
|
content: ownReplyContent,
|
|
emojiTags: [emojiTag],
|
|
linkPreviewTags: [linkPreviewTag],
|
|
mediaTags: [imetaTag],
|
|
mentionPubkeys: [TEST_IDENTITIES.alice.pubkey],
|
|
mentionTags: [mentionTag],
|
|
parentEventId: null,
|
|
sentFromThreadTag: ["buzz:sent-from-thread", rootId, rootContent],
|
|
});
|
|
|
|
await page.getByTestId("auxiliary-panel-close").click();
|
|
|
|
const sharedRow = timeline
|
|
.getByTestId("message-row")
|
|
.filter({ hasText: replySummary })
|
|
.last();
|
|
await expect
|
|
.poll(async () => {
|
|
if (await sharedRow.isVisible()) return true;
|
|
const scrollToLatest = page.getByTestId("message-scroll-to-latest");
|
|
if (await scrollToLatest.isVisible()) await scrollToLatest.click();
|
|
return false;
|
|
})
|
|
.toBe(true);
|
|
await expect(sharedRow.getByTestId("message-author")).toHaveText(
|
|
"npub1mock...",
|
|
);
|
|
await expect(sharedRow.getByTestId("message-avatar-fallback")).toBeVisible();
|
|
await expect(sharedRow.locator('[data-mention=""]')).toContainText("alice");
|
|
await expect(sharedRow.locator("img[data-custom-emoji]")).toHaveAttribute(
|
|
"src",
|
|
customEmojiUrl,
|
|
);
|
|
await expect(sharedRow.getByTestId("file-card")).toContainText(
|
|
"launch-notes.txt",
|
|
);
|
|
await expect(
|
|
sharedRow.locator('[data-link-preview="github-pull-request"]'),
|
|
).toContainText("Add Send to channel for thread messages");
|
|
const sourceLine = sharedRow.getByTestId("sent-from-thread");
|
|
await expect(sourceLine).toContainText("Sent from thread:");
|
|
await expect(sourceLine).toHaveClass(/message-markdown/);
|
|
await expect(sourceLine).toHaveClass(/pt-0\.5/);
|
|
await expect(sourceLine).toHaveClass(/text-sm/);
|
|
await expect(sourceLine).toHaveClass(/font-normal/);
|
|
await expect(sourceLine).toHaveClass(/leading-4/);
|
|
await expect(sourceLine).toHaveClass(/text-muted-foreground\/70/);
|
|
const rootLink = sourceLine.locator("[data-message-link]");
|
|
const sourcePrefix = sourceLine.locator("span").first();
|
|
const rootLinkLabel = rootContent;
|
|
await expect(rootLink).toHaveText(rootLinkLabel);
|
|
await expect(rootLink).toHaveAttribute(
|
|
"aria-label",
|
|
"Open thread in general",
|
|
);
|
|
await expect(rootLink).toHaveAttribute("title", rootLinkLabel);
|
|
await expect(rootLink).toHaveClass(/max-w-80/);
|
|
await expect(rootLink).toHaveClass(/truncate/);
|
|
await expect(rootLink).toHaveClass(/inline-block/);
|
|
await expect(rootLink).toHaveClass(/font-medium/);
|
|
await expect(rootLink).not.toHaveClass(/mention-chip/);
|
|
await expect(rootLink).not.toHaveClass(/border-b/);
|
|
const rootLinkText = rootLink.locator("[data-message-link-text]");
|
|
const rootLinkEmoji = rootLink.locator("[data-message-link-emoji]");
|
|
await expect(rootLinkText).toHaveText(` Share source thread ${timestamp}`);
|
|
await expect(rootLinkText).not.toHaveClass(/border-b/);
|
|
await expect(rootLinkEmoji).toHaveText("🧵");
|
|
await expect(rootLinkEmoji).not.toHaveClass(/border-b/);
|
|
await expect(rootLink).not.toHaveAttribute("data-hovered");
|
|
const [prefixColor, linkColorBeforeHover] = await Promise.all([
|
|
sourcePrefix.evaluate((element) => getComputedStyle(element).color),
|
|
rootLink.evaluate((element) => getComputedStyle(element).color),
|
|
]);
|
|
expect(linkColorBeforeHover).not.toBe(prefixColor);
|
|
await expect
|
|
.poll(() =>
|
|
rootLink.evaluate((element) => getComputedStyle(element).backgroundColor),
|
|
)
|
|
.toBe("rgba(0, 0, 0, 0)");
|
|
await expect
|
|
.poll(() =>
|
|
rootLinkText.evaluate((element) => getComputedStyle(element).boxShadow),
|
|
)
|
|
.toBe("none");
|
|
|
|
await rootLink.hover();
|
|
await expect(rootLink).toHaveAttribute("data-hovered", "");
|
|
await expect
|
|
.poll(() => rootLink.evaluate((element) => getComputedStyle(element).color))
|
|
.toBe(linkColorBeforeHover);
|
|
await expect
|
|
.poll(() =>
|
|
rootLinkText.evaluate(
|
|
(element) => getComputedStyle(element).boxShadow !== "none",
|
|
),
|
|
)
|
|
.toBe(true);
|
|
|
|
await expect
|
|
.poll(() =>
|
|
rootLinkEmoji.evaluate((element) => getComputedStyle(element).boxShadow),
|
|
)
|
|
.toBe("none");
|
|
|
|
await rootLink.click();
|
|
await expect(threadPanel).toBeVisible();
|
|
await expect(threadPanel.getByTestId("message-thread-head")).toContainText(
|
|
rootContent,
|
|
);
|
|
});
|
|
|
|
test("shows your avatar on your own message when profile avatar is set", async ({
|
|
page,
|
|
}) => {
|
|
const message = `Avatar message ${Date.now()}`;
|
|
const avatarUrl =
|
|
'data:image/svg+xml,%3Csvg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"%3E%3Crect width="16" height="16" rx="4" fill="%2300a36c"/%3E%3C/svg%3E';
|
|
|
|
await page.goto("/");
|
|
await openSettings(page, "profile");
|
|
await page.getByTestId("profile-avatar-edit").click();
|
|
await page.getByTestId("profile-avatar-url").fill(avatarUrl);
|
|
await page.getByTestId("profile-avatar-done").click();
|
|
await page.getByTestId("settings-back-to-app").click();
|
|
|
|
await page.getByTestId("channel-general").click();
|
|
await expect(page.getByTestId("chat-title")).toHaveText("general");
|
|
|
|
await page.getByTestId("message-input").fill(message);
|
|
await page.getByTestId("send-message").click();
|
|
|
|
const lastMessage = page.getByTestId("message-row").last();
|
|
await expect(lastMessage).toContainText(message);
|
|
await expect(lastMessage.getByTestId("message-avatar-image")).toHaveAttribute(
|
|
"src",
|
|
avatarUrl,
|
|
);
|
|
});
|
|
|
|
test("opens a single-level thread panel with inline expansion", async ({
|
|
page,
|
|
}) => {
|
|
const timestamp = Date.now();
|
|
const firstReply = `First threaded reply ${timestamp}`;
|
|
const siblingReply = `Sibling threaded reply ${timestamp}`;
|
|
const nestedReply = `Nested threaded reply ${timestamp}`;
|
|
const nestedReplyFromAgent = `Nested reply from agent ${timestamp}`;
|
|
const nestedReplyFromBob = `Nested reply from Bob ${timestamp}`;
|
|
const fillerReplies = Array.from(
|
|
{ length: 14 },
|
|
(_, index) => `Thread filler reply ${index} ${timestamp}`,
|
|
);
|
|
|
|
await page.goto("/");
|
|
await page.getByTestId("channel-general").click();
|
|
await expect(page.getByTestId("chat-title")).toHaveText("general");
|
|
await expect(page.getByTestId("message-timeline")).toContainText(
|
|
"Welcome to general",
|
|
);
|
|
|
|
const timeline = page.getByTestId("message-timeline");
|
|
const timelineRows = timeline.getByTestId("message-row");
|
|
const threadPanel = page.getByTestId("message-thread-panel");
|
|
const threadBody = threadPanel.getByTestId("message-thread-body");
|
|
const threadComposer = threadPanel.locator('[data-testid="message-input"]');
|
|
const threadSendButton = threadPanel.getByTestId("send-message");
|
|
const threadReplies = threadPanel.getByTestId("message-thread-replies");
|
|
const rootMessage = timelineRows.first();
|
|
const rootMessageId = await rootMessage.getAttribute("data-message-id");
|
|
if (!rootMessageId) {
|
|
throw new Error("Expected root message row to have a data-message-id.");
|
|
}
|
|
const rootSummaryRow = timeline.locator(
|
|
`[data-testid="message-thread-summary"][data-thread-head-id="${rootMessageId}"]`,
|
|
);
|
|
|
|
await rootMessage.hover();
|
|
await rootMessage.getByRole("button", { name: "Reply" }).click();
|
|
await expect(threadPanel).toBeVisible();
|
|
await expect(threadPanel.getByTestId("message-thread-head")).toContainText(
|
|
"Welcome to general",
|
|
);
|
|
|
|
await threadComposer.fill(firstReply);
|
|
await threadSendButton.click();
|
|
await expect(threadReplies).toContainText(firstReply);
|
|
|
|
await threadComposer.fill(siblingReply);
|
|
await threadSendButton.click();
|
|
await expect(threadReplies).toContainText(siblingReply);
|
|
|
|
for (const fillerReply of fillerReplies) {
|
|
await threadComposer.fill(fillerReply);
|
|
await threadSendButton.click();
|
|
await expect(threadReplies).toContainText(fillerReply);
|
|
}
|
|
|
|
await expect
|
|
.poll(async () => {
|
|
return threadBody.evaluate((element) => {
|
|
const body = element as HTMLDivElement;
|
|
return body.scrollHeight - body.clientHeight;
|
|
});
|
|
})
|
|
// Compact continuation rows intentionally reduce the available overflow;
|
|
// this test only needs enough space to prove the thread body scrolls.
|
|
.toBeGreaterThan(0);
|
|
|
|
await expect(
|
|
timeline.getByTestId("message-row").filter({ hasText: firstReply }),
|
|
).toHaveCount(0);
|
|
await expect(
|
|
timeline.getByTestId("message-row").filter({ hasText: siblingReply }),
|
|
).toHaveCount(0);
|
|
|
|
await expect(rootSummaryRow).toContainText("16 replies");
|
|
await expect(
|
|
rootSummaryRow.getByTestId("message-thread-summary-participant"),
|
|
).toHaveCount(1);
|
|
await expect
|
|
.poll(() =>
|
|
rootSummaryRow
|
|
.getByTestId("message-thread-summary-participant")
|
|
.first()
|
|
.evaluate((wrapper) => {
|
|
const avatar = wrapper.querySelector<HTMLElement>(
|
|
'[data-testid^="message-thread-summary-avatar-"]',
|
|
);
|
|
if (!avatar) return "missing";
|
|
const rect = avatar.getBoundingClientRect();
|
|
return `${Math.round(rect.width)}x${Math.round(rect.height)}`;
|
|
}),
|
|
)
|
|
.toBe("24x24");
|
|
const summaryGeometry = await measureThreadSummaryGeometry(rootSummaryRow);
|
|
expect(
|
|
Math.abs(summaryGeometry.authorLeft - summaryGeometry.bodyLeft),
|
|
).toBeLessThanOrEqual(1);
|
|
expect(
|
|
Math.abs(summaryGeometry.avatarLeft - summaryGeometry.bodyLeft),
|
|
).toBeLessThanOrEqual(1);
|
|
expect(
|
|
Math.abs(
|
|
summaryGeometry.summaryButtonContentLeft - summaryGeometry.bodyLeft,
|
|
),
|
|
).toBeLessThanOrEqual(1);
|
|
expect(
|
|
Math.abs(
|
|
summaryGeometry.summaryButtonLeft - summaryGeometry.messageRowLeft,
|
|
),
|
|
).toBeLessThanOrEqual(1);
|
|
expect(summaryGeometry.summaryButtonLeft).toBeLessThan(
|
|
summaryGeometry.bodyLeft,
|
|
);
|
|
expect(
|
|
Math.abs(
|
|
summaryGeometry.bodyLeft -
|
|
summaryGeometry.summaryButtonLeft -
|
|
summaryGeometry.summaryButtonPaddingLeft,
|
|
),
|
|
).toBeLessThanOrEqual(1);
|
|
expect(summaryGeometry.summarySurfaceLeft).toBeLessThan(
|
|
summaryGeometry.avatarLeft,
|
|
);
|
|
expect(
|
|
Math.abs(
|
|
summaryGeometry.avatarLeft - summaryGeometry.summarySurfaceLeft - 8,
|
|
),
|
|
).toBeLessThanOrEqual(1);
|
|
expect(
|
|
Math.abs(summaryGeometry.topPadding - summaryGeometry.bottomPadding),
|
|
).toBeLessThanOrEqual(1);
|
|
|
|
await page.mouse.move(0, 0);
|
|
const rootSummaryWidthBeforeHover = await rootSummaryRow.evaluate((row) =>
|
|
Math.round(row.getBoundingClientRect().width),
|
|
);
|
|
await expect
|
|
.poll(() =>
|
|
rootSummaryRow
|
|
.getByTestId("message-thread-summary-last-reply")
|
|
.evaluate((label) =>
|
|
Number.parseFloat(getComputedStyle(label).opacity),
|
|
),
|
|
)
|
|
.toBeGreaterThan(0.8);
|
|
await expect
|
|
.poll(() =>
|
|
rootSummaryRow
|
|
.getByTestId("message-thread-summary-hover-action")
|
|
.evaluate((label) =>
|
|
Number.parseFloat(getComputedStyle(label).opacity),
|
|
),
|
|
)
|
|
.toBeLessThan(0.1);
|
|
await rootSummaryRow.hover();
|
|
await expect
|
|
.poll(() =>
|
|
rootSummaryRow
|
|
.getByTestId("message-thread-summary-last-reply")
|
|
.evaluate((label) =>
|
|
Number.parseFloat(getComputedStyle(label).opacity),
|
|
),
|
|
)
|
|
.toBeLessThan(0.1);
|
|
await expect
|
|
.poll(() =>
|
|
rootSummaryRow
|
|
.getByTestId("message-thread-summary-hover-action")
|
|
.evaluate((label) =>
|
|
Number.parseFloat(getComputedStyle(label).opacity),
|
|
),
|
|
)
|
|
.toBeGreaterThan(0.8);
|
|
await expect
|
|
.poll(() =>
|
|
rootSummaryRow.evaluate((row) =>
|
|
Math.round(row.getBoundingClientRect().width),
|
|
),
|
|
)
|
|
.toBe(rootSummaryWidthBeforeHover);
|
|
|
|
await threadPanel.getByTestId("auxiliary-panel-close").click();
|
|
await expect(threadPanel).toBeHidden();
|
|
|
|
await rootSummaryRow.click();
|
|
await expect(threadPanel).toBeVisible();
|
|
await expect(threadPanel.getByTestId("message-thread-head")).toContainText(
|
|
"Welcome to general",
|
|
);
|
|
|
|
const firstReplyRow = threadReplies
|
|
.getByTestId("message-row")
|
|
.filter({ hasText: firstReply })
|
|
.first();
|
|
await firstReplyRow.hover();
|
|
await firstReplyRow.getByRole("button", { name: "Reply" }).click();
|
|
|
|
await expect(threadPanel.getByTestId("message-thread-head")).toContainText(
|
|
"Welcome to general",
|
|
);
|
|
await expect(threadPanel.getByTestId("message-thread-back")).toHaveCount(0);
|
|
|
|
await threadComposer.fill(nestedReply);
|
|
await threadSendButton.click();
|
|
|
|
const nestedReplyRow = threadReplies
|
|
.getByTestId("message-row")
|
|
.filter({ hasText: nestedReply })
|
|
.first();
|
|
await expect(nestedReplyRow).toBeVisible();
|
|
await expect(
|
|
timeline.getByTestId("message-row").filter({ hasText: nestedReply }),
|
|
).toHaveCount(0);
|
|
|
|
await expect(
|
|
threadReplies.getByTestId("message-row").filter({ hasText: siblingReply }),
|
|
).toHaveCount(1);
|
|
await expectThreadReplyUnobscured(nestedReplyRow);
|
|
|
|
const firstReplyId = await firstReplyRow.getAttribute("data-message-id");
|
|
if (!firstReplyId) {
|
|
throw new Error("Expected first reply row to have a data-message-id.");
|
|
}
|
|
|
|
await page.evaluate(
|
|
({ content, parentEventId, pubkey }) => {
|
|
window.__BUZZ_E2E_EMIT_MOCK_MESSAGE__?.({
|
|
channelName: "general",
|
|
content,
|
|
parentEventId,
|
|
pubkey,
|
|
});
|
|
},
|
|
{
|
|
content: nestedReplyFromBob,
|
|
parentEventId: firstReplyId,
|
|
pubkey: TEST_IDENTITIES.bob.pubkey,
|
|
},
|
|
);
|
|
const nestedReplyFromBobRow = threadReplies
|
|
.getByTestId("message-row")
|
|
.filter({ hasText: nestedReplyFromBob })
|
|
.first();
|
|
await expect(nestedReplyFromBobRow).toBeVisible();
|
|
|
|
await page.evaluate(
|
|
({ content, parentEventId, pubkey }) => {
|
|
window.__BUZZ_E2E_EMIT_MOCK_MESSAGE__?.({
|
|
channelName: "general",
|
|
content,
|
|
parentEventId,
|
|
pubkey,
|
|
});
|
|
},
|
|
{
|
|
content: nestedReplyFromAgent,
|
|
parentEventId: firstReplyId,
|
|
pubkey: TEST_IDENTITIES.alice.pubkey,
|
|
},
|
|
);
|
|
const nestedReplyFromAgentRow = threadReplies
|
|
.getByTestId("message-row")
|
|
.filter({ hasText: nestedReplyFromAgent })
|
|
.first();
|
|
await expect(nestedReplyFromAgentRow).toBeVisible();
|
|
|
|
const firstReplySummaryRow = threadReplies.locator(
|
|
`[data-testid="message-thread-summary"][data-thread-head-id="${firstReplyId}"]`,
|
|
);
|
|
await expect(firstReplySummaryRow).toHaveCount(0);
|
|
const firstReplyBranchGuide = threadReplies.locator(
|
|
`[data-testid="thread-collapse-guide"][data-thread-head-id="${firstReplyId}"]`,
|
|
);
|
|
await expect(firstReplyBranchGuide).not.toHaveCount(0);
|
|
|
|
await expect(rootSummaryRow).toContainText("19 replies");
|
|
await expect(
|
|
rootSummaryRow.getByTestId("message-thread-summary-participant"),
|
|
).toHaveCount(3);
|
|
await expect
|
|
.poll(() =>
|
|
rootSummaryRow
|
|
.getByTestId("message-thread-summary-participant")
|
|
.evaluateAll((participants) =>
|
|
participants
|
|
.map((participant) => getComputedStyle(participant).zIndex)
|
|
.join(","),
|
|
),
|
|
)
|
|
.toBe("1,2,3");
|
|
const stackedParticipants = rootSummaryRow.getByTestId(
|
|
"message-thread-summary-participant",
|
|
);
|
|
const stackGeometry = await stackedParticipants.evaluateAll((participants) =>
|
|
participants.map((participant) => {
|
|
const avatar = participant.querySelector<HTMLElement>(
|
|
'[data-testid^="message-thread-summary-avatar-"]',
|
|
);
|
|
if (!avatar) throw new Error("Expected a stacked thread avatar.");
|
|
const rect = avatar.getBoundingClientRect();
|
|
return { left: rect.left, width: rect.width };
|
|
}),
|
|
);
|
|
expect(stackGeometry[1].left - stackGeometry[0].left).toBeCloseTo(
|
|
stackGeometry[0].width - 4,
|
|
1,
|
|
);
|
|
expect(stackGeometry[2].left - stackGeometry[1].left).toBeCloseTo(
|
|
stackGeometry[1].width - 4,
|
|
1,
|
|
);
|
|
let foregroundAgentIndex = -1;
|
|
await expect
|
|
.poll(async () => {
|
|
foregroundAgentIndex = await stackedParticipants.evaluateAll(
|
|
(participants) =>
|
|
participants.findIndex(
|
|
(participant, index) =>
|
|
index > 0 && participant.querySelector(".rounded-squircle"),
|
|
),
|
|
);
|
|
return foregroundAgentIndex;
|
|
})
|
|
.toBeGreaterThan(0);
|
|
const maskBehindAgent = rootSummaryRow.getByTestId(
|
|
`message-thread-summary-stack-mask-${foregroundAgentIndex - 1}`,
|
|
);
|
|
const stackMaskImage = await maskBehindAgent.evaluate(
|
|
(element) => getComputedStyle(element).maskImage,
|
|
);
|
|
expect(stackMaskImage).toContain("data:image/svg+xml");
|
|
expect(decodeURIComponent(stackMaskImage)).toContain(
|
|
'd="M .5 0 C .93 0 1 .07 1 .5',
|
|
);
|
|
|
|
await expectThreadReplyUnobscured(nestedReplyRow);
|
|
|
|
await firstReplyBranchGuide.first().click();
|
|
await expect(firstReplySummaryRow).toHaveCount(1);
|
|
await expect(firstReplySummaryRow).toContainText("3 replies");
|
|
await expect(
|
|
threadReplies.getByTestId("message-row").filter({ hasText: nestedReply }),
|
|
).toHaveCount(0);
|
|
await expect(
|
|
threadReplies
|
|
.getByTestId("message-row")
|
|
.filter({ hasText: nestedReplyFromBob }),
|
|
).toHaveCount(0);
|
|
await expect(
|
|
threadReplies
|
|
.getByTestId("message-row")
|
|
.filter({ hasText: nestedReplyFromAgent }),
|
|
).toHaveCount(0);
|
|
});
|
|
|
|
test("thread panel width uses session storage and reset handle", async ({
|
|
page,
|
|
}) => {
|
|
const customWidthPx = 520;
|
|
const defaultWidthPx = 380;
|
|
|
|
await page.addInitScript((width) => {
|
|
window.sessionStorage.setItem(
|
|
"buzz.desktop.thread-panel-width",
|
|
String(width),
|
|
);
|
|
}, customWidthPx);
|
|
|
|
await page.goto("/");
|
|
await page.getByTestId("channel-general").click();
|
|
await expect(page.getByTestId("chat-title")).toHaveText("general");
|
|
|
|
const timeline = page.getByTestId("message-timeline");
|
|
const rootMessage = timeline.getByTestId("message-row").first();
|
|
const threadPanel = page.getByTestId("message-thread-panel");
|
|
const resizeHandle = threadPanel.getByTestId(
|
|
"right-auxiliary-pane-resize-handle",
|
|
);
|
|
|
|
await rootMessage.hover();
|
|
await rootMessage.getByRole("button", { name: "Reply" }).click();
|
|
await expect(threadPanel).toBeVisible();
|
|
|
|
await expect
|
|
.poll(async () => {
|
|
return threadPanel.evaluate((panel) => {
|
|
const element = panel as HTMLElement;
|
|
return Math.round(element.getBoundingClientRect().width);
|
|
});
|
|
})
|
|
.toBe(customWidthPx);
|
|
|
|
await resizeHandle.dblclick();
|
|
|
|
await expect
|
|
.poll(async () => {
|
|
return threadPanel.evaluate((panel) => {
|
|
const element = panel as HTMLElement;
|
|
return Math.round(element.getBoundingClientRect().width);
|
|
});
|
|
})
|
|
.toBe(defaultWidthPx);
|
|
|
|
await threadPanel.getByTestId("auxiliary-panel-close").click();
|
|
await expect(threadPanel).toBeHidden();
|
|
|
|
await rootMessage.hover();
|
|
await rootMessage.getByRole("button", { name: "Reply" }).click();
|
|
await expect(threadPanel).toBeVisible();
|
|
|
|
await expect
|
|
.poll(async () => {
|
|
return threadPanel.evaluate((panel) => {
|
|
const element = panel as HTMLElement;
|
|
return Math.round(element.getBoundingClientRect().width);
|
|
});
|
|
})
|
|
.toBe(defaultWidthPx);
|
|
});
|
|
|
|
test("narrow thread view collapses channel header actions into a menu", async ({
|
|
page,
|
|
}) => {
|
|
await page.setViewportSize({ width: 980, height: 720 });
|
|
|
|
await page.goto("/");
|
|
await page.getByTestId("channel-general").click();
|
|
await expect(page.getByTestId("chat-title")).toHaveText("general");
|
|
await expect(page.getByTestId("channel-add-bot-trigger")).toHaveCount(0);
|
|
await expect(page.getByTestId("channel-actions-menu-trigger")).toHaveCount(0);
|
|
|
|
const rootMessage = page.locator('[data-message-id="mock-general-alice"]');
|
|
const threadPanel = page.getByTestId("message-thread-panel");
|
|
|
|
await rootMessage.hover();
|
|
await page.getByTestId("reply-message-mock-general-alice").click();
|
|
await expect(threadPanel).toBeVisible();
|
|
await expect(threadPanel.getByTestId("message-thread-back")).toHaveCount(0);
|
|
|
|
const menuTrigger = page.getByTestId("channel-actions-menu-trigger");
|
|
await expect(menuTrigger).toBeVisible();
|
|
await expect(page.getByTestId("channel-add-bot-trigger")).toHaveCount(0);
|
|
await expect(page.getByTestId("channel-members-trigger")).toBeHidden();
|
|
await expect(page.getByTestId("channel-management-trigger")).toBeHidden();
|
|
|
|
const menuBox = await menuTrigger.boundingBox();
|
|
const threadPanelBox = await threadPanel.boundingBox();
|
|
if (!menuBox || !threadPanelBox) {
|
|
throw new Error("Expected header action menu and thread panel bounds");
|
|
}
|
|
const menuGap = threadPanelBox.x - (menuBox.x + menuBox.width);
|
|
const headerPaddingInlineEnd = await page
|
|
.getByTestId("chat-header")
|
|
.evaluate((header) =>
|
|
Number.parseFloat(window.getComputedStyle(header).paddingRight),
|
|
);
|
|
expect(menuGap).toBeGreaterThanOrEqual(0);
|
|
expect(menuGap).toBeLessThanOrEqual(headerPaddingInlineEnd + menuBox.width);
|
|
|
|
await menuTrigger.click();
|
|
|
|
await expect(page.getByTestId("channel-add-bot-trigger")).toHaveCount(0);
|
|
await expect(page.getByTestId("channel-members-trigger")).toBeVisible();
|
|
await expect(page.getByTestId("channel-start-huddle-trigger")).toBeVisible();
|
|
await expect(page.getByTestId("channel-management-trigger")).toBeVisible();
|
|
});
|
|
|
|
test("single-panel thread view hides channel actions", async ({ page }) => {
|
|
await page.setViewportSize({ width: 860, height: 720 });
|
|
|
|
await page.goto("/");
|
|
await page.getByTestId("channel-general").click();
|
|
await expect(page.getByTestId("chat-title")).toHaveText("general");
|
|
await expect(page.getByTestId("channel-add-bot-trigger")).toHaveCount(0);
|
|
|
|
const rootMessage = page.locator('[data-message-id="mock-general-alice"]');
|
|
const threadPanel = page.getByTestId("message-thread-panel");
|
|
|
|
await rootMessage.hover();
|
|
await page.getByTestId("reply-message-mock-general-alice").click();
|
|
await expect(threadPanel).toBeVisible();
|
|
await expect(threadPanel.getByTestId("message-thread-back")).toBeVisible();
|
|
await expect(page.getByTestId("channel-actions-menu-trigger")).toHaveCount(0);
|
|
await expect(page.getByTestId("channel-add-bot-trigger")).toHaveCount(0);
|
|
|
|
await threadPanel.getByTestId("message-thread-back").click();
|
|
await expect(page.getByTestId("chat-title")).toHaveText("general");
|
|
await expect(page.getByTestId("channel-add-bot-trigger")).toHaveCount(0);
|
|
});
|
|
|
|
test("composer is focused after selecting a channel", async ({ page }) => {
|
|
await page.goto("/");
|
|
await page.getByTestId("channel-general").click();
|
|
await expect(page.getByTestId("chat-title")).toHaveText("general");
|
|
|
|
// Without clicking the input, typing should land in the composer.
|
|
const input = page.getByTestId("message-input");
|
|
await expect(input).toBeFocused();
|
|
|
|
await page.keyboard.type("autofocus-on-channel-select");
|
|
await expect(input).toHaveText("autofocus-on-channel-select");
|
|
});
|
|
|
|
test("composer is focused after switching to a different channel", async ({
|
|
page,
|
|
}) => {
|
|
await page.goto("/");
|
|
await page.getByTestId("channel-general").click();
|
|
await expect(page.getByTestId("chat-title")).toHaveText("general");
|
|
|
|
await page.getByTestId("channel-random").click();
|
|
await expect(page.getByTestId("chat-title")).toHaveText("random");
|
|
|
|
const input = page.getByTestId("message-input");
|
|
await expect(input).toBeFocused();
|
|
});
|
|
|
|
test("thread composer is focused after clicking the reply icon", async ({
|
|
page,
|
|
}) => {
|
|
await page.goto("/");
|
|
await page.getByTestId("channel-general").click();
|
|
await expect(page.getByTestId("chat-title")).toHaveText("general");
|
|
|
|
// Seed a message to reply to.
|
|
const seed = `Thread autofocus seed ${Date.now()}`;
|
|
const mainInput = page.getByTestId("message-input");
|
|
await mainInput.fill(seed);
|
|
await page.getByTestId("send-message").click();
|
|
await expect(page.getByTestId("message-timeline")).toContainText(seed);
|
|
|
|
const rootMessage = page
|
|
.getByTestId("message-timeline")
|
|
.getByTestId("message-row")
|
|
.last();
|
|
await rootMessage.hover();
|
|
await rootMessage.getByRole("button", { name: "Reply" }).click();
|
|
|
|
const threadPanel = page.getByTestId("message-thread-panel");
|
|
await expect(threadPanel).toBeVisible();
|
|
|
|
const threadInput = threadPanel.getByTestId("message-input");
|
|
await expect(threadInput).toBeFocused();
|
|
|
|
await page.keyboard.type("typed-into-thread");
|
|
await expect(threadInput).toHaveText("typed-into-thread");
|
|
});
|
|
|
|
test("thread refetch preserves a live reply and reaction received in flight", async ({
|
|
page,
|
|
}) => {
|
|
await installMockBridge(page, { threadRepliesDelayMs: 800 });
|
|
await page.goto("/");
|
|
await page.getByTestId("channel-general").click();
|
|
await expect(page.getByTestId("chat-title")).toHaveText("general");
|
|
|
|
const rootMessage = page
|
|
.getByTestId("message-timeline")
|
|
.getByTestId("message-row")
|
|
.first();
|
|
const rootId = await rootMessage.getAttribute("data-message-id");
|
|
if (!rootId) throw new Error("Expected a thread root id.");
|
|
|
|
await rootMessage.hover();
|
|
await rootMessage.getByRole("button", { name: "Reply" }).click();
|
|
const threadPanel = page.getByTestId("message-thread-panel");
|
|
await expect(threadPanel).toBeVisible();
|
|
|
|
const reply = `Live reply during thread fetch ${Date.now()}`;
|
|
const replyId = await page.evaluate(
|
|
async ({ channelId, content, parentEventId }) => {
|
|
const bridgeWindow = window as Window & {
|
|
__BUZZ_E2E_INVOKE_MOCK_COMMAND__?: (
|
|
command: string,
|
|
payload?: Record<string, unknown>,
|
|
) => Promise<unknown>;
|
|
};
|
|
const invoke = bridgeWindow.__BUZZ_E2E_INVOKE_MOCK_COMMAND__;
|
|
if (!invoke) throw new Error("Mock Tauri invoke bridge is unavailable.");
|
|
const sent = (await invoke("send_channel_message", {
|
|
channelId,
|
|
content,
|
|
parentEventId,
|
|
})) as { event_id: string };
|
|
await invoke("add_reaction", { eventId: sent.event_id, emoji: "👍" });
|
|
return sent.event_id;
|
|
},
|
|
{
|
|
channelId: "9a1657ac-f7aa-5db0-b632-d8bbeb6dfb50",
|
|
content: reply,
|
|
parentEventId: rootId,
|
|
},
|
|
);
|
|
|
|
const replyRow = threadPanel.locator(`[data-message-id="${replyId}"]`);
|
|
await expect(replyRow).toContainText(reply);
|
|
await expect(replyRow.getByLabel("Toggle 👍 reaction")).toBeVisible();
|
|
|
|
// The delayed get_thread_replies response was snapshotted before the live
|
|
// events. Wait past query completion: neither cache addition may disappear.
|
|
await page.waitForTimeout(1_200);
|
|
await expect(replyRow).toContainText(reply);
|
|
await expect(replyRow.getByLabel("Toggle 👍 reaction")).toBeVisible();
|
|
});
|
|
|
|
test("thread reply appears after relay closes and restores its live subscription", async ({
|
|
page,
|
|
}) => {
|
|
await installMockBridge(page, { closeChannelLiveSubscriptionOnce: true });
|
|
await page.goto("/");
|
|
await page.getByTestId("channel-general").click();
|
|
await expect(page.getByTestId("chat-title")).toHaveText("general");
|
|
|
|
const seed = `Thread CLOSED seed ${Date.now()}`;
|
|
await page.getByTestId("message-input").fill(seed);
|
|
await page.getByTestId("send-message").click();
|
|
await expect(page.getByTestId("message-timeline")).toContainText(seed);
|
|
|
|
const rootMessage = page
|
|
.getByTestId("message-timeline")
|
|
.getByTestId("message-row")
|
|
.last();
|
|
await rootMessage.hover();
|
|
await rootMessage.getByRole("button", { name: "Reply" }).click();
|
|
|
|
const threadPanel = page.getByTestId("message-thread-panel");
|
|
const reply = `Thread reply after CLOSED ${Date.now()}`;
|
|
await threadPanel.getByTestId("message-input").fill(reply);
|
|
await page.waitForTimeout(1_100);
|
|
await threadPanel.getByTestId("send-message").click();
|
|
|
|
await expect(threadPanel).toContainText(reply);
|
|
});
|
|
|
|
test("thread composer keeps focus after sending a thread reply", async ({
|
|
page,
|
|
}) => {
|
|
await page.goto("/");
|
|
await page.getByTestId("channel-general").click();
|
|
await expect(page.getByTestId("chat-title")).toHaveText("general");
|
|
|
|
// Seed a root message we can open a thread on. At this point only one
|
|
// composer is mounted, so plain getByTestId is unambiguous.
|
|
const seed = `Thread focus-after-send seed ${Date.now()}`;
|
|
await page.getByTestId("message-input").fill(seed);
|
|
await page.getByTestId("send-message").click();
|
|
await expect(page.getByTestId("message-timeline")).toContainText(seed);
|
|
|
|
const rootMessage = page
|
|
.getByTestId("message-timeline")
|
|
.getByTestId("message-row")
|
|
.last();
|
|
await rootMessage.hover();
|
|
await rootMessage.getByRole("button", { name: "Reply" }).click();
|
|
|
|
const threadPanel = page.getByTestId("message-thread-panel");
|
|
await expect(threadPanel).toBeVisible();
|
|
|
|
const threadInput = threadPanel.getByTestId("message-input");
|
|
await expect(threadInput).toBeFocused();
|
|
|
|
// Send a thread reply. After the send, `isSending` flips and back to false
|
|
// in both the main and thread composers; the thread input must keep focus.
|
|
const reply = `Thread reply ${Date.now()}`;
|
|
await page.keyboard.type(reply);
|
|
await expect(threadInput).toHaveText(reply);
|
|
await page.keyboard.press("Enter");
|
|
|
|
// Wait for the send to settle.
|
|
await expect(threadPanel).toContainText(reply);
|
|
|
|
// The thread input should still be focused — not the main composer.
|
|
// Both composers expose the same `message-input` data-testid, so we
|
|
// verify directly that `document.activeElement` lives inside the thread
|
|
// panel rather than the main pane.
|
|
const focusInThreadPanel = await page.evaluate(() => {
|
|
const panel = document.querySelector<HTMLElement>(
|
|
'[data-testid="message-thread-panel"]',
|
|
);
|
|
const active = document.activeElement as HTMLElement | null;
|
|
return Boolean(panel && active && panel.contains(active));
|
|
});
|
|
expect(focusInThreadPanel).toBe(true);
|
|
|
|
await expect(threadInput).toBeFocused();
|
|
});
|
|
|
|
test("editing the thread root uses and focuses the main composer", async ({
|
|
page,
|
|
}) => {
|
|
const root = `Root edit routing ${Date.now()}`;
|
|
|
|
await page.goto("/");
|
|
await page.getByTestId("channel-general").click();
|
|
await expect(page.getByTestId("chat-title")).toHaveText("general");
|
|
|
|
const mainInput = page
|
|
.getByTestId("channel-composer-overlay")
|
|
.getByTestId("message-input");
|
|
await mainInput.fill(root);
|
|
await mainInput.press("Enter");
|
|
|
|
const timeline = page.getByTestId("message-timeline");
|
|
const timelineRoot = timeline.getByTestId("message-row").last();
|
|
await expect(timelineRoot).toContainText(root);
|
|
await timelineRoot.hover();
|
|
await timelineRoot.getByRole("button", { name: "Reply" }).click();
|
|
|
|
const threadPanel = page.getByTestId("message-thread-panel");
|
|
await expect(threadPanel).toBeVisible();
|
|
const threadRoot = threadPanel.getByTestId("message-row").first();
|
|
await threadRoot.hover();
|
|
await threadRoot.getByRole("button", { name: "More actions" }).click();
|
|
await page.getByRole("menuitem", { name: "Edit message" }).click();
|
|
|
|
await expect(page.getByTestId("edit-target")).toHaveCount(1);
|
|
await expect(threadPanel.getByTestId("edit-target")).toHaveCount(0);
|
|
await expect(mainInput).toHaveText(root);
|
|
await expect(mainInput).toBeFocused();
|
|
});
|
|
|
|
test("editing a pre-seeded thread reply uses and focuses the thread composer", async ({
|
|
page,
|
|
}) => {
|
|
const root = `Reply edit routing root ${Date.now()}`;
|
|
const reply = `Reply edit routing ${Date.now()}`;
|
|
|
|
await page.goto("/");
|
|
await page.waitForFunction(
|
|
() => typeof window.__BUZZ_E2E_EMIT_MOCK_MESSAGE__ === "function",
|
|
);
|
|
const { replyId, rootId } = await page.evaluate(
|
|
({ replyContent, rootContent }) => {
|
|
const emit = window.__BUZZ_E2E_EMIT_MOCK_MESSAGE__;
|
|
if (!emit) throw new Error("Mock message emitter is unavailable.");
|
|
const rootEvent = emit({
|
|
channelName: "general",
|
|
content: rootContent,
|
|
});
|
|
const replyEvent = emit({
|
|
channelName: "general",
|
|
content: replyContent,
|
|
parentEventId: rootEvent.id,
|
|
});
|
|
return { replyId: replyEvent.id, rootId: rootEvent.id };
|
|
},
|
|
{ replyContent: reply, rootContent: root },
|
|
);
|
|
|
|
await page.getByTestId("channel-general").click();
|
|
await expect(page.getByTestId("chat-title")).toHaveText("general");
|
|
const timelineRoot = page
|
|
.getByTestId("message-timeline")
|
|
.locator(`[data-message-id="${rootId}"]`);
|
|
await expect(timelineRoot).toContainText(root);
|
|
await timelineRoot.hover();
|
|
await timelineRoot.getByRole("button", { name: "Reply" }).click();
|
|
|
|
const threadPanel = page.getByTestId("message-thread-panel");
|
|
const threadInput = threadPanel.getByTestId("message-input");
|
|
const threadReply = threadPanel.locator(`[data-message-id="${replyId}"]`);
|
|
await expect(threadReply).toContainText(reply);
|
|
await threadReply.hover();
|
|
await threadReply.getByRole("button", { name: "More actions" }).click();
|
|
await page.getByRole("menuitem", { name: "Edit message" }).click();
|
|
|
|
await expect(threadPanel.getByTestId("edit-target")).toBeVisible();
|
|
await expect(threadInput).toHaveText(reply);
|
|
await expect(threadInput).toBeFocused();
|
|
});
|
|
|
|
test("thread composer switches directly between visible reply edits", async ({
|
|
page,
|
|
}) => {
|
|
const root = `Thread edit switch root ${Date.now()}`;
|
|
const first = `Thread edit switch first ${Date.now()}`;
|
|
const second = `Thread edit switch second ${Date.now()}`;
|
|
|
|
await page.goto("/");
|
|
await page.waitForFunction(
|
|
() => typeof window.__BUZZ_E2E_EMIT_MOCK_MESSAGE__ === "function",
|
|
);
|
|
const { firstId, rootId, secondId } = await page.evaluate(
|
|
({ firstContent, rootContent, secondContent }) => {
|
|
const emit = window.__BUZZ_E2E_EMIT_MOCK_MESSAGE__;
|
|
if (!emit) throw new Error("Mock message emitter is unavailable.");
|
|
const rootEvent = emit({
|
|
channelName: "general",
|
|
content: rootContent,
|
|
});
|
|
const firstEvent = emit({
|
|
channelName: "general",
|
|
content: firstContent,
|
|
parentEventId: rootEvent.id,
|
|
});
|
|
const secondEvent = emit({
|
|
channelName: "general",
|
|
content: secondContent,
|
|
parentEventId: rootEvent.id,
|
|
});
|
|
return {
|
|
firstId: firstEvent.id,
|
|
rootId: rootEvent.id,
|
|
secondId: secondEvent.id,
|
|
};
|
|
},
|
|
{ firstContent: first, rootContent: root, secondContent: second },
|
|
);
|
|
|
|
await page.getByTestId("channel-general").click();
|
|
const timelineRoot = page
|
|
.getByTestId("message-timeline")
|
|
.locator(`[data-message-id="${rootId}"]`);
|
|
await timelineRoot.hover();
|
|
await timelineRoot.getByRole("button", { name: "Reply" }).click();
|
|
|
|
const threadPanel = page.getByTestId("message-thread-panel");
|
|
const threadInput = threadPanel.getByTestId("message-input");
|
|
const secondReply = threadPanel.locator(`[data-message-id="${secondId}"]`);
|
|
await secondReply.hover();
|
|
await secondReply.getByRole("button", { name: "More actions" }).click();
|
|
await page.getByTestId(`edit-message-${secondId}`).click();
|
|
await expect(threadInput).toHaveText(second);
|
|
|
|
const firstReply = threadPanel.locator(`[data-message-id="${firstId}"]`);
|
|
await firstReply.hover();
|
|
await firstReply.getByRole("button", { name: "More actions" }).click();
|
|
await page.getByTestId(`edit-message-${firstId}`).click();
|
|
|
|
await expect(threadInput).toHaveText(first);
|
|
await expect(threadInput).toBeFocused();
|
|
await expect(page.getByRole("menu")).toHaveCount(0);
|
|
await expect(page.getByText("Finish or cancel your edit first.")).toHaveCount(
|
|
0,
|
|
);
|
|
});
|
|
|
|
test("editing a broadcast reply from a thread returns to the main composer", async ({
|
|
page,
|
|
}) => {
|
|
await page.goto("/");
|
|
await page.waitForFunction(
|
|
() => typeof window.__BUZZ_E2E_EMIT_MOCK_MESSAGE__ === "function",
|
|
);
|
|
const { broadcastId, rootId } = await page.evaluate(() => {
|
|
const emit = window.__BUZZ_E2E_EMIT_MOCK_MESSAGE__;
|
|
if (!emit) throw new Error("Mock message emitter is unavailable.");
|
|
const rootEvent = emit({
|
|
channelName: "general",
|
|
content: "Broadcast edit root",
|
|
});
|
|
const broadcastEvent = emit({
|
|
channelName: "general",
|
|
content: "Broadcast reply to edit",
|
|
parentEventId: rootEvent.id,
|
|
extraTags: [["broadcast", "1"]],
|
|
});
|
|
return { broadcastId: broadcastEvent.id, rootId: rootEvent.id };
|
|
});
|
|
|
|
await page.getByTestId("channel-general").click();
|
|
const timelineRoot = page.locator(`[data-message-id="${rootId}"]`);
|
|
await timelineRoot.hover();
|
|
await timelineRoot
|
|
.getByRole("button", { name: "Reply" })
|
|
.click({ force: true });
|
|
const threadPanel = page.getByTestId("message-thread-panel");
|
|
const broadcastReply = threadPanel.locator(
|
|
`[data-message-id="${broadcastId}"]`,
|
|
);
|
|
await expect(broadcastReply).toContainText("Broadcast reply to edit");
|
|
await broadcastReply.hover();
|
|
await broadcastReply.getByRole("button", { name: "More actions" }).click();
|
|
await page.getByRole("menuitem", { name: "Edit message" }).click();
|
|
|
|
await expect(threadPanel.getByTestId("edit-target")).toHaveCount(0);
|
|
const mainInput = page
|
|
.getByTestId("channel-composer-overlay")
|
|
.getByTestId("message-input");
|
|
await expect(mainInput).toHaveText("Broadcast reply to edit");
|
|
await expect(mainInput).toBeFocused();
|
|
});
|
|
|
|
test("editing a live thread reply uses and focuses the thread composer", async ({
|
|
page,
|
|
}) => {
|
|
const root = `Live reply edit root ${Date.now()}`;
|
|
const reply = `Live reply edit ${Date.now()}`;
|
|
|
|
await page.goto("/");
|
|
await page.getByTestId("channel-general").click();
|
|
const mainInput = page
|
|
.getByTestId("channel-composer-overlay")
|
|
.getByTestId("message-input");
|
|
await mainInput.fill(root);
|
|
await mainInput.press("Enter");
|
|
const timelineRoot = page
|
|
.getByTestId("message-timeline")
|
|
.getByTestId("message-row")
|
|
.last();
|
|
await timelineRoot.hover();
|
|
await timelineRoot
|
|
.getByRole("button", { name: "Reply" })
|
|
.click({ force: true });
|
|
|
|
const threadPanel = page.getByTestId("message-thread-panel");
|
|
const threadInput = threadPanel.getByTestId("message-input");
|
|
await threadInput.fill(reply);
|
|
await threadInput.press("Enter");
|
|
const threadReply = threadPanel.getByTestId("message-row").last();
|
|
await expect(threadReply).toContainText(reply);
|
|
await threadReply.hover();
|
|
await threadReply.getByRole("button", { name: "More actions" }).click();
|
|
await page.getByRole("menuitem", { name: "Edit message" }).click();
|
|
|
|
await expect(threadPanel.getByTestId("edit-target")).toBeVisible();
|
|
await expect(threadInput).toHaveText(reply);
|
|
await expect(threadInput).toBeFocused();
|
|
});
|
|
|
|
test("editing a thread root in single-panel view returns to the main composer", async ({
|
|
page,
|
|
}) => {
|
|
await page.setViewportSize({ width: 860, height: 720 });
|
|
const root = `Narrow root edit ${Date.now()}`;
|
|
|
|
await page.goto("/");
|
|
await page.getByTestId("channel-general").click();
|
|
const input = page.getByTestId("message-input");
|
|
await input.fill(root);
|
|
await input.press("Enter");
|
|
const timelineRoot = page
|
|
.getByTestId("message-timeline")
|
|
.getByTestId("message-row")
|
|
.last();
|
|
await timelineRoot.hover();
|
|
await timelineRoot
|
|
.getByRole("button", { name: "Reply" })
|
|
.click({ force: true });
|
|
|
|
const threadPanel = page.getByTestId("message-thread-panel");
|
|
const threadRoot = threadPanel.getByTestId("message-row").first();
|
|
await threadRoot.hover();
|
|
await threadRoot.getByRole("button", { name: "More actions" }).click();
|
|
await page.getByRole("menuitem", { name: "Edit message" }).click();
|
|
|
|
await expect(threadPanel).toBeHidden();
|
|
const mainInput = page
|
|
.getByTestId("channel-composer-overlay")
|
|
.getByTestId("message-input");
|
|
await expect(mainInput).toHaveText(root);
|
|
await expect(mainInput).toBeFocused();
|
|
});
|
|
|
|
test("editing a thread root in focus mode dismisses the drawer before focusing the main composer", async ({
|
|
page,
|
|
}) => {
|
|
await page.addInitScript(() => {
|
|
localStorage.setItem("buzz.channels.threadViewMode", "focus");
|
|
});
|
|
const root = `Focus root edit ${Date.now()}`;
|
|
|
|
await page.goto("/");
|
|
await page.getByTestId("channel-general").click();
|
|
const mainInput = page
|
|
.getByTestId("channel-composer-overlay")
|
|
.getByTestId("message-input");
|
|
await mainInput.fill(root);
|
|
await mainInput.press("Enter");
|
|
const timelineRoot = page
|
|
.getByTestId("message-timeline")
|
|
.getByTestId("message-row")
|
|
.last();
|
|
await timelineRoot.hover();
|
|
await timelineRoot
|
|
.getByRole("button", { name: "Reply" })
|
|
.click({ force: true });
|
|
|
|
const drawer = page.getByTestId("focus-thread-drawer");
|
|
const threadRoot = drawer.getByTestId("message-row").first();
|
|
await threadRoot.hover();
|
|
await threadRoot.getByRole("button", { name: "More actions" }).click();
|
|
await page.getByRole("menuitem", { name: "Edit message" }).click();
|
|
|
|
await expect(drawer).toBeHidden();
|
|
await expect(mainInput).toHaveText(root);
|
|
await expect(mainInput).toBeFocused();
|
|
});
|
|
|
|
test("focus mode preserves an active reply edit, then Escape makes root editing available", async ({
|
|
page,
|
|
}) => {
|
|
await page.addInitScript(() => {
|
|
localStorage.setItem("buzz.channels.threadViewMode", "focus");
|
|
});
|
|
const root = `Focus guarded root ${Date.now()}`;
|
|
const reply = `Focus guarded reply ${Date.now()}`;
|
|
const unsaved = `${reply} unsaved`;
|
|
|
|
await page.goto("/");
|
|
await page.getByTestId("channel-general").click();
|
|
const mainInput = page
|
|
.getByTestId("channel-composer-overlay")
|
|
.getByTestId("message-input");
|
|
await mainInput.fill(root);
|
|
await mainInput.press("Enter");
|
|
const timelineRoot = page
|
|
.getByTestId("message-timeline")
|
|
.getByTestId("message-row")
|
|
.last();
|
|
await timelineRoot.hover();
|
|
await timelineRoot
|
|
.getByRole("button", { name: "Reply" })
|
|
.click({ force: true });
|
|
|
|
const drawer = page.getByTestId("focus-thread-drawer");
|
|
const threadInput = drawer.getByTestId("message-input");
|
|
await threadInput.fill(reply);
|
|
await threadInput.press("Enter");
|
|
const threadReply = drawer
|
|
.getByTestId("message-row")
|
|
.filter({ hasText: reply })
|
|
.last();
|
|
await expect(threadReply).toContainText(reply);
|
|
const threadReplyId = await threadReply.getAttribute("data-message-id");
|
|
expect(threadReplyId).not.toBeNull();
|
|
await threadReply.hover();
|
|
await threadReply.getByRole("button", { name: "More actions" }).click();
|
|
await page
|
|
.locator('[role="menu"]:visible')
|
|
.getByTestId(`edit-message-${threadReplyId}`)
|
|
.click();
|
|
await expect(page.locator('[role="menu"]:visible')).toHaveCount(0);
|
|
await threadInput.fill(unsaved);
|
|
|
|
const threadRoot = drawer
|
|
.getByTestId("message-thread-head")
|
|
.getByTestId("message-row");
|
|
const rootMessageId = await threadRoot.getAttribute("data-message-id");
|
|
expect(rootMessageId).not.toBeNull();
|
|
expect(rootMessageId).not.toBe(threadReplyId);
|
|
await threadRoot.hover();
|
|
await threadRoot.getByRole("button", { name: "More actions" }).click();
|
|
await page
|
|
.locator('[role="menu"]:visible')
|
|
.getByTestId(`edit-message-${rootMessageId}`)
|
|
.click();
|
|
await expect(page.locator('[role="menu"]:visible')).toHaveCount(0);
|
|
await expect(drawer).toBeVisible();
|
|
await expect(threadInput).toHaveText(unsaved);
|
|
await expect(
|
|
page.getByText("Finish or cancel your edit first."),
|
|
).toBeVisible();
|
|
|
|
// A refused cross-message edit must not remain deferred and appear later.
|
|
await page.getByTestId("focus-thread-drawer-scrim").click({
|
|
force: true,
|
|
position: { x: 10, y: 360 },
|
|
});
|
|
await expect(drawer).toBeVisible();
|
|
await expect(threadInput).toHaveText(unsaved);
|
|
|
|
// Selecting Edit for the active message keeps the existing toggle-to-cancel behavior.
|
|
await threadReply.hover();
|
|
await threadReply.getByRole("button", { name: "More actions" }).click();
|
|
await page
|
|
.locator('[role="menu"]:visible')
|
|
.getByTestId(`edit-message-${threadReplyId}`)
|
|
.click();
|
|
await expect(drawer.getByTestId("edit-target")).toHaveCount(0);
|
|
await expect(threadInput).toHaveText("");
|
|
|
|
// Focus-mode Escape reaches the composer before the drawer close handler.
|
|
await threadInput.click();
|
|
await page.keyboard.press("ArrowUp");
|
|
await expect(drawer.getByTestId("edit-target")).toBeVisible();
|
|
await threadInput.fill(unsaved);
|
|
await page.keyboard.press("Escape");
|
|
await expect(drawer.getByTestId("edit-target")).toHaveCount(0);
|
|
await expect(drawer).toBeVisible();
|
|
|
|
await threadRoot.hover();
|
|
await threadRoot.getByRole("button", { name: "More actions" }).click();
|
|
await page
|
|
.locator('[role="menu"]:visible')
|
|
.getByTestId(`edit-message-${rootMessageId}`)
|
|
.click();
|
|
await expect(drawer).toBeHidden();
|
|
await expect(mainInput).toHaveText(root);
|
|
});
|
|
|
|
test("ArrowUp routes a narrow thread root without consuming into a hidden composer", async ({
|
|
page,
|
|
}) => {
|
|
await page.setViewportSize({ width: 860, height: 720 });
|
|
const root = `Narrow ArrowUp root ${Date.now()}`;
|
|
await page.goto("/");
|
|
await page.getByTestId("channel-general").click();
|
|
const input = page.getByTestId("message-input");
|
|
await input.fill(root);
|
|
await input.press("Enter");
|
|
const timelineRoot = page
|
|
.getByTestId("message-timeline")
|
|
.getByTestId("message-row")
|
|
.last();
|
|
await timelineRoot.hover();
|
|
await timelineRoot
|
|
.getByRole("button", { name: "Reply" })
|
|
.click({ force: true });
|
|
const threadInput = page
|
|
.getByTestId("message-thread-panel")
|
|
.getByTestId("message-input");
|
|
await expect(threadInput).toBeFocused();
|
|
await page.keyboard.press("ArrowUp");
|
|
await expect(page.getByTestId("message-thread-panel")).toBeHidden();
|
|
const mainInput = page
|
|
.getByTestId("channel-composer-overlay")
|
|
.getByTestId("message-input");
|
|
await expect(mainInput).toHaveText(root);
|
|
await expect(mainInput).toBeFocused();
|
|
});
|
|
|
|
test("closing a thread while editing a reply preserves the typed edit", async ({
|
|
page,
|
|
}) => {
|
|
const root = `Close guard root ${Date.now()}`;
|
|
const reply = `Close guard reply ${Date.now()}`;
|
|
const edited = `${reply} with unsaved text`;
|
|
|
|
await page.goto("/");
|
|
await page.getByTestId("channel-general").click();
|
|
const mainInput = page
|
|
.getByTestId("channel-composer-overlay")
|
|
.getByTestId("message-input");
|
|
await mainInput.fill(root);
|
|
await mainInput.press("Enter");
|
|
const timelineRoot = page
|
|
.getByTestId("message-timeline")
|
|
.getByTestId("message-row")
|
|
.filter({ hasText: root });
|
|
await expect(timelineRoot).toContainText(root);
|
|
await waitForAnimations(page);
|
|
// Hover auto-scrolls and re-resolves the row if rendering replaces it.
|
|
await timelineRoot.hover();
|
|
await timelineRoot.getByRole("button", { name: "Reply" }).click();
|
|
|
|
const threadPanel = page.getByTestId("message-thread-panel");
|
|
const threadInput = threadPanel.getByTestId("message-input");
|
|
await threadInput.fill(reply);
|
|
await threadInput.press("Enter");
|
|
const threadReply = threadPanel.getByTestId("message-row").last();
|
|
await expect(threadReply).toContainText(reply);
|
|
await threadReply.hover();
|
|
await threadReply.getByRole("button", { name: "More actions" }).click();
|
|
await page.getByRole("menuitem", { name: "Edit message" }).click();
|
|
await expectReplyEditReady(threadPanel, reply);
|
|
await threadInput.fill(edited);
|
|
|
|
await threadPanel.getByTestId("auxiliary-panel-close").click();
|
|
|
|
await expect(threadPanel).toBeVisible();
|
|
await expect(threadPanel.getByTestId("edit-target")).toBeVisible();
|
|
await expect(threadInput).toHaveText(edited);
|
|
await expect(
|
|
page.getByText("Finish or cancel your edit before leaving the thread."),
|
|
).toBeVisible();
|
|
});
|
|
|
|
test("main ArrowUp ignores closed-thread replies and edits the visible timeline message", async ({
|
|
page,
|
|
}) => {
|
|
const root = `Main ArrowUp root ${Date.now()}`;
|
|
const reply = `Main ArrowUp hidden reply ${Date.now()}`;
|
|
|
|
await page.goto("/");
|
|
await page.getByTestId("channel-general").click();
|
|
const mainInput = page
|
|
.getByTestId("channel-composer-overlay")
|
|
.getByTestId("message-input");
|
|
await mainInput.fill(root);
|
|
await mainInput.press("Enter");
|
|
const timelineRoot = page
|
|
.getByTestId("message-timeline")
|
|
.getByTestId("message-row")
|
|
.last();
|
|
await timelineRoot.hover();
|
|
await timelineRoot
|
|
.getByRole("button", { name: "Reply" })
|
|
.click({ force: true });
|
|
|
|
const threadPanel = page.getByTestId("message-thread-panel");
|
|
const threadInput = threadPanel.getByTestId("message-input");
|
|
await threadInput.fill(reply);
|
|
await threadInput.press("Enter");
|
|
await expect(threadPanel).toContainText(reply);
|
|
await threadPanel.getByTestId("auxiliary-panel-close").click();
|
|
await expect(threadPanel).toBeHidden();
|
|
|
|
await mainInput.click();
|
|
await page.keyboard.press("ArrowUp");
|
|
await expect(page.getByTestId("edit-target")).toBeVisible();
|
|
await expect(mainInput).toHaveText(root);
|
|
|
|
// No hidden reply edit may block reopening its thread.
|
|
await mainInput.press("Escape");
|
|
await timelineRoot.hover();
|
|
await timelineRoot
|
|
.getByRole("button", { name: "Reply" })
|
|
.click({ force: true });
|
|
await expect(threadPanel).toBeVisible();
|
|
});
|
|
|
|
test("main ArrowUp refuses to replace a dirty thread edit", async ({
|
|
page,
|
|
}) => {
|
|
const root = `Main ArrowUp refusal root ${Date.now()}`;
|
|
const reply = `Main ArrowUp refusal reply ${Date.now()}`;
|
|
const unsaved = `${reply} with unsaved text`;
|
|
|
|
await page.goto("/");
|
|
await page.getByTestId("channel-general").click();
|
|
const mainInput = page
|
|
.getByTestId("channel-composer-overlay")
|
|
.getByTestId("message-input");
|
|
await mainInput.fill(root);
|
|
await mainInput.press("Enter");
|
|
const timelineRoot = page
|
|
.getByTestId("message-timeline")
|
|
.getByTestId("message-row")
|
|
.filter({ hasText: root });
|
|
await expect(timelineRoot).toContainText(root);
|
|
await waitForAnimations(page);
|
|
// Hover auto-scrolls and re-resolves the row if rendering replaces it.
|
|
await timelineRoot.hover();
|
|
await timelineRoot.getByRole("button", { name: "Reply" }).click();
|
|
|
|
const threadPanel = page.getByTestId("message-thread-panel");
|
|
const threadInput = threadPanel.getByTestId("message-input");
|
|
await threadInput.fill(reply);
|
|
await threadInput.press("Enter");
|
|
const threadReply = threadPanel.getByTestId("message-row").last();
|
|
await expect(threadReply).toContainText(reply);
|
|
await threadReply.hover();
|
|
await threadReply.getByRole("button", { name: "More actions" }).click();
|
|
await page.getByRole("menuitem", { name: "Edit message" }).click();
|
|
await expectReplyEditReady(threadPanel, reply);
|
|
await threadInput.fill(unsaved);
|
|
|
|
await mainInput.click();
|
|
await page.keyboard.press("ArrowUp");
|
|
await expect(
|
|
page.getByText("Finish or cancel your edit first."),
|
|
).toBeVisible();
|
|
await expect(threadPanel.getByTestId("edit-target")).toBeVisible();
|
|
await expect(threadInput).toHaveText(unsaved);
|
|
await expect(mainInput).toHaveText("");
|
|
|
|
// Refusal must not arm a deferred edit that appears after cancellation.
|
|
await threadInput.press("Escape");
|
|
await expect(threadPanel.getByTestId("edit-target")).toHaveCount(0);
|
|
await expect(mainInput).toHaveText("");
|
|
});
|
|
|
|
test("main composer switches directly between visible message edits", async ({
|
|
page,
|
|
}) => {
|
|
const first = `Main edit switch first ${Date.now()}`;
|
|
const second = `Main edit switch second ${Date.now()}`;
|
|
|
|
await page.goto("/");
|
|
await page.getByTestId("channel-general").click();
|
|
const mainInput = page
|
|
.getByTestId("channel-composer-overlay")
|
|
.getByTestId("message-input");
|
|
await mainInput.fill(first);
|
|
await mainInput.press("Enter");
|
|
await expect(
|
|
page
|
|
.getByTestId("message-timeline")
|
|
.getByTestId("message-row")
|
|
.filter({ hasText: first }),
|
|
).toBeVisible();
|
|
await page.waitForTimeout(1_100);
|
|
await mainInput.fill(second);
|
|
await mainInput.press("Enter");
|
|
await expect(
|
|
page
|
|
.getByTestId("message-timeline")
|
|
.getByTestId("message-row")
|
|
.filter({ hasText: second }),
|
|
).toBeVisible();
|
|
|
|
await mainInput.click();
|
|
await page.keyboard.press("ArrowUp");
|
|
await expect(mainInput).toHaveText(second);
|
|
|
|
const firstMessage = page
|
|
.getByTestId("message-timeline")
|
|
.getByTestId("message-row")
|
|
.filter({ hasText: first })
|
|
.last();
|
|
await firstMessage.hover();
|
|
await firstMessage.getByRole("button", { name: "More actions" }).click();
|
|
await page.getByRole("menuitem", { name: "Edit message" }).click();
|
|
|
|
await expect(mainInput).toHaveText(first);
|
|
await expect(mainInput).toBeFocused();
|
|
await expect(page.getByText("Finish or cancel your edit first.")).toHaveCount(
|
|
0,
|
|
);
|
|
});
|
|
|
|
test("a refused message deep link retries after the thread edit is canceled", async ({
|
|
page,
|
|
}) => {
|
|
const sourceRoot = `Deep link retry source ${Date.now()}`;
|
|
const reply = `Deep link retry reply ${Date.now()}`;
|
|
const destinationRoot = `Deep link retry destination ${Date.now()}`;
|
|
|
|
await page.goto("/");
|
|
await page.getByTestId("channel-general").click();
|
|
const mainInput = page
|
|
.getByTestId("channel-composer-overlay")
|
|
.getByTestId("message-input");
|
|
await mainInput.fill(destinationRoot);
|
|
await mainInput.press("Enter");
|
|
const destination = page
|
|
.getByTestId("message-timeline")
|
|
.getByTestId("message-row")
|
|
.filter({ hasText: destinationRoot })
|
|
.last();
|
|
const destinationId = await destination.getAttribute("data-message-id");
|
|
expect(destinationId).not.toBeNull();
|
|
await mainInput.fill(
|
|
`Retry link buzz://message?channel=9a1657ac-f7aa-5db0-b632-d8bbeb6dfb50&id=${destinationId}`,
|
|
);
|
|
await mainInput.press("Enter");
|
|
const destinationLink = page
|
|
.getByTestId("message-row")
|
|
.filter({ hasText: "Retry link" })
|
|
.last()
|
|
.getByRole("button", { name: "Open message in channel general" });
|
|
await expect(destinationLink).toBeVisible();
|
|
|
|
await mainInput.fill(sourceRoot);
|
|
await mainInput.press("Enter");
|
|
const source = page
|
|
.getByTestId("message-timeline")
|
|
.getByTestId("message-row")
|
|
.filter({ hasText: sourceRoot })
|
|
.last();
|
|
await source.hover();
|
|
await source.getByRole("button", { name: "Reply" }).click();
|
|
const threadPanel = page.getByTestId("message-thread-panel");
|
|
const threadInput = threadPanel.getByTestId("message-input");
|
|
await threadInput.fill(reply);
|
|
await threadInput.press("Enter");
|
|
const threadReply = threadPanel
|
|
.getByTestId("message-row")
|
|
.filter({ hasText: reply })
|
|
.last();
|
|
await threadReply.hover();
|
|
await threadReply.getByRole("button", { name: "More actions" }).click();
|
|
await page.getByRole("menuitem", { name: "Edit message" }).click();
|
|
await expectReplyEditReady(threadPanel, reply);
|
|
await threadInput.fill(`${reply} unsaved`);
|
|
|
|
const threadUrl = page.url();
|
|
expect(threadUrl).toContain(
|
|
`thread=${await source.getAttribute("data-message-id")}`,
|
|
);
|
|
await destinationLink.click();
|
|
await expect(
|
|
page.getByText("Finish or cancel your edit before leaving the thread."),
|
|
).toBeVisible();
|
|
await expect(threadPanel).toBeVisible();
|
|
await expect(threadPanel.getByTestId("edit-target")).toBeVisible();
|
|
await expect(threadInput).toHaveText(`${reply} unsaved`);
|
|
await expect(page).toHaveURL(threadUrl);
|
|
|
|
// The preserved edit remains rendered and cancelable rather than becoming a
|
|
// hidden target that soft-locks the route.
|
|
await threadInput.press("Escape");
|
|
await expect(threadPanel.getByTestId("edit-target")).toHaveCount(0);
|
|
await expect(threadInput).toHaveText("");
|
|
await destinationLink.click();
|
|
await expect(page).not.toHaveURL(threadUrl);
|
|
const routedDestination = page
|
|
.getByTestId("message-timeline")
|
|
.locator(`[data-message-id="${destinationId}"]`);
|
|
await expect(threadPanel).toBeVisible();
|
|
await expect(threadPanel.getByTestId("message-thread-head")).toContainText(
|
|
destinationRoot,
|
|
);
|
|
await expect(routedDestination).toBeVisible();
|
|
await expect(routedDestination).toHaveClass(/route-target-highlight-fade/);
|
|
});
|
|
|
|
test("a refused sent-from-thread link preserves the edit and retries after cancel", async ({
|
|
page,
|
|
}) => {
|
|
const sourceRoot = `Sent-from-thread guard source ${Date.now()}`;
|
|
const sourceReply = `Sent-from-thread guard reply ${Date.now()}`;
|
|
const destinationRoot = `Sent-from-thread guard destination ${Date.now()}`;
|
|
const sharedMessage = `Sent-from-thread guard shared ${Date.now()}`;
|
|
const dirtyReply = `${sourceReply} unsaved`;
|
|
|
|
await page.goto("/");
|
|
await page.waitForFunction(
|
|
() => typeof window.__BUZZ_E2E_EMIT_MOCK_MESSAGE__ === "function",
|
|
);
|
|
const { destinationRootId, sourceRootId } = await page.evaluate(
|
|
({ destinationRoot, sharedMessage, sourceReply, sourceRoot }) => {
|
|
const emit = window.__BUZZ_E2E_EMIT_MOCK_MESSAGE__;
|
|
if (!emit) throw new Error("Mock message emitter is unavailable.");
|
|
const destination = emit({
|
|
channelName: "general",
|
|
content: destinationRoot,
|
|
});
|
|
const source = emit({ channelName: "general", content: sourceRoot });
|
|
emit({
|
|
channelName: "general",
|
|
content: sourceReply,
|
|
parentEventId: source.id,
|
|
});
|
|
emit({
|
|
channelName: "general",
|
|
content: sharedMessage,
|
|
extraTags: [["buzz:sent-from-thread", destination.id, destinationRoot]],
|
|
});
|
|
return { destinationRootId: destination.id, sourceRootId: source.id };
|
|
},
|
|
{ destinationRoot, sharedMessage, sourceReply, sourceRoot },
|
|
);
|
|
|
|
await page.getByTestId("channel-general").click();
|
|
const timeline = page.getByTestId("message-timeline");
|
|
const source = timeline.locator(`[data-message-id="${sourceRootId}"]`);
|
|
await source.hover();
|
|
await source.getByRole("button", { name: "Reply" }).click();
|
|
const threadPanel = page.getByTestId("message-thread-panel");
|
|
const threadInput = threadPanel.getByTestId("message-input");
|
|
const reply = threadPanel
|
|
.getByTestId("message-row")
|
|
.filter({ hasText: sourceReply })
|
|
.last();
|
|
await reply.hover();
|
|
await reply.getByRole("button", { name: "More actions" }).click();
|
|
await page.getByRole("menuitem", { name: "Edit message" }).click();
|
|
await expectReplyEditReady(threadPanel, sourceReply);
|
|
await threadInput.fill(dirtyReply);
|
|
|
|
const threadUrl = page.url();
|
|
expect(threadUrl).toContain(`thread=${sourceRootId}`);
|
|
const sentFromThreadLink = timeline
|
|
.getByTestId("message-row")
|
|
.filter({ hasText: sharedMessage })
|
|
.getByTestId("sent-from-thread")
|
|
.locator("[data-message-link]");
|
|
await sentFromThreadLink.click();
|
|
await expect(
|
|
page.getByText("Finish or cancel your edit before leaving the thread."),
|
|
).toBeVisible();
|
|
await expect(threadPanel).toBeVisible();
|
|
await expect(threadPanel.getByTestId("edit-target")).toBeVisible();
|
|
await expect(threadInput).toHaveText(dirtyReply);
|
|
await expect(page).toHaveURL(threadUrl);
|
|
|
|
await threadInput.press("Escape");
|
|
await expect(threadPanel.getByTestId("edit-target")).toHaveCount(0);
|
|
await sentFromThreadLink.click();
|
|
await expect(page).not.toHaveURL(threadUrl);
|
|
await expect(threadPanel).toBeVisible();
|
|
await expect(threadPanel.getByTestId("message-thread-head")).toContainText(
|
|
destinationRoot,
|
|
);
|
|
await expect(page).toHaveURL(new RegExp(`thread=${destinationRootId}`));
|
|
});
|
|
|
|
test("a refused search result preserves the edit and retries after cancel", async ({
|
|
page,
|
|
}) => {
|
|
const sourceRoot = `Search guard source ${Date.now()}`;
|
|
const sourceReply = `Search guard reply ${Date.now()}`;
|
|
const destinationRoot = `Search guard destination ${Date.now()}`;
|
|
const dirtyReply = `${sourceReply} unsaved byte-for-byte`;
|
|
|
|
await page.goto("/");
|
|
await page.waitForFunction(
|
|
() => typeof window.__BUZZ_E2E_EMIT_MOCK_MESSAGE__ === "function",
|
|
);
|
|
const { destinationRootId, sourceRootId } = await page.evaluate(
|
|
({ destinationRoot, sourceReply, sourceRoot }) => {
|
|
const emit = window.__BUZZ_E2E_EMIT_MOCK_MESSAGE__;
|
|
if (!emit) throw new Error("Mock message emitter is unavailable.");
|
|
const destination = emit({
|
|
channelName: "general",
|
|
content: destinationRoot,
|
|
});
|
|
const source = emit({ channelName: "general", content: sourceRoot });
|
|
emit({
|
|
channelName: "general",
|
|
content: sourceReply,
|
|
parentEventId: source.id,
|
|
});
|
|
return { destinationRootId: destination.id, sourceRootId: source.id };
|
|
},
|
|
{ destinationRoot, sourceReply, sourceRoot },
|
|
);
|
|
|
|
await page.getByTestId("channel-general").click();
|
|
const timeline = page.getByTestId("message-timeline");
|
|
const source = timeline.locator(`[data-message-id="${sourceRootId}"]`);
|
|
await source.hover();
|
|
await source.getByRole("button", { name: "Reply" }).click();
|
|
const threadPanel = page.getByTestId("message-thread-panel");
|
|
const threadInput = threadPanel.getByTestId("message-input");
|
|
const reply = threadPanel
|
|
.getByTestId("message-row")
|
|
.filter({ hasText: sourceReply })
|
|
.last();
|
|
await reply.hover();
|
|
await reply.getByRole("button", { name: "More actions" }).click();
|
|
await page.getByRole("menuitem", { name: "Edit message" }).click();
|
|
await expectReplyEditReady(threadPanel, sourceReply);
|
|
await threadInput.fill(dirtyReply);
|
|
|
|
const threadUrl = page.url();
|
|
expect(threadUrl).toContain(`thread=${sourceRootId}`);
|
|
await page.getByTestId("open-search").click();
|
|
await page.getByTestId("search-dialog-input").fill(destinationRoot);
|
|
const destinationResult = page.getByTestId(
|
|
`search-result-${destinationRootId}`,
|
|
);
|
|
await expect(destinationResult).toBeVisible();
|
|
await destinationResult.click();
|
|
|
|
const refusal = page.getByText(
|
|
"Finish or cancel your edit before leaving the thread.",
|
|
);
|
|
await expect(refusal).toHaveCount(1);
|
|
await expect(threadPanel).toBeVisible();
|
|
await expect(threadPanel.getByTestId("edit-target")).toBeVisible();
|
|
await expect(threadInput).toHaveText(dirtyReply);
|
|
await expect(page).toHaveURL(threadUrl);
|
|
|
|
await threadInput.press("Escape");
|
|
await expect(threadPanel.getByTestId("edit-target")).toHaveCount(0);
|
|
await page.getByTestId("open-search").click();
|
|
await page.getByTestId("search-dialog-input").fill(destinationRoot);
|
|
await destinationResult.click();
|
|
await expect(page).not.toHaveURL(threadUrl);
|
|
await expect(threadPanel.getByTestId("message-thread-head")).toContainText(
|
|
destinationRoot,
|
|
);
|
|
await expect(page).toHaveURL(new RegExp(`thread=${destinationRootId}`));
|
|
});
|
|
|
|
test("a refused forum search result preserves the edit and retries after cancel", async ({
|
|
page,
|
|
}) => {
|
|
const sourceRoot = `Forum guard source ${Date.now()}`;
|
|
const sourceReply = `Forum guard reply ${Date.now()}`;
|
|
const dirtyReply = `${sourceReply} unsaved byte-for-byte`;
|
|
|
|
await page.goto("/");
|
|
await page.waitForFunction(
|
|
() => typeof window.__BUZZ_E2E_EMIT_MOCK_MESSAGE__ === "function",
|
|
);
|
|
const sourceRootId = await page.evaluate(
|
|
({ sourceReply, sourceRoot }) => {
|
|
const emit = window.__BUZZ_E2E_EMIT_MOCK_MESSAGE__;
|
|
if (!emit) throw new Error("Mock message emitter is unavailable.");
|
|
const source = emit({ channelName: "general", content: sourceRoot });
|
|
emit({
|
|
channelName: "general",
|
|
content: sourceReply,
|
|
parentEventId: source.id,
|
|
});
|
|
return source.id;
|
|
},
|
|
{ sourceReply, sourceRoot },
|
|
);
|
|
|
|
await page.getByTestId("channel-general").click();
|
|
const source = page
|
|
.getByTestId("message-timeline")
|
|
.locator(`[data-message-id="${sourceRootId}"]`);
|
|
await source.hover();
|
|
await source.getByRole("button", { name: "Reply" }).click();
|
|
const threadPanel = page.getByTestId("message-thread-panel");
|
|
const threadInput = threadPanel.getByTestId("message-input");
|
|
const reply = threadPanel
|
|
.getByTestId("message-row")
|
|
.filter({ hasText: sourceReply })
|
|
.last();
|
|
await reply.hover();
|
|
await reply.getByRole("button", { name: "More actions" }).click();
|
|
await page.getByRole("menuitem", { name: "Edit message" }).click();
|
|
await expectReplyEditReady(threadPanel, sourceReply);
|
|
await threadInput.fill(dirtyReply);
|
|
|
|
const threadUrl = page.url();
|
|
const editTarget = threadPanel.getByTestId("edit-target");
|
|
await expect(editTarget).toBeVisible();
|
|
await page.getByTestId("open-search").click();
|
|
await page
|
|
.getByTestId("search-dialog-input")
|
|
.fill("Release checklist: async feedback thread.");
|
|
const forumResult = page.getByTestId(
|
|
"search-result-mock-forum-release-thread",
|
|
);
|
|
await expect(forumResult).toBeVisible();
|
|
await forumResult.click();
|
|
|
|
const refusal = page.getByText(
|
|
"Finish or cancel your edit before leaving the thread.",
|
|
);
|
|
await expect(refusal).toHaveCount(1);
|
|
await expect(threadPanel).toBeVisible();
|
|
await expect(editTarget).toBeVisible();
|
|
await expect(threadInput).toHaveText(dirtyReply);
|
|
await expect(page).toHaveURL(threadUrl);
|
|
|
|
await threadInput.press("Escape");
|
|
await expect(threadPanel.getByTestId("edit-target")).toHaveCount(0);
|
|
await page.getByTestId("open-search").click();
|
|
await page
|
|
.getByTestId("search-dialog-input")
|
|
.fill("Release checklist: async feedback thread.");
|
|
await forumResult.click();
|
|
await expect(page).toHaveURL(
|
|
/#\/channels\/a27e1ee9-76a6-5bdf-a5d5-1d85610dad11\/posts\/mock-forum-release-thread$/,
|
|
);
|
|
await expect(
|
|
page.locator('[data-forum-event-id="mock-forum-release-thread"]'),
|
|
).toContainText("Release checklist: async feedback thread.");
|
|
});
|
|
|
|
for (const targetKind of ["reply", "root"] as const) {
|
|
test(`a refused same-thread ${targetKind} target preserves the edit and retries after cancel`, async ({
|
|
page,
|
|
}) => {
|
|
const sourceRoot = `Same-thread ${targetKind} guard root ${Date.now()}`;
|
|
const sourceReply = `Same-thread ${targetKind} guard reply ${Date.now()}`;
|
|
const dirtyReply = `${sourceReply} unsaved byte-for-byte 🧵`;
|
|
|
|
await page.goto("/");
|
|
await page.waitForFunction(
|
|
() => typeof window.__BUZZ_E2E_EMIT_MOCK_MESSAGE__ === "function",
|
|
);
|
|
const { sourceReplyId, sourceRootId } = await page.evaluate(
|
|
({ sourceReply, sourceRoot, targetKind }) => {
|
|
const emit = window.__BUZZ_E2E_EMIT_MOCK_MESSAGE__;
|
|
if (!emit) throw new Error("Mock message emitter is unavailable.");
|
|
const root = emit({ channelName: "general", content: sourceRoot });
|
|
const reply = emit({
|
|
channelName: "general",
|
|
content: sourceReply,
|
|
parentEventId: root.id,
|
|
});
|
|
const targetId = targetKind === "reply" ? reply.id : root.id;
|
|
emit({
|
|
channelName: "general",
|
|
content: `Same-thread ${targetKind} target buzz://message?channel=9a1657ac-f7aa-5db0-b632-d8bbeb6dfb50&id=${targetId}&thread=${root.id}`,
|
|
});
|
|
return { sourceReplyId: reply.id, sourceRootId: root.id };
|
|
},
|
|
{ sourceReply, sourceRoot, targetKind },
|
|
);
|
|
|
|
await page.getByTestId("channel-general").click();
|
|
const timeline = page.getByTestId("message-timeline");
|
|
const source = timeline.locator(`[data-message-id="${sourceRootId}"]`);
|
|
await source.hover();
|
|
await source.getByRole("button", { name: "Reply" }).click();
|
|
|
|
const threadPanel = page.getByTestId("message-thread-panel");
|
|
const threadInput = threadPanel.getByTestId("message-input");
|
|
const reply = threadPanel.locator(`[data-message-id="${sourceReplyId}"]`);
|
|
await reply.hover();
|
|
await reply.getByRole("button", { name: "More actions" }).click();
|
|
await page.getByRole("menuitem", { name: "Edit message" }).click();
|
|
await expectReplyEditReady(threadPanel, sourceReply);
|
|
await threadInput.fill(dirtyReply);
|
|
|
|
const targetLink = timeline
|
|
.getByTestId("message-row")
|
|
.filter({ hasText: `Same-thread ${targetKind} target` })
|
|
.getByRole("button", { name: "Open message in channel general" });
|
|
const navigationBefore = await page.evaluate(() => ({
|
|
historyLength: history.length,
|
|
url: location.href,
|
|
}));
|
|
const sendsBefore = await page.evaluate(
|
|
() =>
|
|
(window.__BUZZ_E2E_COMMAND_LOG__ ?? []).filter(
|
|
(entry) => entry.command === "send_channel_message",
|
|
).length,
|
|
);
|
|
|
|
await targetLink.click();
|
|
|
|
const refusal = page.getByText(
|
|
"Finish or cancel your edit before leaving the thread.",
|
|
);
|
|
await expect(refusal).toHaveCount(1);
|
|
await expect(threadPanel).toBeVisible();
|
|
await expect(threadPanel.getByTestId("edit-target")).toBeVisible();
|
|
await expect(threadInput).toHaveText(dirtyReply);
|
|
expect(await threadInput.textContent()).toBe(dirtyReply);
|
|
await expect(page).toHaveURL(navigationBefore.url);
|
|
expect(await page.evaluate(() => history.length)).toBe(
|
|
navigationBefore.historyLength,
|
|
);
|
|
expect(
|
|
await page.evaluate(
|
|
() =>
|
|
(window.__BUZZ_E2E_COMMAND_LOG__ ?? []).filter(
|
|
(entry) => entry.command === "send_channel_message",
|
|
).length,
|
|
),
|
|
).toBe(sendsBefore);
|
|
|
|
await threadInput.press("Escape");
|
|
await expect(threadPanel.getByTestId("edit-target")).toHaveCount(0);
|
|
await targetLink.click();
|
|
await expect
|
|
.poll(() => page.evaluate(() => history.length))
|
|
.toBeGreaterThan(navigationBefore.historyLength);
|
|
await expect(threadPanel).toBeVisible();
|
|
await expect(
|
|
threadPanel.locator(
|
|
`[data-message-id="${targetKind === "reply" ? sourceReplyId : sourceRootId}"]`,
|
|
),
|
|
).toBeVisible();
|
|
});
|
|
}
|
|
|
|
test("a refused channel switch preserves the reply edit and retries after cancel", async ({
|
|
page,
|
|
}) => {
|
|
const sourceRoot = `Channel-switch guard root ${Date.now()}`;
|
|
const sourceReply = `Channel-switch guard reply ${Date.now()}`;
|
|
const dirtyReply = `${sourceReply} unsaved byte-for-byte 🧵`;
|
|
|
|
await page.goto("/");
|
|
await page.waitForFunction(
|
|
() => typeof window.__BUZZ_E2E_EMIT_MOCK_MESSAGE__ === "function",
|
|
);
|
|
const { sourceReplyId, sourceRootId } = await page.evaluate(
|
|
({ sourceReply, sourceRoot }) => {
|
|
const emit = window.__BUZZ_E2E_EMIT_MOCK_MESSAGE__;
|
|
if (!emit) throw new Error("Mock message emitter is unavailable.");
|
|
const root = emit({ channelName: "general", content: sourceRoot });
|
|
const reply = emit({
|
|
channelName: "general",
|
|
content: sourceReply,
|
|
parentEventId: root.id,
|
|
});
|
|
return { sourceReplyId: reply.id, sourceRootId: root.id };
|
|
},
|
|
{ sourceReply, sourceRoot },
|
|
);
|
|
|
|
await page.getByTestId("channel-general").click();
|
|
const source = page
|
|
.getByTestId("message-timeline")
|
|
.locator(`[data-message-id="${sourceRootId}"]`);
|
|
await source.hover();
|
|
await source.getByRole("button", { name: "Reply" }).click();
|
|
|
|
const threadPanel = page.getByTestId("message-thread-panel");
|
|
const threadInput = threadPanel.getByTestId("message-input");
|
|
const reply = threadPanel.locator(`[data-message-id="${sourceReplyId}"]`);
|
|
await reply.hover();
|
|
await reply.getByRole("button", { name: "More actions" }).click();
|
|
await page.getByRole("menuitem", { name: "Edit message" }).click();
|
|
await expectReplyEditReady(threadPanel, sourceReply);
|
|
await threadInput.fill(dirtyReply);
|
|
|
|
const navigationBefore = await page.evaluate(() => ({
|
|
historyLength: history.length,
|
|
url: location.href,
|
|
}));
|
|
const sendsBefore = await page.evaluate(
|
|
() =>
|
|
(window.__BUZZ_E2E_COMMAND_LOG__ ?? []).filter(
|
|
(entry) => entry.command === "send_channel_message",
|
|
).length,
|
|
);
|
|
|
|
await page.getByTestId("channel-random").click();
|
|
|
|
await expect(
|
|
page.getByText("Finish or cancel your edit before leaving the thread."),
|
|
).toHaveCount(1);
|
|
await expect(page.getByTestId("chat-title")).toHaveText("general");
|
|
await expect(threadPanel).toBeVisible();
|
|
await expect(threadPanel.getByTestId("edit-target")).toBeVisible();
|
|
await expect(threadInput).toHaveText(dirtyReply);
|
|
expect(await threadInput.textContent()).toBe(dirtyReply);
|
|
await expect(page).toHaveURL(navigationBefore.url);
|
|
expect(await page.evaluate(() => history.length)).toBe(
|
|
navigationBefore.historyLength,
|
|
);
|
|
expect(
|
|
await page.evaluate(
|
|
() =>
|
|
(window.__BUZZ_E2E_COMMAND_LOG__ ?? []).filter(
|
|
(entry) => entry.command === "send_channel_message",
|
|
).length,
|
|
),
|
|
).toBe(sendsBefore);
|
|
|
|
await threadInput.press("Escape");
|
|
await expect(threadPanel.getByTestId("edit-target")).toHaveCount(0);
|
|
await page.getByTestId("channel-random").click();
|
|
await expect(page.getByTestId("chat-title")).toHaveText("random");
|
|
await expect(page).not.toHaveURL(navigationBefore.url);
|
|
});
|
|
|
|
for (const backInput of ["button", "keyboard"] as const) {
|
|
test(`a refused ${backInput} Back preserves the reply edit and retries after cancel`, async ({
|
|
page,
|
|
}) => {
|
|
const sourceRoot = `History guard root ${backInput} ${Date.now()}`;
|
|
const sourceReply = `History guard reply ${backInput} ${Date.now()}`;
|
|
const dirtyReply = `${sourceReply} unsaved byte-for-byte 🧵`;
|
|
|
|
await page.goto("/");
|
|
await page.waitForFunction(
|
|
() => typeof window.__BUZZ_E2E_EMIT_MOCK_MESSAGE__ === "function",
|
|
);
|
|
const { sourceReplyId, sourceRootId } = await page.evaluate(
|
|
({ sourceReply, sourceRoot }) => {
|
|
const emit = window.__BUZZ_E2E_EMIT_MOCK_MESSAGE__;
|
|
if (!emit) throw new Error("Mock message emitter is unavailable.");
|
|
const root = emit({ channelName: "general", content: sourceRoot });
|
|
const reply = emit({
|
|
channelName: "general",
|
|
content: sourceReply,
|
|
parentEventId: root.id,
|
|
});
|
|
return { sourceReplyId: reply.id, sourceRootId: root.id };
|
|
},
|
|
{ sourceReply, sourceRoot },
|
|
);
|
|
|
|
await page.getByTestId("channel-random").click();
|
|
await page.getByTestId("channel-general").click();
|
|
const source = page
|
|
.getByTestId("message-timeline")
|
|
.locator(`[data-message-id="${sourceRootId}"]`);
|
|
await source.hover();
|
|
await source.getByRole("button", { name: "Reply" }).click();
|
|
|
|
const threadPanel = page.getByTestId("message-thread-panel");
|
|
const threadInput = threadPanel.getByTestId("message-input");
|
|
const reply = threadPanel.locator(`[data-message-id="${sourceReplyId}"]`);
|
|
await reply.hover();
|
|
await reply.getByRole("button", { name: "More actions" }).click();
|
|
await page.getByRole("menuitem", { name: "Edit message" }).click();
|
|
await expectReplyEditReady(threadPanel, sourceReply);
|
|
await threadInput.fill(dirtyReply);
|
|
|
|
const navigationBefore = await page.evaluate(() => ({
|
|
historyLength: history.length,
|
|
url: location.href,
|
|
}));
|
|
const sendsBefore = await page.evaluate(
|
|
() =>
|
|
(window.__BUZZ_E2E_COMMAND_LOG__ ?? []).filter(
|
|
(entry) => entry.command === "send_channel_message",
|
|
).length,
|
|
);
|
|
const invokeBack = async () => {
|
|
if (backInput === "button") {
|
|
await page.getByTestId("global-back").click();
|
|
return;
|
|
}
|
|
await page.keyboard.press(
|
|
process.platform === "darwin" ? "Meta+[" : "Alt+ArrowLeft",
|
|
);
|
|
};
|
|
|
|
await invokeBack();
|
|
|
|
await expect(
|
|
page.getByText("Finish or cancel your edit before leaving the thread."),
|
|
).toHaveCount(1);
|
|
await expect(page.getByTestId("chat-title")).toHaveText("general");
|
|
await expect(threadPanel).toBeVisible();
|
|
await expect(threadPanel.getByTestId("edit-target")).toBeVisible();
|
|
await expect(threadInput).toHaveText(dirtyReply);
|
|
expect(await threadInput.textContent()).toBe(dirtyReply);
|
|
await expect(page).toHaveURL(navigationBefore.url);
|
|
expect(await page.evaluate(() => history.length)).toBe(
|
|
navigationBefore.historyLength,
|
|
);
|
|
expect(
|
|
await page.evaluate(
|
|
() =>
|
|
(window.__BUZZ_E2E_COMMAND_LOG__ ?? []).filter(
|
|
(entry) => entry.command === "send_channel_message",
|
|
).length,
|
|
),
|
|
).toBe(sendsBefore);
|
|
|
|
await threadInput.press("Escape");
|
|
await expect(threadPanel.getByTestId("edit-target")).toHaveCount(0);
|
|
await expect(page.getByTestId("global-back")).toBeEnabled();
|
|
await invokeBack();
|
|
await expect(page).not.toHaveURL(navigationBefore.url);
|
|
});
|
|
}
|
|
|
|
test("ArrowUp in an empty composer edits your last message right after sending", async ({
|
|
page,
|
|
}) => {
|
|
// Regression: after a send, the composer keeps DOM focus and ProseMirror
|
|
// would consume ArrowUp before it reached the edit-last-message handler,
|
|
// so ↑ did nothing until you clicked out and back. The handler now lives
|
|
// in the editor keymap, so ↑ must work with no intermediate click.
|
|
const message = `Edit-last via arrow up ${Date.now()}`;
|
|
const input = page.getByTestId("message-input");
|
|
|
|
await page.goto("/");
|
|
await page.getByTestId("channel-general").click();
|
|
await expect(page.getByTestId("chat-title")).toHaveText("general");
|
|
|
|
await input.fill(message);
|
|
await input.press("Enter");
|
|
await expect(page.getByTestId("message-timeline")).toContainText(message);
|
|
|
|
// Composer stays focused after send — no click, just press ↑.
|
|
await expect(input).toBeFocused();
|
|
await page.keyboard.press("ArrowUp");
|
|
|
|
// Edit mode is entered for the just-sent message.
|
|
const editBanner = page.getByTestId("edit-target");
|
|
await expect(editBanner).toBeVisible();
|
|
await expect(editBanner).toContainText("Editing message");
|
|
await expect(editBanner).not.toContainText(message);
|
|
await expect(input).toHaveText(message);
|
|
});
|
|
|
|
test("ArrowUp does not edit when the composer has draft text", async ({
|
|
page,
|
|
}) => {
|
|
// Guard: ↑ must only hijack to edit when the composer is empty, so it
|
|
// never steals the arrow key from someone navigating drafted text.
|
|
const sent = `Sent before draft ${Date.now()}`;
|
|
const draft = `Half-typed draft ${Date.now()}`;
|
|
const input = page.getByTestId("message-input");
|
|
|
|
await page.goto("/");
|
|
await page.getByTestId("channel-general").click();
|
|
await expect(page.getByTestId("chat-title")).toHaveText("general");
|
|
|
|
await input.fill(sent);
|
|
await input.press("Enter");
|
|
await expect(page.getByTestId("message-timeline")).toContainText(sent);
|
|
|
|
await input.fill(draft);
|
|
await expect(input).toHaveText(draft);
|
|
await page.keyboard.press("ArrowUp");
|
|
|
|
// No edit mode; the draft is untouched.
|
|
await expect(page.getByTestId("edit-target")).toHaveCount(0);
|
|
await expect(input).toHaveText(draft);
|
|
});
|
|
|
|
test("ArrowUp edits your last thread reply right after sending it", async ({
|
|
page,
|
|
}) => {
|
|
// Same fix must hold in the thread composer (shares MessageComposer).
|
|
const seed = `Thread arrow-up seed ${Date.now()}`;
|
|
const reply = `Thread reply to edit ${Date.now()}`;
|
|
|
|
await page.goto("/");
|
|
await page.getByTestId("channel-general").click();
|
|
await expect(page.getByTestId("chat-title")).toHaveText("general");
|
|
|
|
await page.getByTestId("message-input").fill(seed);
|
|
await page.getByTestId("send-message").click();
|
|
await expect(page.getByTestId("message-timeline")).toContainText(seed);
|
|
|
|
const rootMessage = page
|
|
.getByTestId("message-timeline")
|
|
.getByTestId("message-row")
|
|
.last();
|
|
await rootMessage.hover();
|
|
await rootMessage.getByRole("button", { name: "Reply" }).click();
|
|
|
|
const threadPanel = page.getByTestId("message-thread-panel");
|
|
await expect(threadPanel).toBeVisible();
|
|
const threadInput = threadPanel.getByTestId("message-input");
|
|
await expect(threadInput).toBeFocused();
|
|
|
|
await page.keyboard.type(reply);
|
|
await page.keyboard.press("Enter");
|
|
await expect(threadPanel).toContainText(reply);
|
|
|
|
// No click — press ↑ in the still-focused thread composer.
|
|
await page.keyboard.press("ArrowUp");
|
|
|
|
const editBanner = threadPanel.getByTestId("edit-target");
|
|
await expect(editBanner).toBeVisible();
|
|
await expect(editBanner).toContainText("Editing message");
|
|
await expect(editBanner).not.toContainText(reply);
|
|
await expect(threadInput).toHaveText(reply);
|
|
});
|
|
|
|
test("action bar stays within the timeline when the thread panel is open", async ({
|
|
page,
|
|
}) => {
|
|
// Narrow viewport + open thread panel => the timeline shrinks to a column.
|
|
// A long unbreakable token must not widen message rows past that column,
|
|
// or the right-anchored action bar is pushed offscreen (regression: #1081
|
|
// fixed the wrap but rows still expanded to content min-width).
|
|
await page.setViewportSize({ width: 1024, height: 800 });
|
|
await page.goto("/");
|
|
await page.getByTestId("channel-general").click();
|
|
await expect(page.getByTestId("chat-title")).toHaveText("general");
|
|
|
|
const timeline = page.getByTestId("message-timeline");
|
|
const input = page.getByTestId("message-input").first();
|
|
const send = page.getByTestId("send-message").first();
|
|
|
|
const longUrl = `https://example.com/${"a".repeat(180)}/path`;
|
|
await input.fill(longUrl);
|
|
await send.click();
|
|
await expect(timeline).toContainText("example.com");
|
|
|
|
const rootMessage = timeline.getByTestId("message-row").first();
|
|
await rootMessage.hover();
|
|
await rootMessage.getByRole("button", { name: "Reply" }).click();
|
|
await expect(page.getByTestId("message-thread-panel")).toBeVisible();
|
|
|
|
const wideRow = timeline.getByTestId("message-row").last();
|
|
await wideRow.scrollIntoViewIfNeeded();
|
|
await wideRow.hover();
|
|
const bar = wideRow.locator('[data-testid^="message-action-bar-"]');
|
|
await expect(bar).toBeVisible();
|
|
|
|
const timelineBox = await timeline.boundingBox();
|
|
const rowBox = await wideRow.boundingBox();
|
|
const barBox = await bar.boundingBox();
|
|
if (!timelineBox || !rowBox || !barBox) {
|
|
throw new Error("Expected timeline, row, and action bar to have geometry.");
|
|
}
|
|
|
|
expect(rowBox.x + rowBox.width).toBeLessThanOrEqual(
|
|
timelineBox.x + timelineBox.width + 1,
|
|
);
|
|
expect(barBox.x + barBox.width).toBeLessThanOrEqual(
|
|
timelineBox.x + timelineBox.width + 1,
|
|
);
|
|
});
|