github.com/derat/nup@v0.0.0-20230418113745-15592ba7c620/test/web/unit/mock-window.js (about) 1 // Copyright 2021 Daniel Erat. 2 // All rights reserved. 3 4 import { error } from './test.js'; 5 6 export default class MockWindow { 7 #old = {}; // orginal window properties as name -> value 8 #fetches = {}; // resource -> array of Promises to hand out 9 #timeouts = {}; // id -> { func, delay } 10 #nextTimeoutId = 1; 11 #localStorage = {}; 12 #listeners = {}; // event name -> array of funcs 13 14 constructor() { 15 this.#replace('addEventListener', (type, func, capture) => { 16 const ls = this.#listeners[type] || []; 17 ls.push(func); 18 this.#listeners[type] = ls; 19 }); 20 21 this.#replace('setTimeout', (func, delay) => { 22 const id = this.#nextTimeoutId++; 23 this.#timeouts[id] = { func, delay: Math.max(delay, 0) }; 24 return id; 25 }); 26 this.#replace('clearTimeout', (id) => delete this.#timeouts[id]); 27 28 this.#replace('fetch', (resource, init) => { 29 const method = (init && init.method) || 'GET'; 30 const promise = this.#getFetchPromise(resource, method); 31 if (!promise) { 32 error(`Unexpected ${method} ${resource} fetch()`); 33 return Promise.reject(); 34 } 35 return promise; 36 }); 37 38 this.#replace('localStorage', createStorage()); 39 40 // Set a |unitTest| property so that code with hard-to-inject dependencies 41 // can change its behavior for testing. 42 this.#replace('navigator', { onLine: true, unitTest: true }); 43 } 44 45 // Restores the window object's original properties and verifies that 46 // expectations were satisfied. 47 finish() { 48 Object.entries(this.#old).forEach(([name, value]) => 49 Object.defineProperty(window, name, { value }) 50 ); 51 Object.entries(this.#fetches).forEach(([key, promises]) => { 52 error(`${promises.length} unsatisfied ${key} fetch()`); 53 }); 54 } 55 56 // Sets window.navigator.onLine to |v| and emits an 'online' or 'offline' 57 // event if the state changed. 58 set online(v) { 59 if (v === window.navigator.onLine) return; 60 window.navigator.onLine = v; 61 this.emit(new Event(v ? 'online' : 'offline')); 62 } 63 64 // Emits event |ev| to all listeners registered for |ev.type|. 65 emit(ev) { 66 for (const f of this.#listeners[ev.type] || []) f(ev); 67 } 68 69 // Expects |resource| (a URL) to be fetched once via |method| (e.g. "POST"). 70 // |text| will be returned as the response body with an HTTP status code of 71 // |status|. 72 expectFetch(resource, method, text, status = 200) { 73 const done = this.expectFetchDeferred(resource, method, text, status); 74 done(); 75 } 76 77 // Like expectFetch() but returns a function that must be run to resolve the 78 // promise returned to the fetch() call. 79 expectFetchDeferred(resource, method, text, status = 200) { 80 let resolve = null; 81 const promise = new Promise((r) => (resolve = r)); 82 83 const key = fetchKey(resource, method); 84 const promises = this.#fetches[key] || []; 85 promises.push(promise); 86 this.#fetches[key] = promises; 87 88 return () => 89 resolve({ 90 ok: status === 200, 91 status, 92 text: () => Promise.resolve(text), 93 json: () => Promise.resolve(JSON.parse(text)), 94 }); 95 } 96 97 // Removes and returns the first promise from |#fetches| with the supplied 98 // resource and method, or null if no matching promise is found. 99 #getFetchPromise(resource, method) { 100 const key = fetchKey(resource, method); 101 const promises = this.#fetches[key]; 102 if (!promises || !promises.length) return null; 103 104 const promise = promises[0]; 105 promises.splice(0, 1); 106 if (!promises.length) delete this.#fetches[key]; 107 return promise; 108 } 109 110 // Number of fetch calls registered via expectFetch() that haven't been seen. 111 get numUnsatisfiedFetches() { 112 return Object.values(this.#fetches).reduce((s, f) => s + f.length, 0); 113 } 114 115 // Number of pending timeouts added via setTimeout(). 116 get numTimeouts() { 117 return Object.keys(this.#timeouts).length; 118 } 119 120 // Advances time and runs timeouts that are scheduled to run within |millis| 121 // seconds. If any timeouts returned promises, the promise returned by this 122 // method will wait for them to be fulfilled. 123 runTimeouts(millis) { 124 // Advance by the minimum amount needed for the earliest timeout to fire. 125 const advance = Math.min( 126 millis, 127 ...Object.values(this.#timeouts).map((i) => i.delay) 128 ); 129 130 // Run all timeouts that are firing. 131 // TODO: Should this also sort by ascending ID to break ties? 132 const results = []; 133 for (const [id, info] of Object.entries(this.#timeouts)) { 134 info.delay -= advance; 135 if (info.delay <= 0) { 136 results.push(info.func()); 137 delete this.#timeouts[id]; 138 } 139 } 140 141 // If no timeouts fired, we're done. 142 if (!results.length) return Promise.resolve(); 143 144 // Wait for the timeouts that we ran to finish, and then call ourselves 145 // again with the remaining time (if any) to run the next round (which 146 // might include new timeouts that were added in this round). 147 return Promise.all(results).then(() => this.runTimeouts(millis - advance)); 148 } 149 150 // Clears all scheduled timeouts. 151 // This can be useful when simulating an object being recreated. 152 clearTimeouts() { 153 this.#timeouts = {}; 154 } 155 156 // Replaces the window property |name| with |val|. 157 // The original value is restored in finish(). 158 #replace(name, value) { 159 this.#old[name] = window[name]; 160 161 // This approach is needed for window.localStorage: 162 // https://github.com/KaiSforza/mock-local-storage/issues/17 163 Object.defineProperty(window, name, { value, configurable: true }); 164 } 165 } 166 167 function fetchKey(resource, method) { 168 return `${method} ${resource}`; 169 } 170 171 function createStorage() { 172 const storage = {}; 173 const def = (name, value) => 174 Object.defineProperty(storage, name, { 175 value, 176 enumerable: false, 177 writable: false, 178 }); 179 def('getItem', (key) => 180 Object.getOwnPropertyDescriptor(storage, key)?.enumerable 181 ? storage[key] 182 : null 183 ); 184 def('setItem', (key, value) => (storage[key] = value)); 185 def('removeItem', (key) => delete storage[key]); 186 def('clear', () => storage.forEach((key) => storage.removeItem(key))); 187 return storage; 188 }