mirror of
https://github.com/block/buzz.git
synced 2026-11-01 23:11:58 +00:00
## Summary Fixes the four desktop product races behind recurring E2E flakes in the 21–28 Oct 2026 CI audit. Each fix has a regression test: - In the channel browser, Enter acted on a stale deferred list. - A stale autocomplete swallowed the Enter that submits an empty edit. - Same-second messages swapped order on screen. - The unread pill read "Jump to latest" instead of "N new messages". Two E2E specs that failed for the same ordering reason also get test-side fixes. This is one of seven PRs split out of the `flakey-tests` branch (siblings below). ### Related issue None found for these. Draft #8559 does not fix the voice-note flake. Draft #8661 (Refs #6733) closes the `#channel` list synchronously when an edit is cleared. §11 below also covers that case, plus the `@mention` and `:emoji` lists, so the two PRs overlap in `useChannelLinks.ts` and `empty-edit-delete.spec.ts`. ### Flaky tests fixed | Test | Failing runs | Cause | Change | |---|---|---|---| | §5 `channel-browser.spec.ts:402` "Enter with no matches jumps to create" | 15 of 50 main runs, e.g. 40597484657, 40582387515, 40566841699, 40367495044 | **Product.** The list renders from `useDeferredValue(query)`. An Enter that landed before the deferred render read the old list, so it opened a stale match or skipped the create row. | While the deferred query lags, Enter filters the live query with the same `selectBrowserChannels` the list renders from. | | §11 `empty-edit-delete.spec.ts:88`, `:111`, `:132` | :88 in 3 jobs, :111 in 3, :132 in 2. :111 failed all 3 attempts on main 39822883395 and 39799090750 | **Product.** Clearing the draft went through the 120 ms query debounce. The `#channel` list stayed open, so Enter re-inserted the suggestion instead of submitting the empty edit. | `useChannelLinks`, `useEmojiAutocomplete` and `useMentions` (typed `@` only) close as soon as the trigger is gone. Typing a query still debounces. `ChannelAutocomplete` gains `data-testid="channel-autocomplete"`. | | §10 `video-attachment.spec.ts:1469` "right-click menus expose distinct selectors…" | 10 of 50 main runs, e.g. 40528171327 | **Product.** The live cache merge (`sortMessages`, ascending id within a second) and the channel-window projection (descending id) disagreed, so two same-second rows swapped on screen as each writer landed. | A new `shared/lib/eventOrder.ts` holds `compareRelayOrder` and its exact reverse, `compareChronologicalOrder`. `sortMessages` and the window and reconciliation sorts all use the window's order, so older-page paging is unchanged. `sortEvents` keeps ascending id. The spec gives its three messages distinct seconds. | | §16 `voice-note.spec.ts:906` "starting a duplicate voice-note player pauses the other instance" | 6 of 50 main runs. All 3 attempts on PRs 39622488737 and 40309414135 | Same-second messages plus index-based card lookup. | Distinct seconds, and each card is found through its own message row. | | §13 `stream.spec.ts:477` "keeps scroll position when new messages arrive above the fold" (integration) | 7 of 50 main runs. All 3 attempts on PR 39546556833 | **Product.** Frozen rows came back reordered (7 of 12 instrumented runs). The selector ended older history at the snapshot's first id, so moved rows were both prepended and kept in the frozen tail. The duplicates filled the row count, `pendingCount` fell to 0, and the pill read "Jump to latest". Deleting a frozen row also dropped the whole freeze. | `useBufferedTimelineMessages` keys rows by `renderKey`, which is stable across an optimistic send and its ack (`frozenRowKeys`). It drops only deleted frozen rows, and ends older history at the first frozen row of any key. `stream.spec` is unchanged. | New regression tests: - `channel-browser.spec`: two forced Enter variants. - `empty-edit-delete.spec`: a `#`/`@`/`:` autocomplete table. - `autocompleteTriggerClose.jsdom-test`. - `projectChannelWindow.test`: the cache merge and the window agree in both arrival orders. - `channel-dense-second-reach.spec`: a RAF sampler sees one order. - `useBufferedTimelineMessages.test`: deletion, reorder and ack rows. - `virtualization.spec`: deleting a row while reading keeps the next arrival counted. - A `useThreadReplies.test` row: the thread fetch and a live append agree on same-second order. Local before/after counts are from the original `flakey-tests` commits (238c08c7c, c66f411f3): | Test | Before | After | |---|---|---| | channel-browser | 0/20 | 20/20 | | autocomplete table | 0/30 | 30/30 | | jsdom | 0/2 | 2/2 | | dense-second sampler | 0/10 | 10/10 | | buffered-timeline unit | 4/7 | 7/7 | | virtualization deletion | 0/10 | 10/10 | | `stream:477` against an isolated relay | 25/30, all failures "Jump to latest" | 30/30, then 20/20 and 10/10 | ### Verification This branch was pushed without local re-verification, by the user's choice: local test runs were overloading the machine and failing for that reason. **Hosted CI is the check.** It was pushed with `git push --no-verify`, because the pre-push hook runs typecheck and the unit suites. #8613's flake-check lane will also repeat the changed specs and unit tests. - Every file except `empty-edit-delete.spec.ts` and `virtualization.spec.ts` is byte-identical to its final version on `flakey-tests` (67185df6a). Those two carry only this PR's hunks; see "Merge order". - Checked for this PR: `git diff --check`, biome on both partial specs (no changes, the same as on `main`), and a conflict-free merge with all six siblings onto `main`. ### Readiness These are **product fixes in the desktop app**. Under AGENTS.md "Before opening a PR", this needs an agent review, an agent run of the affected flows, and a **human in-app check** before `buzz-review-completed` is added. Suggested check, in `just dev` or a local build against any community: 1. **Channel browser:** open "Browse channels", type a name that matches nothing and press Enter straight away. The create-channel flow opens with that name. Then type an existing channel's exact name and press Enter straight away. That channel opens, not an earlier match. 2. **Empty-edit delete:** edit one of your own messages and type `#gen` so the channel list opens. Select all, delete, and press Enter at once. The empty edit is submitted (the delete-by-clearing flow) and no suggestion is re-inserted. Repeat with `@` (mention list) and `:sm` (emoji list). 3. **Same-second order:** send two short messages in quick succession, within one second. They appear in send order and never swap while they land. 4. **Unread pill:** in a busy channel, scroll up past the fold, and have a second client post one message. The pill reads "1 new message". While still scrolled up, delete one of your visible messages, then have the second client post again. The pill still counts the new message. ### Merge order No required order, but two files are shared: - `desktop/tests/e2e/empty-edit-delete.spec.ts` is also edited by **flakey-tests-desktop-e2e-actions**. This PR adds the autocomplete table after line 158. The sibling changes imports, helpers, `beforeEach` and line 143. - `desktop/tests/e2e/virtualization.spec.ts` is also edited by **flakey-tests-desktop-e2e-gates**. This PR adds one test after line 929. The sibling's hunks are all above line 551. The hunks don't touch, so each pair merges cleanly in either order, as checked with `git merge-tree`. Draft #8661 overlaps this PR in `useChannelLinks.ts` and `empty-edit-delete.spec.ts`; whichever lands second will need a rebase. ### Sibling PRs Split from `flakey-tests` (one PR per area): #8676 (PostgreSQL lease + lane), #8677 (relay E2E helper + Windows shell), #8678 (mobile), #8679 (Tauri apt cache + Windows Rust cap), #8681 (desktop E2E state gates), #8682 (desktop E2E composer + row actions). 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Signed-off-by: Matt Toohey <contact@matttoohey.com> Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com> Co-authored-by: Jitter <2b2e6415e748c35180847a37aadae06a11e30dddc76b784e7fd9354c4eb42e7a@buzz.block.builderlab.xyz>
973 lines
35 KiB
TypeScript
973 lines
35 KiB
TypeScript
import { expect, test } from "@playwright/test";
|
||
import type { Page } from "@playwright/test";
|
||
|
||
import { waitForAnimations } from "../helpers/animations";
|
||
import { installMockBridge } from "../helpers/bridge";
|
||
import { expectSmoothCorners } from "../helpers/css";
|
||
|
||
const AUDIO_URL = "http://127.0.0.1:4173/sounds/ping.mp3";
|
||
|
||
async function openMoreActionsMenu(page: Page, messageId: string) {
|
||
const row = page.locator(`[data-message-id="${messageId}"]`);
|
||
await row.hover();
|
||
await page.getByTestId(`more-actions-${messageId}`).click();
|
||
await expect(page.locator('[role="menuitem"]').first()).toBeVisible();
|
||
}
|
||
|
||
async function waitForMockLiveSubscription(page: Page, channelName: string) {
|
||
await expect
|
||
.poll(() =>
|
||
page.evaluate(
|
||
(currentChannelName) =>
|
||
(
|
||
window as Window & {
|
||
__BUZZ_E2E_HAS_MOCK_LIVE_SUBSCRIPTION__?: (input: {
|
||
channelName: string;
|
||
kind: number;
|
||
exactChannel: boolean;
|
||
}) => boolean;
|
||
}
|
||
).__BUZZ_E2E_HAS_MOCK_LIVE_SUBSCRIPTION__?.({
|
||
channelName: currentChannelName,
|
||
kind: 9,
|
||
exactChannel: true,
|
||
}) ?? false,
|
||
channelName,
|
||
),
|
||
)
|
||
.toBe(true);
|
||
}
|
||
|
||
test.beforeEach(async ({ page }, testInfo) => {
|
||
await page.addInitScript(() => {
|
||
Object.defineProperty(navigator.mediaDevices, "getUserMedia", {
|
||
configurable: true,
|
||
async value() {
|
||
const context = new AudioContext();
|
||
const oscillator = context.createOscillator();
|
||
const destination = context.createMediaStreamDestination();
|
||
oscillator.frequency.value = 220;
|
||
oscillator.connect(destination);
|
||
oscillator.start();
|
||
destination.stream.getAudioTracks()[0]?.addEventListener(
|
||
"ended",
|
||
() => {
|
||
oscillator.stop();
|
||
void context.close();
|
||
},
|
||
{ once: true },
|
||
);
|
||
return destination.stream;
|
||
},
|
||
});
|
||
});
|
||
|
||
await installMockBridge(page, {
|
||
deferredComposerUploads: true,
|
||
sendMessageErrors: testInfo.title.includes("shows a send error")
|
||
? ["relay rejected voice note"]
|
||
: undefined,
|
||
uploadDescriptors: [
|
||
{
|
||
duration: 9.4,
|
||
filename: "voice-note-123.mp4",
|
||
sha256: "a".repeat(64),
|
||
size: 16424,
|
||
type: "video/mp4",
|
||
uploaded: Math.floor(Date.now() / 1000),
|
||
url: AUDIO_URL,
|
||
},
|
||
],
|
||
});
|
||
});
|
||
|
||
test("restores the voice note and shows a send error after upload succeeds", async ({
|
||
page,
|
||
}) => {
|
||
await page.goto("/");
|
||
const channel = page.getByTestId("channel-general");
|
||
await expect(channel).toBeVisible({ timeout: 10_000 });
|
||
await channel.click();
|
||
|
||
await page.getByRole("button", { name: "Record voice note" }).click();
|
||
await expect(page.getByTestId("voice-note-recorder")).toBeVisible();
|
||
await page.waitForTimeout(100);
|
||
await page.getByRole("button", { name: "Finish voice note" }).click();
|
||
|
||
const composerCard = page.getByTestId("composer-voice-note-card");
|
||
await expect(composerCard).toBeVisible();
|
||
await page.getByTestId("send-message").click();
|
||
|
||
await expect(composerCard).toBeVisible();
|
||
await expect(
|
||
page.getByText("Message failed to send: relay rejected voice note"),
|
||
).toBeVisible();
|
||
});
|
||
|
||
test("keeps pasted snapshots and channel drops out of an active voice note", async ({
|
||
page,
|
||
}) => {
|
||
await page.goto("/");
|
||
await page.getByTestId("channel-general").click();
|
||
|
||
await page.getByRole("button", { name: "Record voice note" }).click();
|
||
await expect(page.getByTestId("voice-note-recorder")).toBeVisible();
|
||
await page.waitForTimeout(100);
|
||
|
||
await page.getByRole("button", { name: "Finish voice note" }).click();
|
||
await expect(page.getByTestId("composer-voice-note-card")).toBeVisible();
|
||
|
||
await page
|
||
.getByTestId("message-composer")
|
||
.locator(".ProseMirror")
|
||
.evaluate((editor) => {
|
||
const payload = encodeURIComponent(
|
||
JSON.stringify({
|
||
version: 1,
|
||
displayName: "Snapshot",
|
||
filename: "shared.agent.png",
|
||
sha256: "b".repeat(64),
|
||
size: 128,
|
||
type: "image/png",
|
||
url: "https://relay.example/media/shared.agent.png",
|
||
}),
|
||
);
|
||
const clipboardData = new DataTransfer();
|
||
clipboardData.setData(
|
||
"text/html",
|
||
`<a data-buzz-agent-snapshot="${payload}" href="https://relay.example/media/shared.agent.png">Snapshot</a>`,
|
||
);
|
||
editor.dispatchEvent(
|
||
new ClipboardEvent("paste", {
|
||
bubbles: true,
|
||
cancelable: true,
|
||
clipboardData,
|
||
}),
|
||
);
|
||
});
|
||
await expect(page.getByTestId("composer-agent-snapshot-card")).toHaveCount(0);
|
||
|
||
const dataTransfer = await page.evaluateHandle(() => {
|
||
const transfer = new DataTransfer();
|
||
transfer.items.add(
|
||
new File(["second attachment"], "second-attachment.pdf", {
|
||
type: "application/pdf",
|
||
}),
|
||
);
|
||
return transfer;
|
||
});
|
||
const dropZone = page.getByTestId("channel-drop-zone");
|
||
await dropZone.dispatchEvent("dragenter", { dataTransfer });
|
||
await expect(dropZone.getByTestId("drop-zone-overlay")).toHaveCount(0);
|
||
await dropZone.dispatchEvent("drop", { dataTransfer });
|
||
await expect(page.getByTestId("message-composer")).not.toContainText(
|
||
"second-attachment.pdf",
|
||
);
|
||
await expect(page.getByTestId("composer-voice-note-card")).toBeVisible();
|
||
});
|
||
|
||
test("discards an active recording when entering edit mode", async ({
|
||
page,
|
||
}) => {
|
||
await page.goto("/");
|
||
await page.getByTestId("channel-general").click();
|
||
|
||
await page.getByRole("button", { name: "Record voice note" }).click();
|
||
await expect(page.getByTestId("voice-note-recorder")).toBeVisible();
|
||
|
||
await openMoreActionsMenu(page, "mock-general-welcome");
|
||
await page.getByTestId("edit-message-mock-general-welcome").click();
|
||
|
||
await expect(page.getByTestId("edit-target")).toBeVisible();
|
||
await expect(page.getByTestId("voice-note-recorder")).toHaveCount(0);
|
||
await expect(page.getByTestId("composer-voice-note-card")).toHaveCount(0);
|
||
});
|
||
|
||
test("editor Enter never saves an edit while a voice note is recording", async ({
|
||
page,
|
||
}) => {
|
||
await page.goto("/");
|
||
await page.getByTestId("channel-general").click();
|
||
|
||
// Enter edit mode first, then start recording — the mic is available in edit
|
||
// mode, so this is the ordering the recorder submission gate must cover.
|
||
await openMoreActionsMenu(page, "mock-general-welcome");
|
||
await page.getByTestId("edit-message-mock-general-welcome").click();
|
||
await expect(page.getByTestId("edit-target")).toBeVisible();
|
||
const input = page.getByTestId("message-input");
|
||
await expect(input).not.toBeEmpty();
|
||
|
||
// Change the text so a save (if it wrongly happened) is observable, then
|
||
// start recording.
|
||
const editedContent = `Edited mid-recording ${Date.now()}`;
|
||
await input.click();
|
||
await page.keyboard.press("ControlOrMeta+A");
|
||
await page.keyboard.type(editedContent);
|
||
|
||
await page.getByRole("button", { name: "Record voice note" }).click();
|
||
await expect(page.getByTestId("voice-note-recorder")).toBeVisible();
|
||
await page.waitForTimeout(100);
|
||
|
||
// The editor's Enter shortcut must not slip past the Finish/Discard flow: the
|
||
// edit stays unsaved and the recording stays live until explicitly resolved.
|
||
await input.press("Enter");
|
||
|
||
await expect(page.getByTestId("edit-target")).toBeVisible();
|
||
await expect(page.getByTestId("voice-note-recorder")).toBeVisible();
|
||
await expect(page.getByTestId("message-timeline")).not.toContainText(
|
||
editedContent,
|
||
);
|
||
|
||
// Finishing the recording still works, proving the note was never discarded.
|
||
await page.getByRole("button", { name: "Finish voice note" }).click();
|
||
await expect(page.getByTestId("composer-voice-note-card")).toBeVisible();
|
||
});
|
||
|
||
test("discards while voice-note processing is pending", async ({ page }) => {
|
||
await page.addInitScript(() => {
|
||
const pendingDecodes: Array<(buffer: AudioBuffer) => void> = [];
|
||
(
|
||
window as Window & {
|
||
__BUZZ_E2E_RESOLVE_VOICE_NOTE_DECODE__?: () => void;
|
||
}
|
||
).__BUZZ_E2E_RESOLVE_VOICE_NOTE_DECODE__ = () => {
|
||
pendingDecodes.shift()?.({
|
||
duration: 1,
|
||
getChannelData: () => new Float32Array([0]),
|
||
numberOfChannels: 1,
|
||
sampleRate: 8_000,
|
||
} as AudioBuffer);
|
||
};
|
||
AudioContext.prototype.decodeAudioData = () =>
|
||
new Promise<AudioBuffer>((resolve) => pendingDecodes.push(resolve));
|
||
});
|
||
await page.goto("/");
|
||
await page.getByTestId("channel-general").click();
|
||
|
||
const discardPendingRecording = async (keyboard: boolean) => {
|
||
await page.getByRole("button", { name: "Record voice note" }).click();
|
||
await expect(page.getByTestId("voice-note-recorder")).toBeVisible();
|
||
await page.waitForTimeout(100);
|
||
await page.getByRole("button", { name: "Finish voice note" }).click();
|
||
await expect(page.getByText("Preparing voice note…")).toBeVisible();
|
||
const discard = page.getByRole("button", { name: "Discard voice note" });
|
||
await expect(discard).toBeEnabled();
|
||
if (keyboard) {
|
||
await discard.focus();
|
||
await page.keyboard.press("Enter");
|
||
} else {
|
||
await discard.click();
|
||
}
|
||
await expect(page.getByTestId("voice-note-recorder")).toHaveCount(0);
|
||
await page.evaluate(() =>
|
||
(
|
||
window as Window & {
|
||
__BUZZ_E2E_RESOLVE_VOICE_NOTE_DECODE__?: () => void;
|
||
}
|
||
).__BUZZ_E2E_RESOLVE_VOICE_NOTE_DECODE__?.(),
|
||
);
|
||
await expect(page.getByTestId("composer-voice-note-card")).toHaveCount(0);
|
||
};
|
||
|
||
await discardPendingRecording(false);
|
||
await discardPendingRecording(true);
|
||
});
|
||
|
||
test("surfaces waveform and playback failures with retry", async ({ page }) => {
|
||
await page.addInitScript(() => {
|
||
AudioContext.prototype.decodeAudioData = () =>
|
||
Promise.reject(new DOMException("corrupt audio", "EncodingError"));
|
||
HTMLMediaElement.prototype.play = () =>
|
||
Promise.reject(new DOMException("playback denied", "NotAllowedError"));
|
||
});
|
||
await page.goto("/");
|
||
await page.getByTestId("channel-general").click();
|
||
await waitForMockLiveSubscription(page, "general");
|
||
await page.evaluate(
|
||
({ audioUrl }) => {
|
||
const emit = (
|
||
window as Window & {
|
||
__BUZZ_E2E_EMIT_MOCK_MESSAGE__?: (input: {
|
||
channelName: string;
|
||
content: string;
|
||
extraTags: string[][];
|
||
}) => unknown;
|
||
}
|
||
).__BUZZ_E2E_EMIT_MOCK_MESSAGE__;
|
||
if (!emit) throw new Error("Mock message emitter is unavailable.");
|
||
emit({
|
||
channelName: "general",
|
||
content: `[voice-note-123.mp4](${audioUrl})`,
|
||
extraTags: [
|
||
[
|
||
"imeta",
|
||
`url ${audioUrl}`,
|
||
"m video/mp4",
|
||
"duration 9.4",
|
||
"filename voice-note-123.mp4",
|
||
],
|
||
],
|
||
});
|
||
},
|
||
{ audioUrl: AUDIO_URL },
|
||
);
|
||
|
||
const card = page.getByTestId("audio-message-attachment").last();
|
||
const waveform = card.getByTestId("voice-note-playback-waveform");
|
||
await expect(waveform).toHaveAttribute("data-waveform-state", "error");
|
||
await expect(card.getByRole("status")).toContainText(
|
||
"Waveform preview unavailable. Playback may still work.",
|
||
);
|
||
|
||
await card.getByRole("button", { name: "Play voice note" }).click();
|
||
await expect(card.getByRole("alert")).toContainText("Audio unavailable");
|
||
const retry = card.getByRole("button", { name: "Retry voice note" });
|
||
await retry.click();
|
||
await expect(
|
||
card.getByRole("button", { name: "Play voice note" }),
|
||
).toBeVisible();
|
||
await card.locator("audio").dispatchEvent("error");
|
||
await expect(card.getByRole("alert")).toContainText("Audio unavailable");
|
||
});
|
||
|
||
test("resumes a Play click that landed before the source finished loading", async ({
|
||
page,
|
||
}) => {
|
||
let releaseFetch: (() => void) | undefined;
|
||
const fetchGate = new Promise<void>((resolve) => {
|
||
releaseFetch = resolve;
|
||
});
|
||
// Gate the media fetch so the received card has no playback source when the
|
||
// user first clicks Play.
|
||
await page.route(AUDIO_URL, async (route) => {
|
||
await fetchGate;
|
||
await route.continue();
|
||
});
|
||
|
||
await page.goto("/");
|
||
await page.getByTestId("channel-general").click();
|
||
await waitForMockLiveSubscription(page, "general");
|
||
await page.evaluate(
|
||
({ audioUrl }) => {
|
||
const emit = (
|
||
window as Window & {
|
||
__BUZZ_E2E_EMIT_MOCK_MESSAGE__?: (input: {
|
||
channelName: string;
|
||
content: string;
|
||
extraTags: string[][];
|
||
}) => unknown;
|
||
}
|
||
).__BUZZ_E2E_EMIT_MOCK_MESSAGE__;
|
||
if (!emit) throw new Error("Mock message emitter is unavailable.");
|
||
emit({
|
||
channelName: "general",
|
||
content: `[voice-note-123.mp4](${audioUrl})`,
|
||
extraTags: [
|
||
[
|
||
"imeta",
|
||
`url ${audioUrl}`,
|
||
"m video/mp4",
|
||
"duration 9.4",
|
||
"filename voice-note-123.mp4",
|
||
],
|
||
],
|
||
});
|
||
},
|
||
{ audioUrl: AUDIO_URL },
|
||
);
|
||
|
||
const card = page.getByTestId("audio-message-attachment").last();
|
||
await expect(card).toBeVisible();
|
||
|
||
// Click Play while the fetch is still held: the intent must be remembered and
|
||
// surfaced as a loading state, not silently dropped.
|
||
await card.getByRole("button", { name: "Play voice note" }).click();
|
||
await expect(
|
||
card.getByRole("button", { name: "Loading voice note" }),
|
||
).toBeVisible();
|
||
|
||
// Releasing the fetch resolves the source; playback starts without a second
|
||
// click.
|
||
releaseFetch?.();
|
||
await expect(
|
||
card.getByRole("button", { name: "Pause voice note" }),
|
||
).toBeVisible();
|
||
});
|
||
|
||
test("generic audio retains the relay-native download action", async ({
|
||
page,
|
||
}) => {
|
||
const audioUrl = `http://localhost:3000/media/${"d".repeat(64)}.mp3`;
|
||
await page.goto("/");
|
||
await page.getByTestId("channel-general").click();
|
||
await waitForMockLiveSubscription(page, "general");
|
||
await page.evaluate(
|
||
({ href }) => {
|
||
const emit = window.__BUZZ_E2E_EMIT_MOCK_MESSAGE__;
|
||
if (!emit) throw new Error("Mock message emitter is unavailable.");
|
||
emit({
|
||
channelName: "general",
|
||
content: `[meeting.mp3](${href})`,
|
||
extraTags: [
|
||
["imeta", `url ${href}`, "m audio/mpeg", "filename meeting.mp3"],
|
||
],
|
||
});
|
||
},
|
||
{ href: audioUrl },
|
||
);
|
||
|
||
const card = page.getByTestId("audio-message-attachment").last();
|
||
const download = card.getByRole("button", { name: "Download meeting.mp3" });
|
||
await expect(download).toBeVisible();
|
||
await download.click();
|
||
await expect
|
||
.poll(() =>
|
||
page.evaluate(
|
||
() =>
|
||
window.__BUZZ_E2E_COMMAND_LOG__?.find(
|
||
({ command }) => command === "download_file",
|
||
) ?? null,
|
||
),
|
||
)
|
||
.toEqual({
|
||
command: "download_file",
|
||
payload: { filename: "meeting.mp3", url: audioUrl },
|
||
});
|
||
});
|
||
|
||
test("voice notes omit the relay-native download action", async ({ page }) => {
|
||
const audioUrl = `http://localhost:3000/media/${"e".repeat(64)}.mp4`;
|
||
await page.goto("/");
|
||
await page.getByTestId("channel-general").click();
|
||
await waitForMockLiveSubscription(page, "general");
|
||
await page.evaluate(
|
||
({ href }) => {
|
||
const emit = window.__BUZZ_E2E_EMIT_MOCK_MESSAGE__;
|
||
if (!emit) throw new Error("Mock message emitter is unavailable.");
|
||
emit({
|
||
channelName: "general",
|
||
content: `[voice-note-123.mp4](${href})`,
|
||
extraTags: [
|
||
[
|
||
"imeta",
|
||
`url ${href}`,
|
||
"m video/mp4",
|
||
"duration 9.4",
|
||
"filename voice-note-123.mp4",
|
||
],
|
||
],
|
||
});
|
||
},
|
||
{ href: audioUrl },
|
||
);
|
||
|
||
const card = page.getByTestId("audio-message-attachment").last();
|
||
await expect(card).toBeVisible();
|
||
await expect(
|
||
card.getByRole("button", { name: "Download voice-note-123.mp4" }),
|
||
).toHaveCount(0);
|
||
});
|
||
|
||
test("hard-caps audio work and cancels active and queued loads on unmount", async ({
|
||
page,
|
||
}) => {
|
||
await page.addInitScript(() => {
|
||
window.__BUZZ_E2E_HOLD_MEDIA_FETCHES__ = true;
|
||
const originalCreate = URL.createObjectURL.bind(URL);
|
||
const originalRevoke = URL.revokeObjectURL.bind(URL);
|
||
const counters = { created: 0, revoked: 0 };
|
||
(
|
||
window as Window & {
|
||
__BUZZ_E2E_AUDIO_OBJECT_URLS__?: typeof counters;
|
||
}
|
||
).__BUZZ_E2E_AUDIO_OBJECT_URLS__ = counters;
|
||
URL.createObjectURL = (object) => {
|
||
counters.created += 1;
|
||
return originalCreate(object);
|
||
};
|
||
URL.revokeObjectURL = (url) => {
|
||
counters.revoked += 1;
|
||
originalRevoke(url);
|
||
};
|
||
});
|
||
// Keep all 24 cards in the load margin so the test proves queued cancellation,
|
||
// not just the three cards that happened to intersect a short viewport.
|
||
await page.setViewportSize({ width: 1280, height: 3000 });
|
||
await page.goto("/");
|
||
// Seed offscreen before opening the channel: live injection during initial
|
||
// history/scroll settlement can unmount one visible set and load another.
|
||
await page.getByTestId("channel-random").click();
|
||
await expect(page.getByTestId("chat-title")).toHaveText("random");
|
||
await page.evaluate(
|
||
({ audioUrl }) => {
|
||
const emit = (
|
||
window as Window & {
|
||
__BUZZ_E2E_EMIT_MOCK_MESSAGE__?: (input: {
|
||
channelName: string;
|
||
content: string;
|
||
extraTags: string[][];
|
||
createdAt: number;
|
||
}) => unknown;
|
||
}
|
||
).__BUZZ_E2E_EMIT_MOCK_MESSAGE__;
|
||
if (!emit) throw new Error("Mock message emitter is unavailable.");
|
||
const createdAt = Math.floor(Date.now() / 1000);
|
||
for (let index = 0; index < 24; index += 1) {
|
||
emit({
|
||
channelName: "general",
|
||
createdAt: createdAt + index,
|
||
content: `[voice-note-${index}.mp4](${audioUrl})`,
|
||
extraTags: [
|
||
[
|
||
"imeta",
|
||
`url ${audioUrl}`,
|
||
"m video/mp4",
|
||
"duration 9.4",
|
||
`filename voice-note-${index}.mp4`,
|
||
],
|
||
],
|
||
});
|
||
}
|
||
},
|
||
{ audioUrl: AUDIO_URL },
|
||
);
|
||
|
||
await page.getByTestId("channel-general").click();
|
||
const cards = page.getByTestId("audio-message-attachment");
|
||
await expect(cards).toHaveCount(24);
|
||
const readFetchCount = () =>
|
||
page.evaluate(
|
||
() =>
|
||
window.__BUZZ_E2E_COMMANDS__?.filter(
|
||
(command) => command === "fetch_media_bytes",
|
||
).length ?? 0,
|
||
);
|
||
await expect
|
||
.poll(() =>
|
||
page.evaluate(
|
||
() => window.__BUZZ_E2E_MEDIA_FETCH_STATE__ ?? { active: 0, peak: 0 },
|
||
),
|
||
)
|
||
.toEqual({ active: 3, peak: 3 });
|
||
await expect
|
||
.poll(() => page.evaluate(() => window.__BUZZ_E2E_AUDIO_LOAD_STATE__?.()))
|
||
.toEqual({ active: 3, queued: 21 });
|
||
expect(await readFetchCount()).toBe(3);
|
||
expect(
|
||
await page.evaluate(
|
||
() => window.__BUZZ_E2E_AUDIO_OBJECT_URLS__?.created ?? 0,
|
||
),
|
||
).toBe(0);
|
||
|
||
await page.getByTestId("channel-random").click();
|
||
await expect(cards).toHaveCount(0);
|
||
await expect
|
||
.poll(() =>
|
||
page.evaluate(() => window.__BUZZ_E2E_MEDIA_FETCH_STATE__?.active ?? -1),
|
||
)
|
||
.toBe(0);
|
||
await expect
|
||
.poll(() => page.evaluate(() => window.__BUZZ_E2E_AUDIO_LOAD_STATE__?.()))
|
||
.toEqual({ active: 0, queued: 0 });
|
||
const commandCounts = await page.evaluate(() => {
|
||
const commands = window.__BUZZ_E2E_COMMANDS__ ?? [];
|
||
return {
|
||
cancelled: commands.filter((command) => command === "cancel_media_fetch")
|
||
.length,
|
||
fetched: commands.filter((command) => command === "fetch_media_bytes")
|
||
.length,
|
||
released: commands.filter((command) => command === "release_media_fetch")
|
||
.length,
|
||
};
|
||
});
|
||
expect(commandCounts).toEqual({ cancelled: 3, fetched: 3, released: 3 });
|
||
expect(
|
||
await page.evaluate(() => window.__BUZZ_E2E_AUDIO_OBJECT_URLS__),
|
||
).toEqual({ created: 0, revoked: 0 });
|
||
});
|
||
|
||
test("records from the composer and renders an inline waveform card", async ({
|
||
page,
|
||
}) => {
|
||
await page.emulateMedia({ reducedMotion: "no-preference" });
|
||
await page.setViewportSize({ width: 2200, height: 720 });
|
||
await page.goto("/");
|
||
const showError = page.getByRole("button", { name: "Show Error" });
|
||
const channel = page.getByTestId("channel-general");
|
||
await expect(channel.or(showError)).toBeVisible({ timeout: 10_000 });
|
||
if (await showError.isVisible()) {
|
||
await showError.click();
|
||
throw new Error(await page.locator("body").innerText());
|
||
}
|
||
await channel.click();
|
||
|
||
const attach = page.getByRole("button", { name: "Attach file" });
|
||
const record = page.getByRole("button", { name: "Record voice note" });
|
||
const emoji = page.getByRole("button", { name: "Emoji" });
|
||
await expect(record).toBeVisible();
|
||
|
||
const [attachBox, recordBox, emojiBox] = await Promise.all([
|
||
attach.boundingBox(),
|
||
record.boundingBox(),
|
||
emoji.boundingBox(),
|
||
]);
|
||
expect(attachBox).not.toBeNull();
|
||
expect(recordBox).not.toBeNull();
|
||
expect(emojiBox).not.toBeNull();
|
||
expect(recordBox?.x).toBeGreaterThan(attachBox?.x ?? 0);
|
||
expect(recordBox?.x).toBeLessThan(emojiBox?.x ?? Number.POSITIVE_INFINITY);
|
||
|
||
await record.click();
|
||
const recorder = page.getByTestId("voice-note-recorder");
|
||
await expect(recorder).toBeVisible();
|
||
await expect(
|
||
page.getByRole("status", { name: "Recording voice note" }),
|
||
).toHaveCount(0);
|
||
await expect(attach).toBeHidden();
|
||
const discard = page.getByRole("button", { name: "Discard voice note" });
|
||
await expect(discard).toBeVisible();
|
||
const [recorderBox, discardBox] = await Promise.all([
|
||
recorder.boundingBox(),
|
||
discard.boundingBox(),
|
||
]);
|
||
expect(recorderBox).not.toBeNull();
|
||
expect(discardBox).not.toBeNull();
|
||
expect(discardBox?.x ?? Number.POSITIVE_INFINITY).toBeLessThanOrEqual(
|
||
(recorderBox?.x ?? 0) + 1,
|
||
);
|
||
await expect(recorder).toContainText(/\d+:\d{2} \/ 5:00/);
|
||
const finish = page.getByRole("button", { name: "Finish voice note" });
|
||
await expect(finish).toBeEnabled();
|
||
const liveWaveform = page.getByTestId("voice-note-live-waveform");
|
||
const firstRecordedSample = liveWaveform.locator(
|
||
'[data-waveform-sample="recorded-0"]',
|
||
);
|
||
await expect(firstRecordedSample).toBeAttached();
|
||
const firstSampleStart = await firstRecordedSample.boundingBox();
|
||
await page.waitForTimeout(200);
|
||
const firstSampleAfter = await firstRecordedSample.boundingBox();
|
||
expect(firstSampleStart).not.toBeNull();
|
||
expect(firstSampleAfter).not.toBeNull();
|
||
expect(firstSampleAfter?.x ?? 0).toBeLessThan((firstSampleStart?.x ?? 0) - 2);
|
||
expect(firstSampleAfter?.height).toBe(firstSampleStart?.height);
|
||
const [waveformBox, lastBarBox] = await Promise.all([
|
||
liveWaveform.boundingBox(),
|
||
liveWaveform.locator("span").last().boundingBox(),
|
||
]);
|
||
expect(waveformBox).not.toBeNull();
|
||
expect(lastBarBox).not.toBeNull();
|
||
expect((lastBarBox?.x ?? 0) + (lastBarBox?.width ?? 0)).toBeGreaterThan(
|
||
(waveformBox?.x ?? 0) + (waveformBox?.width ?? 0) - 6,
|
||
);
|
||
await waitForAnimations(page);
|
||
await page.getByTestId("message-composer-toolbar").screenshot({
|
||
path: "test-results/voice-note/voice-note-recorder.png",
|
||
});
|
||
|
||
await finish.click();
|
||
const composerCard = page.getByTestId("composer-voice-note-card");
|
||
await expect(composerCard).toBeVisible();
|
||
await waitForAnimations(page);
|
||
const composerCardBox = await composerCard.boundingBox();
|
||
expect(composerCardBox).not.toBeNull();
|
||
expect(
|
||
composerCardBox?.width ?? Number.POSITIVE_INFINITY,
|
||
).toBeLessThanOrEqual(336);
|
||
await expect(
|
||
composerCard.getByRole("button", { name: "Play voice note" }),
|
||
).toBeVisible();
|
||
await expect(
|
||
composerCard.getByRole("slider", { name: "Voice note playback position" }),
|
||
).toBeVisible();
|
||
await expect(composerCard).toContainText(/\d+:\d{2}/);
|
||
await expect(composerCard).not.toContainText(/· Voice note/);
|
||
const composerVoiceNote = composerCard.locator("..");
|
||
const removeVoiceNote = composerVoiceNote.getByTestId(
|
||
"remove-composer-voice-note",
|
||
);
|
||
await expect(removeVoiceNote).toHaveCSS("opacity", "0");
|
||
await composerVoiceNote.hover();
|
||
await expect(removeVoiceNote).toHaveCSS("opacity", "1");
|
||
const removeVoiceNoteBox = await removeVoiceNote.boundingBox();
|
||
expect(removeVoiceNoteBox).not.toBeNull();
|
||
expect(removeVoiceNoteBox?.x ?? Number.POSITIVE_INFINITY).toBeLessThan(
|
||
(composerCardBox?.x ?? 0) + (composerCardBox?.width ?? 0),
|
||
);
|
||
expect(removeVoiceNoteBox?.y ?? Number.POSITIVE_INFINITY).toBeLessThan(
|
||
composerCardBox?.y ?? 0,
|
||
);
|
||
expect(
|
||
(removeVoiceNoteBox?.x ?? 0) + (removeVoiceNoteBox?.width ?? 0),
|
||
).toBeGreaterThan((composerCardBox?.x ?? 0) + (composerCardBox?.width ?? 0));
|
||
expect(
|
||
(removeVoiceNoteBox?.y ?? 0) + (removeVoiceNoteBox?.height ?? 0),
|
||
).toBeGreaterThan(composerCardBox?.y ?? 0);
|
||
const composerPlaybackWaveform = composerCard.getByTestId(
|
||
"voice-note-playback-waveform",
|
||
);
|
||
const [playbackBox, playbackLastBarBox] = await Promise.all([
|
||
composerPlaybackWaveform.boundingBox(),
|
||
composerPlaybackWaveform.locator("span").last().boundingBox(),
|
||
]);
|
||
expect(playbackBox).not.toBeNull();
|
||
expect(playbackLastBarBox).not.toBeNull();
|
||
expect(
|
||
(playbackLastBarBox?.x ?? 0) + (playbackLastBarBox?.width ?? 0),
|
||
).toBeGreaterThan((playbackBox?.x ?? 0) + (playbackBox?.width ?? 0) - 6);
|
||
await waitForAnimations(page);
|
||
await page.screenshot({
|
||
clip: {
|
||
height: (composerCardBox?.height ?? 0) + 16,
|
||
width: (composerCardBox?.width ?? 0) + 16,
|
||
x: (composerCardBox?.x ?? 0) - 8,
|
||
y: (composerCardBox?.y ?? 0) - 8,
|
||
},
|
||
path: "test-results/voice-note/voice-note-composer-card.png",
|
||
});
|
||
await expect(page.getByTestId("send-message")).toBeEnabled();
|
||
await page.getByTestId("send-message").click();
|
||
|
||
const card = page.getByTestId("audio-message-attachment").last();
|
||
await expect(card).toBeVisible();
|
||
const playbackWaveform = card.getByTestId("voice-note-playback-waveform");
|
||
await expect(playbackWaveform).toHaveAttribute(
|
||
"data-waveform-state",
|
||
"ready",
|
||
);
|
||
await expect
|
||
.poll(() =>
|
||
playbackWaveform
|
||
.locator("span")
|
||
.evaluateAll((bars) =>
|
||
bars.some((bar) => bar.getBoundingClientRect().height > 3),
|
||
),
|
||
)
|
||
.toBe(true);
|
||
const waveformGeometry = await playbackWaveform.evaluate((waveform) => {
|
||
const waveformBox = waveform.getBoundingClientRect();
|
||
return Array.from(waveform.querySelectorAll("span")).map((bar) => {
|
||
const barBox = bar.getBoundingClientRect();
|
||
return {
|
||
bottom: barBox.bottom,
|
||
radius: Number.parseFloat(getComputedStyle(bar).borderTopLeftRadius),
|
||
top: barBox.top,
|
||
waveformBottom: waveformBox.bottom,
|
||
waveformTop: waveformBox.top,
|
||
width: barBox.width,
|
||
};
|
||
});
|
||
});
|
||
expect(waveformGeometry.length).toBeGreaterThan(0);
|
||
for (const bar of waveformGeometry) {
|
||
expect(bar.radius).toBeGreaterThanOrEqual(bar.width / 2);
|
||
expect(bar.top).toBeGreaterThanOrEqual(bar.waveformTop);
|
||
expect(bar.bottom).toBeLessThanOrEqual(bar.waveformBottom);
|
||
}
|
||
const playbackControl = card.getByTestId("voice-note-playback-control");
|
||
await expect(card).toHaveClass(/\brounded-2xl\b/);
|
||
await expect(playbackControl).toHaveClass(/\brounded-lg\b/);
|
||
await expectSmoothCorners(card);
|
||
await expectSmoothCorners(playbackControl);
|
||
await expect(
|
||
card.getByRole("button", { name: "Play voice note" }),
|
||
).toBeVisible();
|
||
const playPauseIcon = card.getByTestId("voice-note-play-pause-icon");
|
||
const primaryIconPath = card.getByTestId("voice-note-play-pause-path-0");
|
||
await expect(playPauseIcon).toHaveAttribute("data-icon-state", "play");
|
||
await expect(playPauseIcon).toHaveCSS("width", "23px");
|
||
await expect(playPauseIcon).toHaveCSS("height", "23px");
|
||
await expect(primaryIconPath).toHaveCSS("fill", /rgb/);
|
||
await expect(primaryIconPath).toHaveCSS("stroke-linejoin", "round");
|
||
const playPath = await primaryIconPath.getAttribute("d");
|
||
await primaryIconPath.evaluate((element) => {
|
||
element.setAttribute("data-morph-identity", "preserved");
|
||
});
|
||
await card.getByRole("button", { name: "Play voice note" }).click();
|
||
await expect(
|
||
card.getByRole("button", { name: "Pause voice note" }),
|
||
).toBeVisible();
|
||
await expect(playPauseIcon).toHaveAttribute("data-icon-state", "pause");
|
||
await expect(primaryIconPath).toHaveAttribute(
|
||
"data-morph-identity",
|
||
"preserved",
|
||
);
|
||
await expect.poll(() => primaryIconPath.getAttribute("d")).not.toBe(playPath);
|
||
await waitForAnimations(page);
|
||
await card.screenshot({
|
||
path: "test-results/voice-note/voice-note-card-paused.png",
|
||
});
|
||
await card.getByRole("button", { name: "Pause voice note" }).click();
|
||
await page.mouse.move(0, 0);
|
||
await page.evaluate(() => {
|
||
if (document.activeElement instanceof HTMLElement) {
|
||
document.activeElement.blur();
|
||
}
|
||
});
|
||
await expect(
|
||
card.getByRole("slider", { name: "Voice note playback position" }),
|
||
).toBeVisible();
|
||
await expect(card).toContainText(/\d+:\d{2}/);
|
||
await expect(card).not.toContainText(/· Voice note/);
|
||
const playbackRate = card.getByTestId("voice-note-playback-rate");
|
||
const playbackRateValue = card.getByTestId("voice-note-playback-rate-value");
|
||
await expect(playbackRate).toHaveCSS("opacity", "0");
|
||
await waitForAnimations(page);
|
||
await card.hover();
|
||
await expect(playbackRate).toHaveCSS("opacity", "1");
|
||
await expectSmoothCorners(playbackRate);
|
||
await expect(playbackRate).toHaveCSS("padding-left", "10px");
|
||
await expect(playbackRate).toHaveCSS("padding-top", "2px");
|
||
const [rateBackground, playButtonBackground] = await Promise.all([
|
||
playbackRate.evaluate(
|
||
(element) => getComputedStyle(element).backgroundColor,
|
||
),
|
||
card
|
||
.getByRole("button", { name: "Play voice note" })
|
||
.locator("..")
|
||
.evaluate((element) => getComputedStyle(element).backgroundColor),
|
||
]);
|
||
expect(rateBackground).toBe(playButtonBackground);
|
||
const widestPlaybackRateWidth = (await playbackRate.boundingBox())?.width;
|
||
expect(widestPlaybackRateWidth).toBeGreaterThan(0);
|
||
await expect(playbackRateValue).toHaveText("1×");
|
||
await playbackRate.click();
|
||
await expect(playbackRateValue).toHaveText("1.5×");
|
||
expect((await playbackRate.boundingBox())?.width).toBe(
|
||
widestPlaybackRateWidth,
|
||
);
|
||
await expect(card.locator("audio")).toHaveJSProperty("playbackRate", 1.5);
|
||
await playbackRate.click();
|
||
await expect(playbackRateValue).toHaveText("2×");
|
||
expect((await playbackRate.boundingBox())?.width).toBe(
|
||
widestPlaybackRateWidth,
|
||
);
|
||
await playbackRate.click();
|
||
await expect(playbackRateValue).toHaveText(".5×");
|
||
expect((await playbackRate.boundingBox())?.width).toBe(
|
||
widestPlaybackRateWidth,
|
||
);
|
||
await playbackRate.click();
|
||
await expect(playbackRateValue).toHaveText("1×");
|
||
|
||
const slider = card.getByRole("slider", {
|
||
name: "Voice note playback position",
|
||
});
|
||
await slider.focus();
|
||
await expect(card.getByTestId("voice-note-playback-waveform")).toHaveCSS(
|
||
"box-shadow",
|
||
/rgb/,
|
||
);
|
||
await slider.evaluate((element: HTMLInputElement) => {
|
||
element.value = String(Number(element.max) / 2);
|
||
element.dispatchEvent(new Event("input", { bubbles: true }));
|
||
});
|
||
const progressClip = await card
|
||
.getByTestId("voice-note-progress-waveform")
|
||
.evaluate((element) => getComputedStyle(element).clipPath);
|
||
expect(progressClip).toContain("50%");
|
||
await waitForAnimations(page);
|
||
await card.screenshot({
|
||
path: "test-results/voice-note/voice-note-card.png",
|
||
});
|
||
});
|
||
|
||
test("keeps emoji available but blocks GIFs beside a queued voice note", async ({
|
||
page,
|
||
}) => {
|
||
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.goto("/");
|
||
await page.getByTestId("channel-general").click();
|
||
await page.getByRole("button", { name: "Record voice note" }).click();
|
||
await page.waitForTimeout(100);
|
||
await page.getByRole("button", { name: "Finish voice note" }).click();
|
||
await expect(page.getByTestId("composer-voice-note-card")).toBeVisible();
|
||
|
||
const pickerButton = page.getByTestId("composer-emoji-button");
|
||
await expect(pickerButton).toBeEnabled();
|
||
await expect(pickerButton).toHaveAccessibleName("Insert emoji or GIF");
|
||
await pickerButton.click();
|
||
await expect(page.getByRole("tab", { name: "Emoji" })).toBeEnabled();
|
||
await expect(page.getByRole("tab", { name: "GIFs" })).toBeDisabled();
|
||
});
|
||
|
||
test("starting a duplicate voice-note player pauses the other instance", async ({
|
||
page,
|
||
}) => {
|
||
await page.goto("/");
|
||
await page.getByTestId("channel-general").click();
|
||
await waitForMockLiveSubscription(page, "general");
|
||
|
||
// Two messages share one audio URL. Distinct seconds fix their order, and
|
||
// each card is located through its own message row, never by index.
|
||
const [firstId, secondId] = await page.evaluate(
|
||
({ audioUrl, createdAt }) => {
|
||
const emit = (
|
||
window as Window & {
|
||
__BUZZ_E2E_EMIT_MOCK_MESSAGE__?: (input: {
|
||
channelName: string;
|
||
content: string;
|
||
createdAt: number;
|
||
extraTags: string[][];
|
||
}) => { id: string } | undefined;
|
||
}
|
||
).__BUZZ_E2E_EMIT_MOCK_MESSAGE__;
|
||
if (!emit) throw new Error("Mock message emitter is unavailable.");
|
||
return [0, 1].map((offset) => {
|
||
const filename = `voice-note-12${offset}.mp4`;
|
||
const emitted = emit({
|
||
channelName: "general",
|
||
content: `[${filename}](${audioUrl})`,
|
||
createdAt: createdAt + offset,
|
||
extraTags: [
|
||
[
|
||
"imeta",
|
||
`url ${audioUrl}`,
|
||
"m video/mp4",
|
||
"duration 9.4",
|
||
`filename ${filename}`,
|
||
],
|
||
],
|
||
});
|
||
if (!emitted?.id) throw new Error("mock message emit returned no id");
|
||
return emitted.id;
|
||
});
|
||
},
|
||
{ audioUrl: AUDIO_URL, createdAt: Math.floor(Date.now() / 1000) },
|
||
);
|
||
|
||
const cards = page.getByTestId("audio-message-attachment");
|
||
await expect(cards).toHaveCount(2);
|
||
// The ping fixture lasts about one second, so it can finish before the
|
||
// playing state is observed. Loop it so only exclusivity pauses a player.
|
||
await cards.locator("audio").evaluateAll((audios) => {
|
||
for (const audio of audios) (audio as HTMLAudioElement).loop = true;
|
||
});
|
||
const firstCard = page
|
||
.locator(`[data-message-id="${firstId}"]`)
|
||
.getByTestId("audio-message-attachment");
|
||
const secondCard = page
|
||
.locator(`[data-message-id="${secondId}"]`)
|
||
.getByTestId("audio-message-attachment");
|
||
const firstAudio = firstCard.locator("audio");
|
||
const secondAudio = secondCard.locator("audio");
|
||
|
||
await firstCard.getByRole("button", { name: "Play voice note" }).click();
|
||
await expect(firstAudio).toHaveJSProperty("paused", false);
|
||
await secondCard.getByRole("button", { name: "Play voice note" }).click();
|
||
await expect(firstAudio).toHaveJSProperty("paused", true);
|
||
await expect(secondAudio).toHaveJSProperty("paused", false);
|
||
});
|