// @vitest-environment jsdom // // The notification center: a row keeps its link and its unread mark in sight, its dismiss // shows on hover (on touch it is in the "…"), and everything it can do is its ONE menu — opened by // the row's "…" or a right-click; a hidden / watched notice's Find in feed and Undo live only there. // The band keeps Mark all read; Clear everything moved into the header's "…" and asks first. // i18n is key-passthrough, so labels read as keys. import { afterEach, describe, expect, it, onTestFinished, vi } from "vitest"; import { act, cleanup, fireEvent, render, screen, waitFor, within } from "@testing-library/react"; import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import { api, type AppNotification } from "../../lib/api"; import { msg, type Notification as ClientNotif, raw } from "../../lib/notifications"; const h = vi.hoisted(() => ({ server: [] as unknown[], client: [] as unknown[], setPage: vi.fn(), openFile: vi.fn(), focusSettingsTab: vi.fn(), setFilters: vi.fn(), remove: vi.fn(), clearAll: vi.fn(), confirm: vi.fn(async () => true), })); vi.mock("react-i18next", async (importOriginal) => ({ ...(await importOriginal()), useTranslation: () => ({ i18n: { language: "en" }, t: (key: string) => key }), })); vi.mock("../../components/ui/confirmContext", () => ({ useConfirm: () => h.confirm })); vi.mock("../../lib/useLiveQuery", () => ({ useLiveQuery: () => ({ data: { items: h.server, total: h.server.length }, isError: false, isLoading: false, refetch: vi.fn(), }), })); vi.mock("../../lib/notifications", async (importOriginal) => ({ ...(await importOriginal()), subscribe: () => () => {}, getNotifications: () => h.client, getUnreadCount: () => 0, markAllRead: () => {}, remove: (id: number) => h.remove(id), clearAll: () => h.clearAll(), })); vi.mock("../../lib/settingsTab", () => ({ focusSettingsTab: h.focusSettingsTab })); vi.mock("../../features/navigation/navigationContext", () => ({ useNavigationActions: () => ({ setPage: h.setPage }), })); vi.mock("../files/filesContext", () => ({ useFilesActions: () => ({ openFile: h.openFile }) })); vi.mock("../channels/channelsContext", () => ({ useChannelsActions: () => ({ focusChannel: vi.fn() }), })); vi.mock("../feed/feedFiltersContext", () => ({ // Every narrowing a feed can carry (the year, the custom range, the length, the // content types and the scope survived "Find in feed" and could still hide the video). useFeedFilters: () => ({ tags: [54], tagModes: { language: "and", topic: "or", other: "or" }, untagged: true, q: "rust", maxAgeDays: 30, dateFrom: "2024-01-01", dateTo: "2024-02-01", year: 2024, minDuration: 60, maxDuration: 600, includeNormal: true, includeShorts: false, includeLive: false, scope: "my", librarySource: "organic", sort: "oldest", show: "unwatched", channelIds: ["x"], }), useFeedFiltersActions: () => ({ setFilters: h.setFilters }), })); vi.mock("../../components/ui/PageShell", async (importOriginal) => ({ ...(await importOriginal()), PageToolbar: ({ children }: { children: React.ReactNode }) => <>{children}, })); import NotificationsPanel from "./NotificationsPanel"; import PageShell from "../../components/ui/PageShell"; afterEach(() => { cleanup(); vi.restoreAllMocks(); for (const f of [ h.setPage, h.openFile, h.focusSettingsTab, h.setFilters, h.remove, h.clearAll, h.confirm, ]) f.mockClear(); }); const server = (id: number, extra: Partial = {}) => ({ id, type: "download_done", title: `Server ${id}`, body: `Body ${id}`, data: { title: `Ready ${id}` }, read: false, created_at: new Date().toISOString(), ...extra, }) as unknown as AppNotification; const client = (id: number, extra: Partial = {}) => ({ id, level: "success", message: raw(`Activity ${id}`), requiresInteraction: false, ts: Date.now(), read: true, dismissed: true, transient: false, repeat: 1, ...extra, }) as unknown as ClientNotif; function mount() { const client = new QueryClient(); const tree = () => ( ); const { rerender } = render(tree()); return { rerender: () => rerender(tree()) }; } const rowOf = (text: string) => screen.getByText(text).closest("[data-nav-index]")!; const moreOf = (text: string) => rowOf(text).querySelector('button[aria-label="inbox.rowMenu"]')!; const menuRows = () => screen.getAllByRole("menuitem", { hidden: true }).map((b) => b.textContent ?? ""); async function openMenu(open: () => void) { act(open); await waitFor(() => expect(screen.getAllByRole("menuitem", { hidden: true }).length).toBeGreaterThan(0), ); } const choose = (label: string) => fireEvent.click( screen.getAllByRole("menuitem", { hidden: true }).find((b) => b.textContent === label)!, ); describe("NotificationsPanel · a server row", () => { it("keeps its link and mark read in sight; dismiss shows on hover, then its '…'", () => { h.server = [server(1)]; h.client = []; mount(); const row = rowOf("Ready 1"); expect(row.classList.contains("group")).toBe(true); const buttons = [...row.querySelectorAll("button")]; expect(buttons.map((b) => b.getAttribute("aria-label") ?? b.textContent)).toEqual([ "inbox.openFile", "inbox.markRead", "inbox.dismiss", "inbox.rowMenu", ]); // Hover-only on a mouse (and gone on touch, where the menu holds it). expect(buttons[2]!.className).toContain("reveal-hover"); expect(moreOf("Ready 1").classList.contains("reveal")).toBe(true); }); it("a finished download opens on its file, in its folder; an older notice on Files", async () => { h.server = [ server(1, { data: { title: "Ready 1", kind: "download", job_id: 41, folder_id: 3 } }), server(2), ]; h.client = []; mount(); fireEvent.click(within(rowOf("Ready 1")).getByText("inbox.openFile")); expect(h.openFile).toHaveBeenCalledWith(41, 3); fireEvent.click(within(rowOf("Ready 2")).getByText("inbox.openFile")); expect(h.setPage).toHaveBeenCalledWith("files"); expect(h.openFile).toHaveBeenCalledTimes(1); }); it("a token about to run out is named in the reader's words and opens Settings on its tab", async () => { h.server = [ server(1, { type: "api_token_expiring", title: "API token Phone expires soon", data: { token_id: 4, name: "Phone", expires_at: "2026-10-21T10:00:00+00:00" }, }), ]; h.client = []; mount(); const row = rowOf("inbox.apiTokenExpiring.title"); expect(row.textContent).toContain("inbox.apiTokenExpiring.body"); expect(row.textContent).not.toContain("API token Phone expires soon"); fireEvent.click(within(row).getByText("inbox.openTokens")); expect(h.focusSettingsTab).toHaveBeenCalledWith("tokens"); expect(h.setPage).toHaveBeenCalledWith("settings"); await openMenu(() => fireEvent.click(moreOf("inbox.apiTokenExpiring.title"))); expect(menuRows()).toEqual(["inbox.openTokens", "inbox.markRead", "inbox.dismiss"]); }); it("a Takeout import's run opens the user's own import, any other run the Scheduler", async () => { const run = (id: number, job: string) => server(id, { type: "scheduler", data: { job_id: job, status: "ok", summary: `${job} done` }, }); h.server = [run(1, "takeout_import"), run(2, "channel_backfill")]; h.client = []; mount(); fireEvent.click(screen.getByText("inbox.openTakeout")); expect(h.focusSettingsTab).toHaveBeenCalledWith("account"); expect(h.setPage).toHaveBeenLastCalledWith("settings"); fireEvent.click(screen.getByText("inbox.openScheduler")); expect(h.setPage).toHaveBeenLastCalledWith("scheduler"); const takeoutRow = screen .getByText("inbox.openTakeout") .closest("[data-nav-index]")!; await openMenu(() => fireEvent.click(takeoutRow.querySelector('button[aria-label="inbox.rowMenu"]')!), ); expect(menuRows()).toEqual(["inbox.openTakeout", "inbox.markRead", "inbox.dismiss"]); h.focusSettingsTab.mockClear(); h.setPage.mockClear(); choose("inbox.openTakeout"); await waitFor(() => expect(h.focusSettingsTab).toHaveBeenCalledWith("account")); expect(h.setPage).toHaveBeenCalledWith("settings"); }); it("holds the link, mark read and dismiss", async () => { h.server = [server(1)]; h.client = []; const dismiss = vi.spyOn(api, "dismissNotification").mockResolvedValue({} as never); mount(); await openMenu(() => fireEvent.click(moreOf("Ready 1"))); expect(menuRows()).toEqual(["inbox.openFile", "inbox.markRead", "inbox.dismiss"]); choose("inbox.dismiss"); await waitFor(() => expect(dismiss).toHaveBeenCalledWith(1, expect.anything())); }); it("closes on a second press of the same '…', on a server and an activity row", async () => { h.server = [server(1)]; h.client = [client(8)]; mount(); for (const name of ["Ready 1", "Activity 8"]) { await openMenu(() => fireEvent.click(moreOf(name))); act(() => { fireEvent.pointerDown(moreOf(name), { pointerType: "mouse" }); fireEvent.mouseDown(moreOf(name)); fireEvent.click(moreOf(name), { detail: 1 }); }); await waitFor(() => expect(screen.queryAllByRole("menuitem", { hidden: true })).toHaveLength(0), ); } }); it("still closes when a poll replaced the notification under the open menu", async () => { h.server = [server(1)]; h.client = []; const { rerender } = mount(); await openMenu(() => fireEvent.click(moreOf("Ready 1"))); h.server = [server(1)]; // the next poll: a NEW object for the same notification rerender(); act(() => { fireEvent.pointerDown(moreOf("Ready 1"), { pointerType: "mouse" }); fireEvent.mouseDown(moreOf("Ready 1")); fireEvent.click(moreOf("Ready 1"), { detail: 1 }); }); await waitFor(() => expect(screen.queryAllByRole("menuitem", { hidden: true })).toHaveLength(0), ); }); it("leaves mark read out of a read row's menu", async () => { h.server = [server(2, { read: true, type: "maintenance", data: {} } as never)]; h.client = []; mount(); await openMenu(() => fireEvent.contextMenu(rowOf("Server 2"))); expect(menuRows()).toEqual(["inbox.dismiss"]); }); }); describe("NotificationsPanel · an activity row", () => { it("puts a hidden notice's Find in feed and Undo into its menu only", async () => { h.server = []; // Its toast is still up (not dismissed), so it carries the toast's Undo too: the row shows // neither that nor Find in feed — only its dismiss and its "…". h.client = [ client(7, { dismissed: false, action: { label: msg("feed.undo"), onClick: () => {} }, meta: { kind: "video-hidden", videoId: "v", channelId: "c" }, } as never), ]; const setState = vi.spyOn(api, "setState").mockResolvedValue({} as never); mount(); expect( [...rowOf("Activity 7").querySelectorAll("button")].map((b) => b.getAttribute("aria-label")), ).toEqual(["inbox.dismiss", "inbox.rowMenu"]); await openMenu(() => fireEvent.contextMenu(rowOf("Activity 7"))); expect(menuRows()).toEqual([ "notifications.findInFeed", "notifications.unhide", "inbox.dismiss", ]); choose("notifications.unhide"); await waitFor(() => expect(setState).toHaveBeenCalledWith("v", "new")); }); it("a live notice with two actions shows both, in the row and in its menu", async () => { h.server = []; const copy = vi.fn(); const open = vi.fn(); h.client = [ client(5, { dismissed: false, action: { label: msg("tray.done.copyLink"), onClick: copy }, secondaryAction: { label: msg("tray.done.open"), onClick: open }, }), ]; mount(); fireEvent.click(within(rowOf("Activity 5")).getByText("tray.done.open")); expect(open).toHaveBeenCalled(); await openMenu(() => fireEvent.contextMenu(rowOf("Activity 5"))); expect(menuRows()).toEqual(["tray.done.copyLink", "tray.done.open", "inbox.dismiss"]); choose("tray.done.copyLink"); await waitFor(() => expect(copy).toHaveBeenCalled()); }); it("finds a watched notice's video with nothing narrowing the feed past it", async () => { h.server = []; h.client = [ client(9, { meta: { kind: "video-watched", videoId: "v", channelId: "c" } } as never), ]; mount(); await openMenu(() => fireEvent.contextMenu(rowOf("Activity 9"))); choose("notifications.findInFeed"); await waitFor(() => expect(h.setFilters).toHaveBeenCalled()); // Nothing but the state and the channel narrows; the reader's sort and tag modes stay theirs. expect(h.setFilters).toHaveBeenCalledWith({ tags: [], tagModes: { language: "and", topic: "or", other: "or" }, untagged: false, q: "", includeNormal: true, includeShorts: true, includeLive: true, scope: "all", librarySource: "all", sort: "oldest", show: "watched", channelIds: ["c"], }); expect(h.setPage).toHaveBeenCalledWith("feed"); }); it("dismisses a plain activity row from its menu", async () => { h.server = []; h.client = [client(8)]; mount(); await openMenu(() => fireEvent.click(moreOf("Activity 8"))); expect(menuRows()).toEqual(["inbox.dismiss"]); choose("inbox.dismiss"); await waitFor(() => expect(h.remove).toHaveBeenCalledWith(8)); }); }); describe("NotificationsPanel · the header's '…'", () => { it("keeps Mark all read in the band and asks before Clear everything", async () => { h.server = [server(1)]; h.client = [client(8)]; const clear = vi.spyOn(api, "clearNotifications").mockResolvedValue({} as never); mount(); expect(screen.getByRole("button", { name: "inbox.markAllRead" })).toBeTruthy(); expect(screen.queryByRole("button", { name: "inbox.clearAll" })).toBeNull(); await openMenu(() => fireEvent.click(screen.getByRole("button", { name: "inbox.menu" }))); expect(menuRows()).toEqual(["inbox.markAllRead", "inbox.clearAll"]); h.confirm.mockResolvedValueOnce(false); choose("inbox.clearAll"); await waitFor(() => expect(h.confirm).toHaveBeenCalledTimes(1)); expect(h.confirm).toHaveBeenCalledWith(expect.objectContaining({ danger: true })); expect(clear).not.toHaveBeenCalled(); expect(h.clearAll).not.toHaveBeenCalled(); await openMenu(() => fireEvent.click(screen.getByRole("button", { name: "inbox.menu" }))); choose("inbox.clearAll"); await waitFor(() => expect(clear).toHaveBeenCalled()); expect(h.clearAll).toHaveBeenCalled(); }); }); describe("NotificationsPanel · the page's background menu", () => { // DESIGN "Background menu": it mirrors the band, and a right-click on blank page must never be one // click from a destructive row — Clear everything stays behind the "…" and its confirm. it("holds Mark all read, never Clear everything", async () => { // PageShell measures its header and the scroller its edges; jsdom has no ResizeObserver. vi.stubGlobal( "ResizeObserver", class { observe() {} unobserve() {} disconnect() {} }, ); onTestFinished(() => { vi.unstubAllGlobals(); }); h.server = [server(1)]; h.client = [client(8)]; const client_ = new QueryClient(); const { container } = render( , ); const blank = container.querySelector("main") ?? container.firstElementChild!; await openMenu(() => fireEvent.contextMenu(blank, { clientX: 5, clientY: 5 })); expect(screen.getByRole("menu", { hidden: true }).getAttribute("aria-label")).toBe( "common.pageMenu", ); expect(menuRows()).toEqual(["inbox.markAllRead"]); }); }); describe("a download-lifecycle notice", () => { it("is named from its data in the reader's language, never the server's English title", () => { h.server = [ server(1, { type: "download_shared", title: "Cats was shared with you", body: null, data: { kind: "download_shared", from: "a@b.c", title: "Cats" }, } as never), server(2, { type: "download_expiring", title: "Dogs", data: { kind: "download_expiring", title: "Dogs" }, } as never), server(3, { type: "download_expired", title: "download", // an untitled video: the server's English fallback word data: { kind: "download_expired", title: null }, } as never), server(4, { type: "download_evicted", title: "Birds", data: { kind: "download_evicted", title: "Birds" }, } as never), ]; mount(); for (const key of ["downloadShared", "downloadExpiring", "downloadExpired", "downloadEvicted"]) expect(screen.getByText(`inbox.${key}.title`)).toBeTruthy(); for (const title of ["Cats", "Dogs", "Birds"]) expect(screen.getByText(title)).toBeTruthy(); expect(screen.queryByText("Cats was shared with you")).toBeNull(); expect(screen.queryByText("download")).toBeNull(); }); }); describe("NotificationsPanel · a finished job's title", () => { it("names what finished: a download, a clip, an upload", () => { h.server = [ server(1), server(2, { data: { title: "Ready 2", kind: "edit" } }), server(3, { data: { title: "Ready 3", kind: "upload" } }), ]; h.client = []; mount(); const titleOf = (text: string) => rowOf(text).textContent ?? ""; expect(titleOf("Ready 1")).toContain("inbox.downloadDone.title"); expect(titleOf("Ready 2")).toContain("inbox.editDone.title"); expect(titleOf("Ready 3")).toContain("inbox.uploadDone.title"); }); });