Skip to content

Commit

Permalink
Breaking: Add spies and proxy for window.history to track its updates…
Browse files Browse the repository at this point in the history
… to the original window.location
  • Loading branch information
evelynhathaway committed Oct 19, 2023
1 parent 8ef5de8 commit 628a60d
Show file tree
Hide file tree
Showing 12 changed files with 2,663 additions and 1,202 deletions.
8 changes: 8 additions & 0 deletions config/jest-setup.ts
Original file line number Diff line number Diff line change
@@ -1,2 +1,10 @@
// Default setup side-effects
import "../src";

// Enable fetch mock for use in React Router tests
// - Reference: https://reactrouter.com/en/main/routers/picking-a-router#testing
// - `Request` is undefined without this, even in Node.js v20
import "whatwg-fetch";

// Add matchers from Testing Library
import "@testing-library/jest-dom";
3,570 changes: 2,386 additions & 1,184 deletions package-lock.json

Large diffs are not rendered by default.

32 changes: 20 additions & 12 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -46,27 +46,35 @@
},
"dependencies": {
"@jedmao/location": "^3.0.0",
"jest-diff": "^29.6.4"
"jest-diff": "^29.7.0"
},
"devDependencies": {
"@semantic-release/changelog": "^6.0.3",
"@semantic-release/git": "^10.0.1",
"@types/jest": "^29.5.4",
"@typescript-eslint/eslint-plugin": "^5.60.1",
"@typescript-eslint/parser": "^5.60.1",
"commitlint": "^17.7.1",
"@testing-library/jest-dom": "^6.1.4",
"@testing-library/react": "^14.0.0",
"@types/jest": "^29.5.6",
"@types/react": "^18.2.29",
"@types/react-dom": "^18.2.14",
"@typescript-eslint/eslint-plugin": "^5.62.0",
"@typescript-eslint/parser": "^5.62.0",
"commitlint": "^17.8.0",
"conventional-changelog-evelyn": "^1.3.1",
"eslint": "^8.44.0",
"eslint": "^8.51.0",
"eslint-plugin-evelyn": "^9.0.0",
"eslint-plugin-unicorn": "^47.0.0",
"husky": "^8.0.3",
"jest": "^29.6.4",
"jest-environment-jsdom": "^29.6.4",
"lint-staged": "^14.0.1",
"semantic-release": "^21.1.1",
"sort-package-json": "^2.5.1",
"jest": "^29.7.0",
"jest-environment-jsdom": "^29.7.0",
"lint-staged": "^15.0.1",
"react": "^18.2.0",
"react-dom": "^18.2.0",
"react-router-dom": "^6.17.0",
"semantic-release": "^22.0.5",
"sort-package-json": "^2.6.0",
"ts-jest": "^29.1.1",
"typescript": "^5.2.2"
"typescript": "^5.2.2",
"whatwg-fetch": "^3.6.19"
},
"engines": {
"node": "^16.10.0 || >=18.0.0"
Expand Down
85 changes: 85 additions & 0 deletions src/__tests__/react-router-dom.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
import React from "react";
import {screen, render, act} from "@testing-library/react";
import {
BrowserRouter,
createBrowserRouter,
MemoryRouter,
RouterProvider,
Routes,
Route,
Link,
} from "react-router-dom";

describe("react-router-dom", () => {
describe("createBrowserRouter() and <RouterProvider>", () => {
it("should change routes alongside window.location", () => {
const router = createBrowserRouter([
{
path: "/",
element: <Link to="/e8ab27c6-7e83-4fa8-a52b-b3bab5023ff0">Link from Root to Page</Link>,
},
{
path: "/e8ab27c6-7e83-4fa8-a52b-b3bab5023ff0",
element: <Link to="/">Link from Page to Root</Link>,
},
]);
render(<RouterProvider router={router} />);
act(() => {
screen.getByText("Link from Root to Page").click();
});
expect(window.location).toBeAt("/e8ab27c6-7e83-4fa8-a52b-b3bab5023ff0");
expect(screen.getByText("Link from Page to Root")).toBeInTheDocument();
});
});

describe("<BrowserRouter>", () => {
it("should change routes alongside window.location", () => {
render(
<BrowserRouter>
<Routes>
<Route
path="/"
element={<Link to="/5c99ad6a-c1ed-4c6d-b4a9-9460a6da36f8">Link from Root to Page</Link>}
/>
<Route
path="/5c99ad6a-c1ed-4c6d-b4a9-9460a6da36f8"
element={<Link to="/">Link from Page to Root</Link>}
/>
</Routes>
</BrowserRouter>
);
act(() => {
screen.getByText("Link from Root to Page").click();
});
expect(window.location).toBeAt("/5c99ad6a-c1ed-4c6d-b4a9-9460a6da36f8");
expect(screen.getByText("Link from Page to Root")).toBeInTheDocument();
});
});

describe("<MemoryRouter>", () => {
it("should change routes, but window.location should not update", () => {
render(
<MemoryRouter>
<Routes>
<Route
path="/"
element={<Link to="/1494ea67-a401-43c8-b393-dc8c843f394b">Link from Root to Page</Link>}
/>
<Route
path="/1494ea67-a401-43c8-b393-dc8c843f394b"
element={<Link to="/">Link from Page to Root</Link>}
/>
</Routes>
</MemoryRouter>
);
act(() => {
screen.getByText("Link from Root to Page").click();
});
// MemoryRouter doesn't change the `window.location`
// - This time I checked a clean environment without any mocks or other tests
expect(window.location).toBeAt("/");
// The router should still behave like normal, even though it wouldn't change the location on the browser
expect(screen.getByText("Link from Page to Root")).toBeInTheDocument();
});
});
});
33 changes: 29 additions & 4 deletions src/__tests__/spies.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,17 +2,42 @@ describe("window.location", () => {
it("should have spy for assign()", () => {
window.location.assign("/b3a06294-6e91-44c4-9a16-e8f328105940");
expect(window.location.assign).toHaveBeenCalledWith("/b3a06294-6e91-44c4-9a16-e8f328105940");
});
});

it("should have spy for reload()", () => {
window.location.reload();
expect(window.location.reload).toHaveBeenCalled();
});
window.location.reload();
expect(window.location.reload).toHaveBeenCalled();
});

