/* 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 { fireEvent, render } 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 { RecentSearches } from "content-src/components/Widgets/RecentSearches/RecentSearches"; const mockState = { ...INITIAL_STATE, Prefs: { ...INITIAL_STATE.Prefs, values: { ...INITIAL_STATE.Prefs.values, "widgets.system.enabled": true, "widgets.system.recentSearches.enabled": true, "widgets.recentSearches.enabled": true, "widgets.recentSearches.size": "medium", "widgets.recentSearches.interaction": false, }, }, }; function WrapWithProvider({ children, state = INITIAL_STATE }) { const store = createStore(combineReducers(reducers), state); return {children}; } function renderWidget(dispatch = jest.fn(), props = {}, state = mockState) { const handleUserInteraction = props.handleUserInteraction || jest.fn(); const { container, unmount } = render( ); return { container, unmount, dispatch, handleUserInteraction }; } describe("RecentSearches widget", () => { it("renders the widget at the resolved size", () => { const { container } = renderWidget(); const root = container.querySelector("article.recent-searches"); expect(root).toBeTruthy(); expect(root.className).toContain("medium-widget"); }); it("renders the localized title", () => { const { container } = renderWidget(); const title = container.querySelector(".recent-searches-title"); expect(title.getAttribute("data-l10n-id")).toBe( "newtab-recent-searches-widget-title" ); }); it("renders a labelled context menu button", () => { const { container } = renderWidget(); expect( container.querySelector( ".recent-searches-context-menu-button[data-l10n-id='newtab-recent-searches-widget-menu-button']" ) ).toBeTruthy(); }); it("renders an empty body with no rows", () => { const { container } = renderWidget(); const body = container.querySelector(".recent-searches-body"); expect(body).toBeTruthy(); expect(body.children.length).toBe(0); }); it("reflects the searches from the store", () => { const withSearches = { ...mockState, RecentSearches: { initialized: true, searches: ["alpha", "beta"] }, }; const { container } = renderWidget(jest.fn(), {}, withSearches); expect( container .querySelector(".recent-searches-body") .getAttribute("data-search-count") ).toBe("2"); }); it("shows the New badge until the widget has been interacted with", () => { const { container } = renderWidget(); expect(container.querySelector(".recent-searches-new-badge")).toBeTruthy(); }); it("hides the New badge once the interaction pref is set", () => { const interacted = { ...mockState, Prefs: { ...mockState.Prefs, values: { ...mockState.Prefs.values, "widgets.recentSearches.interaction": true, }, }, }; const { container } = renderWidget(jest.fn(), {}, interacted); expect(container.querySelector(".recent-searches-new-badge")).toBeNull(); }); it("offers only medium and large in the size submenu", () => { const { container } = renderWidget(); const sizes = Array.from( container.querySelectorAll("panel-item[data-size]") ).map(el => el.getAttribute("data-size")); expect(sizes).toEqual(["medium", "large"]); }); it("omits the size submenu when the layout cannot maximize", () => { const { container } = renderWidget(jest.fn(), { widgetsMayBeMaximized: false, }); expect( container.querySelector("panel-list[id='recent-searches-size-submenu']") ).toBeNull(); }); it("renders the shared menu footer without a leading divider", () => { const { container } = renderWidget(); const menu = container.querySelector( "panel-list[id='recent-searches-context-menu']" ); expect(menu).toBeTruthy(); expect(menu.querySelector("hr")).toBeNull(); expect( menu.querySelector("panel-item[data-l10n-id='newtab-widget-menu-hide']") ).toBeTruthy(); expect( menu.querySelector( "panel-item[data-l10n-id='newtab-recent-searches-menu-learn-more']" ) ).toBeTruthy(); }); describe("context menu actions", () => { it("picking a size writes the pref and records change_size", () => { const { container, dispatch, handleUserInteraction } = renderWidget(); fireEvent.click( container.querySelector( "#recent-searches-size-submenu panel-item[data-size='large']" ) ); const setPref = dispatch.mock.calls.find( ([action]) => action?.type === at.SET_PREF && action.data?.name === "widgets.recentSearches.size" ); expect(setPref[0].data.value).toBe("large"); const userEvent = dispatch.mock.calls.find( ([action]) => action?.type === at.WIDGETS_USER_EVENT && action.data?.user_action === "change_size" ); // widget_size must be the NEW size, not the pre-change one. expect(userEvent[0].data).toMatchObject({ widget_name: "recent_searches", widget_source: "context_menu", action_value: "large", widget_size: "large", }); expect(handleUserInteraction).toHaveBeenCalledWith("recentSearches"); }); it("Learn more records the event without opening the link itself", () => { const { container, dispatch, handleUserInteraction } = renderWidget(); fireEvent.click( container.querySelector( "panel-item[data-l10n-id='newtab-recent-searches-menu-learn-more']" ) ); const userEvent = dispatch.mock.calls.find( ([action]) => action?.type === at.WIDGETS_USER_EVENT && action.data?.user_action === "learn_more" ); expect(userEvent[0].data).toMatchObject({ widget_name: "recent_searches", widget_source: "context_menu", }); expect(handleUserInteraction).toHaveBeenCalledWith("recentSearches"); }); }); 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 once, keyed on the telemetry name", () => { const dispatch = jest.fn(); renderWidget(dispatch); 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: "recent_searches", widget_size: "medium", }); }); }); });