/* 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 https://mozilla.org/MPL/2.0/. */ import { render, fireEvent, act } from "@testing-library/react"; import { Provider } from "react-redux"; import { combineReducers, createStore } from "redux"; import { INITIAL_STATE, reducers } from "common/Reducers.sys.mjs"; import { actionTypes as at } from "common/Actions.mjs"; import { Crossword } from "content-src/components/Widgets/Crossword/Crossword"; // Origin of the endpoint in baseState; the component only accepts inbound // messages from, and targets outbound commands at, this exact origin. const MERINO_ORIGIN = "https://prod-games-particle.merino.prod.webservices.mozgcp.net"; const baseState = { ...INITIAL_STATE, Prefs: { ...INITIAL_STATE.Prefs, values: { ...INITIAL_STATE.Prefs.values, "widgets.system.enabled": true, "widgets.enabled": true, "widgets.system.crossword.enabled": true, "widgets.crossword.enabled": true, "widgets.crossword.size": "", "widgets.crossword.endpoint": "https://prod-games-particle.merino.prod.webservices.mozgcp.net/index.html", }, }, }; function WrapWithProvider({ children, state = baseState }) { const store = createStore(combineReducers(reducers), state); return {children}; } function renderCrossword({ state = baseState, dispatch = jest.fn(), handleUserInteraction = jest.fn(), widgetsMayBeMaximized = true, } = {}) { const widgetEnabledMap = { crossword: true }; const utils = render( ); return { ...utils, dispatch, handleUserInteraction }; } // Dispatch a postMessage-style event at the window as if it came from the // widget iframe. jsdom does not honor `source` from the MessageEvent init // dict, so it is forced onto the instance. `origin` and `source` default to // the trusted iframe values but can be overridden to exercise the filters. function postWidgetMessage( container, { data, origin = MERINO_ORIGIN, source } ) { const frame = container.querySelector("iframe.crossword-frame"); const event = new MessageEvent("message", { data, origin }); Object.defineProperty(event, "source", { configurable: true, value: source === undefined ? frame.contentWindow : source, }); act(() => { window.dispatchEvent(event); }); } function validMessage(type, payload) { return { channel: "crossword_widget", type, payload }; } describe("", () => { describe("rendering", () => { it("renders the article root with the default medium size class", () => { const { container } = renderCrossword(); const root = container.querySelector("article.crossword"); expect(root).toBeInTheDocument(); expect(root).toHaveClass("widget"); expect(root).toHaveClass("col-4"); expect(root).toHaveClass("medium-widget"); }); it("applies the size from the size pref when set", () => { const state = { ...baseState, Prefs: { ...baseState.Prefs, values: { ...baseState.Prefs.values, "widgets.crossword.size": "large", }, }, }; const { container } = renderCrossword({ state }); expect(container.querySelector("article.crossword")).toHaveClass( "large-widget" ); }); it("renders the size submenu only when widgetsMayBeMaximized", () => { const { container, rerender } = renderCrossword({ widgetsMayBeMaximized: true, }); expect( container.querySelector("#crossword-size-submenu") ).toBeInTheDocument(); rerender( ); expect( container.querySelector("#crossword-size-submenu") ).not.toBeInTheDocument(); }); }); describe("hide action", () => { it("dispatches SET_PREF and WIDGETS_ENABLED when hide is clicked", () => { const { container, dispatch } = renderCrossword(); const hideItem = container.querySelector( "panel-item[data-l10n-id='newtab-widget-menu-hide']" ); fireEvent.click(hideItem); const setPrefCall = dispatch.mock.calls.find( ([action]) => action?.type === at.SET_PREF && action.data?.name === "widgets.crossword.enabled" ); expect(setPrefCall?.[0].data.value).toBe(false); const enabledCall = dispatch.mock.calls.find( ([action]) => action?.type === at.WIDGETS_ENABLED ); expect(enabledCall?.[0].data).toMatchObject({ widget_name: "crossword", widget_source: "context_menu", enabled: false, }); }); }); describe("learn more action", () => { it("dispatches OPEN_LINK with the shared SUMO URL", () => { const { container, dispatch } = renderCrossword(); const learnMoreItem = container.querySelector("panel-item.learn-more"); fireEvent.click(learnMoreItem); const openLinkCall = dispatch.mock.calls.find( ([action]) => action?.type === at.OPEN_LINK ); expect(openLinkCall?.[0].data.url).toBe( "https://support.mozilla.org/kb/firefox-new-tab-widgets" ); }); it("dispatches a learn_more WIDGETS_USER_EVENT", () => { const { container, dispatch } = renderCrossword(); fireEvent.click(container.querySelector("panel-item.learn-more")); const userEventCall = dispatch.mock.calls.find( ([action]) => action?.type === at.WIDGETS_USER_EVENT && action.data?.user_action === "learn_more" ); expect(userEventCall?.[0].data).toMatchObject({ widget_name: "crossword", widget_source: "context_menu", user_action: "learn_more", widget_size: "medium", }); }); }); describe("impression telemetry", () => { let originalIntersectionObserver; let observerInstances; beforeEach(() => { observerInstances = []; originalIntersectionObserver = global.IntersectionObserver; global.IntersectionObserver = class MockIntersectionObserver { constructor(callback) { this.callback = callback; this.observed = []; observerInstances.push(this); } observe(el) { this.observed.push(el); } unobserve() {} disconnect() {} }; }); afterEach(() => { global.IntersectionObserver = originalIntersectionObserver; }); it("dispatches WIDGETS_IMPRESSION only once even when intersection fires repeatedly", () => { const { dispatch } = renderCrossword(); const [observer] = observerInstances; const [target] = observer.observed; observer.callback([{ isIntersecting: true, target }], observer); observer.callback([{ isIntersecting: true, target }], observer); const impressions = dispatch.mock.calls.filter( ([action]) => action?.type === at.WIDGETS_IMPRESSION ); expect(impressions).toHaveLength(1); expect(impressions[0][0].data).toMatchObject({ widget_name: "crossword", widget_size: "medium", }); }); }); describe("crossword iframe", () => { it("renders a sandboxed iframe pointing at the resolved endpoint", () => { const { container } = renderCrossword(); const frame = container.querySelector("iframe.crossword-frame"); expect(frame).toBeInTheDocument(); expect(frame).toHaveAttribute( "sandbox", "allow-scripts allow-same-origin allow-popups allow-popups-to-escape-sandbox" ); expect(frame).toHaveAttribute( "src", "https://prod-games-particle.merino.prod.webservices.mozgcp.net/index.html" ); }); it("prefers the trainhopConfig endpoint over the raw pref", () => { const trainhopEndpoint = "https://example.com/crossword/index.html"; const state = { ...baseState, Prefs: { ...baseState.Prefs, values: { ...baseState.Prefs.values, trainhopConfig: { widgets: { crosswordEndpoint: trainhopEndpoint }, }, }, }, }; const { container } = renderCrossword({ state }); expect(container.querySelector("iframe.crossword-frame")).toHaveAttribute( "src", trainhopEndpoint ); }); }); describe("change size action", () => { it("dispatches SET_PREF and a change_size WIDGETS_USER_EVENT when a size is picked", () => { const { container, dispatch } = renderCrossword(); const largeSizeItem = container.querySelector( "#crossword-size-submenu panel-item[data-size='large']" ); fireEvent.click(largeSizeItem); const setPrefCall = dispatch.mock.calls.find( ([action]) => action?.type === at.SET_PREF && action.data?.name === "widgets.crossword.size" ); expect(setPrefCall?.[0].data.value).toBe("large"); const userEventCall = dispatch.mock.calls.find( ([action]) => action?.type === at.WIDGETS_USER_EVENT && action.data?.user_action === "change_size" ); expect(userEventCall?.[0].data).toMatchObject({ widget_name: "crossword", widget_source: "context_menu", user_action: "change_size", action_value: "large", widget_size: "large", }); }); }); describe("menu actions — postMessage send", () => { let originalCrypto; beforeEach(() => { originalCrypto = global.crypto; Object.defineProperty(global, "crypto", { configurable: true, value: { randomUUID: () => "test-request-id" }, }); }); afterEach(() => { Object.defineProperty(global, "crypto", { configurable: true, value: originalCrypto, }); }); it("posts show_all_clues to the iframe on the crossword channel with the merino targetOrigin", () => { const { container } = renderCrossword(); const frame = container.querySelector("iframe.crossword-frame"); const postSpy = jest .spyOn(frame.contentWindow, "postMessage") .mockImplementation(() => {}); fireEvent.click(container.querySelector("panel-item.show-all-clues")); expect(postSpy).toHaveBeenCalledTimes(1); const [[message, targetOrigin]] = postSpy.mock.calls; expect(message).toMatchObject({ channel: "crossword_widget", type: "menu_action", action: "show_all_clues", }); expect(message.requestId).toMatch(/^firefox-menu-/); expect(targetOrigin).toBe(MERINO_ORIGIN); expect(targetOrigin).not.toBe("*"); }); it("posts reveal_grid when Solve puzzle is clicked", () => { const { container } = renderCrossword(); const frame = container.querySelector("iframe.crossword-frame"); const postSpy = jest .spyOn(frame.contentWindow, "postMessage") .mockImplementation(() => {}); fireEvent.click(container.querySelector("panel-item.solve-puzzle")); expect(postSpy).toHaveBeenCalledTimes(1); expect(postSpy.mock.calls[0][0]).toMatchObject({ type: "menu_action", action: "reveal_grid", }); }); it("dispatches a menu_action WIDGETS_USER_EVENT carrying the action", () => { const { container, dispatch } = renderCrossword(); const frame = container.querySelector("iframe.crossword-frame"); jest .spyOn(frame.contentWindow, "postMessage") .mockImplementation(() => {}); fireEvent.click(container.querySelector("panel-item.show-all-clues")); const userEventCall = dispatch.mock.calls.find( ([action]) => action?.type === at.WIDGETS_USER_EVENT && action.data?.user_action === "menu_action" ); expect(userEventCall?.[0].data).toMatchObject({ widget_name: "crossword", widget_source: "context_menu", user_action: "menu_action", action_value: "show_all_clues", widget_size: "medium", }); }); it("does not post when the endpoint is malformed (no resolvable origin)", () => { const state = { ...baseState, Prefs: { ...baseState.Prefs, values: { ...baseState.Prefs.values, "widgets.crossword.endpoint": "not-a-valid-url", }, }, }; const { container } = renderCrossword({ state }); const frame = container.querySelector("iframe.crossword-frame"); const postSpy = jest .spyOn(frame.contentWindow, "postMessage") .mockImplementation(() => {}); fireEvent.click(container.querySelector("panel-item.show-all-clues")); expect(postSpy).not.toHaveBeenCalled(); }); }); describe("inbound widget events + security filtering", () => { it("dispatches a puzzle_completed WIDGETS_USER_EVENT for a valid message", () => { const { container, dispatch } = renderCrossword(); postWidgetMessage(container, { data: validMessage("puzzle_completed", { elapsedTimeSeconds: 42, hintsTaken: 3, }), }); const call = dispatch.mock.calls.find( ([action]) => action?.type === at.WIDGETS_USER_EVENT && action.data?.user_action === "puzzle_completed" ); expect(call?.[0].data).toMatchObject({ widget_name: "crossword", widget_source: "iframe", user_action: "puzzle_completed", action_value: 3, widget_size: "medium", }); }); it("dispatches an interaction WIDGETS_USER_EVENT for a valid message", () => { const { container, dispatch } = renderCrossword(); postWidgetMessage(container, { data: validMessage("interaction", { action: "cell_focus" }), }); const call = dispatch.mock.calls.find( ([action]) => action?.type === at.WIDGETS_USER_EVENT && action.data?.user_action === "interaction" ); expect(call?.[0].data).toMatchObject({ widget_source: "iframe", user_action: "interaction", action_value: "cell_focus", }); }); it("ignores messages from an unexpected origin", () => { const { container, dispatch } = renderCrossword(); postWidgetMessage(container, { data: validMessage("interaction", { action: "cell_focus" }), origin: "https://evil.example.com", }); expect( dispatch.mock.calls.some( ([action]) => action?.type === at.WIDGETS_USER_EVENT ) ).toBe(false); }); it("ignores messages whose source is not the widget iframe", () => { const { container, dispatch } = renderCrossword(); postWidgetMessage(container, { data: validMessage("interaction", { action: "cell_focus" }), source: window, }); expect( dispatch.mock.calls.some( ([action]) => action?.type === at.WIDGETS_USER_EVENT ) ).toBe(false); }); it("ignores messages missing the crossword channel", () => { const { container, dispatch } = renderCrossword(); postWidgetMessage(container, { data: { type: "interaction", payload: { action: "cell_focus" }, }, }); expect( dispatch.mock.calls.some( ([action]) => action?.type === at.WIDGETS_USER_EVENT ) ).toBe(false); }); it("ignores events whose payload fails validation", () => { const { container, dispatch } = renderCrossword(); postWidgetMessage(container, { data: validMessage("puzzle_completed", { hintsTaken: "three" }), }); expect( dispatch.mock.calls.some( ([action]) => action?.type === at.WIDGETS_USER_EVENT ) ).toBe(false); }); it("ignores puzzle_completed with a non-whole hintsTaken", () => { const { container, dispatch } = renderCrossword(); postWidgetMessage(container, { data: validMessage("puzzle_completed", { elapsedTimeSeconds: 42, hintsTaken: -1, }), }); expect( dispatch.mock.calls.some( ([action]) => action?.type === at.WIDGETS_USER_EVENT ) ).toBe(false); }); }); describe("completion state", () => { it("hides Solve puzzle but keeps Show clues after a valid puzzle_completed", () => { const { container } = renderCrossword(); expect( container.querySelector("panel-item.solve-puzzle") ).toBeInTheDocument(); expect( container.querySelector("panel-item.show-all-clues") ).toBeInTheDocument(); postWidgetMessage(container, { data: validMessage("puzzle_completed", { elapsedTimeSeconds: 42, hintsTaken: 3, }), }); expect( container.querySelector("panel-item.solve-puzzle") ).not.toBeInTheDocument(); expect( container.querySelector("panel-item.show-all-clues") ).toBeInTheDocument(); }); it("keeps Solve puzzle when a completion message fails validation", () => { const { container } = renderCrossword(); postWidgetMessage(container, { data: validMessage("puzzle_completed", { hintsTaken: "three" }), }); expect( container.querySelector("panel-item.solve-puzzle") ).toBeInTheDocument(); }); }); describe("puzzle_state sizing", () => { function largeState() { return { ...baseState, Prefs: { ...baseState.Prefs, values: { ...baseState.Prefs.values, "widgets.crossword.size": "large", }, }, }; } it("forces the large layout while the puzzle is in_progress", () => { const { container } = renderCrossword(); const root = container.querySelector("article.crossword"); expect(root).toHaveClass("medium-widget"); postWidgetMessage(container, { data: validMessage("puzzle_state", { crosswordId: "gid", state: "in_progress", sourceLabel: "live", }), }); expect(root).toHaveClass("large-widget"); expect(root).not.toHaveClass("medium-widget"); }); it("stays large through the completed state after solving", () => { const { container } = renderCrossword(); const root = container.querySelector("article.crossword"); postWidgetMessage(container, { data: validMessage("puzzle_state", { state: "in_progress" }), }); expect(root).toHaveClass("large-widget"); postWidgetMessage(container, { data: validMessage("puzzle_state", { state: "completed" }), }); expect(root).toHaveClass("large-widget"); expect(root).not.toHaveClass("medium-widget"); }); it("stays large on puzzle_completed after solving", () => { const { container } = renderCrossword(); const root = container.querySelector("article.crossword"); postWidgetMessage(container, { data: validMessage("puzzle_state", { state: "in_progress" }), }); expect(root).toHaveClass("large-widget"); postWidgetMessage(container, { data: validMessage("puzzle_completed", { elapsedTimeSeconds: 42, hintsTaken: 1, }), }); expect(root).toHaveClass("large-widget"); }); it("stays medium when loading directly into completed (returning card)", () => { const { container } = renderCrossword(); const root = container.querySelector("article.crossword"); postWidgetMessage(container, { data: validMessage("puzzle_state", { state: "completed" }), }); expect(root).toHaveClass("medium-widget"); expect(root).not.toHaveClass("large-widget"); }); it("returns to medium when a new puzzle goes back to the intro state", () => { const { container } = renderCrossword(); const root = container.querySelector("article.crossword"); postWidgetMessage(container, { data: validMessage("puzzle_state", { state: "in_progress" }), }); expect(root).toHaveClass("large-widget"); postWidgetMessage(container, { data: validMessage("puzzle_state", { state: "intro" }), }); expect(root).toHaveClass("medium-widget"); }); it("does not force large when widgetsMayBeMaximized is false", () => { const { container } = renderCrossword({ widgetsMayBeMaximized: false }); const root = container.querySelector("article.crossword"); postWidgetMessage(container, { data: validMessage("puzzle_state", { state: "in_progress" }), }); expect(root).toHaveClass("medium-widget"); expect(root).not.toHaveClass("large-widget"); }); it("keeps a user-chosen large size on the intro state", () => { const { container } = renderCrossword({ state: largeState() }); const root = container.querySelector("article.crossword"); expect(root).toHaveClass("large-widget"); postWidgetMessage(container, { data: validMessage("puzzle_state", { state: "intro" }), }); expect(root).toHaveClass("large-widget"); }); it("ignores a puzzle_state with an unknown state value", () => { const { container } = renderCrossword(); const root = container.querySelector("article.crossword"); postWidgetMessage(container, { data: validMessage("puzzle_state", { state: "bogus" }), }); expect(root).toHaveClass("medium-widget"); expect(root).not.toHaveClass("large-widget"); }); }); describe("powered by particle action", () => { it("dispatches OPEN_LINK to particle.news", () => { const { container, dispatch } = renderCrossword(); fireEvent.click( container.querySelector("panel-item.powered-by-particle") ); const openLinkCall = dispatch.mock.calls.find( ([action]) => action?.type === at.OPEN_LINK ); expect(openLinkCall?.[0].data.url).toBe("https://particle.news"); }); it("dispatches a powered_by_particle WIDGETS_USER_EVENT", () => { const { container, dispatch } = renderCrossword(); fireEvent.click( container.querySelector("panel-item.powered-by-particle") ); const userEventCall = dispatch.mock.calls.find( ([action]) => action?.type === at.WIDGETS_USER_EVENT && action.data?.user_action === "powered_by_particle" ); expect(userEventCall?.[0].data).toMatchObject({ widget_name: "crossword", widget_source: "context_menu", user_action: "powered_by_particle", widget_size: "medium", }); }); }); describe("menu strings", () => { it("hardcodes the crossword-specific items and keeps shared chrome ids", () => { const { container } = renderCrossword(); const menu = container.querySelector("#crossword-context-menu"); // Crossword-specific strings are hardcoded inline (English-only). expect(menu.querySelector("panel-item.show-all-clues")).toHaveTextContent( "Show clues" ); expect(menu.querySelector("panel-item.solve-puzzle")).toHaveTextContent( "Solve puzzle" ); expect( menu.querySelector("panel-item.powered-by-particle") ).toHaveTextContent("Powered by Particle"); // Generic chrome keeps the shared, translated Fluent ids. expect( menu.querySelector( "span[data-l10n-id='newtab-widget-menu-change-size']" ) ).toBeInTheDocument(); expect( container.querySelector( "#crossword-size-submenu panel-item[data-l10n-id='newtab-widget-size-medium']" ) ).toBeInTheDocument(); expect( container.querySelector( "#crossword-size-submenu panel-item[data-l10n-id='newtab-widget-size-large']" ) ).toBeInTheDocument(); expect( menu.querySelector("panel-item[data-l10n-id='newtab-widget-menu-hide']") ).toBeInTheDocument(); expect(menu.querySelector("hr")).toBeInTheDocument(); }); }); describe("new badge", () => { it("renders the New badge before the title when not interacted with", () => { const { container } = renderCrossword(); const badge = container.querySelector("moz-badge.crossword-new-badge"); expect(badge).toBeInTheDocument(); // No type="new": that looks up the unregistered moz-badge-new2 string. expect(badge).not.toHaveAttribute("type"); expect(badge).toHaveAttribute( "data-l10n-id", "newtab-widget-lists-label-new" ); // The badge renders immediately before the title. expect(badge.nextElementSibling).toBe( container.querySelector("h3.newtab-crossword-title") ); }); it("hides the New badge once the interaction pref is set", () => { const state = { ...baseState, Prefs: { ...baseState.Prefs, values: { ...baseState.Prefs.values, "widgets.crossword.interaction": true, }, }, }; const { container } = renderCrossword({ state }); expect( container.querySelector("moz-badge.crossword-new-badge") ).not.toBeInTheDocument(); expect( container.querySelector("h3.newtab-crossword-title") ).toBeInTheDocument(); }); }); describe("interaction pref", () => { it("flips the interaction pref via handleUserInteraction on a menu action", () => { const handleUserInteraction = jest.fn(); const { container } = renderCrossword({ handleUserInteraction }); fireEvent.click(container.querySelector("panel-item.learn-more")); expect(handleUserInteraction).toHaveBeenCalledWith("crossword"); }); it("does NOT flip the interaction pref when hiding the widget", () => { const handleUserInteraction = jest.fn(); const { container } = renderCrossword({ handleUserInteraction }); fireEvent.click( container.querySelector( "panel-item[data-l10n-id='newtab-widget-menu-hide']" ) ); expect(handleUserInteraction).not.toHaveBeenCalled(); }); }); });