it("should have spy for replace()", () => {
window.location.replace("/181bcd32-84ee-4cf2-ad3c-f12363293050");
expect(window.location.replace).toHaveBeenCalledWith("/181bcd32-84ee-4cf2-ad3c-f12363293050");
});
});

describe("window.history", () => {
it("should have spy for replaceState()", () => {
window.history.replaceState(null, "", "/c25b924e-163f-4b8a-936e-224229fbbd4b");
expect(window.history.replaceState).toHaveBeenCalledWith(null, "", "/c25b924e-163f-4b8a-936e-224229fbbd4b");
});

it("should have spy() for pushState", () => {
window.history.pushState(null, "", "/c25b924e-163f-4b8a-936e-224229fbbd4b");
expect(window.history.pushState).toHaveBeenCalledWith(null, "", "/c25b924e-163f-4b8a-936e-224229fbbd4b");
});

it("should have spy for go()", () => {
window.history.go(-2);
expect(window.history.go).toHaveBeenCalledWith(-2);
});

it("should have spy for back()", () => {
window.history.back();
expect(window.history.back).toHaveBeenCalled();
});

it("should have spy for forward()", () => {
window.history.forward();
expect(window.history.forward).toHaveBeenCalled();
});
});
55 changes: 55 additions & 0 deletions src/__tests__/window-history.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
describe("window.history", () => {
describe("replaceState()", () => {
it("should update location mock", () => {
expect(window.location).toBeAt("/");
window.history.replaceState(null, "", "/0e37bca8-b8e3-49ee-8ac6-57e50c2884d3");
expect(window.location).toBeAt("/0e37bca8-b8e3-49ee-8ac6-57e50c2884d3");
});
});

describe("pushState()", () => {
it("should update location mock", () => {
expect(window.location).toBeAt("/");
window.history.pushState(null, "", "/e8bf1e04-46e9-4f30-abcb-320412243ea2");
expect(window.location).toBeAt("/e8bf1e04-46e9-4f30-abcb-320412243ea2");
});
});

// Skipped as JSDOM doesn't handle history traversal well
// - `back()` and `forward()` do not change the location in JSDOM, therefore we're not currently testing for
// handling updating the mock
// - This may change as the JSDOM improves or the potentially if the scope of the project expands to replacing
// JSDOM's mocks with our own that are better suited to single page application testing
// - Related: https://github.com/jsdom/jsdom/issues/1565
describe.skip("history", () => {
describe("go()", () => {
it("should update location mock", () => {
expect(window.location).toBeAt("/");
window.history.pushState(null, "", "/04d333bd-4694-49be-aa25-d7e92515eabb");
expect(window.location).toBeAt("/04d333bd-4694-49be-aa25-d7e92515eabb");
window.history.pushState(null, "", "/ca1ed895-b590-421e-be9b-84ef2a5aa5fd");
expect(window.location).toBeAt("/ca1ed895-b590-421e-be9b-84ef2a5aa5fd");
window.history.go(-1);
expect(window.location).toBeAt("/04d333bd-4694-49be-aa25-d7e92515eabb");
window.history.go(-1);
expect(window.location).toBeAt("/");
window.history.go(2);
expect(window.location).toBeAt("/ca1ed895-b590-421e-be9b-84ef2a5aa5fd");
});
});
describe("back() and forward()", () => {
it("should update location mock", () => {
expect(window.location).toBeAt("/");
window.history.pushState(null, "", "/9330c6b2-153d-411f-ac67-8adb74b614f0");
expect(window.location).toBeAt("/9330c6b2-153d-411f-ac67-8adb74b614f0");
window.history.back();
expect(window.location).toBeAt("/");
window.history.forward();
expect(window.location).toBeAt("/9330c6b2-153d-411f-ac67-8adb74b614f0");
// Forward cannot progress past the most recent state, so this should should do nothing compared to above
window.history.forward();
expect(window.location).toBeAt("/9330c6b2-153d-411f-ac67-8adb74b614f0");
});
});
});
});
12 changes: 12 additions & 0 deletions src/hooks/__tests__/replace-history-node.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
/** @jest-environment node */
import {replaceHistory} from "../replace-history";

