/
window-navigation.js
296 lines (268 loc) · 9.04 KB
/
window-navigation.js
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
/* 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 http://mozilla.org/MPL/2.0/. */
// @flow
/**
* jsdom leaves the history in place after every test, so the history will
* be dirty. Also its implementation for window.location is lacking severely.
* So this file implements mocks for both window.history and
* window.location. They work together so that history methods update the
* location, and changing the location pushes a new state in the history.
*
* There are 2 exports:
* - autoMockFullNavigation
* - mockFullNavigation
*
* `autoMockFullNavigation` is the simplest: calling it in a test file (outside
* any test) will register beforeEach and afterEach lifecycle functions to take
* care of the cleanup automatically.`
*
* describe('SomeFile', () => {
* autoMockFullNavigation()
*
* test('it supports history', () => {
* ...
* });
* });
*
* If you want to start with a specific URL, you can use
* `window.location.replace` or `window.history.replaceState` to replace the
* default URL.`
*
* `mockFullNavigation` can be useful if you want more control over the process.
* It takes the initial URL as a parameter and returns a cleanup function that
* you _must_ call after your test ends.
*/
import { coerceMatchingShape } from '../../../utils/flow';
// This symbol will be used in the mock for window.location so that the mock for
// window.history can change the inner location directly.
const internalLocationAssign = Symbol.for('internalLocationAssign');
// This symbol will be used in the mock for window.history so that we can reset
// it from tests.
const internalHistoryReset = Symbol.for('internalHistoryReset');
/**
* This mock creates a location API that allows for assigning to the location,
* which we need to be able to do for certain tests.
*/
function mockWindowLocation(location: string = 'http://localhost') {
// This is the internal state.
let url = new URL(location);
function internalSetLocation(
newUrl: string | { toString: () => string }
): void {
url = new URL(newUrl.toString(), url);
}
const nativeLocation = Object.getOwnPropertyDescriptor(window, 'location');
// It seems node v8 doesn't let us change the value unless we delete it before.
delete window.location;
const property = {
get(): $Shape<Location> {
return {
toString: () => url.toString(),
ancestorOrigins: [],
get href() {
return url.toString();
},
get origin() {
return url.origin;
},
get protocol() {
return url.protocol;
},
get host() {
return url.host;
},
get hostname() {
return url.hostname;
},
get port() {
return url.port;
},
get pathname() {
return url.pathname;
},
get search() {
return url.search;
},
get hash() {
return url.hash;
},
set href(newUrl) {
this.assign(newUrl.toString());
},
set protocol(v) {
const newUrl = new URL(url.toString());
newUrl.protocol = v;
this.assign(newUrl.toString());
},
set host(v) {
const newUrl = new URL(url.toString());
newUrl.host = v;
this.assign(newUrl.toString());
},
set hostname(v) {
const newUrl = new URL(url.toString());
newUrl.hostname = v;
this.assign(newUrl.toString());
},
set port(v) {
const newUrl = new URL(url.toString());
newUrl.port = v;
this.assign(newUrl.toString());
},
set pathname(v) {
const newUrl = new URL(url.toString());
newUrl.pathname = v;
this.assign(newUrl.toString());
},
set search(v) {
const newUrl = new URL(url.toString());
newUrl.search = v;
this.assign(newUrl.toString());
},
set hash(v) {
const newUrl = new URL(url.toString());
newUrl.hash = v;
this.assign(newUrl.toString());
},
// $FlowExpectError Flow doesn't know about symbol properties sadly.
[internalLocationAssign]: internalSetLocation,
assign: (newUrl: string) => window.history.pushState(null, '', newUrl),
reload: jest.fn(),
replace: (newUrl: string) =>
window.history.replaceState(null, '', newUrl),
};
},
configurable: true,
set(newUrl: string) {
window.history.pushState(null, '', newUrl);
},
};
// $FlowExpectError because the value we pass isn't a proper Location object.
Object.defineProperty(window, 'location', property);
// Return a function that resets the mock.
return () => {
// This "delete" call doesn't seem to be necessary, but better do it so that
// we don't have surprises in the future.
delete window.location;
// $FlowExpectError because nativeLocation doesn't match the type expected by Flow.
Object.defineProperty(window, 'location', nativeLocation);
};
}
/**
* This mock creates a history API that can be thrown away after every use.
*/
function mockWindowHistory() {
const originalHistory = Object.getOwnPropertyDescriptor(window, 'history');
let states, urls, index;
function reset() {
states = [null];
urls = [window.location.href];
index = 0;
}
reset();
const history = {
get length() {
return states.length;
},
scrollRestoration: 'auto',
get state() {
return states[index] ?? null;
},
back() {
if (index <= 0) {
return;
}
index--;
// $FlowExpectError Flow doesn't know about this internal property.
window.location[internalLocationAssign](urls[index]);
window.dispatchEvent(new Event('popstate'));
},
forward() {
if (index === states.length - 1) {
return;
}
index++;
// $FlowExpectError Flow doesn't know about this internal property.
window.location[internalLocationAssign](urls[index]);
window.dispatchEvent(new Event('popstate'));
},
go() {
throw new Error('Not implemented.');
},
pushState(newState: any, _title: string, url?: string) {
if (url) {
// Let's assign the URL to the window.location mock. This should also
// make the URL correct if it's relative, we'll get an absolute URL when
// retrieving later through window.location.href.
// $FlowExpectError Flow doesn't know about this internal property.
window.location[internalLocationAssign](url);
}
urls = urls.slice(0, index + 1);
urls.push(window.location.href);
states = states.slice(0, index + 1);
states.push(newState);
index++;
},
replaceState(newState: any, _title: string, url?: string) {
if (url) {
// Let's assign the URL to the window.location mock.
// $FlowExpectError Flow doesn't know about this internal property.
window.location[internalLocationAssign](url);
urls[index] = window.location.href;
}
states[index] = newState;
},
// $FlowExpectError Flow doesn't know about symbol properties sadly.
[internalHistoryReset]: reset,
};
// This "delete" call doesn't seem to be necessary, but better do it so that
// we don't have surprises in the future.
delete window.history;
Object.defineProperty(window, 'history', {
value: coerceMatchingShape<History>(history),
configurable: true,
});
// Return a function that resets the mock.
return () => {
// For unknown reasons, we can't assign back the old descriptor without
// deleting the current one first... Not deleting would keep the mock
// without throwing any error.
delete window.history;
// $FlowExpectError - Flow can't handle getOwnPropertyDescriptor being used on defineProperty.
Object.defineProperty(window, 'history', originalHistory);
};
}
// This mocks both window.location and window.history. See the top of the file
// for more information.
export function mockFullNavigation({
initialUrl,
}: $Shape<{ initialUrl: string }> = {}): () => void {
const restoreLocation = mockWindowLocation(initialUrl);
const restoreHistory = mockWindowHistory();
return () => {
restoreLocation();
restoreHistory();
};
}
// This registers lifecycle functions to mock both window.location and
// window.history for each test. Take a look at the top of this file for more
// information about how to use this.
export function autoMockFullNavigation() {
let cleanup;
beforeEach(() => {
cleanup = mockFullNavigation();
});
afterEach(() => {
if (cleanup) {
cleanup();
cleanup = null;
}
});
}
export function resetHistoryWithUrl(url: string = window.location.href) {
// $FlowExpectError Flow doesn't know about this internal property.
window.location[internalLocationAssign](url);
// $FlowExpectError Flow doesn't know about this internal property.
window.history[internalHistoryReset]();
}