The panels judge a GIF's length in whole milliseconds between the ends as sent, the server's own rule, and a refused length reads rounded up. A shared picture says it failed only once its retry failed too. Join's reason shows on a key's focus; the editor's failure toasts name GIFs. The edit route's cap is test-held, and DESIGN.md records the GIF UI.
559 lines
23 KiB
TypeScript
559 lines
23 KiB
TypeScript
// @vitest-environment jsdom
|
||
//
|
||
// 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 share paths", () => {
|
||
it.each(["/watch/tok-p", "/file/tok-p", "/folder/tok-p", "/file/tok-p/extra"])(
|
||
"reads the token off %s",
|
||
async (path) => {
|
||
window.history.replaceState({}, "", path);
|
||
mockMeta({ needs_password: false, audio: false, file_url: "/f.mp4", title: "A clip" });
|
||
render(<WatchPage />);
|
||
await waitFor(() =>
|
||
expect(fetch).toHaveBeenCalledWith("/api/public/watch/tok-p", expect.anything()),
|
||
);
|
||
},
|
||
);
|
||
});
|
||
|
||
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);
|
||
});
|
||
});
|
||
|
||
// 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");
|
||
});
|
||
|
||
// DESIGN B6: the page the stranger sees wears the app's surfaces — the scheme's ambient
|
||
// backdrop (no page fill over the body's), the title and actions on a glass panel, a glass Download.
|
||
it("sits on the app's surfaces: backdrop, a glass panel, a glass Download", async () => {
|
||
window.history.replaceState({}, "", "/watch/tok-b6");
|
||
mockMeta({
|
||
needs_password: false,
|
||
audio: false,
|
||
allow_download: true,
|
||
file_url: "/f.mp4",
|
||
title: "A clip",
|
||
});
|
||
const view = render(<WatchPage />);
|
||
const title = await view.findByRole("heading", { name: "A clip" });
|
||
expect((view.container.firstElementChild as HTMLElement).className).not.toMatch(
|
||
/(^|\s)bg-bg(\s|$)/,
|
||
);
|
||
expect(title.closest(".glass")).toBeTruthy();
|
||
const download = view.container.querySelector('a[href="/f.mp4"]')!;
|
||
expect(download.className).toContain("glass-card");
|
||
});
|
||
});
|
||
|
||
describe("WatchPage media element", () => {
|
||
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", 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();
|
||
// The page's one action is the filled primary, not a muted outline.
|
||
expect(dl!.className).toContain("state-filled");
|
||
});
|
||
|
||
it("shows a shared picture as itself, no player, with its kind, size and the chat tip", async () => {
|
||
window.history.replaceState({}, "", "/watch/tok-gif");
|
||
mockMeta({
|
||
needs_password: false,
|
||
media_kind: "file",
|
||
preview: "image",
|
||
title: "Evening clip",
|
||
container: "gif",
|
||
width: 480,
|
||
height: 270,
|
||
size_bytes: 6_500_000,
|
||
allow_download: true,
|
||
file_url: "/f.gif",
|
||
});
|
||
const page = render(<WatchPage />);
|
||
const img = await page.findByRole("img", { name: "Evening clip" });
|
||
expect(img.getAttribute("src")).toBe("/f.gif");
|
||
expect(page.container.querySelector("video")).toBeNull();
|
||
expect(page.getByText(/GIF · 480×270 · 6\.2 MB/)).toBeTruthy();
|
||
expect(page.getByRole("link", { name: "Download" }).getAttribute("href")).toBe("/f.gif");
|
||
expect(page.getByText(/drag the downloaded file in/)).toBeTruthy();
|
||
});
|
||
|
||
it("a picture that does not load says so, rather than looking like a file without one", async () => {
|
||
window.history.replaceState({}, "", "/watch/tok-gif-gone");
|
||
mockMeta({
|
||
needs_password: false,
|
||
media_kind: "file",
|
||
preview: "image",
|
||
title: "Gone",
|
||
container: "gif",
|
||
allow_download: true,
|
||
file_url: "/gone.gif",
|
||
});
|
||
const page = render(<WatchPage />);
|
||
fireEvent.error(await page.findByRole("img", { name: "Gone" }));
|
||
// The first miss may be a hiccup: nothing is said while the retry waits…
|
||
expect(page.queryByRole("status")).toBeNull();
|
||
// …and when the retry fails too, the page says so.
|
||
const retry = await page.findByRole("img", { name: "Gone" }, { timeout: 3000 });
|
||
expect(retry.getAttribute("src")).toBe("/gone.gif?r=1");
|
||
fireEvent.error(retry);
|
||
expect((await page.findByRole("status")).textContent).toBe("Couldn't load the picture.");
|
||
});
|
||
|
||
it("a view-only picture link shows the picture, without Download or the chat tip", async () => {
|
||
window.history.replaceState({}, "", "/watch/tok-gif-view");
|
||
mockMeta({
|
||
needs_password: false,
|
||
media_kind: "file",
|
||
preview: "image",
|
||
title: "Still",
|
||
container: "gif",
|
||
allow_download: false,
|
||
file_url: "/f.gif",
|
||
});
|
||
const page = render(<WatchPage />);
|
||
await page.findByRole("img", { name: "Still" });
|
||
expect(page.queryByRole("link", { name: "Download" })).toBeNull();
|
||
expect(page.queryByText(/drag the downloaded file in/)).toBeNull();
|
||
});
|
||
|
||
it("omits the Download for a view-only file link", 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();
|
||
});
|
||
|
||
it("a 404 whose video is being made again says to come back, with a retry", async () => {
|
||
window.history.replaceState({}, "", "/watch/tok-preparing");
|
||
vi.stubGlobal(
|
||
"fetch",
|
||
vi
|
||
.fn()
|
||
.mockResolvedValueOnce(res(404, { detail: { code: 404, reason: "video_preparing" } }))
|
||
.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.getByRole("alert").textContent).toContain("This video is being prepared.");
|
||
expect(page.queryByText(GONE)).toBeNull();
|
||
|
||
fireEvent.click(retry);
|
||
await waitFor(() => expect(page.container.querySelector("video")).not.toBeNull());
|
||
});
|
||
|
||
it("a Retry that is answered 'still preparing' says so and keeps the focus on Retry", async () => {
|
||
// The answer replaces the button the viewer was on: announced (an alert, inserted filled), and
|
||
// the new Retry takes the focus instead of leaving it on <body>.
|
||
window.history.replaceState({}, "", "/watch/tok-preparing");
|
||
const preparing = () => res(404, { detail: { code: 404, reason: "video_preparing" } });
|
||
vi.stubGlobal(
|
||
"fetch",
|
||
vi.fn().mockImplementation(() => Promise.resolve(preparing())),
|
||
);
|
||
const page = render(<WatchPage />);
|
||
const first = await page.findByRole("button", { name: "Try again" });
|
||
expect(document.activeElement).not.toBe(first); // the first load moves no focus
|
||
|
||
fireEvent.click(first);
|
||
await waitFor(() =>
|
||
expect(document.activeElement).toBe(page.getByRole("button", { name: "Try again" })),
|
||
);
|
||
expect(page.getByRole("alert").textContent).toContain("This video is being prepared.");
|
||
});
|
||
|
||
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.each([
|
||
[{ kind: "file", video: true }, "This video is password-protected", "Watch"],
|
||
[{ kind: "file", video: false }, "This file is password-protected", "Open"],
|
||
[{ kind: "folder", video: false }, "This folder is password-protected", "Open"],
|
||
[{}, "This video is password-protected", "Watch"],
|
||
])("a password link that opens %o asks in its words", async (kind, title, button) => {
|
||
window.history.replaceState({}, "", "/file/tok-lock");
|
||
mockMeta({ needs_password: true, ...kind });
|
||
const page = render(<WatchPage />);
|
||
await page.findByText(title);
|
||
expect(page.getByRole("button", { name: button })).toBeTruthy();
|
||
});
|
||
|
||
it("a repeated Enter sends one try while it is on its way", async () => {
|
||
window.history.replaceState({}, "", "/watch/tok-enter");
|
||
const fetchMock = vi.fn().mockResolvedValueOnce(res(200, { needs_password: true }));
|
||
fetchMock.mockImplementation(() => new Promise(() => {}));
|
||
vi.stubGlobal("fetch", fetchMock);
|
||
const page = render(<WatchPage />);
|
||
const field = await page.findByPlaceholderText("Enter password");
|
||
fireEvent.change(field, { target: { value: "guess" } });
|
||
fireEvent.keyDown(field, { key: "Enter" });
|
||
await waitFor(() => expect(fetchMock).toHaveBeenCalledTimes(2));
|
||
fireEvent.keyDown(field, { key: "Enter" });
|
||
fireEvent.keyDown(field, { key: "Enter" });
|
||
await new Promise((r) => setTimeout(r, 20));
|
||
expect(fetchMock).toHaveBeenCalledTimes(2);
|
||
});
|
||
|
||
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.",
|
||
);
|
||
});
|
||
});
|
||
|
||
// A shared link opens where THIS browser stopped in it (the viewer has no account), and keeps the
|
||
// point as it plays — per link, so another link starts at its own place.
|
||
describe("WatchPage resume", () => {
|
||
function loaded(video: HTMLVideoElement, duration = 600) {
|
||
let t = 0;
|
||
Object.defineProperties(video, {
|
||
currentTime: { get: () => t, set: (v: number) => void (t = v), configurable: true },
|
||
duration: { get: () => duration, configurable: true },
|
||
readyState: { get: () => 1, configurable: true },
|
||
});
|
||
video.dispatchEvent(new Event("loadedmetadata"));
|
||
return video;
|
||
}
|
||
|
||
it("keeps where it stopped, and opens there on the next visit", async () => {
|
||
window.history.replaceState({}, "", "/watch/tok-resume");
|
||
mockMeta({ needs_password: false, audio: false, file_url: "/f.mp4", title: "A clip" });
|
||
const first = render(<WatchPage />);
|
||
await waitFor(() => expect(first.container.querySelector("video")).not.toBeNull());
|
||
const video = loaded(first.container.querySelector("video")!);
|
||
expect(video.currentTime).toBe(0);
|
||
video.dispatchEvent(new Event("playing"));
|
||
video.currentTime = 250;
|
||
video.dispatchEvent(new Event("pause"));
|
||
cleanup();
|
||
|
||
const second = render(<WatchPage />);
|
||
await waitFor(() => expect(second.container.querySelector("video")).not.toBeNull());
|
||
expect(loaded(second.container.querySelector("video")!).currentTime).toBe(250);
|
||
cleanup();
|
||
|
||
window.history.replaceState({}, "", "/watch/tok-other");
|
||
const other = render(<WatchPage />);
|
||
await waitFor(() => expect(other.container.querySelector("video")).not.toBeNull());
|
||
expect(loaded(other.container.querySelector("video")!).currentTime).toBe(0);
|
||
});
|
||
});
|
||
|
||
// A folder link opens the folder's list: a video or a song plays in the same player a file link has,
|
||
// above the list; a document only downloads; Download follows the link's own setting.
|
||
describe("WatchPage folder", () => {
|
||
const file = (id: number, extra: Record<string, unknown> = {}) => ({
|
||
id,
|
||
title: `Clip ${id}`,
|
||
audio: false,
|
||
media_kind: "video",
|
||
size_bytes: 1000,
|
||
duration_s: 71,
|
||
file_url: `/api/public/watch/tok-folder/files/${id}/file`,
|
||
poster_url: null,
|
||
...extra,
|
||
});
|
||
const folder = (extra: Record<string, unknown> = {}) => ({
|
||
needs_password: false,
|
||
kind: "folder",
|
||
name: "Friends",
|
||
allow_download: true,
|
||
files: [file(1), file(2), file(3, { title: "Plan.pdf", media_kind: "file", duration_s: null })],
|
||
...extra,
|
||
});
|
||
const scrolled = vi.fn();
|
||
Element.prototype.scrollIntoView = scrolled;
|
||
|
||
it("lists the folder's files and plays the one picked above the list", async () => {
|
||
window.history.replaceState({}, "", "/watch/tok-folder");
|
||
mockMeta(folder());
|
||
const page = render(<WatchPage />);
|
||
expect((await page.findByRole("heading", { level: 1 })).textContent).toBe("Friends");
|
||
expect(page.getByText(/^3 files/)).toBeTruthy();
|
||
expect(page.container.querySelector("video")).toBeNull();
|
||
|
||
fireEvent.click(page.getByRole("button", { name: "Play: Clip 2" }));
|
||
const video = page.container.querySelector("video")!;
|
||
expect(video.getAttribute("src")).toBe("/api/public/watch/tok-folder/files/2/file");
|
||
expect(video.autoplay).toBe(true);
|
||
expect(page.getByRole("button", { name: "Play: Clip 2" }).getAttribute("aria-current")).toBe(
|
||
"true",
|
||
);
|
||
// The file's title sits under the folder's, one level down.
|
||
expect(page.getByRole("heading", { level: 2 }).textContent).toBe("Clip 2");
|
||
expect(scrolled).toHaveBeenCalled();
|
||
|
||
fireEvent.click(page.getByRole("button", { name: "Play: Clip 1" }));
|
||
expect(page.container.querySelector("video")!.getAttribute("src")).toBe(
|
||
"/api/public/watch/tok-folder/files/1/file",
|
||
);
|
||
});
|
||
|
||
it("opens a picture above the list, shown rather than played", async () => {
|
||
window.history.replaceState({}, "", "/watch/tok-folder");
|
||
mockMeta(
|
||
folder({
|
||
files: [
|
||
file(1),
|
||
file(4, {
|
||
title: "Wave.gif",
|
||
media_kind: "file",
|
||
preview: "image",
|
||
container: "gif",
|
||
duration_s: null,
|
||
}),
|
||
],
|
||
}),
|
||
);
|
||
const page = render(<WatchPage />);
|
||
fireEvent.click(await page.findByRole("button", { name: "Open: Wave.gif" }));
|
||
const img = await page.findByRole("img", { name: "Wave.gif" });
|
||
expect(img.getAttribute("src")).toBe("/api/public/watch/tok-folder/files/4/file");
|
||
expect(page.container.querySelector("video")).toBeNull();
|
||
expect(page.getByRole("heading", { level: 2 }).textContent).toBe("Wave.gif");
|
||
});
|
||
|
||
it("offers a document for download only, and Download only where the link allows it", async () => {
|
||
window.history.replaceState({}, "", "/watch/tok-folder");
|
||
mockMeta(folder());
|
||
const page = render(<WatchPage />);
|
||
await page.findByText("Plan.pdf");
|
||
expect(page.queryByRole("button", { name: "Play: Plan.pdf" })).toBeNull();
|
||
const pdf = page.getByRole("link", { name: "Download: Plan.pdf" });
|
||
expect(pdf.getAttribute("href")).toBe("/api/public/watch/tok-folder/files/3/file");
|
||
cleanup();
|
||
|
||
mockMeta(folder({ allow_download: false }));
|
||
const streamOnly = render(<WatchPage />);
|
||
await streamOnly.findByText("Plan.pdf");
|
||
expect(streamOnly.queryAllByRole("link", { name: /^Download/ })).toEqual([]);
|
||
});
|
||
|
||
it("each file keeps its own place in a folder", async () => {
|
||
window.history.replaceState({}, "", "/watch/tok-folder-resume");
|
||
mockMeta(folder());
|
||
const page = render(<WatchPage />);
|
||
fireEvent.click(await page.findByRole("button", { name: "Play: Clip 1" }));
|
||
const video = page.container.querySelector("video")!;
|
||
let t = 0;
|
||
Object.defineProperties(video, {
|
||
currentTime: { get: () => t, set: (v: number) => void (t = v), configurable: true },
|
||
duration: { get: () => 600, configurable: true },
|
||
readyState: { get: () => 1, configurable: true },
|
||
});
|
||
video.dispatchEvent(new Event("loadedmetadata"));
|
||
video.dispatchEvent(new Event("playing"));
|
||
video.currentTime = 250;
|
||
video.dispatchEvent(new Event("pause"));
|
||
const points = JSON.stringify(localStorage);
|
||
expect(points).toContain("tok-folder-resume/1");
|
||
expect(points).not.toContain('"tok-folder-resume"');
|
||
});
|
||
|
||
it("says when the folder is empty", async () => {
|
||
window.history.replaceState({}, "", "/watch/tok-folder");
|
||
mockMeta(folder({ files: [] }));
|
||
const page = render(<WatchPage />);
|
||
expect(await page.findByText("This folder is empty.")).toBeTruthy();
|
||
expect(page.getByText("0 files")).toBeTruthy();
|
||
});
|
||
|
||
it("a password folder link asks for the folder's password", async () => {
|
||
window.history.replaceState({}, "", "/watch/tok-folder-locked");
|
||
mockMeta({ needs_password: true, kind: "folder" });
|
||
const page = render(<WatchPage />);
|
||
expect(await page.findByText("This folder is password-protected")).toBeTruthy();
|
||
});
|
||
});
|