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  }