Skip to content

Instantly share code, notes, and snippets.

@tkrotoff
Last active November 25, 2024 09:31
Show Gist options
  • Save tkrotoff/52f4a29e919445d6e97f9a9e44ada449 to your computer and use it in GitHub Desktop.
Save tkrotoff/52f4a29e919445d6e97f9a9e44ada449 to your computer and use it in GitHub Desktop.
Jest/Vitest mocks for JSDOM window.location & window.history
/* eslint-disable unicorn/no-null */
/*
* Resetting window.location between tests is unfortunately a hard topic with JSDOM.
*
* https://gist.github.com/tkrotoff/52f4a29e919445d6e97f9a9e44ada449
*
* FIXME JSDOM leaves the history in place after every test, so the history will be dirty.
* Also its implementations for window.location and window.history are lacking.
* - https://github.com/jsdom/jsdom/blob/22.1.0/lib/jsdom/living/window/Location-impl.js
* - https://github.com/jsdom/jsdom/blob/22.1.0/lib/jsdom/living/window/History-impl.js
* - https://github.com/jsdom/jsdom/blob/22.1.0/lib/jsdom/living/window/SessionHistory.js
*
* What about Happy DOM? Implementations are empty:
* - https://github.com/capricorn86/happy-dom/blob/v12.10.3/packages/happy-dom/src/location/Location.ts
* - https://github.com/capricorn86/happy-dom/blob/v12.10.3/packages/happy-dom/src/history/History.ts
*
*
* window.location and window.history should work together:
* window.history should update the location, and changing the location should push a new state in the history
*
* Solution: re-implement window.location and window.history
* The code is synchronous instead of asynchronous, yet it fires "popstate" events
*
* Inspired by:
* - https://github.com/jestjs/jest/issues/5124#issuecomment-792768806
* - https://github.com/firefox-devtools/profiler/blob/f894531be77dee00bb641f49a657b072183ec1fa/src/test/fixtures/mocks/window-navigation.js
*
*
* Related issues:
* - https://github.com/jestjs/jest/issues/5987
* - https://github.com/jestjs/jest/issues/890
* - https://github.com/jestjs/jest/issues/5124
* - https://stackoverflow.com/a/76424392
*
* - Huge hope on jsdom.reconfigure() (tried by patching Vitest JSDOM env), doesn't work
* https://github.com/vitest-dev/vitest/discussions/2383
* https://github.com/simon360/jest-environment-jsdom-global/blob/v4.0.0/environment.js
* https://github.com/simon360/jest-environment-jsdom-global/blob/v4.0.0/README.md#using-jsdom-in-your-test-suite
*/
class WindowLocationMock implements Location {
private url: URL;
internalSetURLFromHistory(newURL: string | URL) {
this.url = new URL(newURL, this.url);
}
constructor(url: string) {
this.url = new URL(url);
}
toString() {
return this.url.toString();
}
readonly ancestorOrigins = [] as unknown as DOMStringList;
get href() {
return this.url.toString();
}
set href(newUrl) {
this.assign(newUrl);
}
get origin() {
return this.url.origin;
}
get protocol() {
return this.url.protocol;
}
set protocol(v) {
const newUrl = new URL(this.url);
newUrl.protocol = v;
this.assign(newUrl);
}
get host() {
return this.url.host;
}
set host(v) {
const newUrl = new URL(this.url);
newUrl.host = v;
this.assign(newUrl);
}
get hostname() {
return this.url.hostname;
}
set hostname(v) {
const newUrl = new URL(this.url);
newUrl.hostname = v;
this.assign(newUrl);
}
get port() {
return this.url.port;
}
set port(v) {
const newUrl = new URL(this.url);
newUrl.port = v;
this.assign(newUrl);
}
get pathname() {
return this.url.pathname;
}
set pathname(v) {
const newUrl = new URL(this.url);
newUrl.pathname = v;
this.assign(newUrl);
}
get search() {
return this.url.search;
}
set search(v) {
const newUrl = new URL(this.url);
newUrl.search = v;
this.assign(newUrl);
}
get hash() {
return this.url.hash;
}
set hash(v) {
const newUrl = new URL(this.url);
newUrl.hash = v;
this.assign(newUrl);
}
assign(newUrl: string | URL) {
window.history.pushState(null, 'origin:location', newUrl);
this.reload();
}
replace(newUrl: string | URL) {
window.history.replaceState(null, 'origin:location', newUrl);
this.reload();
}
// eslint-disable-next-line class-methods-use-this
reload() {
// Do nothing
}
}
const originalLocation = window.location;
export function mockWindowLocation(url: string) {
//window.location = new WindowLocationMock(url);
//document.location = window.location;
Object.defineProperty(window, 'location', {
writable: true,
value: new WindowLocationMock(url)
});
}
export function restoreWindowLocation() {
//window.location = originalLocation;
Object.defineProperty(window, 'location', {
writable: true,
value: originalLocation
});
}
function verifyOrigin(newURL: string | URL, method: 'pushState' | 'replaceState') {
const currentOrigin = window.location.origin;
if (new URL(newURL, currentOrigin).origin !== currentOrigin) {
// Same error message as Chrome 118
throw new DOMException(
`Failed to execute '${method}' on 'History': A history state object with URL '${newURL.toString()}' cannot be created in a document with origin '${currentOrigin}' and URL '${
window.location.href
}'.`
);
}
}
export class WindowHistoryMock implements History {
private index = 0;
// Should be private but making it public makes it really easy to verify everything is OK in some tests
public sessionHistory: [{ state: any; url: string }] = [
{ state: null, url: window.location.href }
];
get length() {
return this.sessionHistory.length;
}
scrollRestoration = 'auto' as const;
get state() {
// eslint-disable-next-line @typescript-eslint/no-unsafe-return
return this.sessionHistory[this.index].state;
}
back() {
this.go(-1);
}
forward() {
this.go(+1);
}
go(delta = 0) {
if (delta === 0) {
window.location.reload();
}
const newIndex = this.index + delta;
if (newIndex < 0 || newIndex >= this.length) {
// Do nothing
} else if (newIndex === this.index) {
// Do nothing
} else {
this.index = newIndex;
(window.location as WindowLocationMock).internalSetURLFromHistory(
this.sessionHistory[this.index].url
);
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
dispatchEvent(new PopStateEvent('popstate', { state: this.state }));
}
}
pushState(data: any, unused: string, url?: string | URL | null) {
if (url) {
if (unused !== 'origin:location') verifyOrigin(url, 'pushState');
(window.location as WindowLocationMock).internalSetURLFromHistory(url);
}
this.sessionHistory.push({
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
state: structuredClone(data),
url: window.location.href
});
this.index++;
}
replaceState(data: any, unused: string, url?: string | URL | null) {
if (url) {
if (unused !== 'origin:location') verifyOrigin(url, 'replaceState');
(window.location as WindowLocationMock).internalSetURLFromHistory(url);
}
this.sessionHistory[this.index] = {
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
state: structuredClone(data),
url: window.location.href
};
}
}
const originalHistory = window.history;
export function mockWindowHistory() {
//window.history = new WindowHistoryMock();
Object.defineProperty(window, 'history', {
writable: true,
value: new WindowHistoryMock()
});
}
export function restoreWindowHistory() {
//window.history = originalHistory;
Object.defineProperty(window, 'history', {
writable: true,
value: originalHistory
});
}
/* eslint-disable unicorn/no-null */
import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest';
import {
mockWindowHistory,
mockWindowLocation,
restoreWindowHistory,
restoreWindowLocation,
WindowHistoryMock
} from './mockWindowLocation';
/**
* Taken from https://en.wikipedia.org/wiki/Uniform_Resource_Identifier#Example_URIs
*
* userinfo host port
* ┌──┴───┐ ┌──────┴──────┐ ┌┴┐
* https://[email protected]:123/forum/questions/?tag=networking&order=newest#top
* └─┬─┘ └─────────────┬────────────┘└───────┬───────┘ └────────────┬────────────┘ └┬┘
* scheme authority path query fragment
*
*/
const urlNetworking =
'https://[email protected]:123/forum/questions/?tag=networking&order=newest#top';
const urlComputing = 'https://[email protected]:123/forum/answers/?tag=computing';
const urlMathematics = 'https://[email protected]:123/forum/answers/?tag=mathematics';
const urlCryptography = 'https://[email protected]:123/forum/answers/?tag=cryptography';
const urlWikipedia = 'https://en.wikipedia.org/wiki/Main_Page';
beforeEach(() => {
mockWindowLocation(urlNetworking);
mockWindowHistory();
});
afterEach(() => {
restoreWindowLocation();
restoreWindowHistory();
});
describe('window.location', () => {
test('mock and restore window.location', () => {
expect(window.location.href).toBe(urlNetworking);
expect(document.location.href).toBe('http://localhost:3000/'); // :-/
expect(document.URL).toBe('http://localhost:3000/'); // :-/
expect(document.documentURI).toBe('http://localhost:3000/'); // :-/
restoreWindowLocation();
expect(window.location.href).toBe('http://localhost:3000/');
expect(document.location.href).toBe('http://localhost:3000/');
expect(document.URL).toBe('http://localhost:3000/');
expect(document.documentURI).toBe('http://localhost:3000/');
});
test('getters', () => {
expect(window.location.toString()).toBe(urlNetworking);
expect(window.location.href).toBe(urlNetworking);
expect(window.location.origin).toBe('https://www.example.com:123');
expect(window.location.protocol).toBe('https:');
expect(window.location.host).toBe('www.example.com:123');
expect(window.location.hostname).toBe('www.example.com');
expect(window.location.port).toBe('123');
expect(window.location.pathname).toBe('/forum/questions/');
expect(window.location.search).toBe('?tag=networking&order=newest');
expect(window.location.hash).toBe('#top');
});
test('set href', () => {
expect(window.location.href).toBe(urlNetworking);
window.location.href = urlWikipedia;
expect(window.history.length).toBe(2);
expect((window.history as WindowHistoryMock).sessionHistory).toEqual([
{ state: null, url: urlNetworking },
{ state: null, url: urlWikipedia }
]);
expect(window.history.state).toBeNull();
expect(window.location.href).toBe(urlWikipedia);
});
test('set protocol', () => {
expect(window.location.protocol).toBe('https:');
window.location.protocol = 'http:';
expect(window.history.length).toBe(2);
expect((window.history as WindowHistoryMock).sessionHistory).toEqual([
{ state: null, url: urlNetworking },
{
state: null,
url: 'http://[email protected]:123/forum/questions/?tag=networking&order=newest#top'
}
]);
expect(window.history.state).toBeNull();
expect(window.location.href).toBe(
'http://[email protected]:123/forum/questions/?tag=networking&order=newest#top'
);
});
test('set host', () => {
expect(window.location.host).toBe('www.example.com:123');
window.location.host = 'en.wikipedia.org';
expect(window.history.length).toBe(2);
expect((window.history as WindowHistoryMock).sessionHistory).toEqual([
{ state: null, url: urlNetworking },
{
state: null,
url: 'https://[email protected]:123/forum/questions/?tag=networking&order=newest#top'
}
]);
expect(window.history.state).toBeNull();
expect(window.location.href).toBe(
'https://[email protected]:123/forum/questions/?tag=networking&order=newest#top'
);
});
test('set hostname', () => {
expect(window.location.hostname).toBe('www.example.com');
window.location.hostname = 'en.wikipedia.org';
expect(window.history.length).toBe(2);
expect((window.history as WindowHistoryMock).sessionHistory).toEqual([
{ state: null, url: urlNetworking },
{
state: null,
url: 'https://[email protected]:123/forum/questions/?tag=networking&order=newest#top'
}
]);
expect(window.history.state).toBeNull();
expect(window.location.href).toBe(
'https://[email protected]:123/forum/questions/?tag=networking&order=newest#top'
);
});
test('set port', () => {
expect(window.location.port).toBe('123');
window.location.port = '1234';
expect(window.history.length).toBe(2);
expect((window.history as WindowHistoryMock).sessionHistory).toEqual([
{ state: null, url: urlNetworking },
{
state: null,
url: 'https://[email protected]:1234/forum/questions/?tag=networking&order=newest#top'
}
]);
expect(window.history.state).toBeNull();
expect(window.location.href).toBe(
'https://[email protected]:1234/forum/questions/?tag=networking&order=newest#top'
);
});
test('set pathname', () => {
expect(window.location.pathname).toBe('/forum/questions/');
window.location.pathname = '/forum/answers/';
expect(window.history.length).toBe(2);
expect((window.history as WindowHistoryMock).sessionHistory).toEqual([
{ state: null, url: urlNetworking },
{
state: null,
url: 'https://[email protected]:123/forum/answers/?tag=networking&order=newest#top'
}
]);
expect(window.history.state).toBeNull();
expect(window.location.href).toBe(
'https://[email protected]:123/forum/answers/?tag=networking&order=newest#top'
);
});
test('set search', () => {
expect(window.location.search).toBe('?tag=networking&order=newest');
window.location.search = '?tag=networking&order=oldest';
expect(window.history.length).toBe(2);
expect((window.history as WindowHistoryMock).sessionHistory).toEqual([
{ state: null, url: urlNetworking },
{
state: null,
url: 'https://[email protected]:123/forum/questions/?tag=networking&order=oldest#top'
}
]);
expect(window.history.state).toBeNull();
expect(window.location.href).toBe(
'https://[email protected]:123/forum/questions/?tag=networking&order=oldest#top'
);
});
test('set hash', () => {
expect(window.location.hash).toBe('#top');
window.location.hash = '#bottom';
expect(window.history.length).toBe(2);
expect((window.history as WindowHistoryMock).sessionHistory).toEqual([
{ state: null, url: urlNetworking },
{
state: null,
url: 'https://[email protected]:123/forum/questions/?tag=networking&order=newest#bottom'
}
]);
expect(window.history.state).toBeNull();
expect(window.location.href).toBe(
'https://[email protected]:123/forum/questions/?tag=networking&order=newest#bottom'
);
});
test('assign()', () => {
window.location.assign(urlComputing);
expect(window.history.length).toBe(2);
expect((window.history as WindowHistoryMock).sessionHistory).toEqual([
{ state: null, url: urlNetworking },
{ state: null, url: urlComputing }
]);
expect(window.history.state).toBeNull();
expect(window.location.href).toBe(urlComputing);
});
test('replace()', () => {
window.location.replace(urlComputing);
expect(window.history.length).toBe(1);
expect((window.history as WindowHistoryMock).sessionHistory).toEqual([
{ state: null, url: urlComputing }
]);
expect(window.history.state).toBeNull();
expect(window.location.href).toBe(urlComputing);
});
test('reload()', () => {
window.location.reload();
expect(window.history.length).toBe(1);
expect((window.history as WindowHistoryMock).sessionHistory).toEqual([
{ state: null, url: urlNetworking }
]);
expect(window.history.state).toBeNull();
expect(window.location.href).toBe(urlNetworking);
});
});
describe('window.history', () => {
test('mock and restore window.history', () => {
expect((window.history as WindowHistoryMock).sessionHistory).toEqual([
{ state: null, url: urlNetworking }
]);
restoreWindowHistory();
// Back to JSDOM window.history
expect((window.history as WindowHistoryMock).sessionHistory).toBeUndefined();
});
test('go()', () => {
expect(window.history.length).toBe(1);
window.history.pushState('computing', 'unused', '/forum/answers/?tag=computing');
window.history.pushState('mathematics', 'unused', '/forum/answers/?tag=mathematics');
window.history.pushState('cryptography', 'unused', '/forum/answers/?tag=cryptography');
expect(window.history.length).toBe(4);
expect(window.location.href).toBe(urlCryptography);
window.history.go(-1);
expect(window.location.href).toBe(urlMathematics);
// Reload current page
window.history.go(0);
expect(window.location.href).toBe(urlMathematics);
window.history.go(-2);
expect(window.location.href).toBe(urlNetworking);
// Do nothing
window.history.go(-1);
expect(window.location.href).toBe(urlNetworking);
window.history.go(+1);
expect(window.location.href).toBe(urlComputing);
window.history.go(+2);
expect(window.location.href).toBe(urlCryptography);
// Do nothing
window.history.go(+1);
expect(window.location.href).toBe(urlCryptography);
});
test('back()', () => {
window.history.pushState('computing', 'unused', '/forum/answers/?tag=computing');
window.history.pushState('mathematics', 'unused', '/forum/answers/?tag=mathematics');
window.history.pushState('cryptography', 'unused', '/forum/answers/?tag=cryptography');
expect(window.location.href).toBe(urlCryptography);
window.history.back();
expect(window.location.href).toBe(urlMathematics);
window.history.back();
expect(window.location.href).toBe(urlComputing);
window.history.back();
expect(window.location.href).toBe(urlNetworking);
// Do nothing
window.history.back();
expect(window.location.href).toBe(urlNetworking);
});
test('forward()', () => {
window.history.pushState('computing', 'unused', '/forum/answers/?tag=computing');
window.history.pushState('mathematics', 'unused', '/forum/answers/?tag=mathematics');
window.history.pushState('cryptography', 'unused', '/forum/answers/?tag=cryptography');
expect(window.location.href).toBe(urlCryptography);
window.history.back();
window.history.back();
window.history.back();
expect(window.location.href).toBe(urlNetworking);
window.history.forward();
expect(window.location.href).toBe(urlComputing);
window.history.forward();
expect(window.location.href).toBe(urlMathematics);
window.history.forward();
expect(window.location.href).toBe(urlCryptography);
// Do nothing
window.history.forward();
expect(window.location.href).toBe(urlCryptography);
});
test('pushState()', () => {
expect(window.history.length).toBe(1);
expect((window.history as WindowHistoryMock).sessionHistory).toEqual([
{ state: null, url: urlNetworking }
]);
expect(window.history.state).toBeNull();
window.history.pushState('computing', 'unused', '/forum/answers/?tag=computing');
expect(window.history.length).toBe(2);
expect((window.history as WindowHistoryMock).sessionHistory).toEqual([
{ state: null, url: urlNetworking },
{ state: 'computing', url: urlComputing }
]);
expect(window.history.state).toBe('computing');
expect(window.location.href).toBe(urlComputing);
window.history.pushState('mathematics', 'unused', '/forum/answers/?tag=mathematics');
expect(window.history.length).toBe(3);
expect((window.history as WindowHistoryMock).sessionHistory).toEqual([
{ state: null, url: urlNetworking },
{ state: 'computing', url: urlComputing },
{ state: 'mathematics', url: urlMathematics }
]);
expect(window.history.state).toBe('mathematics');
expect(window.location.href).toBe(urlMathematics);
});
test('replaceState()', () => {
expect(window.history.length).toBe(1);
expect((window.history as WindowHistoryMock).sessionHistory).toEqual([
{ state: null, url: urlNetworking }
]);
expect(window.history.state).toBeNull();
window.history.replaceState('computing', 'unused', '/forum/answers/?tag=computing');
expect(window.history.length).toBe(1);
expect((window.history as WindowHistoryMock).sessionHistory).toEqual([
{ state: 'computing', url: urlComputing }
]);
expect(window.history.state).toBe('computing');
expect(window.location.href).toBe(urlComputing);
window.history.pushState('mathematics', 'unused', '/forum/answers/?tag=mathematics');
expect(window.history.length).toBe(2);
expect((window.history as WindowHistoryMock).sessionHistory).toEqual([
{ state: 'computing', url: urlComputing },
{ state: 'mathematics', url: urlMathematics }
]);
expect(window.history.state).toBe('mathematics');
expect(window.location.href).toBe(urlMathematics);
window.history.replaceState('cryptography', 'unused', '/forum/answers/?tag=cryptography');
expect(window.history.length).toBe(2);
expect((window.history as WindowHistoryMock).sessionHistory).toEqual([
{ state: 'computing', url: urlComputing },
{ state: 'cryptography', url: urlCryptography }
]);
expect(window.history.state).toBe('cryptography');
expect(window.location.href).toBe(urlCryptography);
});
test('pushState() and replaceState() throw when pushing an URL with another origin', () => {
expect(() => {
window.history.pushState({}, 'unused', urlWikipedia);
}).toThrow(
"Failed to execute 'pushState' on 'History': A history state object with URL 'https://en.wikipedia.org/wiki/Main_Page' cannot be created in a document with origin 'https://www.example.com:123' and URL 'https://[email protected]:123/forum/questions/?tag=networking&order=newest#top'."
);
expect(() => {
window.history.replaceState({}, 'unused', urlWikipedia);
}).toThrow(
"Failed to execute 'replaceState' on 'History': A history state object with URL 'https://en.wikipedia.org/wiki/Main_Page' cannot be created in a document with origin 'https://www.example.com:123' and URL 'https://[email protected]:123/forum/questions/?tag=networking&order=newest#top'."
);
});
test('popstate event - see window-history-playground.html', () => {
const popstateSpy = vi.fn();
function popstateListener({ state }: PopStateEvent) {
popstateSpy(state);
}
window.addEventListener('popstate', popstateListener);
expect(popstateSpy).toHaveBeenCalledTimes(0);
window.history.pushState('page=1', 'page=1', '?page=1');
window.history.pushState('page=2', 'page=2', '?page=2');
window.history.pushState('page=3', 'page=3', '?page=3');
window.history.replaceState('page=3-replace', 'page=3-replace', '?page=3-replace');
// popstate page=2
window.history.back();
expect(popstateSpy).toHaveBeenCalledTimes(1);
expect(popstateSpy).toHaveBeenNthCalledWith(1, 'page=2');
// popstate page=1
window.history.back();
expect(popstateSpy).toHaveBeenCalledTimes(2);
expect(popstateSpy).toHaveBeenNthCalledWith(2, 'page=1');
// popstate null (i.e. root page)
window.history.back();
expect(popstateSpy).toHaveBeenCalledTimes(3);
expect(popstateSpy).toHaveBeenNthCalledWith(3, null);
// popstate page=2
window.history.go(2);
expect(popstateSpy).toHaveBeenCalledTimes(4);
expect(popstateSpy).toHaveBeenNthCalledWith(4, 'page=2');
// popstate page=1
window.history.go(-1);
expect(popstateSpy).toHaveBeenCalledTimes(5);
expect(popstateSpy).toHaveBeenNthCalledWith(5, 'page=1');
// load page=1
window.history.go();
expect(popstateSpy).toHaveBeenCalledTimes(5);
window.removeEventListener('popstate', popstateListener);
});
});
/**
* JSDOM is missing some important DOM APIs so we need to mock them.
*/
import { afterEach, beforeEach } from 'vitest';
import {
mockWindowHistory,
mockWindowLocation,
restoreWindowHistory,
restoreWindowLocation
} from './mockWindowLocation';
beforeEach(() => {
mockWindowLocation('http://localhost:3000');
mockWindowHistory();
});
afterEach(() => {
restoreWindowLocation();
restoreWindowHistory();
});
<!DOCTYPE html>
<html lang="en">
<head>
<title>window.history playground</title>
</head>
<body>
<script type="module">
// See mockWindowLocation.test.ts "popstate event" test
window.addEventListener('load', event => {
console.info('load', location.href);
});
window.addEventListener('popstate', ({ state }) => {
console.info('popstate', location.href, state);
});
// window.location.assign("https://en.wikipedia.org/wiki/Main_Page");
window.history.pushState('page=1', 'page=1', '?page=1');
window.history.pushState('page=2', 'page=2', '?page=2');
window.history.pushState('page=3', 'page=3', '?page=3');
window.history.replaceState('page=3-replace', 'page=3-replace', '?page=3-replace');
// popstate page=2
//window.history.back();
// popstate page=1
//window.history.back();
// popstate null (i.e. root page)
//window.history.back();
// popstate page=2
//window.history.go(+2);
// popstate page=1
//window.history.go(-1);
//window.history.go();
</script>
</body>
</html>
@gaborbernat
Copy link

Could be worth doing an npm release for this?

@amosjyng
Copy link

amosjyng commented Nov 5, 2024

Doesn't appear to work for me on Vitest with Svelte. I get

__vite_ssr_import_10__.replaceState is not a function

But this is more a problem with Svelte than anything else.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment