/* This Source Code Form is subject to the terms of the Mozilla Public * License, v. 2.0. If a copy of the MPL was not distributed with this file, * You can obtain one at http://mozilla.org/MPL/2.0/. */ import { fireEvent, render } from "@testing-library/react"; import { INITIAL_STATE } from "common/Reducers.sys.mjs"; import { actionTypes as at } from "common/Actions.mjs"; import { WrapWithProvider } from "test/jest/test-utils"; import { WatchLiveModal } from "content-src/components/Widgets/SportsWidget/WatchLiveModal"; const watchLiveData = { your_region: [ { product_name: "Tubi", entitlement: "Free", url: "https://tubitv.com/", }, { product_name: "FIFA+", // Not in the entitlement map — should fall back to the literal string. entitlement: "FIFA+", url: "https://fifa.plus/", }, ], other_regions: [ { country_code: "CAN", streams: [ { product_name: "RDS", entitlement: "Free and Paid", url: "https://rds.ca/", }, ], }, ], }; function makeState({ loaded = false, data = null } = {}) { return { ...INITIAL_STATE, SportsWidget: { ...INITIAL_STATE.SportsWidget, watchLive: { loaded, data }, }, }; } function renderModal({ state, onClose, dispatch, widgetSize = "medium" }) { return render( ); } function findUserEvents(dispatch, userAction) { return dispatch.mock.calls .map(([action]) => action) .filter( a => a.type === at.WIDGETS_USER_EVENT && a.data.user_action === userAction ); } describe("", () => { beforeAll(() => { // jsdom doesn't implement these — the modal calls them imperatively. HTMLDialogElement.prototype.showModal = jest.fn(); HTMLDialogElement.prototype.close = jest.fn(); // Scrolled when Other regions expand; jsdom has no native scrollIntoView. Element.prototype.scrollIntoView = jest.fn(); }); beforeEach(() => { jest.clearAllMocks(); }); it("renders the loading placeholder while loaded is false", () => { const { container } = renderModal({ state: makeState() }); expect( container.querySelector(".watch-live-modal-loading") ).toBeInTheDocument(); expect( container.querySelector(".watch-live-modal-list") ).not.toBeInTheDocument(); }); it("renders the your_region stream list when loaded with data", () => { const { container } = renderModal({ state: makeState({ loaded: true, data: watchLiveData }), }); expect( container.querySelector(".watch-live-modal-loading") ).not.toBeInTheDocument(); // 2 your_region rows; other_regions is collapsed by default. expect(container.querySelectorAll(".watch-live-modal-row")).toHaveLength(2); }); it("dispatches WATCH_LIVE_REQUEST when opened", () => { const dispatch = jest.fn(); renderModal({ state: makeState(), dispatch }); const requests = dispatch.mock.calls .map(([action]) => action) .filter(a => a.type === at.WIDGETS_SPORTS_WATCH_LIVE_REQUEST); expect(requests).toHaveLength(1); }); describe("telemetry", () => { it("fires an open user event on mount with widget_source, action_value, and widget_size", () => { const dispatch = jest.fn(); renderModal({ state: makeState(), dispatch, widgetSize: "large" }); const opens = findUserEvents(dispatch, "open"); expect(opens).toHaveLength(1); expect(opens[0].data).toMatchObject({ widget_name: "sports", widget_source: "widget", user_action: "open", action_value: "watch_live_modal", widget_size: "large", }); expect(opens[0].meta).toEqual( expect.objectContaining({ to: "ActivityStream:Main", skipLocal: true, }) ); }); it("fires a dismiss user event on backdrop click", () => { const dispatch = jest.fn(); const { container } = renderModal({ state: makeState(), dispatch }); fireEvent.click(container.querySelector(".watch-live-modal-dialog")); const dismisses = findUserEvents(dispatch, "dismiss"); expect(dismisses).toHaveLength(1); expect(dismisses[0].data).toMatchObject({ widget_name: "sports", widget_source: "widget", action_value: "watch_live_modal", }); }); it("fires a dismiss user event on Escape (dialog cancel)", () => { const dispatch = jest.fn(); const { container } = renderModal({ state: makeState(), dispatch }); fireEvent( container.querySelector(".watch-live-modal-dialog"), new Event("cancel", { bubbles: true, cancelable: true }) ); expect(findUserEvents(dispatch, "dismiss")).toHaveLength(1); }); it("fires a dismiss user event on close-button click", () => { const dispatch = jest.fn(); const { container } = renderModal({ state: makeState(), dispatch }); fireEvent.click(container.querySelector(".watch-live-modal-close")); expect(findUserEvents(dispatch, "dismiss")).toHaveLength(1); }); it("fires a stream_click user event with action_value=product_name on stream link click", () => { const dispatch = jest.fn(); const { container } = renderModal({ state: makeState({ loaded: true, data: watchLiveData }), dispatch, }); const tubiLink = container.querySelector( ".watch-live-modal-row .watch-live-modal-row-link" ); fireEvent.click(tubiLink); const clicks = findUserEvents(dispatch, "stream_click"); expect(clicks).toHaveLength(1); expect(clicks[0].data).toMatchObject({ widget_name: "sports", widget_source: "widget", action_value: "Tubi", }); }); }); it("opens stream links in a new tab with a safe rel", () => { const { container } = renderModal({ state: makeState({ loaded: true, data: watchLiveData }), }); const links = container.querySelectorAll(".watch-live-modal-row-link"); expect(links.length).toBeGreaterThan(0); links.forEach(link => { expect(link).toHaveAttribute("target", "_blank"); expect(link).toHaveAttribute("rel", "noopener noreferrer"); }); }); it("does not render a navigable href for a non-http(s) stream url", () => { const { container } = renderModal({ state: makeState({ loaded: true, data: { your_region: [ { product_name: "Sketchy", entitlement: "Free", // eslint-disable-next-line no-script-url url: "javascript:alert(1)", }, ], }, }), }); const link = container.querySelector(".watch-live-modal-row-link"); expect(link).toBeInTheDocument(); expect(link.getAttribute("href")).toBe(""); }); describe("entitlement label mapping", () => { it("maps a known entitlement string (case-insensitive) to a Fluent id", () => { const { container } = renderModal({ state: makeState({ loaded: true, data: watchLiveData }), }); // "Free and Paid" → free-paid id; "Free" → free id. Confirm one with // mixed case made it through the lowercase lookup. const free = container.querySelector( "[data-l10n-id='newtab-sports-widget-watch-stream-free']" ); expect(free).toBeInTheDocument(); // Literal stays inside as Fluent's fallback content. expect(free.textContent).toBe("Free"); }); it("renders the raw entitlement string when no map entry exists", () => { const { container } = renderModal({ state: makeState({ loaded: true, data: watchLiveData }), }); const entitlements = container.querySelectorAll( ".watch-live-modal-entitlement" ); const fifa = Array.from(entitlements).find( el => el.textContent === "FIFA+" ); expect(fifa).toBeTruthy(); expect(fifa.hasAttribute("data-l10n-id")).toBe(false); }); }); describe("dismissal", () => { it("calls onClose when the backdrop (the dialog itself) is clicked", () => { const onClose = jest.fn(); const { container } = renderModal({ state: makeState(), onClose }); fireEvent.click(container.querySelector(".watch-live-modal-dialog")); expect(onClose).toHaveBeenCalledTimes(1); }); it("does not call onClose for clicks on inner content", () => { const onClose = jest.fn(); const { container } = renderModal({ state: makeState(), onClose }); fireEvent.click(container.querySelector(".watch-live-modal-content")); expect(onClose).not.toHaveBeenCalled(); }); it("calls onClose on the dialog cancel event (Escape)", () => { const onClose = jest.fn(); const { container } = renderModal({ state: makeState(), onClose }); const dialog = container.querySelector(".watch-live-modal-dialog"); fireEvent( dialog, new Event("cancel", { bubbles: true, cancelable: true }) ); expect(onClose).toHaveBeenCalledTimes(1); }); }); describe("other regions toggle", () => { it("starts collapsed with aria-expanded=false and no other-regions section", () => { const { container } = renderModal({ state: makeState({ loaded: true, data: watchLiveData }), }); const toggle = container.querySelector( ".watch-live-modal-other-regions-toggle" ); expect(toggle).toHaveAttribute("aria-expanded", "false"); expect( container.querySelector(".watch-live-modal-other-regions") ).not.toBeInTheDocument(); }); it("expands to show other-regions content on click", () => { const { container } = renderModal({ state: makeState({ loaded: true, data: watchLiveData }), }); const toggle = container.querySelector( ".watch-live-modal-other-regions-toggle" ); fireEvent.click(toggle); expect(toggle).toHaveAttribute("aria-expanded", "true"); expect( container.querySelector(".watch-live-modal-other-regions") ).toBeInTheDocument(); // 2 your_region rows + 1 CAN row in other_regions. expect(container.querySelectorAll(".watch-live-modal-row")).toHaveLength( 3 ); }); }); });