mirror of
https://github.com/block/buzz.git
synced 2026-10-30 14:01:37 +00:00
Fixes the CI flakes that failed most often across `main` and unrelated branches from Oct 5 to Oct 14. The counts come from 150 failed `CI` runs. Every change except the nextest config is test-only. | Flake | Runs hit | Root cause | Fix | |---|---|---|---| | `probe_codex_acp_version_…descendant_holds_pipe_open` | 5 | The test writes its script in-process and then execs it. If a sibling test spawns a process in that window, the child inherits the open write fd and Linux rejects the exec with `ETXTBSY`. The probe swallows that error as `None`. | Write probe scripts from a short-lived `/bin/sh` (`write_executable_script`) | | `empty-edit-delete.spec.ts` (`:51`, `:74`, `:95`) | 11 | The seeded `mock-general-welcome` id isn't hex, so `formatTimelineMessages` can't overlay its edit. The post-subscribe window refetch then reverts the row. | Emit a per-test message with a real id after the exact channel subscription is live | | `huddle-transcription.spec.ts:798` | 9 | The composer's delayed initial autofocus (TipTap `focus()` runs in rAF) lands after the voice popover opens and dismisses it. | Wait for `message-input` focus before opening the popover | | `voice-note.spec.ts:906` | 6 | The fixture `ping.mp3` is about 1s long, so playback can end before `paused === false` is observed. | Loop the fixture audio | | `workflows.spec.ts:291` | 5 | The `Message text` fill runs before the inspector swaps to the new step, so it fills the trigger's condition instead. | Scope the fill to the step inspector after its `Action` menu appears | | `cluster_global_probe_commits_tokens_and_sessions_prove_coverage` | 3 | The `max-threads = 1` group only serializes these tests with each other. Other tests' sessions still mask cluster-wide `pg_stat_activity` (`MaskedActivity`). | `threads-required = "num-test-threads"` | | `test_usage_metrics_lock_has_single_owner_and_releases_on_drop` | 1 (this PR) | The test retries the lock right after dropping the leader. Postgres releases a session advisory lock only after the backend notices the disconnect, so the retry can run first. | Poll for the successor, bounded at 5s | ### Validation - **ETXTBSY:** a Linux experiment with Rust 1.95 using the same write→chmod→spawn pattern under concurrent spawns hit `ETXTBSY` 4–7% of the time. Using the `sh` writer, it hit 0 of 6,000. I did not catch it inside the full suite locally, so this fix is based on the mechanism, not an in-suite repro. All 11 probe tests pass on Linux and macOS. - **empty-edit-delete:** the baseline failed 16 of 45 runs (`--repeat-each`, 6 workers, no retries). The spec fix alone brings that to 4/45. The remaining failures come from a separate product race, described below. - **huddle:** the baseline failed 5 of 10 runs. With the fix, 60 of 60 passed without retries. - **nextest:** checked with a throwaway crate on 0.9.136. A grouped test with `num-test-threads` ran alone, even with `max-threads = 1`. - I have not stress-run the voice-note and workflows fixes; they rely on CI. ### Not in this PR - **Product bug in `useChannelLinks`:** the debounced `#channel` autocomplete close lets Enter accept a stale suggestion for about 120ms. That accounts for the remaining `empty-edit-delete` failures, and it affects users too. With the spec fix plus a synchronous close, the spec passed 120 of 120 runs. This needs a product decision, so it's held for a separate PR. - **Composer autofocus race:** the delayed autofocus can close an overlay the user just opened. The real fix is to revalidate when the delayed focus runs. Overlaps #6530. - **`thread-head-stale-edit.spec.ts:55`:** a forced click on a pointer-events-none action bar is suspected, but I couldn't reproduce it. - **Remaining ETXTBSY exposure:** about 40 other Rust tests write and then exec scripts, and 17 specs still edit the non-hex seed message. Root-cause lanes by Princess Donut, Mongo and Mordecai. Carl integrated and verified them. 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Signed-off-by: Wes <wesbillman@users.noreply.github.com> Co-authored-by: Carl <32a2e2c9d428ee08902cab75d956da2c1d235a22d4766b0dd4138bf6e2e5db1d@buzz.block.builderlab.xyz> Co-authored-by: Princess Donut <5d97ac8c272fa949af56c71e586146b1706e5c4c3daa125db5b4625e12e0686c@buzz.block.builderlab.xyz> Co-authored-by: Mongo <81cabd2ca1792494372ed410283c00770c0cf35afca052e940fa522883212b45@buzz.block.builderlab.xyz> Co-authored-by: Mordecai <f314b6033b6e13df94c8e9e1187d3666b6b9ce54e66ee986e31cdaa909e8dcbc@buzz.block.builderlab.xyz> Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
960 lines
34 KiB
TypeScript
960 lines
34 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");
|
||
|
||
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.");
|
||
const input = {
|
||
channelName: "general",
|
||
content: `[voice-note-123.mp4](${audioUrl})`,
|
||
extraTags: [
|
||
[
|
||
"imeta",
|
||
`url ${audioUrl}`,
|
||
"m video/mp4",
|
||
"duration 9.4",
|
||
"filename voice-note-123.mp4",
|
||
],
|
||
],
|
||
};
|
||
emit(input);
|
||
emit(input);
|
||
},
|
||
{ audioUrl: AUDIO_URL },
|
||
);
|
||
|
||
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 firstAudio = cards.nth(0).locator("audio");
|
||
const secondAudio = cards.nth(1).locator("audio");
|
||
|
||
await cards.nth(0).getByRole("button", { name: "Play voice note" }).click();
|
||
await expect(firstAudio).toHaveJSProperty("paused", false);
|
||
await cards.nth(1).getByRole("button", { name: "Play voice note" }).click();
|
||
await expect(firstAudio).toHaveJSProperty("paused", true);
|
||
await expect(secondAudio).toHaveJSProperty("paused", false);
|
||
});
|