/* 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",
});
});
});
});