9dfa06ffee
Docker image / Build (linux/amd64) (push) Has been cancelled
Docker image / Build (linux/arm64) (push) Has been cancelled
Docker image / Merge release multi-arch manifest (push) Has been cancelled
Docker image / Merge debug multi-arch manifest (push) Has been cancelled
Docker image / Build public push gateway (linux/amd64) (push) Has been cancelled
Docker image / Build public push gateway (linux/arm64) (push) Has been cancelled
Docker image / Publish public push gateway image (push) Has been cancelled
Sprig image / Build (linux/amd64) (push) Has been cancelled
Sprig image / Build (linux/arm64) (push) Has been cancelled
Sprig image / Merge multi-arch manifest (push) Has been cancelled
Harbor Buzz Orchestra / Python tests and lint (push) Has been cancelled
CI / Detect Changed Paths (push) Has been cancelled
CI / Rust Lint (push) Has been cancelled
CI / Unit Tests (push) Has been cancelled
CI / Desktop Core (push) Has been cancelled
CI / Desktop Smoke E2E (1) (push) Has been cancelled
CI / Desktop Smoke E2E (2) (push) Has been cancelled
CI / Desktop Smoke E2E (3) (push) Has been cancelled
CI / Desktop Smoke E2E (4) (push) Has been cancelled
CI / Desktop (push) Has been cancelled
CI / Desktop E2E Relay (push) Has been cancelled
CI / Desktop E2E Integration (1/2) (push) Has been cancelled
CI / Desktop E2E Integration (2/2) (push) Has been cancelled
CI / Desktop E2E Integration (push) Has been cancelled
CI / Backend Integration (relay e2e) (push) Has been cancelled
CI / Relay E2E (push) Has been cancelled
CI / Web (push) Has been cancelled
CI / Mobile (push) Has been cancelled
CI / Security (push) Has been cancelled
CI / Dead Token Reference Guard (push) Has been cancelled
CI / Server Cross-Compile (aarch64-unknown-linux-musl) (push) Has been cancelled
CI / Server Cross-Compile (x86_64-unknown-linux-musl) (push) Has been cancelled
CI / Windows Rust (x86_64-pc-windows-msvc) (push) Has been cancelled
CI / Desktop Build (macOS) (push) Has been cancelled
helm chart / lint + unittest + render matrix (push) Has been cancelled
helm chart / install on kind (gated) (push) Has been cancelled
helm chart / publish chart to GHCR (push) Has been cancelled
Mesh Lifecycle / Relay-Driven Mesh Lifecycle Smoke (push) Has been cancelled
Sprig / Build (aarch64-unknown-linux-musl) (push) Has been cancelled
Sprig / Build (x86_64-unknown-linux-musl) (push) Has been cancelled
Sprig / Publish rolling release (push) Has been cancelled
Sprig / Publish tagged release (push) Has been cancelled
Signed-off-by: cls_宁波本机 <908705107@qq.com>
1355 lines
47 KiB
TypeScript
1355 lines
47 KiB
TypeScript
import { expect, 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 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=";
|
|
|
|
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: { extraTags?: string[][]; parentEventId?: string } = {},
|
|
) {
|
|
return page.evaluate(
|
|
({ channelName, content, extraTags, parentEventId }) => {
|
|
const emit = (
|
|
window as Window & {
|
|
__BUZZ_E2E_EMIT_MOCK_MESSAGE__?: (input: {
|
|
channelName: string;
|
|
content: string;
|
|
extraTags?: string[][];
|
|
parentEventId?: string;
|
|
}) => unknown;
|
|
}
|
|
).__BUZZ_E2E_EMIT_MOCK_MESSAGE__;
|
|
if (!emit) {
|
|
throw new Error("Mock message emitter is unavailable.");
|
|
}
|
|
return emit({ channelName, content, extraTags, parentEventId });
|
|
},
|
|
{
|
|
channelName,
|
|
content,
|
|
extraTags: options.extraTags,
|
|
parentEventId: options.parentEventId,
|
|
},
|
|
);
|
|
}
|
|
|
|
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").getByAltText("Video attachment bbbb"),
|
|
).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 composerPoster = composer.getByAltText("Video attachment bbbb");
|
|
await expect(composerPoster).toBeVisible();
|
|
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", ``, {
|
|
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%");
|
|
|
|
const restingControlsBox = await controls.boundingBox();
|
|
const restingIconTransform = await centerIcon.evaluate(
|
|
(element) => window.getComputedStyle(element).transform,
|
|
);
|
|
expect(restingControlsBox).not.toBeNull();
|
|
await expect(controls).toHaveCSS("opacity", "0");
|
|
await surface.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
|
|
.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("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 { id: string };
|
|
const videoReply = (await emitMockMessage(
|
|
page,
|
|
"general",
|
|
``,
|
|
{
|
|
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",
|
|
],
|
|
],
|
|
parentEventId: root.id,
|
|
},
|
|
)) as { id: string };
|
|
await emitMockMessage(page, "general", "[00:01] Tighten this transition.", {
|
|
parentEventId: videoReply.id,
|
|
});
|
|
|
|
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",
|
|
);
|
|
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.",
|
|
);
|
|
});
|
|
|
|
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",
|
|
`\n\n`,
|
|
{
|
|
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", ``, {
|
|
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",
|
|
``,
|
|
{
|
|
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 }: { url: string; sha: string; filename: string },
|
|
) {
|
|
return emitMockMessage(page, "general", ``, {
|
|
extraTags: [
|
|
[
|
|
"imeta",
|
|
`url ${url}`,
|
|
"m video/mp4",
|
|
`x ${sha}`,
|
|
"size 987654",
|
|
"dim 160x80",
|
|
"duration 12.5",
|
|
`image ${POSTER_DATA_URL}`,
|
|
`filename ${filename}`,
|
|
],
|
|
],
|
|
});
|
|
}
|
|
|
|
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");
|
|
|
|
// ── Link menu: Open link + Copy link, never image/video attributes ────────
|
|
await emitMockMessage(
|
|
page,
|
|
"general",
|
|
"Docs at https://example.com/handbook",
|
|
);
|
|
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) ─────────────────────────────
|
|
await emitVideoMessage(page, {
|
|
url: MENU_RELAY_VIDEO_URL,
|
|
sha: MENU_RELAY_VIDEO_SHA,
|
|
filename: "relay-clip.mp4",
|
|
});
|
|
const relayPlayer = page.getByTestId("video-player").last();
|
|
await expect(relayPlayer).toBeVisible();
|
|
// Right-click the player surface. `force` skips the actionability guard: the
|
|
// Play-button overlay sits above the video, but the contextmenu event still
|
|
// capture-bubbles to the surface handler that opens the menu.
|
|
await relayPlayer.click({ button: "right", force: true });
|
|
|
|
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 ─
|
|
await emitVideoMessage(page, {
|
|
url: MENU_OFF_RELAY_VIDEO_URL,
|
|
sha: MENU_OFF_RELAY_VIDEO_SHA,
|
|
filename: "external-clip.mp4",
|
|
});
|
|
const offRelayPlayer = page.getByTestId("video-player").last();
|
|
await expect(offRelayPlayer).toBeVisible();
|
|
await offRelayPlayer.click({ button: "right", force: true });
|
|
|
|
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);
|
|
});
|