/* 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 } from "@testing-library/react"; import { actionTypes as at } from "common/Actions.mjs"; import { useWidgetTelemetry } from "content-src/components/Widgets/useWidgetTelemetry"; const WEATHER_WIDGET = { id: "weather", telemetryName: "weather" }; const FOCUS_TIMER_WIDGET = { id: "focusTimer", telemetryName: "focus_timer" }; const LISTS_WIDGET = { id: "lists", telemetryName: "lists" }; function TestComponent({ dispatch, widget, widgetSize, legacyImpressionTypes, legacyUserEventType, onImpression, showEl = true, onRender, }) { const telemetry = useWidgetTelemetry({ dispatch, widget, widgetSize, legacyImpressionTypes, legacyUserEventType, onImpression, }); onRender(telemetry); return showEl ?
: null; } describe("useWidgetTelemetry", () => { let dispatch; let observerStub; beforeEach(() => { dispatch = jest.fn(); observerStub = jest .spyOn(window, "IntersectionObserver") .mockImplementation(function (cb) { this.observe = jest.fn(); this.unobserve = jest.fn(); this.disconnect = jest.fn(); this.callback = cb; }); }); afterEach(() => { jest.restoreAllMocks(); }); describe("impression observer", () => { it("dispatches WIDGETS_IMPRESSION once when element intersects", () => { const { container } = render( {}} /> ); const [observerInstance] = observerStub.mock.instances; const el = container.querySelector("div"); observerInstance.callback([{ isIntersecting: true, target: el }]); expect(dispatch).toHaveBeenCalledTimes(1); const [[action]] = dispatch.mock.calls; expect(action.type).toBe(at.WIDGETS_IMPRESSION); expect(action.data).toEqual({ widget_name: "weather", widget_size: "medium", }); }); it("does not dispatch a second impression after the first", () => { const { container } = render( {}} /> ); const [observerInstance] = observerStub.mock.instances; const el = container.querySelector("div"); observerInstance.callback([{ isIntersecting: true, target: el }]); observerInstance.callback([{ isIntersecting: true, target: el }]); expect(dispatch).toHaveBeenCalledTimes(1); }); it("observes an element that mounts after the initial render", () => { const { container, rerender } = render( {}} /> ); const [observerInstance] = observerStub.mock.instances; // Initial render: no element, observer didn't observe anything yet. expect(observerInstance.observe).not.toHaveBeenCalled(); // Later render reveals the element; the callback ref should observe it. rerender( {}} /> ); expect(observerInstance.observe).toHaveBeenCalledTimes(1); const el = container.querySelector("div"); observerInstance.callback([{ isIntersecting: true, target: el }]); expect(dispatch).toHaveBeenCalledTimes(1); expect(dispatch.mock.calls[0][0].type).toBe(at.WIDGETS_IMPRESSION); }); it("ignores a queued intersection entry for a previously-unobserved target", () => { const { container, rerender } = render( {}} /> ); const [observerInstance] = observerStub.mock.instances; const firstEl = container.querySelector("div"); // Reassign the ref to a different node; firstEl is no longer the // observed target. rerender( {}} /> ); rerender( {}} /> ); // A queued callback for the old target fires; hook must not dispatch. observerInstance.callback([{ isIntersecting: true, target: firstEl }]); expect(dispatch).not.toHaveBeenCalled(); }); it("unobserves the previous element when the ref is reassigned", () => { const { container, rerender } = render( {}} /> ); const [observerInstance] = observerStub.mock.instances; const firstEl = container.querySelector("div"); expect(observerInstance.observe).toHaveBeenCalledTimes(1); expect(observerInstance.observe).toHaveBeenCalledWith(firstEl); // Force the element to remount with a different node. rerender( {}} /> ); expect(observerInstance.unobserve).toHaveBeenCalledTimes(1); expect(observerInstance.unobserve).toHaveBeenCalledWith(firstEl); rerender( {}} /> ); const secondEl = container.querySelector("div"); expect(observerInstance.observe).toHaveBeenCalledTimes(2); expect(observerInstance.observe.mock.lastCall[0]).toBe(secondEl); }); }); describe("onImpression", () => { it("invokes onImpression once when the impression fires", () => { const onImpression = jest.fn(); const { container } = render( {}} /> ); const [observerInstance] = observerStub.mock.instances; const el = container.querySelector("div"); observerInstance.callback([{ isIntersecting: true, target: el }]); observerInstance.callback([{ isIntersecting: true, target: el }]); expect(onImpression).toHaveBeenCalledTimes(1); }); it("also fires onImpression for a manual recordImpression", () => { let telemetry; const onImpression = jest.fn(); render( (telemetry = t)} /> ); telemetry.recordImpression(); expect(onImpression).toHaveBeenCalledTimes(1); }); }); describe("recordImpression", () => { it("dispatches WIDGETS_IMPRESSION manually with the current size", () => { let telemetry; render( (telemetry = t)} /> ); telemetry.recordImpression(); expect(dispatch).toHaveBeenCalledTimes(1); const [[action]] = dispatch.mock.calls; expect(action.type).toBe(at.WIDGETS_IMPRESSION); expect(action.data).toEqual({ widget_name: "weather", widget_size: "medium", }); }); it("shares the impressionFired guard with the observer", () => { let telemetry; const { container } = render( (telemetry = t)} /> ); const [observerInstance] = observerStub.mock.instances; const el = container.querySelector("div"); observerInstance.callback([{ isIntersecting: true, target: el }]); telemetry.recordImpression(); expect(dispatch).toHaveBeenCalledTimes(1); }); it("honors per-call size override", () => { let telemetry; render( (telemetry = t)} /> ); telemetry.recordImpression({ size: "large" }); expect(dispatch.mock.calls[0][0].data.widget_size).toBe("large"); }); }); describe("recordUserAction", () => { it("dispatches WIDGETS_USER_EVENT via OnlyToMain by default", () => { let telemetry; render( (telemetry = t)} /> ); telemetry.recordUserAction("learn_more", { source: "context_menu" }); expect(dispatch).toHaveBeenCalledTimes(1); const [[action]] = dispatch.mock.calls; expect(action.type).toBe(at.WIDGETS_USER_EVENT); expect(action.meta.to).toBe("ActivityStream:Main"); expect(action.meta.skipLocal).toBe(true); expect(action.data).toEqual({ widget_name: "weather", widget_size: "small", widget_source: "context_menu", user_action: "learn_more", }); }); it("dispatches via AlsoToMain when alsoToMain: true", () => { let telemetry; render( (telemetry = t)} /> ); telemetry.recordUserAction("task_complete", { source: "widget", alsoToMain: true, }); const [[action]] = dispatch.mock.calls; expect(action.type).toBe(at.WIDGETS_USER_EVENT); expect(action.meta.to).toBe("ActivityStream:Main"); expect(action.meta.skipLocal).not.toBe(true); }); it("includes action_value when value is provided", () => { let telemetry; render( (telemetry = t)} /> ); telemetry.recordUserAction("change_temperature_units", { source: "context_menu", value: "c", }); expect(dispatch.mock.calls[0][0].data.action_value).toBe("c"); }); it("omits action_value when value is not provided", () => { let telemetry; render( (telemetry = t)} /> ); telemetry.recordUserAction("learn_more", { source: "context_menu" }); expect(dispatch.mock.calls[0][0].data).not.toHaveProperty("action_value"); }); it("reads the latest widget_size after a prop change", () => { let telemetry; const { rerender } = render( (telemetry = t)} /> ); rerender( (telemetry = t)} /> ); telemetry.recordUserAction("provider_link_click", { source: "widget" }); expect(dispatch.mock.calls[0][0].data.widget_size).toBe("large"); }); it("honors an explicit size override", () => { let telemetry; render( (telemetry = t)} /> ); telemetry.recordUserAction("change_size", { source: "context_menu", value: "small", size: "small", }); expect(dispatch.mock.calls[0][0].data.widget_size).toBe("small"); }); }); describe("recordEnabled", () => { it("dispatches WIDGETS_ENABLED with enabled flag and widget_size", () => { let telemetry; render( (telemetry = t)} /> ); telemetry.recordEnabled(false, { source: "context_menu" }); expect(dispatch).toHaveBeenCalledTimes(1); const [[action]] = dispatch.mock.calls; expect(action.type).toBe(at.WIDGETS_ENABLED); expect(action.data).toEqual({ widget_name: "weather", widget_size: "medium", widget_source: "context_menu", enabled: false, }); }); it("honors size override", () => { let telemetry; render( (telemetry = t)} /> ); telemetry.recordEnabled(true, { source: "widget", size: "large" }); expect(dispatch.mock.calls[0][0].data.widget_size).toBe("large"); expect(dispatch.mock.calls[0][0].data.enabled).toBe(true); }); }); describe("recordError", () => { it("dispatches WIDGETS_ERROR with error_type and no widget_source", () => { let telemetry; render( (telemetry = t)} /> ); telemetry.recordError("load_error"); expect(dispatch).toHaveBeenCalledTimes(1); const [[action]] = dispatch.mock.calls; expect(action.type).toBe(at.WIDGETS_ERROR); expect(action.data).toEqual({ widget_name: "weather", widget_size: "medium", error_type: "load_error", }); expect(action.data).not.toHaveProperty("widget_source"); }); it("honors size override", () => { let telemetry; render( (telemetry = t)} /> ); telemetry.recordError("load_error", { size: "small" }); expect(dispatch.mock.calls[0][0].data.widget_size).toBe("small"); }); }); // Bug 2012779 transition: WIDGETS_TIMER_* / WIDGETS_LISTS_* legacy events // co-dispatch alongside the unified events. Delete this block once they go. describe("legacy co-dispatch", () => { describe("impression", () => { it("co-dispatches legacy impression types BEFORE WIDGETS_IMPRESSION", () => { const { container } = render( {}} /> ); const [observerInstance] = observerStub.mock.instances; const el = container.querySelector("div"); observerInstance.callback([{ isIntersecting: true, target: el }]); expect(dispatch).toHaveBeenCalledTimes(2); expect(dispatch.mock.calls[0][0].type).toBe( at.WIDGETS_TIMER_USER_IMPRESSION ); expect(dispatch.mock.calls[1][0].type).toBe(at.WIDGETS_IMPRESSION); }); }); describe("user event", () => { it("co-dispatches the legacy user-event type BEFORE the unified event when legacy: true", () => { let telemetry; render( (telemetry = t)} /> ); telemetry.recordUserAction("timer_play", { source: "widget", legacy: true, }); expect(dispatch).toHaveBeenCalledTimes(2); const [[legacy], [modern]] = dispatch.mock.calls; expect(legacy.type).toBe(at.WIDGETS_TIMER_USER_EVENT); expect(legacy.data).toEqual({ userAction: "timer_play" }); expect(modern.type).toBe(at.WIDGETS_USER_EVENT); }); it("does NOT co-dispatch legacy when legacy flag is omitted", () => { let telemetry; render( (telemetry = t)} /> ); telemetry.recordUserAction("change_size", { source: "context_menu", value: "small", size: "small", }); expect(dispatch).toHaveBeenCalledTimes(1); expect(dispatch.mock.calls[0][0].type).toBe(at.WIDGETS_USER_EVENT); }); it("legacy co-dispatch follows the alsoToMain routing flag", () => { let telemetry; render( (telemetry = t)} /> ); telemetry.recordUserAction("task_complete", { source: "widget", alsoToMain: true, legacy: true, }); expect(dispatch).toHaveBeenCalledTimes(2); expect(dispatch.mock.calls[0][0].meta.skipLocal).not.toBe(true); expect(dispatch.mock.calls[1][0].meta.skipLocal).not.toBe(true); }); }); }); });