/* eslint-disable @typescript-eslint/ban-ts-comment */ import { render, unmountComponentAtNode } from "react-dom"; import { act } from "react-dom/test-utils"; import { create } from "react-test-renderer"; import { mockIsIntersecting } from "react-intersection-observer/test-utils"; import { createStore, Provider } from "../../../connect/src"; import Link from ".."; import { config } from "../utils"; let store; let container; let fetch: jest.Mock; let get: jest.Mock; beforeEach(() => { fetch = jest.fn(); get = jest.fn(); jest.useFakeTimers(); container = document.createElement("div"); document.body.appendChild(container); window.scrollTo = jest.fn(); store = createStore({ state: { frontity: {}, theme: { autoPrefetch: "hover", }, source: { url: "http://backendurl.com", get: () => get, }, }, actions: { router: { set(link) { return link; }, }, source: { fetch: () => fetch, }, }, }); }); afterEach(() => { unmountComponentAtNode(container); window.scrollTo = null; container.remove(); container = null; }); describe("Link", () => { test("should render a regular link", () => { const LinkComponent = create( This is a link ); expect(LinkComponent.toJSON()).toMatchInlineSnapshot(` This is a link `); }); test("should add classname if provided", () => { const LinkComponent = create( This is a link ); expect(LinkComponent.toJSON()).toMatchInlineSnapshot(` This is a link `); }); test("clicking a link works as expected", () => { const onClick = jest.fn(); const linkUrl = "/my-page"; act(() => { render( This is a link , container ); }); jest.spyOn(store.actions.router, "set"); const anchor = document.querySelector("a.my-link"); act(() => { anchor.dispatchEvent(new MouseEvent("click", { bubbles: true })); }); expect(onClick).toHaveBeenCalledTimes(1); expect(window.scrollTo).toHaveBeenCalledWith(0, 0); expect(store.actions.router.set).toHaveBeenCalledWith(linkUrl); }); test("clicking a link without scrolling", () => { const linkUrl = "/my-page"; act(() => { render( This is a link , container ); }); jest.spyOn(store.actions.router, "set"); const anchor = document.querySelector("a.my-link"); act(() => { anchor.dispatchEvent(new MouseEvent("click", { bubbles: true })); }); expect(window.scrollTo).not.toHaveBeenCalled(); expect(store.actions.router.set).toHaveBeenCalledWith(linkUrl); }); test("clicking a link with target=_blank does not do anything", () => { const linkUrl = "/my-page"; act(() => { render( This is a link , container ); }); jest.spyOn(store.actions.router, "set"); const anchor = document.querySelector("a.my-link"); act(() => { anchor.dispatchEvent(new MouseEvent("click", { bubbles: true })); }); expect(window.scrollTo).not.toHaveBeenCalled(); expect(store.actions.router.set).not.toHaveBeenCalledWith(linkUrl); }); test("clicking a link that starts with http does not do anything", () => { const linkUrl = "https://externallink.com"; act(() => { render( This is a link , container ); }); jest.spyOn(store.actions.router, "set"); const anchor = document.querySelector("a.my-link"); act(() => { anchor.dispatchEvent(new MouseEvent("click", { bubbles: true })); }); expect(window.scrollTo).not.toHaveBeenCalled(); expect(store.actions.router.set).not.toHaveBeenCalledWith(linkUrl); }); test("forcing a link to open in a new tab/window works", () => { const linkUrl = "/my-link"; act(() => { render( This is a link , container ); }); jest.spyOn(store.actions.router, "set"); const anchor = document.querySelector("a.my-link"); act(() => { // ctrl + click anchor.dispatchEvent( new MouseEvent("click", { bubbles: true, ctrlKey: true }) ); }); expect(window.scrollTo).not.toHaveBeenCalled(); expect(store.actions.router.set).not.toHaveBeenCalledWith(linkUrl); act(() => { // shift + click anchor.dispatchEvent( new MouseEvent("click", { bubbles: true, shiftKey: true }) ); }); expect(window.scrollTo).not.toHaveBeenCalled(); expect(store.actions.router.set).not.toHaveBeenCalledWith(linkUrl); act(() => { // cmd + click anchor.dispatchEvent( new MouseEvent("click", { bubbles: true, metaKey: true }) ); }); expect(window.scrollTo).not.toHaveBeenCalled(); expect(store.actions.router.set).not.toHaveBeenCalledWith(linkUrl); act(() => { // middle mouse button anchor.dispatchEvent( new MouseEvent("click", { bubbles: true, button: 1 }) ); }); expect(window.scrollTo).not.toHaveBeenCalled(); expect(store.actions.router.set).not.toHaveBeenCalledWith(linkUrl); }); test("it removes the source url from links", () => { const linkUrl = store.state.source.url + "/internal-link"; act(() => { render( This is a link This is a link , container ); }); jest.spyOn(store.actions.router, "set"); const anchor = document.querySelector("a.my-link"); const anchor2 = document.querySelector("a.my-link-2") as HTMLAnchorElement; act(() => { anchor.dispatchEvent(new MouseEvent("click", { bubbles: true })); anchor2.dispatchEvent(new MouseEvent("click", { bubbles: true })); }); expect(store.actions.router.set).toHaveBeenCalledWith("/internal-link"); expect(store.actions.router.set).toHaveBeenCalledTimes(1); expect(anchor2.href).toEqual(linkUrl); }); test("it removes the source url from links if WordPress is multisite", () => { store.state.source.url = "http://backend.url/subsite/"; const linkUrl = store.state.source.url + "internal-link"; act(() => { render( This is a link This is a link , container ); }); jest.spyOn(store.actions.router, "set"); const anchor = document.querySelector("a.my-link"); const anchor2 = document.querySelector("a.my-link-2") as HTMLAnchorElement; act(() => { anchor.dispatchEvent(new MouseEvent("click", { bubbles: true })); anchor2.dispatchEvent(new MouseEvent("click", { bubbles: true })); }); expect(store.actions.router.set).toHaveBeenCalledWith("/internal-link"); expect(store.actions.router.set).toHaveBeenCalledTimes(1); expect(anchor2.href).toEqual(linkUrl); }); test("it prepends the `state.frontity.url` pathname", () => { store.state.frontity.url = "https://frontityurl.com/blog"; const linkUrl = store.state.source.url + "/internal-link"; act(() => { render( This is a link This is a link , container ); }); jest.spyOn(store.actions.router, "set"); const anchor = document.querySelector("a.my-link"); const anchor2 = document.querySelector("a.my-link-2") as HTMLAnchorElement; act(() => { anchor.dispatchEvent(new MouseEvent("click", { bubbles: true })); anchor2.dispatchEvent(new MouseEvent("click", { bubbles: true })); }); expect(store.actions.router.set).toHaveBeenCalledWith( "/blog/internal-link" ); expect(store.actions.router.set).toHaveBeenCalledTimes(1); expect(anchor2.href).toEqual(linkUrl); }); test("it takes into account the `match` property before replacing internal links", () => { const storeWithMatch = { ...store }; // should only match /blog links storeWithMatch.state.frontity.match = [ "https?:\\/\\/[^/]+\\/blog([^-\\w]|$)", ]; const linkThatDoesNotMatch = store.state.source.url + "/internal-link"; const linkThatMatches = store.state.source.url + "/blog/blog-link"; act(() => { render( This is a link This is a link , container ); }); jest.spyOn(store.actions.router, "set"); const anchor = document.querySelector("a.my-link") as HTMLAnchorElement; const anchor2 = document.querySelector("a.my-link-that-matches"); act(() => { anchor.dispatchEvent(new MouseEvent("click", { bubbles: true })); anchor2.dispatchEvent(new MouseEvent("click", { bubbles: true })); }); expect(store.actions.router.set).toHaveBeenCalledWith("/blog/blog-link"); expect(store.actions.router.set).not.toHaveBeenCalledWith("/internal-link"); expect(store.actions.router.set).toHaveBeenCalledTimes(1); // the link that does not match should remain the same expect(anchor.href).toEqual(linkThatDoesNotMatch); }); test("it does not fetch tel:, sms: and mailto: links", () => { const onClick = jest.fn(); act(() => { render( This is a link This is a link This is a link , container ); }); jest.spyOn(store.actions.router, "set"); const anchor = document.querySelector("a.my-link"); const anchor2 = document.querySelector("a.my-link-2"); const anchor3 = document.querySelector("a.my-link-3"); act(() => { anchor.dispatchEvent(new MouseEvent("click", { bubbles: true })); anchor2.dispatchEvent(new MouseEvent("click", { bubbles: true })); anchor3.dispatchEvent(new MouseEvent("click", { bubbles: true })); }); expect(onClick).not.toHaveBeenCalled(); expect(window.scrollTo).not.toHaveBeenCalled(); expect(store.actions.router.set).not.toHaveBeenCalled(); }); }); describe("Link prefetching", () => { test("disabling works", () => { const linkUrl1 = "/post-name"; const linkUrl2 = "/post-name-2"; const storeAllMode = { ...store }; storeAllMode.state.theme.autoPrefetch = "all"; get.mockReturnValue({ isReady: false, isFetching: false }); jest.spyOn(store.actions.source, "fetch"); act(() => { render( This is a link This is a link , container ); }); jest.runAllTimers(); expect(store.actions.source.fetch).toHaveBeenCalledTimes(1); expect(store.actions.source.fetch).not.toHaveBeenCalledWith(linkUrl1); expect(store.actions.source.fetch).toHaveBeenCalledWith(linkUrl2); }); test("does not prefetch if it is disabled", () => { const linkUrl1 = "/post-name"; const linkUrl2 = "/post-name-2"; const storeNoPrefetchMode = { ...store }; storeNoPrefetchMode.state.theme.autoPrefetch = "no"; get.mockReturnValue({ isReady: false, isFetching: false }); jest.spyOn(store.actions.source, "fetch"); act(() => { render( This is a link This is a link , container ); }); jest.runAllTimers(); expect(store.actions.source.fetch).toHaveBeenCalledTimes(0); expect(store.actions.source.fetch).not.toHaveBeenCalledWith(linkUrl1); expect(store.actions.source.fetch).not.toHaveBeenCalledWith(linkUrl2); }); test("does not prefetch if setting is missing", () => { const linkUrl1 = "/post-name"; const linkUrl2 = "/post-name-2"; const storeNoPrefetchMode = { ...store }; delete storeNoPrefetchMode.state.theme.autoPrefetch; get.mockReturnValue({ isReady: false, isFetching: false }); jest.spyOn(store.actions.source, "fetch"); act(() => { render( This is a link This is a link , container ); }); jest.runAllTimers(); expect(store.actions.source.fetch).toHaveBeenCalledTimes(0); expect(store.actions.source.fetch).not.toHaveBeenCalledWith(linkUrl1); expect(store.actions.source.fetch).not.toHaveBeenCalledWith(linkUrl2); }); test("does not run on slow connections", () => { const linkUrl1 = "/post-name"; const storeAllMode = { ...store }; storeAllMode.state.theme.autoPrefetch = "all"; get.mockReturnValue({ isReady: false, isFetching: false }); jest.spyOn(store.actions.source, "fetch"); // simulate save data mode // @ts-ignore (navigator as Navigator & { connection }).connection = { saveData: true }; act(() => { render( This is a link , container ); }); expect(store.actions.source.fetch).toHaveBeenCalledTimes(0); expect(store.actions.source.fetch).not.toHaveBeenCalledWith(linkUrl1); // @ts-ignore (navigator as Navigator & { connection }).connection = { saveData: false }; }); test("all mode works", () => { const linkUrl1 = "/post-name-all-1"; const linkUrl2 = "/post-name-all-2"; const linkUrl3 = "/post-name-all-3"; const linkUrl4 = "/post-name-all-4"; const linkUrl5 = "/post-name-all-5"; const storeAllMode = { ...store }; storeAllMode.state.theme.autoPrefetch = "all"; get.mockReturnValue({ isReady: false, isFetching: false }); jest.spyOn(store.actions.source, "fetch"); act(() => { render( This is a link This is a link This is a link This is a link This is a link This is a link This is a link , container ); }); jest.runAllTimers(); expect(store.actions.source.fetch).toHaveBeenCalledTimes(5); expect(store.actions.source.fetch).toHaveBeenCalledWith(linkUrl1); expect(store.actions.source.fetch).toHaveBeenCalledWith(linkUrl2); expect(store.actions.source.fetch).toHaveBeenCalledWith(linkUrl3); expect(store.actions.source.fetch).toHaveBeenCalledWith(linkUrl4); expect(store.actions.source.fetch).toHaveBeenCalledWith(linkUrl5); }); test("hover mode works", () => { const linkUrl = "/post-name-hover-1"; const linkUrlNoPrefetch = "/post-name-hover-2"; act(() => { render( This is a link This is a link This is a link , container ); }); jest.spyOn(store.actions.source, "fetch"); get.mockReturnValue({ isReady: false, isFetching: false }); const anchor = document.querySelector("a.my-link"); act(() => { anchor.dispatchEvent(new MouseEvent("mouseover", { bubbles: true })); }); jest.runAllTimers(); expect(store.actions.source.fetch).toHaveBeenCalledWith(linkUrl); // if data is already avaliable no need to prefetch again. get.mockReturnValue({ isReady: true, isFetching: false }); act(() => { anchor.dispatchEvent(new MouseEvent("mouseover", { bubbles: true })); }); jest.runAllTimers(); const anchor2 = document.querySelector("a.my-link-2"); act(() => { anchor2.dispatchEvent(new MouseEvent("mouseover", { bubbles: true })); }); jest.runAllTimers(); // a link that was not prefetched should not call fetch, it should go through the router instead const anchor3 = document.querySelector("a.my-link-3"); act(() => { anchor3.dispatchEvent(new MouseEvent("click", { bubbles: true })); }); expect(store.actions.source.fetch).toHaveBeenCalledTimes(1); }); test("in-view mode works", () => { const linkUrl = "/post-name-hover-1"; const linkUrl2 = "/post-name-hover-2"; const storeInViewMode = { ...store }; storeInViewMode.state.theme.autoPrefetch = "in-view"; jest.spyOn(store.actions.source, "fetch"); get.mockReturnValue({ isReady: false, isFetching: false }); act(() => { render( This is a link This is a link , container ); }); const anchor = document.querySelector("a.my-link"); mockIsIntersecting(anchor, true); jest.runAllTimers(); expect(store.actions.source.fetch).toHaveBeenCalledWith(linkUrl); expect(store.actions.source.fetch).not.toHaveBeenCalledWith(linkUrl2); expect(store.actions.source.fetch).toHaveBeenCalledTimes(1); const anchor2 = document.querySelector("a.my-link-2"); mockIsIntersecting(anchor2, true); jest.runAllTimers(); expect(store.actions.source.fetch).toHaveBeenCalledWith(linkUrl2); expect(store.actions.source.fetch).toHaveBeenCalledTimes(2); }); test("works in batches", () => { const links = [ "/post-name-1", "/post-name-2", "/post-name-3", "/post-name-4", "/post-name-5", "/post-name-6", "/post-name-7", "/post-name-8", "/post-name-9", "/post-name-10", ]; const storeInViewMode = { ...store }; storeInViewMode.state.theme.autoPrefetch = "all"; jest.spyOn(store.actions.source, "fetch"); get.mockReturnValue({ isReady: false, isFetching: false }); act(() => { render( {links.map((link, i) => ( This is a link ))} , container ); }); const numBatches = links.length / config.requestsPerBatch; for (let i = 1; i < numBatches; i++) { // process batch jest.runOnlyPendingTimers(); expect(store.actions.source.fetch).toHaveBeenCalledTimes( config.requestsPerBatch * i ); } }); });