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