Files
buzz/desktop/tests/e2e/video-attachment.spec.ts
Matt Toohey 1eb67bb525 fix(desktop): fix the product races behind four E2E flakes (#8680)
## 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>
2026-10-30 15:15:36 +11:00

1725 lines
58 KiB
TypeScript

import { expect, type Locator, type Page, test } from "@playwright/test";
import { installMockBridge } from "../helpers/bridge";
import { expectCornerRadiusPx, expectSmoothCorners } from "../helpers/css";
const VIDEO_SHA = "b".repeat(64);
const VIDEO_URL = `http://localhost:3000/media/${VIDEO_SHA}.mp4`;
const EXTENSIONLESS_VIDEO_URL = `http://localhost:3000/media/${VIDEO_SHA}`;
const GENERAL_CHANNEL_ID = "9a1657ac-f7aa-5db0-b632-d8bbeb6dfb50";
const PORTRAIT_VIDEO_SHA = "c".repeat(64);
const PORTRAIT_VIDEO_URL = `http://localhost:3000/media/${PORTRAIT_VIDEO_SHA}.mp4`;
const CONSTRAINED_LANDSCAPE_VIDEO_SHA = "d".repeat(64);
const CONSTRAINED_LANDSCAPE_VIDEO_URL = `http://localhost:3000/media/${CONSTRAINED_LANDSCAPE_VIDEO_SHA}.mp4`;
// Distinct-selector context-menu probes. The relay video is on the resolved
// relay origin (localhost:3000) so it is Download-eligible once the origin
// resolves; the off-relay video shares an identical /media/ path shape on a
// different origin, so it renders and offers Copy link but never Download.
const MENU_RELAY_VIDEO_SHA = "e".repeat(64);
const MENU_RELAY_VIDEO_URL = `http://localhost:3000/media/${MENU_RELAY_VIDEO_SHA}.mp4`;
const MENU_OFF_RELAY_VIDEO_SHA = "f".repeat(64);
const MENU_OFF_RELAY_VIDEO_URL = `https://cdn.example.com/media/${MENU_OFF_RELAY_VIDEO_SHA}.mp4`;
const VIDEO_REVIEW_NEUTRAL_ACCENT = "neutral";
const VIDEO_REVIEW_LIGHT_THEME = "catppuccin-latte";
// The fresh-profile default is the Buzz theme, which pins the neutral accent
// regardless of the stored accent color. Accent-driven review foreground
// assertions must run on a non-Buzz theme for the seeded accent to apply.
const VIDEO_REVIEW_ACCENT_THEME = "houston";
const VIDEO_REVIEW_ACCENT = "#ec4899";
const VIDEO_REVIEW_ACCENT_FOREGROUND_RGB = "rgb(240, 115, 177)";
const VIDEO_REVIEW_INDIGO_ACCENT = "#6366f1";
const VIDEO_REVIEW_INDIGO_FOREGROUND_RGB = "rgb(141, 143, 245)";
const VIDEO_REVIEW_NEUTRAL_DARK_RGB = "rgb(250, 250, 250)";
const POSTER_DATA_URL =
"data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAxNjAgODAiPjxyZWN0IHdpZHRoPSIxNjAiIGhlaWdodD0iODAiIGZpbGw9IiMyNjQ2NTMiLz48Y2lyY2xlIGN4PSI1NCIgY3k9IjQwIiByPSIyMiIgZmlsbD0iI2YyYzE0ZSIvPjxwYXRoIGQ9Ik05MiAyNGg0NHYzMkg5MnoiIGZpbGw9IiNmNzgxNTQiLz48L3N2Zz4=";
type MockFeedMessage = {
content: string;
created_at: number;
id: string;
kind: number;
pubkey: string;
tags: string[][];
};
async function waitForMockLiveSubscription(page: Page, channelName: string) {
await expect
.poll(async () => {
return page.evaluate((channelName) => {
return (
(
window as Window & {
__BUZZ_E2E_HAS_MOCK_LIVE_SUBSCRIPTION__?: (input: {
channelName: string;
}) => boolean;
}
).__BUZZ_E2E_HAS_MOCK_LIVE_SUBSCRIPTION__?.({ channelName }) ?? false
);
}, channelName);
})
.toBe(true);
}
function emitMockMessage(
page: Page,
channelName: string,
content: string,
options: {
createdAt?: number;
extraTags?: string[][];
parentEventId?: string;
} = {},
) {
return page.evaluate(
({ channelName, content, createdAt, extraTags, parentEventId }) => {
const emit = (
window as Window & {
__BUZZ_E2E_EMIT_MOCK_MESSAGE__?: (input: {
channelName: string;
content: string;
createdAt?: number;
extraTags?: string[][];
parentEventId?: string;
}) => unknown;
}
).__BUZZ_E2E_EMIT_MOCK_MESSAGE__;
if (!emit) {
throw new Error("Mock message emitter is unavailable.");
}
return emit({
channelName,
content,
createdAt,
extraTags,
parentEventId,
});
},
{
channelName,
content,
createdAt: options.createdAt,
extraTags: options.extraTags,
parentEventId: options.parentEventId,
},
);
}
function pushMockFeedItems(page: Page, messages: MockFeedMessage[]) {
return page.evaluate(
({ channelId, messages }) => {
const pushFeedItem = (
window as Window & {
__BUZZ_E2E_PUSH_MOCK_FEED_ITEM__?: (item: {
category: "mention";
channel_id: string;
channel_name: string;
content: string;
created_at: number;
id: string;
kind: number;
pubkey: string;
tags: string[][];
}) => unknown;
}
).__BUZZ_E2E_PUSH_MOCK_FEED_ITEM__;
if (!pushFeedItem) throw new Error("Mock feed helper is unavailable.");
for (const message of messages) {
pushFeedItem({
category: "mention",
channel_id: channelId,
channel_name: "general",
content: message.content,
created_at: message.created_at,
id: message.id,
kind: message.kind,
pubkey: message.pubkey,
tags: message.tags,
});
}
},
{ channelId: GENERAL_CHANNEL_ID, messages },
);
}
async function installVideoReviewHarness(
page: Page,
{
accentColor = VIDEO_REVIEW_ACCENT,
themeName,
}: { accentColor?: string; themeName?: string } = {},
) {
await page.addInitScript(
({ accentColor, themeName }) => {
if (themeName) {
window.localStorage.setItem("buzz-theme", themeName);
}
window.localStorage.setItem("buzz-accent-color", accentColor);
type MediaState = {
currentTime: number;
paused: boolean;
};
const mediaState = new WeakMap<HTMLMediaElement, MediaState>();
const getMediaState = (element: HTMLMediaElement) => {
let state = mediaState.get(element);
if (!state) {
state = { currentTime: 0, paused: true };
mediaState.set(element, state);
}
return state;
};
Object.defineProperty(HTMLMediaElement.prototype, "load", {
configurable: true,
value() {
getMediaState(this as HTMLMediaElement).currentTime = 0;
},
});
Object.defineProperty(HTMLMediaElement.prototype, "play", {
configurable: true,
value() {
getMediaState(this as HTMLMediaElement).paused = false;
this.dispatchEvent(new Event("play"));
return Promise.resolve();
},
});
Object.defineProperty(HTMLMediaElement.prototype, "pause", {
configurable: true,
value() {
getMediaState(this as HTMLMediaElement).paused = true;
this.dispatchEvent(new Event("pause"));
},
});
Object.defineProperty(HTMLMediaElement.prototype, "paused", {
configurable: true,
get() {
return getMediaState(this as HTMLMediaElement).paused;
},
});
Object.defineProperty(HTMLMediaElement.prototype, "currentTime", {
configurable: true,
get() {
return getMediaState(this as HTMLMediaElement).currentTime;
},
set(value) {
getMediaState(this as HTMLMediaElement).currentTime =
Number(value) || 0;
this.dispatchEvent(new Event("seeked"));
},
});
Object.defineProperty(HTMLMediaElement.prototype, "duration", {
configurable: true,
get() {
return 12.5;
},
});
},
{ accentColor, themeName },
);
await installMockBridge(page, {
uploadDescriptors: [
{
url: VIDEO_URL,
sha256: VIDEO_SHA,
size: 987_654,
type: "video/mp4",
uploaded: Math.floor(Date.now() / 1000),
duration: 12.5,
image: POSTER_DATA_URL,
dim: "160x80",
filename: "launch-demo.mp4",
},
],
});
}
async function openReviewWithPostedTimecode(
page: Page,
commentText = "Neutral accent check",
) {
await page.goto("/");
await page.getByTestId("channel-general").click();
await expect(page.getByTestId("chat-title")).toHaveText("general");
await waitForMockLiveSubscription(page, "general");
await page.getByRole("button", { name: "Attach file" }).click();
await expect(
page
.getByTestId("message-composer")
.getByRole("button", { name: "Video attachment bbbb", exact: true }),
).toBeVisible();
await page.getByTestId("send-message").click();
await expect(page.getByText("Sending")).toHaveCount(0);
const reviewButton = page
.getByRole("button", { name: "Open video review" })
.last();
await expect(reviewButton).toBeVisible();
await page.waitForFunction(() => {
const launcher = document.querySelector("[data-video-review-launcher]");
const row = launcher?.closest("[data-message-id]");
const messageId = row?.getAttribute("data-message-id") ?? "";
return Boolean(messageId) && !messageId.startsWith("optimistic");
});
await reviewButton.evaluate((button) =>
(button as HTMLButtonElement).click(),
);
const reviewDialog = page.getByTestId("video-review-dialog");
await expect(reviewDialog).toBeVisible();
await reviewDialog.locator("video").evaluate((video) => {
const el = video as HTMLVideoElement;
el.currentTime = 10;
el.dispatchEvent(new Event("timeupdate"));
});
await expect(page.getByTestId("video-review-composer-timecode")).toHaveText(
"00:10",
);
const commentBox = reviewDialog.getByTestId("message-input");
await commentBox.click();
await commentBox.fill(commentText);
await reviewDialog.getByTestId("send-message").click();
await expect(page.getByTestId("video-review-comments")).toContainText(
commentText,
);
return reviewDialog;
}
test("video upload previews use poster frames and inline videos open review mode", async ({
page,
}) => {
await installVideoReviewHarness(page, {
themeName: VIDEO_REVIEW_ACCENT_THEME,
});
await page.goto("/");
await page.getByTestId("channel-general").click();
await expect(page.getByTestId("chat-title")).toHaveText("general");
await waitForMockLiveSubscription(page, "general");
await page.getByRole("button", { name: "Attach file" }).click();
const composer = page.getByTestId("message-composer");
const composerTrigger = composer.getByRole("button", {
name: "Video attachment bbbb",
exact: true,
});
await expect(composerTrigger).toBeVisible();
const composerPoster = composerTrigger.locator("img");
await expect(composerPoster).toHaveAttribute("src", POSTER_DATA_URL);
const box = await composerPoster.boundingBox();
expect(box?.width).toBeGreaterThanOrEqual(53);
expect(box?.width).toBeLessThanOrEqual(58);
expect(box?.height).toBeGreaterThanOrEqual(52);
expect(box?.height).toBeLessThanOrEqual(58);
await page.getByTestId("send-message").click();
await expect(page.getByText("Sending")).toHaveCount(0);
const reviewButton = page
.getByRole("button", { name: "Open video review" })
.last();
await expect(reviewButton).toBeVisible();
await page.waitForFunction(() => {
const launcher = document.querySelector("[data-video-review-launcher]");
const row = launcher?.closest("[data-message-id]");
const messageId = row?.getAttribute("data-message-id") ?? "";
return Boolean(messageId) && !messageId.startsWith("optimistic");
});
const videoMessageId = await reviewButton.evaluate((button) =>
button.closest("[data-message-id]")?.getAttribute("data-message-id"),
);
if (!videoMessageId) {
throw new Error("Expected uploaded video row to have a message id.");
}
const inlinePlayer = page.getByTestId("video-player").last();
const inlineSurface = inlinePlayer.locator("[data-smooth-corners]").first();
await expectCornerRadiusPx(inlineSurface, 16);
await expectSmoothCorners(inlineSurface);
const inlineVideo = inlinePlayer.locator("video");
await inlinePlayer.getByRole("button", { name: "Play video" }).click();
// Inline playback uses our own controls — the native browser UI must
// never appear.
await expect(inlineVideo).not.toHaveAttribute("controls", "");
await expect(
inlinePlayer.getByRole("button", { name: "Pause video" }),
).toBeVisible();
await expect(page.getByTestId("video-inline-time")).toHaveText("00:00");
await expect(page.getByTestId("video-inline-duration")).toHaveText("00:12");
// Scrub the inline timeline to the middle.
const inlineTrack = page.getByTestId("video-inline-progress-track");
const inlineTrackBox = await inlineTrack.boundingBox();
expect(inlineTrackBox).not.toBeNull();
if (!inlineTrackBox) {
throw new Error("Expected the inline timeline track to have a box");
}
await page.mouse.click(
inlineTrackBox.x + inlineTrackBox.width * 0.5,
inlineTrackBox.y + inlineTrackBox.height / 2,
);
await expect
.poll(() =>
inlineVideo.evaluate((video) => (video as HTMLVideoElement).currentTime),
)
.toBeGreaterThan(6);
await expect
.poll(() =>
inlineVideo.evaluate((video) => (video as HTMLVideoElement).currentTime),
)
.toBeLessThan(6.5);
await expect(page.getByTestId("video-inline-time")).toHaveText("00:06");
await inlinePlayer.getByRole("button", { name: "Pause video" }).click();
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");
const restoredInlinePlayer = page.getByTestId("video-player").last();
const restoredInlineVideo = restoredInlinePlayer.locator("video");
await restoredInlineVideo.evaluate((video) => {
video.dispatchEvent(new Event("loadedmetadata"));
});
await expect
.poll(() =>
restoredInlineVideo.evaluate(
(video) => (video as HTMLVideoElement).currentTime,
),
)
.toBeGreaterThan(6);
await expect
.poll(() =>
restoredInlineVideo.evaluate(
(video) => (video as HTMLVideoElement).currentTime,
),
)
.toBeLessThan(6.5);
await restoredInlinePlayer
.getByRole("button", { name: "Play video" })
.click();
await expect(
restoredInlinePlayer.getByRole("button", { name: "Pause video" }),
).toBeVisible();
const inlineSpeedButton =
restoredInlinePlayer.getByTestId("video-inline-speed");
await expect(inlineSpeedButton).toHaveText("1x");
await inlineSpeedButton.click();
const inlineSpeedMenu = page.getByTestId("video-inline-speed-menu");
await expect(inlineSpeedMenu.getByRole("button")).toHaveText([
"2x",
"1.75x",
"1.5x",
"1.25x",
"1x",
"0.75x",
"0.5x",
"0.25x",
]);
await inlineSpeedMenu
.getByRole("button", { name: "1.5x", exact: true })
.click();
await expect(inlineSpeedButton).toHaveText("1.5x");
await expect
.poll(() =>
restoredInlineVideo.evaluate(
(video) => (video as HTMLVideoElement).playbackRate,
),
)
.toBe(1.5);
// Inline volume controls.
await inlinePlayer.getByRole("button", { name: "Mute" }).click();
await expect
.poll(() =>
inlineVideo.evaluate((video) => (video as HTMLVideoElement).muted),
)
.toBe(true);
await inlinePlayer.getByRole("button", { name: "Unmute" }).click();
await expect
.poll(() =>
inlineVideo.evaluate((video) => (video as HTMLVideoElement).muted),
)
.toBe(false);
// Reset the inline position so the review dialog opens from the start.
await inlinePlayer.getByRole("button", { name: "Pause video" }).click();
await inlineVideo.evaluate((video) => {
(video as HTMLVideoElement).currentTime = 0;
});
await reviewButton.evaluate((button) =>
(button as HTMLButtonElement).click(),
);
const reviewDialog = page.getByTestId("video-review-dialog");
await expect(reviewDialog).toBeVisible();
await expect(reviewDialog.getByText("launch-demo.mp4")).toBeVisible();
const reviewBox = await reviewDialog.boundingBox();
const viewport = page.viewportSize();
expect(reviewBox?.x).toBeGreaterThan(0);
expect(reviewBox?.y).toBeGreaterThan(0);
expect(reviewBox?.width).toBeLessThan(viewport?.width ?? 0);
expect(reviewBox?.height).toBeLessThan(viewport?.height ?? 0);
await expect(
reviewDialog.getByRole("button", { name: "Play review video" }),
).toBeVisible();
await expect(reviewDialog.getByLabel("Video timeline")).toBeVisible();
const reviewVideo = reviewDialog.locator("video");
await expect(reviewVideo).not.toHaveAttribute("controls", "");
const reviewSpeedButton = reviewDialog.getByTestId("video-review-speed");
await expect(reviewSpeedButton).toHaveText("1.5x");
await expect
.poll(() =>
reviewVideo.evaluate((video) => (video as HTMLVideoElement).playbackRate),
)
.toBe(1.5);
await reviewSpeedButton.click();
await page
.getByTestId("video-review-speed-menu")
.getByRole("button", { name: "0.25x", exact: true })
.click();
await expect(reviewSpeedButton).toHaveText("0.25x");
await expect
.poll(() =>
reviewVideo.evaluate((video) => (video as HTMLVideoElement).playbackRate),
)
.toBe(0.25);
await reviewDialog.getByRole("button", { name: "Play review video" }).click();
await expect(
reviewDialog.getByRole("button", { name: "Pause review video" }),
).toBeVisible();
const progressThumb = page.getByTestId("video-review-progress-thumb");
await reviewVideo.evaluate((video) => {
const el = video as HTMLVideoElement;
el.currentTime = 10;
});
await reviewDialog
.getByRole("button", { name: "Pause review video" })
.click();
await expect(
reviewDialog.getByRole("button", { name: "Play review video" }),
).toBeVisible();
await expect
.poll(() =>
reviewVideo.evaluate((video) => (video as HTMLVideoElement).currentTime),
)
.toBe(10);
await expect(page.getByTestId("video-review-composer-timecode")).toHaveText(
"00:10",
);
await expect(page.getByTestId("video-review-time-current")).toHaveText(
"00:10",
);
await expect(page.getByTestId("video-review-time-duration")).toHaveText(
"00:12",
);
const progressTrack = page.getByTestId("video-review-progress-track");
await expect
.poll(() =>
progressTrack.evaluate(
(el) => window.getComputedStyle(el).backgroundColor,
),
)
.toContain("0.16");
await expect
.poll(() =>
progressThumb.evaluate((el) => window.getComputedStyle(el).opacity),
)
.toBe("1");
const timeline = page.getByTestId("video-review-timeline");
await expect(timeline).toBeVisible();
const trackBox = await progressTrack.boundingBox();
expect(trackBox).not.toBeNull();
if (!trackBox) {
throw new Error(
"Expected the review timeline track to have a bounding box",
);
}
expect(trackBox.width).toBeGreaterThan(0);
const timelineY = trackBox.y + trackBox.height / 2;
await page.mouse.click(trackBox.x + trackBox.width * 0.5, timelineY);
await expect
.poll(() =>
reviewVideo.evaluate((video) => (video as HTMLVideoElement).currentTime),
)
.toBeGreaterThan(6);
await expect
.poll(() =>
reviewVideo.evaluate((video) => (video as HTMLVideoElement).currentTime),
)
.toBeLessThan(6.5);
await expect(page.getByTestId("video-review-composer-timecode")).toHaveText(
"00:06",
);
await page.mouse.move(trackBox.x + trackBox.width * 0.2, timelineY);
await page.mouse.down();
await page.mouse.move(trackBox.x + trackBox.width * 0.8, timelineY);
await page.mouse.up();
await expect
.poll(() =>
reviewVideo.evaluate((video) => (video as HTMLVideoElement).currentTime),
)
.toBe(10);
await expect(page.getByTestId("video-review-composer-timecode")).toHaveText(
"00:10",
);
await page.mouse.move(trackBox.x + trackBox.width * 0.8, timelineY);
await page.mouse.down();
await page.mouse.move(trackBox.x + trackBox.width * 0.5, timelineY);
await page.mouse.up();
await expect
.poll(() =>
reviewVideo.evaluate((video) => (video as HTMLVideoElement).currentTime),
)
.toBeGreaterThan(6);
await expect
.poll(() =>
reviewVideo.evaluate((video) => (video as HTMLVideoElement).currentTime),
)
.toBeLessThan(6.5);
await expect(page.getByTestId("video-review-composer-timecode")).toHaveText(
"00:06",
);
await page.mouse.click(trackBox.x + trackBox.width * 0.8, timelineY);
await expect
.poll(() =>
reviewVideo.evaluate((video) => (video as HTMLVideoElement).currentTime),
)
.toBe(10);
await expect(page.getByTestId("video-review-composer-timecode")).toHaveText(
"00:10",
);
await page.mouse.move(trackBox.x + trackBox.width * 0.5, timelineY);
await expect
.poll(() =>
progressThumb.evaluate((el) => window.getComputedStyle(el).opacity),
)
.toBe("1");
await expect(page.getByTestId("video-review-reaction-tray")).toBeVisible();
await expect
.poll(() =>
page
.getByTestId("video-review-progress-fill")
.evaluate((el) => (el as HTMLElement).style.width),
)
.toBe("80%");
await reviewDialog.getByRole("button", { name: "More reactions" }).click();
await expect(page.getByTestId("video-review-emoji-picker")).toBeVisible();
await reviewDialog.getByRole("button", { name: "More reactions" }).click();
await expect(page.getByTestId("video-review-composer-timecode")).toHaveText(
"00:10",
);
await reviewDialog.getByTestId("video-review-reaction-0").click();
await expect(page.getByTestId("video-review-comments")).toContainText("😂");
await reviewVideo.evaluate((video) => {
const el = video as HTMLVideoElement;
el.currentTime = 10.7;
el.dispatchEvent(new Event("timeupdate"));
});
await reviewDialog.getByTestId("video-review-reaction-1").click();
const fractionalTimecode = reviewDialog
.getByRole("button", { name: "Jump to 00:10.7" })
.first();
await expect(fractionalTimecode).toBeVisible();
await page.mouse.click(trackBox.x + trackBox.width * 0.5, timelineY);
await fractionalTimecode.click();
await expect
.poll(() =>
reviewVideo.evaluate((video) => (video as HTMLVideoElement).currentTime),
)
.toBeGreaterThan(10.65);
await expect
.poll(() =>
reviewVideo.evaluate((video) => (video as HTMLVideoElement).currentTime),
)
.toBeLessThan(10.75);
await reviewVideo.evaluate((video) => {
const el = video as HTMLVideoElement;
el.currentTime = 10;
el.dispatchEvent(new Event("timeupdate"));
});
await expect(page.getByTestId("video-review-composer-timecode")).toHaveText(
"00:10",
);
// The comments panel embeds the standard message composer.
const reviewComposer = reviewDialog.getByTestId("message-composer");
await expect(reviewComposer).toBeVisible();
const commentBox = reviewDialog.getByTestId("message-input");
await commentBox.click();
await expect(commentBox).toBeFocused();
await commentBox.fill("Color pass looks right");
await expect(commentBox).toHaveText("Color pass looks right");
await reviewDialog.getByTestId("send-message").click();
await expect(page.getByTestId("video-review-comments")).toContainText(
"Color pass looks right",
);
await expect(
reviewDialog.getByRole("button", { name: "Seek to 00:10" }).first(),
).toBeVisible();
const commentTimecode = reviewDialog
.getByRole("button", { name: "Jump to 00:10" })
.first();
await expect(commentTimecode).toBeVisible();
await expect(
reviewDialog.getByTestId("video-review-comment-timecode").first(),
).toHaveText("00:10");
await expect(
reviewDialog.getByTestId("video-review-comment-timecode").first(),
).toHaveCSS("color", VIDEO_REVIEW_ACCENT_FOREGROUND_RGB);
// Regression: a timeline re-render (live message arriving in the channel)
// must not remount the review dialog or wipe an in-progress comment draft.
await commentBox.click();
await commentBox.fill("Second pass note");
const commentEditor = await commentBox.elementHandle();
if (!commentEditor) {
throw new Error("Expected the review comment editor to be mounted.");
}
await emitMockMessage(page, "general", "Unrelated chatter mid-review");
await page.evaluate(
() =>
new Promise<void>((resolve) => {
requestAnimationFrame(() => requestAnimationFrame(() => resolve()));
}),
);
expect(await commentEditor.evaluate((element) => element.isConnected)).toBe(
true,
);
await expect(commentBox).toHaveText("Second pass note");
await expect(commentBox).toBeFocused();
await expect(page.getByTestId("video-review-composer-timecode")).toHaveText(
"00:10",
);
await commentBox.fill("");
await page.mouse.click(trackBox.x + trackBox.width * 0.5, timelineY);
await expect
.poll(() =>
reviewVideo.evaluate((video) => (video as HTMLVideoElement).currentTime),
)
.toBeGreaterThan(6);
await expect
.poll(() =>
reviewVideo.evaluate((video) => (video as HTMLVideoElement).currentTime),
)
.toBeLessThan(6.5);
await commentTimecode.click();
await expect
.poll(() =>
reviewVideo.evaluate((video) => (video as HTMLVideoElement).currentTime),
)
.toBe(10);
await expect(page.getByTestId("video-review-composer-timecode")).toHaveText(
"00:10",
);
await expect(page.getByTestId("video-review-time-current")).toHaveText(
"00:10",
);
await expect(page.getByTestId("video-review-time-duration")).toHaveText(
"00:12",
);
// Reply to the text comment: posts nested under it, with no new timecode.
const textCommentCard = page
.getByTestId("video-review-comments")
.locator("article")
.filter({ hasText: "Color pass looks right" });
await textCommentCard
.getByTestId("video-review-comment-reply")
.first()
.click();
await expect(commentBox).toBeFocused();
await expect(reviewDialog.getByTestId("reply-target")).toBeVisible();
await commentBox.fill("Agreed, shipping it");
await reviewDialog.getByTestId("send-message").click();
await expect(textCommentCard).toContainText("Agreed, shipping it");
await expect(reviewDialog.getByTestId("reply-target")).toHaveCount(0);
const replyTimecodeChips = textCommentCard.getByTestId(
"video-review-comment-timecode",
);
await expect(replyTimecodeChips).toHaveCount(1);
// Frame-stamp toggle off → the comment posts without a timecode chip and
// the composer chip drops to its muted state.
const frameToggle = reviewDialog.getByTestId("video-review-frame-toggle");
await expect(frameToggle).toHaveAttribute("data-state", "checked");
await frameToggle.click();
await expect(frameToggle).toHaveAttribute("data-state", "unchecked");
await commentBox.fill("Untimed general note");
await reviewDialog.getByTestId("send-message").click();
const untimedCard = page
.getByTestId("video-review-comments")
.locator("article")
.filter({ hasText: "Untimed general note" });
await expect(untimedCard).toBeVisible();
await expect(
untimedCard.getByTestId("video-review-comment-timecode"),
).toHaveCount(0);
await frameToggle.click();
await expect(frameToggle).toHaveAttribute("data-state", "checked");
// The comments panel collapses and reopens (animated width).
const commentsPanel = page.getByTestId("video-review-comments-panel");
await reviewDialog.getByTestId("video-review-toggle-comments").click();
await expect
.poll(() =>
commentsPanel.evaluate((el) => el.getBoundingClientRect().width),
)
.toBe(0);
await expect(commentsPanel).toHaveAttribute("inert", "");
await reviewDialog.getByTestId("video-review-toggle-comments").click();
await expect
.poll(() =>
commentsPanel.evaluate((el) => el.getBoundingClientRect().width),
)
.toBe(380);
await expect(commentsPanel).not.toHaveAttribute("inert", "");
await expect(reviewDialog.getByTestId("message-composer")).toBeVisible();
await reviewDialog
.getByRole("button", { name: "Close video review" })
.click();
await expect(page.getByTestId("video-review-dialog")).toHaveCount(0);
await expect(
restoredInlinePlayer.getByTestId("video-inline-speed"),
).toHaveText("0.25x");
await expect
.poll(() =>
restoredInlineVideo.evaluate(
(video) => (video as HTMLVideoElement).playbackRate,
),
)
.toBe(0.25);
await reviewButton.evaluate((button) =>
(button as HTMLButtonElement).click(),
);
await expect(page.getByTestId("video-review-dialog")).toBeVisible();
await page
.getByTestId("video-review-backdrop")
.click({ position: { x: 4, y: 4 } });
await expect(page.getByTestId("video-review-dialog")).toHaveCount(0);
// Re-open the channel so the thread summary is sourced from the persisted
// mock history instead of depending on whether the background timeline row
// stayed mounted while the modal handled live comment updates.
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");
const videoSummaryRow = page.locator(
`[data-thread-head-id="${videoMessageId}"]`,
);
await expect(videoSummaryRow).toBeVisible();
await videoSummaryRow.click();
const threadPanel = page.getByTestId("message-thread-panel");
await expect(threadPanel).toBeVisible();
const threadHead = threadPanel.getByTestId("message-thread-head");
await expect(threadHead.getByTestId("video-player")).toBeVisible();
await threadHead.getByRole("button", { name: "Open video review" }).click();
const threadReviewDialog = page.getByTestId("video-review-dialog");
await expect(threadReviewDialog).toBeVisible();
await expect(
threadReviewDialog.getByTestId("video-review-comments-panel"),
).toBeVisible();
await expect(
threadReviewDialog.getByTestId("message-composer"),
).toBeVisible();
await expect(
threadReviewDialog.getByTestId("video-review-comments"),
).toContainText("Color pass looks right");
});
test("inline video hover reveals a timeline without a second play control", async ({
page,
}) => {
await installVideoReviewHarness(page);
await page.goto("/");
await page.getByTestId("channel-general").click();
await expect(page.getByTestId("chat-title")).toHaveText("general");
await waitForMockLiveSubscription(page, "general");
await emitMockMessage(page, "general", `![video](${VIDEO_URL})`, {
extraTags: [
[
"imeta",
`url ${VIDEO_URL}`,
"m video/mp4",
`x ${VIDEO_SHA}`,
"size 987654",
"dim 160x80",
"duration 12.5",
`image ${POSTER_DATA_URL}`,
"filename launch-demo.mp4",
],
],
});
const player = page.getByTestId("video-player").last();
const video = player.locator("video");
const surface = video.locator("..");
const centerPlayback = player.getByTestId("video-inline-center-playback");
const centerIcon = player.getByTestId("video-inline-center-icon");
const controls = player.getByTestId("video-inline-controls");
await expect(centerPlayback).toHaveAttribute("aria-label", "Play video");
await expect(
controls.getByRole("button", { name: /^(?:Play|Pause) video$/ }),
).toHaveCount(0);
await expect(
player.getByRole("button", { name: "Open video review" }),
).toBeVisible();
await expect(player.getByTestId("video-inline-duration")).toHaveText("00:12");
await video.evaluate((element) => {
element.currentTime = 6.25;
element.dispatchEvent(new Event("timeupdate"));
});
await expect
.poll(() =>
player
.getByTestId("video-inline-progress-fill")
.evaluate((element) => element.style.width),
)
.toBe("50%");
// The timeline can shift the whole row by a pixel while the pointer moves
// in, so measure the controls against their own player in a single frame.
const controlsOffset = () =>
controls.evaluate((element) => {
const playerTop = element
.closest('[data-testid="video-player"]')
?.getBoundingClientRect().top;
return element.getBoundingClientRect().top - (playerTop ?? Number.NaN);
});
const restingControlsOffset = await controlsOffset();
const restingIconTransform = await centerIcon.evaluate(
(element) => window.getComputedStyle(element).transform,
);
await expect(controls).toHaveCSS("opacity", "0");
await surface.hover();
await expect(controls).toHaveCSS("opacity", "1");
expect(
Math.abs((await controlsOffset()) - restingControlsOffset),
).toBeLessThan(0.5);
await expect
.poll(() =>
centerIcon.evaluate(
(element) => window.getComputedStyle(element).transform,
),
)
.toBe(restingIconTransform);
await centerPlayback.click();
await expect
.poll(() => video.evaluate((element) => element.paused))
.toBe(false);
await expect(centerPlayback).toHaveAttribute("aria-label", "Pause video");
await page.mouse.move(0, 0);
await expect(centerPlayback).toHaveCSS("opacity", "0");
await surface.hover();
await expect(centerPlayback).toHaveCSS("opacity", "1");
await expect(centerPlayback).toHaveCSS("transition-property", "opacity");
await expect(centerPlayback).toHaveCSS("transition-duration", "0.15s");
await page.emulateMedia({ reducedMotion: "reduce" });
await expect(centerPlayback).toHaveCSS("transition-property", "none");
await expect(controls).toHaveCSS("transition-property", "none");
await centerPlayback.click();
await expect
.poll(() => video.evaluate((element) => element.paused))
.toBe(true);
});
test("expanded video controls fade with the video hover boundary", async ({
page,
}) => {
await installVideoReviewHarness(page);
await page.goto("/");
await page.getByTestId("channel-general").click();
await expect(page.getByTestId("chat-title")).toHaveText("general");
await waitForMockLiveSubscription(page, "general");
await emitMockMessage(page, "general", `![video](${VIDEO_URL})`, {
extraTags: [
[
"imeta",
`url ${VIDEO_URL}`,
"m video/mp4",
`x ${VIDEO_SHA}`,
"size 987654",
"dim 160x80",
"duration 12.5",
`image ${POSTER_DATA_URL}`,
"filename launch-demo.mp4",
],
],
});
await page.getByRole("button", { name: "Open video review" }).last().click();
const dialog = page.getByTestId("video-review-dialog");
const mediaSurface = dialog.locator(".video-review-media-surface");
const controls = dialog.locator(".video-review-controls");
await expect(mediaSurface).toBeVisible();
await dialog.getByTestId("video-review-comments-panel").hover();
const restingControlsBox = await controls.boundingBox();
expect(restingControlsBox).not.toBeNull();
await expect(controls).toHaveCSS("opacity", "0");
await mediaSurface.hover();
await expect(controls).toHaveCSS("opacity", "1");
const hoveredControlsBox = await controls.boundingBox();
expect(hoveredControlsBox).not.toBeNull();
expect(
Math.abs((hoveredControlsBox?.y ?? 0) - (restingControlsBox?.y ?? 0)),
).toBeLessThan(0.5);
await expect(controls).toHaveCSS("transition-property", "opacity");
await expect(controls).toHaveCSS("transition-duration", "0.15s");
const playButton = controls.getByRole("button", { name: /review video$/ });
await playButton.click();
await expect(playButton).toBeFocused();
await dialog.getByTestId("video-review-comments-panel").hover();
await expect(controls).toHaveCSS("opacity", "0");
await page.keyboard.press("Tab");
await expect(controls).toHaveCSS("opacity", "1");
await page.emulateMedia({ reducedMotion: "reduce" });
await expect(controls).toHaveCSS("transition-property", "none");
});
test("video replies in threads open the review comments view", async ({
page,
}) => {
await installVideoReviewHarness(page);
await page.goto("/");
await page.getByTestId("channel-general").click();
await expect(page.getByTestId("chat-title")).toHaveText("general");
await waitForMockLiveSubscription(page, "general");
const root = (await emitMockMessage(
page,
"general",
"Can you review this cut?",
)) as MockFeedMessage;
const videoReply = (await emitMockMessage(
page,
"general",
`![video](${EXTENSIONLESS_VIDEO_URL})`,
{
extraTags: [
[
"imeta",
`url ${EXTENSIONLESS_VIDEO_URL}`,
"m video/mp4",
`x ${VIDEO_SHA}`,
"size 987654",
"dim 160x80",
"duration 12.5",
`image ${POSTER_DATA_URL}`,
"filename launch-demo.mp4",
],
],
parentEventId: root.id,
},
)) as { id: string };
const reviewComment = (await emitMockMessage(
page,
"general",
"[00:01] > Tighten this transition.",
{ parentEventId: videoReply.id },
)) as MockFeedMessage;
await pushMockFeedItems(page, [videoReply, reviewComment]);
const threadSummary = page.locator(`[data-thread-head-id="${root.id}"]`);
await expect(threadSummary).toBeVisible();
await threadSummary.click();
const threadPanel = page.getByTestId("message-thread-panel");
const threadReplies = threadPanel.getByTestId("message-thread-replies");
const reviewButton = threadReplies.getByRole("button", {
name: "Open video review",
});
await expect(reviewButton).toBeVisible();
const nestedVideoSummary = threadReplies.locator(
`[data-thread-head-id="${videoReply.id}"]`,
);
await expect(nestedVideoSummary).toBeVisible();
await expect(threadReplies.locator("video")).toHaveAttribute(
"preload",
"metadata",
);
await nestedVideoSummary.click();
const outsideTimecode = threadReplies.getByRole("button", {
name: "Jump to 00:01",
});
await expect(outsideTimecode).toBeVisible();
await expect(outsideTimecode).toHaveAttribute(
"data-testid",
"video-review-comment-timecode",
);
await expect(outsideTimecode.locator("xpath=ancestor::p")).toContainText(
"Tighten this transition.",
);
await expect(
outsideTimecode.locator("xpath=ancestor::blockquote"),
).toBeVisible();
const outsideTimecodeStyles = await outsideTimecode.evaluate((element) => {
const styles = window.getComputedStyle(element);
return {
backgroundColor: styles.backgroundColor,
borderRadius: styles.borderRadius,
fontFamily: styles.fontFamily,
height: styles.height,
paddingLeft: styles.paddingLeft,
paddingRight: styles.paddingRight,
};
});
expect(outsideTimecodeStyles.backgroundColor).not.toBe("rgba(0, 0, 0, 0)");
await outsideTimecode.click();
const reviewDialog = page.getByTestId("video-review-dialog");
const reviewVideo = reviewDialog.locator("video");
const posterPreview = reviewDialog.getByTestId("video-review-poster-preview");
await expect(posterPreview).toBeVisible();
await expect(posterPreview).toHaveAttribute("src", POSTER_DATA_URL);
await expect(
reviewDialog.getByTestId("video-review-comments-panel"),
).toHaveCSS("background-color", "oklch(0.145 0 0)");
const modalTimecode = reviewDialog
.getByRole("button", { name: "Jump to 00:01" })
.first();
await expect(modalTimecode).toBeVisible();
const modalTimecodeStyles = await modalTimecode.evaluate((element) => {
const styles = window.getComputedStyle(element);
return {
borderRadius: styles.borderRadius,
fontFamily: styles.fontFamily,
height: styles.height,
paddingLeft: styles.paddingLeft,
paddingRight: styles.paddingRight,
};
});
expect(outsideTimecodeStyles).toMatchObject(modalTimecodeStyles);
await expect(reviewVideo).toHaveAttribute("preload", "auto");
await expect
.poll(() =>
reviewVideo.evaluate((video) => (video as HTMLVideoElement).paused),
)
.toBe(true);
await expect
.poll(() =>
reviewVideo.evaluate((video) => (video as HTMLVideoElement).currentTime),
)
.toBe(1);
await reviewVideo.dispatchEvent("loadeddata");
await expect(posterPreview).toHaveCount(0);
await expect(
reviewDialog.getByTestId("video-review-comments-panel"),
).toBeVisible();
await expect(reviewDialog.getByTestId("message-composer")).toBeVisible();
await expect(reviewDialog.getByTestId("video-review-comments")).toContainText(
"Tighten this transition.",
);
await page
.getByTestId("video-review-backdrop")
.click({ position: { x: 4, y: 4 } });
await page.getByRole("button", { name: "Inbox", exact: true }).click();
const inboxRow = page.getByTestId(`home-inbox-item-${reviewComment.id}`);
await expect(inboxRow).toBeVisible();
const inboxPreviewTimecode = inboxRow.getByTestId(
"video-review-comment-timecode",
);
await expect(inboxPreviewTimecode.locator("xpath=ancestor::p")).toContainText(
"Tighten this transition.",
);
await expect(
inboxPreviewTimecode.locator("xpath=ancestor::blockquote"),
).toBeVisible();
await inboxRow.click();
const inboxDetail = page.getByTestId("home-inbox-detail");
const inboxDetailTimecode = inboxDetail.getByRole("button", {
name: "Jump to 00:01",
});
await expect(inboxDetailTimecode).toBeVisible();
await expect(inboxDetailTimecode.locator("xpath=ancestor::p")).toContainText(
"Tighten this transition.",
);
await expect(
inboxDetailTimecode.locator("xpath=ancestor::blockquote"),
).toBeVisible();
await inboxDetailTimecode.click();
await expect(page.getByTestId("video-review-dialog")).toBeVisible();
});
test("Inbox preserves bracketed timestamps without video evidence", async ({
page,
}) => {
await installVideoReviewHarness(page);
await page.goto("/");
await page.getByTestId("channel-general").click();
await expect(page.getByTestId("chat-title")).toHaveText("general");
await waitForMockLiveSubscription(page, "general");
const root = (await emitMockMessage(page, "general", "Planning note")) as {
id: string;
};
const reply = (await emitMockMessage(
page,
"general",
"[12:30] Meeting starts",
{ parentEventId: root.id },
)) as MockFeedMessage;
await pushMockFeedItems(page, [reply]);
await page.getByRole("button", { name: "Inbox", exact: true }).click();
const inboxRow = page.getByTestId(`home-inbox-item-${reply.id}`);
await expect(inboxRow).toContainText("[12:30] Meeting starts");
await expect(
inboxRow.getByTestId("video-review-comment-timecode"),
).toHaveCount(0);
});
test("Inbox recognizes reference-style video ancestors with custom alt text", async ({
page,
}) => {
await installVideoReviewHarness(page);
await page.goto("/");
await page.getByTestId("channel-general").click();
await expect(page.getByTestId("chat-title")).toHaveText("general");
await waitForMockLiveSubscription(page, "general");
const root = (await emitMockMessage(
page,
"general",
"Can you review this cut?",
)) as { id: string };
const video = (await emitMockMessage(
page,
"general",
`![Launch demo][cut]\n\n[cut]: ${VIDEO_URL}`,
{ parentEventId: root.id },
)) as MockFeedMessage;
const comment = (await emitMockMessage(
page,
"general",
"[00:01] Tighten this transition.",
{ parentEventId: video.id },
)) as MockFeedMessage;
await pushMockFeedItems(page, [video, comment]);
await page.getByRole("button", { name: "Inbox", exact: true }).click();
const inboxRow = page.getByTestId(`home-inbox-item-${comment.id}`);
await expect(
inboxRow.getByTestId("video-review-comment-timecode"),
).toBeVisible();
await inboxRow.click();
await page
.getByTestId("home-inbox-detail")
.getByRole("button", { name: "Jump to 00:01" })
.click();
await expect(page.getByTestId("video-review-dialog")).toBeVisible();
});
test("message timecodes deterministically open the first attached video", async ({
page,
}) => {
await installVideoReviewHarness(page);
await page.goto("/");
await page.getByTestId("channel-general").click();
await expect(page.getByTestId("chat-title")).toHaveText("general");
await waitForMockLiveSubscription(page, "general");
const videoMessage = (await emitMockMessage(
page,
"general",
`![first video](${VIDEO_URL})\n\n![second video](${PORTRAIT_VIDEO_URL})`,
{
extraTags: [
[
"imeta",
`url ${VIDEO_URL}`,
"m video/mp4",
`x ${VIDEO_SHA}`,
"dim 160x80",
"duration 12.5",
"filename first-cut.mp4",
],
[
"imeta",
`url ${PORTRAIT_VIDEO_URL}`,
"m video/mp4",
`x ${PORTRAIT_VIDEO_SHA}`,
"dim 80x160",
"duration 12.5",
"filename second-cut.mp4",
],
],
},
)) as { id: string };
await emitMockMessage(page, "general", "[00:01] Check this frame.", {
parentEventId: videoMessage.id,
});
const threadSummary = page.locator(
`[data-thread-head-id="${videoMessage.id}"]`,
);
await expect(threadSummary).toBeVisible();
await threadSummary.click();
const threadPanel = page.getByTestId("message-thread-panel");
const threadHead = threadPanel.getByTestId("message-thread-head");
const inlineVideos = threadHead.locator("video");
await expect(inlineVideos).toHaveCount(2);
const firstVideoSrc = await inlineVideos.nth(0).getAttribute("src");
const secondVideoSrc = await inlineVideos.nth(1).getAttribute("src");
expect(firstVideoSrc).toBeTruthy();
expect(firstVideoSrc).not.toBe(secondVideoSrc);
await threadPanel
.getByTestId("message-thread-replies")
.getByRole("button", { name: "Jump to 00:01" })
.click();
const reviewVideo = page.getByTestId("video-review-dialog").locator("video");
await expect(reviewVideo).toHaveAttribute("src", firstVideoSrc ?? "");
await expect
.poll(() =>
reviewVideo.evaluate((video) => (video as HTMLVideoElement).currentTime),
)
.toBe(1);
});
test("narrow inline videos hide playback speed control", async ({ page }) => {
await installVideoReviewHarness(page);
await page.goto("/");
await page.getByTestId("channel-general").click();
await expect(page.getByTestId("chat-title")).toHaveText("general");
await waitForMockLiveSubscription(page, "general");
await emitMockMessage(page, "general", `![video](${PORTRAIT_VIDEO_URL})`, {
extraTags: [
[
"imeta",
`url ${PORTRAIT_VIDEO_URL}`,
"m video/mp4",
`x ${PORTRAIT_VIDEO_SHA}`,
"size 987654",
"dim 80x160",
"duration 12.5",
`image ${POSTER_DATA_URL}`,
"filename portrait-demo.mp4",
],
],
});
const portraitPlayer = page.getByTestId("video-player").last();
await expect(portraitPlayer).toBeVisible();
const portraitBox = await portraitPlayer.boundingBox();
expect(portraitBox?.width).toBeLessThan(220);
await portraitPlayer.getByRole("button", { name: "Play video" }).click();
await expect(
portraitPlayer.getByTestId("video-inline-controls"),
).toBeVisible();
await expect(portraitPlayer.getByTestId("video-inline-speed")).toHaveCount(0);
await portraitPlayer
.getByRole("button", { name: "Open video review" })
.click();
const reviewDialog = page.getByTestId("video-review-dialog");
await expect(reviewDialog).toBeVisible();
await expect(reviewDialog.getByTestId("video-review-speed")).toHaveText("1x");
});
test("constrained landscape inline videos measure rendered width before showing speed", async ({
page,
}) => {
await installVideoReviewHarness(page);
await page.goto("/");
await page.getByTestId("channel-general").click();
await expect(page.getByTestId("chat-title")).toHaveText("general");
await waitForMockLiveSubscription(page, "general");
await emitMockMessage(
page,
"general",
`![video](${CONSTRAINED_LANDSCAPE_VIDEO_URL})`,
{
extraTags: [
[
"imeta",
`url ${CONSTRAINED_LANDSCAPE_VIDEO_URL}`,
"m video/mp4",
`x ${CONSTRAINED_LANDSCAPE_VIDEO_SHA}`,
"size 987654",
"dim 160x80",
"duration 12.5",
`image ${POSTER_DATA_URL}`,
"filename constrained-landscape-demo.mp4",
],
],
},
);
const landscapePlayer = page.getByTestId("video-player").last();
await expect(landscapePlayer).toBeVisible();
const fullWidthBox = await landscapePlayer.boundingBox();
expect(fullWidthBox?.width).toBeGreaterThan(220);
await landscapePlayer.getByRole("button", { name: "Play video" }).click();
await expect(
landscapePlayer.getByTestId("video-inline-controls"),
).toBeVisible();
await expect(landscapePlayer.getByTestId("video-inline-speed")).toBeVisible();
await landscapePlayer.evaluate((element) => {
(element as HTMLElement).style.width = "180px";
});
await expect
.poll(async () => {
const box = await landscapePlayer.boundingBox();
return box?.width ?? 0;
})
.toBeLessThan(220);
await expect(landscapePlayer.getByTestId("video-inline-speed")).toHaveCount(
0,
);
});
test("neutral accent uses the forced-dark review foreground", async ({
page,
}) => {
await installVideoReviewHarness(page, {
accentColor: VIDEO_REVIEW_NEUTRAL_ACCENT,
themeName: VIDEO_REVIEW_LIGHT_THEME,
});
const reviewDialog = await openReviewWithPostedTimecode(page);
await expect(
reviewDialog.getByTestId("video-review-composer-timecode"),
).toHaveCSS("color", VIDEO_REVIEW_NEUTRAL_DARK_RGB);
await expect(
reviewDialog.getByTestId("video-review-comment-timecode").first(),
).toHaveCSS("color", VIDEO_REVIEW_NEUTRAL_DARK_RGB);
});
test("dark accent uses a contrast-safe review foreground", async ({ page }) => {
await installVideoReviewHarness(page, {
accentColor: VIDEO_REVIEW_INDIGO_ACCENT,
themeName: VIDEO_REVIEW_ACCENT_THEME,
});
const reviewDialog = await openReviewWithPostedTimecode(
page,
"Indigo contrast check",
);
await expect(
reviewDialog.getByTestId("video-review-composer-timecode"),
).toHaveCSS("color", VIDEO_REVIEW_INDIGO_FOREGROUND_RGB);
await expect(
reviewDialog.getByTestId("video-review-comment-timecode").first(),
).toHaveCSS("color", VIDEO_REVIEW_INDIGO_FOREGROUND_RGB);
});
// Emit a video message with the imeta the renderer needs to pick the video
// player and, for the relay case, a resolvable download URL.
function emitVideoMessage(
page: Page,
{
url,
sha,
filename,
createdAt,
}: { url: string; sha: string; filename: string; createdAt?: number },
) {
return emitMockMessage(page, "general", `![video](${url})`, {
createdAt,
extraTags: [
[
"imeta",
`url ${url}`,
"m video/mp4",
`x ${sha}`,
"size 987654",
"dim 160x80",
"duration 12.5",
`image ${POSTER_DATA_URL}`,
`filename ${filename}`,
],
],
});
}
// A live row reaches the timeline floor in two scroll steps: first to the
// virtualizer's estimated floor, which leaves a video row hanging under the
// composer overlay, then to its measured floor. Right-click only once the
// player clears the composer. Earlier, the click lands on the composer, and
// the second step's scroll dismisses any menu that did open.
async function rightClickSettledPlayer(player: Locator) {
await expect
.poll(() =>
player.evaluate((element) => {
const composer = document.querySelector(
'[data-testid="message-composer"]',
);
return (
composer !== null &&
element.getBoundingClientRect().bottom <=
composer.getBoundingClientRect().top
);
}),
)
.toBe(true);
// No `force`: the Play button is inside the player, so the hit-target check
// passes, and the contextmenu still reaches the surface's capture handler.
await player.click({ button: "right" });
}
test("right-click menus expose distinct selectors for links, relay video, and off-relay video", async ({
page,
}) => {
await installVideoReviewHarness(page);
await page.goto("/");
await page.getByTestId("channel-general").click();
await expect(page.getByTestId("chat-title")).toHaveText("general");
await waitForMockLiveSubscription(page, "general");
// One second per message keeps the three rows in a fixed order.
const createdAt = Math.floor(Date.now() / 1000);
// ── Link menu: Open link + Copy link, never image/video attributes ────────
await emitMockMessage(
page,
"general",
"Docs at https://example.com/handbook",
{ createdAt },
);
const link = page.getByRole("link", { name: "https://example.com/handbook" });
await expect(link).toBeVisible();
await link.scrollIntoViewIfNeeded();
// Fire a real bubbling `contextmenu` MouseEvent on the anchor rather than
// `.click({ button: "right" })`: the link is wrapped in a hover tooltip and a
// positional right-click can land on the tooltip layer (or merely select the
// text) without reaching the anchor's React `onContextMenuCapture`. An
// element-targeted bubbling event drives the capture handler deterministically.
await link.evaluate((el) =>
el.dispatchEvent(
new MouseEvent("contextmenu", { bubbles: true, cancelable: true }),
),
);
const linkMenu = page.locator("[data-link-context-menu]");
await expect(linkMenu).toBeVisible();
await expect(page.locator("[data-media-context-menu]")).toBeVisible();
await expect(
linkMenu.getByRole("button", { name: "Open link" }),
).toBeVisible();
await expect(
linkMenu.getByRole("button", { name: "Copy link" }),
).toBeVisible();
await expect(page.locator("[data-image-context-menu]")).toHaveCount(0);
await expect(page.locator("[data-video-context-menu]")).toHaveCount(0);
// Dismiss the menu before the next probe: the menu closes on any click.
await page.getByTestId("chat-title").click();
await expect(linkMenu).toHaveCount(0);
// ── Relay video menu: Download video + Copy link, appearing only once the
// relay origin resolves (the reactivity fix) ─────────────────────────────
const relayMessage = (await emitVideoMessage(page, {
url: MENU_RELAY_VIDEO_URL,
sha: MENU_RELAY_VIDEO_SHA,
filename: "relay-clip.mp4",
createdAt: createdAt + 1,
})) as { id: string };
const relayPlayer = page
.locator(`[data-message-id="${relayMessage.id}"]`)
.getByTestId("video-player");
await expect(relayPlayer).toBeVisible();
await rightClickSettledPlayer(relayPlayer);
const videoMenu = page.locator("[data-video-context-menu]");
await expect(videoMenu).toBeVisible();
await expect(page.locator("[data-media-context-menu]")).toBeVisible();
// Download eligibility is reactive: the relay origin resolves asynchronously
// (commonly after first render), and when it does the already-open menu
// recomputes to reveal Download — no re-navigation or menu re-open.
await expect(
videoMenu.getByRole("button", { name: "Download video" }),
).toBeVisible();
await expect(
videoMenu.getByRole("button", { name: "Copy link" }),
).toBeVisible();
await expect(page.locator("[data-image-context-menu]")).toHaveCount(0);
await expect(page.locator("[data-link-context-menu]")).toHaveCount(0);
// The Download action drives the native download_file command.
await videoMenu.getByRole("button", { name: "Download video" }).click();
await expect
.poll(() =>
page.evaluate(
() =>
(window as Window & { __BUZZ_E2E_COMMANDS__?: string[] })
.__BUZZ_E2E_COMMANDS__ ?? [],
),
)
.toContain("download_file");
// Dismiss the relay menu before the off-relay probe so `[data-video-context
// -menu]` refers unambiguously to the off-relay video's menu below. The menu
// closes on any click.
await page.getByTestId("chat-title").click();
await expect(page.locator("[data-video-context-menu]")).toHaveCount(0);
// ── Off-relay video control: renders and offers Copy link, never Download ─
const offRelayMessage = (await emitVideoMessage(page, {
url: MENU_OFF_RELAY_VIDEO_URL,
sha: MENU_OFF_RELAY_VIDEO_SHA,
filename: "external-clip.mp4",
createdAt: createdAt + 2,
})) as { id: string };
const offRelayPlayer = page
.locator(`[data-message-id="${offRelayMessage.id}"]`)
.getByTestId("video-player");
await expect(offRelayPlayer).toBeVisible();
await rightClickSettledPlayer(offRelayPlayer);
const offRelayMenu = page.locator("[data-video-context-menu]");
await expect(offRelayMenu).toBeVisible();
await expect(
offRelayMenu.getByRole("button", { name: "Copy link" }),
).toBeVisible();
// An off-relay video would fail the download command's SSRF gate, so the
// menu must never offer Download for it.
await expect(
offRelayMenu.getByRole("button", { name: "Download video" }),
).toHaveCount(0);
});
test("playback speed persists across videos and reloads", async ({ page }) => {
await installVideoReviewHarness(page);
const openGeneralWithVideo = async (
url: string,
sha: string,
filename: string,
) => {
await page.getByTestId("channel-general").click();
await expect(page.getByTestId("chat-title")).toHaveText("general");
await waitForMockLiveSubscription(page, "general");
const emitted = (await emitMockMessage(
page,
"general",
`![video](${url})`,
{
extraTags: [
[
"imeta",
`url ${url}`,
"m video/mp4",
`x ${sha}`,
"size 987654",
"dim 160x80",
"duration 12.5",
`image ${POSTER_DATA_URL}`,
`filename ${filename}`,
],
],
},
)) as { id: string };
const player = page
.locator(`[data-message-id="${emitted.id}"]`)
.getByTestId("video-player");
await expect(player).toBeVisible();
await player.getByRole("button", { name: "Play video" }).click();
return player;
};
await page.goto("/");
const firstPlayer = await openGeneralWithVideo(
VIDEO_URL,
VIDEO_SHA,
"launch-demo.mp4",
);
const firstSpeedButton = firstPlayer.getByTestId("video-inline-speed");
await expect(firstSpeedButton).toHaveText("1x");
await firstSpeedButton.click();
await page
.getByTestId("video-inline-speed-menu")
.getByRole("button", { name: "2x", exact: true })
.click();
await expect(firstSpeedButton).toHaveText("2x");
// A different video in the same session starts at the chosen speed.
const secondPlayer = await openGeneralWithVideo(
MENU_RELAY_VIDEO_URL,
MENU_RELAY_VIDEO_SHA,
"second-demo.mp4",
);
const secondVideo = secondPlayer.locator("video");
await expect(secondPlayer.getByTestId("video-inline-speed")).toHaveText("2x");
await expect
.poll(() =>
secondVideo.evaluate((video) => (video as HTMLVideoElement).playbackRate),
)
.toBe(2);
// And the preference survives an app restart.
await page.reload();
const reloadedPlayer = await openGeneralWithVideo(
VIDEO_URL,
VIDEO_SHA,
"launch-demo.mp4",
);
const reloadedVideo = reloadedPlayer.locator("video");
await expect(reloadedPlayer.getByTestId("video-inline-speed")).toHaveText(
"2x",
);
await expect
.poll(() =>
reloadedVideo.evaluate(
(video) => (video as HTMLVideoElement).playbackRate,
),
)
.toBe(2);
});