Search code examples
javascriptcsscypressgetcomputedstyle

How can I assert that a HTML element does not change its appearance after a stylesheet has been included?


Setting

I’m writing a plugin for a website. It is going to add elements to the DOM that are styled via the plugin’s CSS. I expect the styling to be limited to the plugin, i.e. no elements outside the plugin should change their appearance once the plugin is included on the web page.

I’m running integration tests using cypress. How can I assert that all the pre-existing elements’ styles stay the same when the plugin is included on the page? I have access to the page before and after the plugin has been loaded.

Approach

This is what I thought should work:

cy.visit('theURL');
getStyles().then(oldStyles => {                      // Get the styles of the elements
    mountPlugin();                                   // Mount the plugin (including CSS)
    getStyles().then(newStyles => {                  // Get the (possibly changed) styles
        newStyles.forEach((newStyle, i) =>           // Compare each element’s style after
            expect(newStyle).to.equal(oldStyles[i])  //+ mounting to the state before mounting
        );
    });
});
function getStyles() {
    return cy.get('.el-on-the-page *').then((elements) => { // Get all elements below a certain root
        const styles: CSSStyleDeclaration[] = []
        elements.each((_, el) => {                          // Get each element’s style
            styles.push(window.getComputedStyle(el));       //+ and put them it an array
        });
        return styles;                                      // Return the styles
    });
}

Problems

Numeric keys in the CSSStyleDeclaration

The line expect(newStyle).to.equal(oldStyles[i]) fails because oldStyles[i] contains numeric keys that only list property names. For instance,

// oldStyles[i] for some i
{
    cssText: "animation-delay: 0s; animation-direction: normal; […more]"
    length: 281
    parentRule: null
    cssFloat: "none"
    0: "animation-delay"   // <-- These elements only list property names, not values
...                        //+
    280: "line-break"      //+
    alignContent: "normal" // <-- Only here there are actual property values
...                        //+
    zoom: "1"              //+
...
}

Workaround

I fix this by looping through the CSS keys manually and checking if the key is a number. However, these numeric keys only appear in the oldStyles, not in newStyles. I’m writing this because this looks fishy to me, and I assume that the error might already be there.

// Instead of newStyles.foreach(…) in the first snippet
newStyles.forEach((newStyle, i) => {
    for (const key in newStyle) {
        if(isNaN(Number(key))) {
            expect(newStyle[key]).to.equal(oldStyles[i][key]);
        }
    }
});

Empty property values

I’m making the implicit assumption here that the DOM is actually loaded and has applied the styles. From my understanding getLinkListStyles’s call to cy.get should be scheduled to run only after cy.visit has waited for the window to fire the load event.

From the Cypress documentation:

cy.visit() resolves when the remote page fires its load event.

However, with the above workaround employed, I get an empty string for the CSS rules in oldStyles. For instance:

//oldStyles[i] for some i
{
    cssText: "animation-delay: ; animation-direction: ; animation-duration: ; […more]"
    length: 0
    parentRule: null
    cssFloat: ""
    alignContent: ""
...
}

Attempted solutions

Note that this behaviour does not change when I explicitly use a callback with cy.visit, i.e.:

cy.visit(Cypress.env('theURL')).then(()=>{
    getStyles().then((oldStyles) => {
        // (rest as above)

Neither does cy.wait(15000) at the beginning of getStyles():

function getStyles() {
    cy.wait(15000); // The page has definitely loaded and applied all styles by now
    cy.get('.el-on-the-page *').then((elements) => {
...

Solution

  • I can't answer the question about empty property values, the workaround should not affect things. If I understand correctly, you get property values when not using the workaround?

    Numeric keys

    These are almost certainly indexes into the cssText style which is the inline styles.

    There are exactly the same number of numeric keys as there are entries in cssText, and the values match up to the LHS of the key-value pairs in cssText.

    Missing numeric keys on 2nd getStyles()

    Are you sure?

    If I run your code without the plugin mount, I get a failure, because it compares the object references,

    getStyles().then(oldStyles => {
      // no plugin mounted
      getStyles().then(newStyles => {                
        newStyles.forEach((newStyle, i) =>           
          expect(newStyle).to.equal(oldStyles[i])
        );
     });
    

    but if I use .to.deep.equal it succeeds

    getStyles().then(oldStyles => {
      // no plugin mounted
      getStyles().then(newStyles => {                
        newStyles.forEach((newStyle, i) =>           
          expect(newStyle).to.deep.equal(oldStyles[i])
        );
     });
    

    getComputedStyle() returns a live object

    MDN Window.getComputedStyle()

    The returned style is a live CSSStyleDeclaration object, which updates automatically when the element's styles are changed.

    so you would need clone the result before comparing, even if the plugin changed something when you compare they would be identical.

    I'd suggest apply JSON/stringify() to the result and compare the strings, it's pretty fast, also removes the need to deep-equal.

    function getStyles() {
      return cy.get('.el-on-the-page *').then((elements) => {
        const styles = []
        elements.each((_, el) => {
          styles.push(window.getComputedStyle(el));
        });
        return JSON.stringify(styles);    
      });
    }
    
    getStyles().then(oldStyles => {         
      mountPlugin();       
      getStyles().then(newStyles => {         
        expect(newStyles).to.equal(oldStyles);
      });
    });