describe("replaceHistory()", () => {
describe("when window not defined", () => {
it("should not change window", () => {
// Should already be run by jest setup, but let's run again for test verbosity and to report any errors
replaceHistory();
expect(typeof window).toBe("undefined");
});
});
});
1 change: 1 addition & 0 deletions src/hooks/index.ts
Original file line number Diff line number Diff line change
@@ -1 +1,2 @@
export * from "./replace-location";
export * from "./replace-history";
55 changes: 55 additions & 0 deletions src/hooks/replace-history.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
import {originalLocationRef} from "./replace-location";

export const replaceHistory = (): void => {
// Do nothing if window is not defined
// - Prevents an error when importing this mock in the setup file when some tests use the node test environment instead of JSDOM
if (typeof window === "undefined") {
return;
}

const proxiedHistory = new Proxy(
window.history,
{
get (target, property, receiver) {
const realValue: unknown = Reflect.get(target, property, receiver);
// If the property of window.history is a method, wrap it in a proxy to update the location mock
if (realValue instanceof Function || jest.isMockFunction(realValue)) {
return new Proxy(
realValue,
{
apply (...args) {
Reflect.apply(...args);
// Update the location mock if the location was updated
if (originalLocationRef.current && window.location.href !== originalLocationRef.current.href) {
window.location.href = originalLocationRef.current.href;
}
},
}
);
}
return realValue;
},
}
);

// Setup Jest spies on the methods for convenience and our matchers
jest.spyOn(proxiedHistory, "replaceState").mockName("window.history.replaceState");
jest.spyOn(proxiedHistory, "pushState").mockName("window.history.pushState");
jest.spyOn(proxiedHistory, "go").mockName("window.history.go");
jest.spyOn(proxiedHistory, "back").mockName("window.history.back");
jest.spyOn(proxiedHistory, "forward").mockName("window.history.forward");

// Add the property to the Window
Object.defineProperty(
window,
"history",
{
set: undefined,
get () {
return proxiedHistory;
},
configurable: true,
enumerable: true,
},
);
};
6 changes: 6 additions & 0 deletions src/hooks/replace-location.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,18 @@
import {LocationMockRelative} from "../utils";

export const originalLocationRef: {current: Location | null} = {current: null};

export const replaceLocation = (): void => {
// Do nothing if window is not defined
// - Prevents an error when importing this mock in the setup file when some tests use the node test environment instead of JSDOM
if (typeof window === "undefined") {
return;
}

if (!originalLocationRef.current) {
originalLocationRef.current = window.location;
}

// Set the base URL for relative URLs to `HOST` environment variable, defaults to localhost
const locationMock = new LocationMockRelative(process.env.HOST || "http://localhost/");

Expand Down
7 changes: 5 additions & 2 deletions src/setup-hooks.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,8 @@
import {replaceLocation} from "./hooks";
import {replaceHistory, replaceLocation} from "./hooks";


// Setup default hooks configuration
beforeEach(replaceLocation);
beforeEach(() => {
replaceLocation();
replaceHistory();
});
1 change: 1 addition & 0 deletions tsconfig.json
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
"outDir": "lib",
"moduleResolution": "node",
"declaration": true,
"jsx": "react"
},
"include": [
"src",
Expand Down

0 comments on commit 628a60d

Please sign in to comment.