javascriptmonkeypatchingqueryselectordefinepropertypropertydescriptor

How to override (monkeypatch) the document and element specific `querySelector` implementations and how to restore each original functionality again?


I have a local testing environment, where I want to temporary override querySelector. I know that monkeypatching is bad practice, but in this case this code will only be used locally on developer side. I have this snippet that I wrote (overrides querySelector to fetch all selectors with another substring in the selector called addonID):

  function maybeOverrideForTestStart(partialIDOfWidget, fullIDOfWidget) {
    if(!isLocal) return;
    const addonID = fullIDOfWidget.replace(partialIDOfWidget, "");
    Element.prototype.querySelectorTemp = Element.prototype.querySelector.bind(Element);
    
    Element.prototype.querySelector = function(selector) {
      const element = this.querySelectorTemp(selector);
      if (element) return element;
      if (addonID) {
        return this.querySelectorTemp(selector + addonID) || null;
      }
    };

  }
  function maybeOverrideForTestEnd() {
    if(!isLocal) return;
    Element.prototype.querySelector = Element.querySelectorTemp;
  }

I call maybeOverrideForTestStart in the beginning of my testing, and maybeOverrideForTestEnd in the end. But this doesn't work, and I'm not sure what I'm missing. I'm getting either someElement.querySelector is not a function or "Uncaught TypeError: Illegal invocation".

Note - I also cannot understand if this also overrides the document.querySelector and document.body.querySelector or just someElement.querySelector.

Help is appreciated, thanks.


Solution

  • I would change the naming of maybeOverrideForTestStart to patchQuerySelectors since its implementation changes as well.

    In order to correctly redefine/patch the modified querySelector implementations (one needs to do it for both Document.prototype and Element.prototype) and also to exactly restore each default state, one should choose an approach which makes use of each prototypal querySelector's property descriptor. The function does patch each modified version but also does return a function which restores each original setting, everything via Object.defineProperty.

    // apply the monkey patch.
    const restoreDefaults = patchQuerySelectors(true, 'bar_123', 'foo');
    
    console.log(
      'after patching ... ', {
      restoreDefaults,
      elementQuery: Element.prototype.querySelector,
      documentQuery: Document.prototype.querySelector,
    });
    
    // use the patched/modified versions of `querySelector`
    console.log(
      "document.querySelector('body') ...",
      document.querySelector('body'),
    );
    console.log(
      "document.body.querySelector('script') ...",
      document.body.querySelector('script'),
    );
    
    // restore each specific `querySelector` to its correct default.
    restoreDefaults();
    
    console.log(
      'after restoring ... ', {
      elementQuery: Element.prototype.querySelector,
      documentQuery: Document.prototype.querySelector,
    });
    
    // use the restored versions of `querySelector`
    console.log(
      "document.querySelector('body') ...",
      document.querySelector('body'),
    );
    console.log(
      "document.body.querySelector('script') ...",
      document.body.querySelector('script'),
    );
    .as-console-wrapper { min-height: 100%!important; top: 0; }
    <script>
    // get the monkey patching right.
    
    function patchQuerySelectors(isLocal, partialIDOfWidget, fullIDOfWidget) {
      if (!isLocal) return;
    
      const addonID = fullIDOfWidget.replace(partialIDOfWidget, "");
      const {
        // the original/native implementation.
        value: elementQuery,
        // the original descriptor of the native implementation.
        ...elementConfig
      } = Object.getOwnPropertyDescriptor(Element.prototype, 'querySelector');
    
      const {
        value: documentQuery,
        ...documentConfig
      } = Object.getOwnPropertyDescriptor(Document.prototype, 'querySelector');
    
      // the modified element specific `querySelector` implementation.
      function modifiedElementQuery(selector) {
    
        // apply the correct context to the original version.
        const element = elementQuery.call(this, selector);
    
        if (element) {
          return element;
        }
        if (addonID) {
        // apply the correct context to the original version.
          return elementQuery.call(this, selector + addonID);
        }
      };
      // the modified document specific `querySelector` implementation.
      function modifiedDocumentQuery(selector) {
        const element = documentQuery.call(this, selector);
    
        if (element) {
          return element;
        }
        if (addonID) {
          return documentQuery.call(this, selector + addonID);
        }
      };
    
      // - redefine the properties via the default descriptors
      //   and the newly assigned modified functions.
      Object
        .defineProperty(Element.prototype, 'querySelector', {
          value: modifiedElementQuery,
          ...elementConfig
        });
      Object
        .defineProperty(Document.prototype, 'querySelector', {
          value: modifiedDocumentQuery,
          ...documentConfig
        });
    
      function restoreDefaults() {
        // - redefine/restore the properties via the default descriptors
        //   and the locally stored original `querySelector` implementations.
        Object
          .defineProperty(Element.prototype, 'querySelector', {
            value: elementQuery,
            ...elementConfig,
          });
        Object
          .defineProperty(Document.prototype, 'querySelector', {
            value: documentQuery,
            ...documentConfig,
          });
      }
      return restoreDefaults;
    }
    </script>