Search code examples
javascriptpuppeteerchrome-devtools-protocol

How to get all events on all DOM elements of a page visited by Puppeteer - basically getEventListeners


I am working on some Puppeteer powered website analytics and would really need to list all events on a page.

It's easy with "normal" JavaScript, so I thought I could just evaluate it in the Puppeteer and get to other tasks.

Well - it is not so easy and "getEventListeners" is not working for example. So this code below is not working (but if I take the code that gets valuated, copy it to browser's console and run - it works well);

exports.getDomEventsOnElements = function (page) {

  return new Promise(async (resolve, reject) => {
    try {
        let events = await page.evaluate(() => {
            let eventsInDom = [];
            const elems = document.querySelectorAll('*');
            for(i = 0; i < elems.length; i++){
                const element = elems[i];
                const allEventsPerEl = getEventListeners(element);
                if(allEventsPerEl){

                  const filteredEvents = Object.keys(allEventsPerEl).map(function(k) {
                    return { event: k, listeners: allEventsPerEl[k] };
                  })

                  if(filteredEvents.length > 0){
                    eventsInDom.push({
                      el: element,
                      ev: filteredEvents
                    })
                  }

                }

            }

            return eventsInDom;
        })
        resolve(events);
    } catch (e) {
        reject(e);
    }
  })
}

I've investigated further and it looks like this will not work in Puppeteer and even tried with good old JQuery's const events = $._data( element[0], 'events' ); but it does not work either.

Then I stumbled upon Chrome DevTools Protocol (CDP) and there it should be possible to get it by defining a single element on beforehand;

 const cdp = await page.target().createCDPSession();
  const INCLUDE_FN = true;
  const { result: {objectId} } = await cdp.send('Runtime.evaluate', {
    expression: 'foo',
    objectGroup: INCLUDE_FN ?
      'provided' : // will include fn
      '' // wont include fn
  });
  const listeners = await cdp.send('DOMDebugger.getEventListeners', { objectId });
  console.dir(listeners, { depth: null });

(src: https://github.com/puppeteer/puppeteer/issues/3349)

But this looks too complicated when I would like to check each and every DOM element for events and add them to an array. I suspect there is a better way than looping the page elements and running CDP for each and every one. Or better said - I hope :)

Any ideas?

I would just like to have an array of all elements with (JS) events like for example:

let allEventsOnThePage : [
   {el: "blutton", events : ["click"]},
   {el: "input", events : ["click", "blur", "focus"]},
   /* you get the picture */
];

Solution

  • I was curious so I looked into expanding on that CDP example you found, and came up with this:

    async function describe (session, selector = '*') {
      // Unique value to allow easy resource cleanup
      const objectGroup = 'dc24d2b3-f5ec-4273-a5c8-1459b5c78ca0';
    
      // Evaluate query selector in the browser
      const { result: { objectId } } = await session.send('Runtime.evaluate', {
        expression: `document.querySelectorAll("${selector}")`,
        objectGroup
      }); 
    
      // Using the returned remote object ID, actually get the list of descriptors
      const { result } = await session.send('Runtime.getProperties', { objectId }); 
    
      // Filter out functions and anything that isn't a node
      const descriptors = result
        .filter(x => x.value !== undefined)
        .filter(x => x.value.objectId !== undefined)
        .filter(x => x.value.className !== 'Function');
    
      const elements = []; 
    
      for (const descriptor of descriptors) {
        const objectId = descriptor.value.objectId;
    
        // Add the event listeners, and description of the node (for attributes)
        Object.assign(descriptor, await session.send('DOMDebugger.getEventListeners', { objectId }));
        Object.assign(descriptor, await session.send('DOM.describeNode', { objectId }));
    
        elements.push(descriptor);
      }
    
      // Clean up after ourselves
      await session.send('Runtime.releaseObjectGroup', { objectGroup }); 
    
      return elements;
    }
    

    It will return an array of objects, each with (at least) node and listeners attributes, and can be used as follows:

    /** Helper function to turn a flat array of key/value pairs into an object */
    function parseAttributes (array) {
      const result = []; 
      for (let i = 0; i < array.length; i += 2) {
        result.push(array.slice(i, i + 2));
      }
      return Object.fromEntries(result);
    }
    
    (async () => {
      const browser = await puppeteer.launch();
      const page = await browser.newPage();
      await page.goto('https://chromedevtools.github.io/devtools-protocol', { waitUntil: 'networkidle0' }); 
      const session = await page.target().createCDPSession();
    
      const result = await describe(session);
    
      for (const { node: { localName, attributes }, listeners } of result) {
        if (listeners.length === 0) { continue; }
    
        const { id, class: _class } = parseAttributes(attributes);
    
        let descriptor = localName;
        if (id !== undefined) { descriptor += `#${id}`; }
        if (_class !== undefined) { descriptor += `.${_class}`; }
    
        console.log(`${descriptor}:`);
        for (const { type, handler: { description } } of listeners) {
          console.log(`    ${type}: ${description}`);
        }   
      }
    
      await browser.close();
    })();
    

    which will return something like:

    button.aside-close-button:
        click: function W(){I.classList.contains("shown")&&(I.classList.remove("shown"),P.focus())}
    main:
        click: function W(){I.classList.contains("shown")&&(I.classList.remove("shown"),P.focus())}
    button.menu-link:
        click: e=>{e.stopPropagation(),I.addEventListener("transitionend",()=>{O.focus()},{once:!0}),I.classList.add("shown")}