- SelectTrigger lays its toolbar value out as a row; the saved-view chip passes its glyph and modified dot through the icon/trailing slots (an <svg> is display:block and broke the chip in two). - One Escape in a dialog's open list closed the list AND the dialog: the list's close flushes synchronously and pops its layer before the dialog's window listener runs. takeEscape() is the one close-on-Escape test (topmost AND not yet taken); Modal, the Welcome lightbox and useLayer use it, and keymap.dispatch ignores a taken Escape. - /watch: the Download sits on the toolbar rung, level with the Repeat chip.
226 lines
9.2 KiB
TypeScript
226 lines
9.2 KiB
TypeScript
// @vitest-environment jsdom
|
|
//
|
|
// S3 item 5a: the public watch page must render a native <audio> element for an audio-only asset
|
|
// (mp3) and a <video> for everything else. WebKit (all iOS browsers) refuses to play an audio-only
|
|
// mp3 inside a <video>, which is exactly why the branch exists — this locks it so a refactor can't
|
|
// silently drop the audio player and reintroduce the iPad-broken behavior.
|
|
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
import { render, waitFor, cleanup, fireEvent } from "@testing-library/react";
|
|
const expose = vi.hoisted(() => vi.fn());
|
|
vi.mock("../lib/browserBackgroundPlay", () => ({ exposeVideoToBackgroundPlay: expose }));
|
|
|
|
import WatchPage from "./WatchPage";
|
|
|
|
function mockMeta(meta: Record<string, unknown>) {
|
|
vi.stubGlobal(
|
|
"fetch",
|
|
vi.fn(async () => ({ ok: true, json: async () => meta })),
|
|
);
|
|
}
|
|
|
|
afterEach(() => {
|
|
cleanup();
|
|
vi.unstubAllGlobals();
|
|
localStorage.clear();
|
|
});
|
|
|
|
describe("WatchPage background play", () => {
|
|
it("hands its <video> to the background-play hook, so Brave iOS keeps it playing", async () => {
|
|
window.history.replaceState({}, "", "/watch/tok-bg");
|
|
mockMeta({ needs_password: false, audio: false, file_url: "/f.mp4", title: "A clip" });
|
|
const { container } = render(<WatchPage />);
|
|
await waitFor(() => expect(container.querySelector("video")).not.toBeNull());
|
|
expect(expose).toHaveBeenCalledWith(container.querySelector("video"));
|
|
});
|
|
});
|
|
|
|
describe("WatchPage repeat", () => {
|
|
it("repeats by default, for a video and for an audio-only asset", async () => {
|
|
window.history.replaceState({}, "", "/watch/tok-repeat-video");
|
|
mockMeta({ needs_password: false, audio: false, file_url: "/f.mp4", title: "A clip" });
|
|
const video = render(<WatchPage />);
|
|
await waitFor(() => expect(video.container.querySelector("video")).not.toBeNull());
|
|
expect(video.container.querySelector("video")!.loop).toBe(true);
|
|
cleanup();
|
|
|
|
window.history.replaceState({}, "", "/watch/tok-repeat-audio");
|
|
mockMeta({ needs_password: false, audio: true, file_url: "/f.mp3", title: "A song" });
|
|
const audio = render(<WatchPage />);
|
|
await waitFor(() => expect(audio.container.querySelector("audio")).not.toBeNull());
|
|
expect(audio.container.querySelector("audio")!.loop).toBe(true);
|
|
});
|
|
|
|
it("the toggle turns it off, and the choice is remembered for the next visit", async () => {
|
|
window.history.replaceState({}, "", "/watch/tok-repeat-toggle");
|
|
mockMeta({ needs_password: false, audio: false, file_url: "/f.mp4", title: "A clip" });
|
|
const first = render(<WatchPage />);
|
|
const toggle = await first.findByRole("button", { name: "Repeat" });
|
|
expect(toggle.getAttribute("aria-pressed")).toBe("true");
|
|
fireEvent.click(toggle);
|
|
expect(toggle.getAttribute("aria-pressed")).toBe("false");
|
|
expect(first.container.querySelector("video")!.loop).toBe(false);
|
|
cleanup();
|
|
|
|
const second = render(<WatchPage />);
|
|
await waitFor(() => expect(second.container.querySelector("video")).not.toBeNull());
|
|
expect(second.container.querySelector("video")!.loop).toBe(false);
|
|
});
|
|
});
|
|
|
|
// E28: the Download link sat at the button's 32 beside the Repeat chip's 28 — one row, two heights.
|
|
describe("WatchPage action row", () => {
|
|
it("puts the Download on the toolbar rung, level with the Repeat chip", async () => {
|
|
window.history.replaceState({}, "", "/watch/tok-row");
|
|
mockMeta({
|
|
needs_password: false,
|
|
audio: false,
|
|
allow_download: true,
|
|
file_url: "/f.mp4",
|
|
title: "A clip",
|
|
});
|
|
const view = render(<WatchPage />);
|
|
const repeat = await view.findByRole("button", { name: "Repeat" });
|
|
const download = view.container.querySelector('a[href="/f.mp4"]')!;
|
|
expect(repeat.className).toContain("h-7");
|
|
expect(download.className).toContain("h-7");
|
|
});
|
|
});
|
|
|
|
describe("WatchPage media element (S3 · 5a)", () => {
|
|
it("renders a native <audio> for an audio-only asset", async () => {
|
|
window.history.replaceState({}, "", "/watch/tok-audio");
|
|
mockMeta({ needs_password: false, audio: true, file_url: "/f.mp3", title: "A song" });
|
|
const { container } = render(<WatchPage />);
|
|
await waitFor(() => expect(container.querySelector("audio")).not.toBeNull());
|
|
expect(container.querySelector("video")).toBeNull();
|
|
});
|
|
|
|
it("renders a <video> for a normal video asset", async () => {
|
|
window.history.replaceState({}, "", "/watch/tok-video");
|
|
mockMeta({ needs_password: false, audio: false, file_url: "/f.mp4", title: "A clip" });
|
|
const { container } = render(<WatchPage />);
|
|
await waitFor(() => expect(container.querySelector("video")).not.toBeNull());
|
|
expect(container.querySelector("audio")).toBeNull();
|
|
});
|
|
|
|
it("renders an info card + Download for a generic file, no player (S5d)", async () => {
|
|
window.history.replaceState({}, "", "/watch/tok-file");
|
|
mockMeta({
|
|
needs_password: false,
|
|
media_kind: "file",
|
|
title: "report.pdf",
|
|
container: "pdf",
|
|
size_bytes: 2_400_000,
|
|
allow_download: true,
|
|
file_url: "/f.pdf",
|
|
});
|
|
const { container, findByText } = render(<WatchPage />);
|
|
await findByText("report.pdf");
|
|
expect(container.querySelector("video")).toBeNull();
|
|
expect(container.querySelector("audio")).toBeNull();
|
|
// Type · size chip and a Download link to the file.
|
|
await findByText(/PDF · 2\.3 MB/);
|
|
const dl = container.querySelector('a[href="/f.pdf"]');
|
|
expect(dl).not.toBeNull();
|
|
});
|
|
|
|
it("omits the Download for a view-only file link (S5d)", async () => {
|
|
window.history.replaceState({}, "", "/watch/tok-file-noview");
|
|
mockMeta({
|
|
needs_password: false,
|
|
media_kind: "file",
|
|
title: "secret.zip",
|
|
container: "zip",
|
|
size_bytes: 1000,
|
|
allow_download: false,
|
|
file_url: "/f.zip",
|
|
});
|
|
const { container, findByText } = render(<WatchPage />);
|
|
await findByText("secret.zip");
|
|
expect(container.querySelector('a[href="/f.zip"]')).toBeNull();
|
|
});
|
|
});
|
|
|
|
// Only a 404 means a link is gone. Every other failure used to render the permanent "no longer
|
|
// available" state too: a server restart, a dropped connection, and the unlock's own rate limit told a
|
|
// viewer with a valid link that it had expired or been revoked, with no way to try again.
|
|
describe("WatchPage honest failures", () => {
|
|
const res = (status: number, body: unknown = {}) => ({
|
|
ok: status < 400,
|
|
status,
|
|
json: async () => body,
|
|
});
|
|
const GONE = "This link is no longer available.";
|
|
|
|
it("a server error is a failure with a retry, not a dead link", async () => {
|
|
window.history.replaceState({}, "", "/watch/tok-5xx");
|
|
vi.stubGlobal(
|
|
"fetch",
|
|
vi
|
|
.fn()
|
|
.mockResolvedValueOnce(res(503))
|
|
.mockResolvedValue(
|
|
res(200, { needs_password: false, file_url: "/f.mp4", title: "A clip" }),
|
|
),
|
|
);
|
|
const page = render(<WatchPage />);
|
|
const retry = await page.findByRole("button", { name: "Try again" });
|
|
expect(page.queryByText(GONE)).toBeNull();
|
|
expect(page.getByRole("alert").textContent).toContain("Couldn't load this link.");
|
|
|
|
fireEvent.click(retry);
|
|
await waitFor(() => expect(page.container.querySelector("video")).not.toBeNull());
|
|
});
|
|
|
|
it("a dropped connection is a failure with a retry too", async () => {
|
|
window.history.replaceState({}, "", "/watch/tok-offline");
|
|
vi.stubGlobal("fetch", vi.fn().mockRejectedValue(new TypeError("Failed to fetch")));
|
|
const page = render(<WatchPage />);
|
|
await page.findByRole("button", { name: "Try again" });
|
|
expect(page.queryByText(GONE)).toBeNull();
|
|
});
|
|
|
|
it("a 404 says the link is gone", async () => {
|
|
window.history.replaceState({}, "", "/watch/tok-404");
|
|
vi.stubGlobal("fetch", vi.fn().mockResolvedValue(res(404)));
|
|
const page = render(<WatchPage />);
|
|
await page.findByText(GONE);
|
|
expect(page.queryByRole("button", { name: "Try again" })).toBeNull();
|
|
});
|
|
|
|
async function tryPassword(second: () => Promise<unknown>) {
|
|
vi.stubGlobal(
|
|
"fetch",
|
|
vi
|
|
.fn()
|
|
.mockResolvedValueOnce(res(200, { needs_password: true }))
|
|
.mockImplementationOnce(second),
|
|
);
|
|
const page = render(<WatchPage />);
|
|
fireEvent.change(await page.findByPlaceholderText("Enter password"), {
|
|
target: { value: "guess" },
|
|
});
|
|
fireEvent.click(page.getByRole("button", { name: "Watch" }));
|
|
return page;
|
|
}
|
|
|
|
it("too many password tries says so, and keeps the form", async () => {
|
|
window.history.replaceState({}, "", "/watch/tok-429");
|
|
const page = await tryPassword(async () => res(429));
|
|
await page.findByText("Too many attempts. Try again in a few minutes.");
|
|
const alert = page.getByRole("alert");
|
|
expect(alert.textContent).toBe("Too many attempts. Try again in a few minutes.");
|
|
expect(page.getByPlaceholderText("Enter password").getAttribute("aria-describedby")).toBe(
|
|
alert.id,
|
|
);
|
|
});
|
|
|
|
it("an unlock that gets no answer says so instead of doing nothing", async () => {
|
|
window.history.replaceState({}, "", "/watch/tok-unlock-offline");
|
|
const page = await tryPassword(() => Promise.reject(new TypeError("Failed to fetch")));
|
|
expect((await page.findByRole("alert")).textContent).toBe(
|
|
"Couldn't check the password. Try again.",
|
|
);
|
|
});
|
|
});
|