nightwatchjs / nightwatch

Integrated end-to-end testing framework written in Node.js and using W3C Webdriver API. Developed at @browserstack
https://nightwatchjs.org
MIT License
11.78k stars 1.31k forks source link

Page objects. Elements cannot have dynamic selectors. #821

Open jesusreal opened 8 years ago

jesusreal commented 8 years ago

I believe it would be great to have the possibility to have dynamic selectors in a page object, in the same way as we already have dynamic urls. I am sure this might be needed by many people, as some css selectors are set dynamically.

module.exports = {
    url:  function() {
        return 'https://' + this.api.globals.host + '/#/products/';
    },
    elements: {
         // What I can do currently
        // productToSelect: {
        //     selector: ".product[data-product-name='T-Shirt']"
        // }
         // What I would like to do
        // productToSelect: {
        //     selector: function() {
        //      return ".product[data-product-name='" + this.api.globals.productName + "']";
        // }
    }
};
stebiger commented 8 years ago

:+1: would like to see this feature too.

yegorski commented 8 years ago

:+1:

GreenAsJade commented 8 years ago

It looks like something along these lines got a burst of attention last year, but I can't find anything more recent. It seems pretty crucial.

How are people working around it in the mean time?

webteckie commented 8 years ago

:+1:

yardfarmer commented 8 years ago

👍

ilyapalkin commented 8 years ago

How are people working around it in the mean time?

If you do not assert anything on productToSelect then you can put a sequence of operations into command. E.g:

module.exports = {
    url:  function() {
        return 'https://' + this.api.globals.host + '/#/products/';
    },
    comands: [{
        selectProduct: function(productName) {
            return this.click(".product[data-product-name='" + productName + "']");
        }
    }]
};
oubre commented 8 years ago

How are people working around it in the mean time?

I've been using the selector member associated with the elements, like this:

module.exports = {
  'Testing locators': function(browser) {
    let myPg = new browser.page.MyPage();
    let myElem = `${myPg.section.mySec.elements.myElem.selector}${i} `;
senocular commented 8 years ago

Similar to what @ilyapalkin mentioned, you can create a page command to perform the selector mutation. The command doesn't have to call other commands; it would basically just parameterize the element selector and give you back what you need.

command:

module.exports = {
    url:  function() {
        return 'https://' + this.api.globals.host + '/#/products/';
    },
    elements: {
        product: ".product[data-product-name='%s']"
    },
    comands: [{
        el: function(elementName, data) {
            var element = this.elements[elementName.slice(1)];
            return util.format(element.selector, data);
        }
    }]
};

test:

'my test': function (browser) {
  var page = browser.page.myPage();
  page.click(page.el('@product', 'milk')); // .product[data-product-name='milk']
}

Might not be the exact right code, you get the idea.

drptbl commented 8 years ago

@senocular this is a great example, thank you.

Any ideas how to make it work with sections? I'm really struggling with that.

module.exports = {
    sections: {
       firstSec: {
          selector: '.product[data-product-name='%s']',
          elements: {
             testEl: '.something',
          },
       },
    },
    commands: [{

        sec: (sectionName, data) => {
            const section = this.sections[sectionName.slice(1)];
            return util.format(section.selector, data);
        },

    }],
};
'my test': function (browser) {
  var page = browser.page.myPage().section.firstSec(?).click('@testEl'); // not sure how to apply command to section
}
senocular commented 8 years ago

@drptbl my particular example wouldn't work for your case because what it does is transforms the '@' selector used in the final call (e.g. click()) into a string selector. The path leading up to that is obscured, handled internally.

What you would need to do is go through and parse the section selector before making the call, something like:

var page = browser.page.myPage();
page.customFunc('firstSec', 'milk');

Where customFunc would be some page command that went through and found a section with the name 'firstSec' and modified its selector for format in the second argument of 'milk'. The problem with this approach is that after doing this, you've baked in the resulting value, replacing the format string. So you can only do it once until you reinstantiate another page object. Now, you could get around that by using the sections props to store the format string, but you'd still have to be careful of tracking the use of the selector since its value would persist past that initial call.

I'm not entirely sure if there's a clean way to do this now. Even adding some kind of intermediary section command would still require changing the underlying section for a persistent change

browser.page.myPage().section.firstSec.customFunc('milk').click('@testEl');

... unless that method completely rebuilt a temporary section from scratch in the background, which could be possible I suppose. The path up to '@testEl' doesn't resolve until click() is called, and that would be from whatever customFunc() returned rather than the original section. Could be a little messy to implement I would guess.

oubre commented 8 years ago

Would

        el: function(sectionName, elementName, data) {
            var section = this.sections[sectionName.slice(1)];
            var element = section.elements[elementName.slice(1)];
            return util.format(element.selector, data);
        }

work?

senocular commented 8 years ago

@oubre the problem is altering section.selector which is used as a path into what will ultimately help resolve section.elements[elementName].selector. The element is what the click() command is getting a reference to, and we can easily swap that out with something else like a non-element string selector (which is what el() was being used for). But section selectors are handled in an internal recursive element query chain that precedes the ultimate command query. So before even the click() selector is ran, the section selector is run providing a base element. Then from that base element the click() selector is run using section result as its base.

senocular commented 8 years ago

@drptbl, @oubre I thought I'd give this a shot, and this is what I came up with. I don't know how robust it is, but it seems to work for this simple case:

var util = require('util');

module.exports = {

  sections: {

    firstSec: {

      commands: [
        {
          formatEl: function (modifier) {

            // create new, temporary selector
            var options = Object.create(this);
            options.sections = {};
            options.elements = {};
            var name;
            for (name in this.section)
              options.sections[name] = Object.create(this.section[name]);
            for (name in this.elements)
              options.elements[name] = Object.create(this.elements[name]);
            var Section = this.constructor;
            var sec = new Section(options);

            // make the selector change
            sec.selector =  util.format(this.props.selector, modifier); // uses props version
            return sec;
          }
        }
      ],

      props: {
        selector: '.product[data-product-name="%s"]' // format string for dynamic selector
      },

      selector: '.product', // default, unformatted selector

      elements: {

        testEl: {
          selector: '.something',
        },
      },
    },
  },
};

Usage:

var firstSec = browser.page.po().section.firstSec;
firstSec.click('@testEl');
// vs
firstSec.formatEl('milk').click('@testEl');

So here we have a section command, formatEl which is dynamically "changing" the selector for the section. And by "changing" I mean creating a new, temporary section based off of the original that has a different selector. That selector is based off of a string in the original section's props object so the original selector of that original section is able to function on its own sans-formatEl() by being just a normal selector unaffected by any changes.

Edit: I guess "formatEl" isn't the best name since we're not working on an element, rather a section selector ;P

GrayedFox commented 7 years ago

Hi there,

Am trying to implement your solution @senocular using Nightwatch 0.9.8 and getting undefined for the passed element. Example:

// page_objects/about.js
module.exports = {
  url: function() {
    const globals = this.api.globals
    return globals.domain + globals.pages.about
  },
  elements: {
    chiefImages: { selector: '.chief-positions div:nth-child(%d) .round-image' }
  },
  commands: [{
    el: (elementName, child) => {
      const element = this.elements[elementName.slice(1)]
      return util.format(element.selector, child)
    }
  }]
}

Calling line:

// tests/aboutPages.js
...
const about = client.page.about()
about.navigate()
    .assert.visible(about.el('@chiefImages', chiefNumber))

Error output: TypeError: Cannot read property 'chiefImages' of undefined

I've tried changing the offending line, for example to be 'this.elements[elementName].selector.slice(1)'

But then strangely, I get the literal passed string in the output: TypeError: Cannot read property '@chiefImages' of undefined

Thought this was the most appropriate place to ask, but happy to create an issue if this is something outside of me missing the obvious?

Thanks for your time

senocular commented 7 years ago

@GrayedFox your problem is that you're using an arrow function for el which is breaking its context. Use a different function syntax instead.

yegorski commented 7 years ago

@GrayedFox this inside an arrow (lambda) function "does not bind its own this, arguments, super, or new.target". Try:

commands: [{
    el: function(elementName, child) {
      const element = this.elements[elementName.slice(1)]
      return util.format(element.selector, child)
    }
  }]
GrayedFox commented 7 years ago

Thank you, also removed the unnecessary slice (which removed my class . shorthand selector!) - silly - thought it was necessary for formatting. Also using %d was a mistake, I think.

Finished function in case people keen:

    chiefImageVisible(child) {
      let element = this.elements.chiefImages.selector
      element = util.format(element, child)
      this.api
        .assert.visible(element, 2000, `Testing if founder image ${child} visible`)
      return this

Now, before test execution, I can generate a random number to represent the length of the returned node list to randomly select an element.

Future custom command will attempt to do some async stuff before test execution and actually get the length from the page (instead of hard coding) using Selenium's elements() - which will make the test more resistant to simple page changes (like adding an image, etc). Thanks again

mrmunch commented 7 years ago

@drptbl @senocular Another solution to this would be (inside PageObject):

var productArray = ['Milk', 'TShirt', 'Apple'];
productArray.forEach(
    function(product){
        module.exports.sections.productSection.elements[product.toLowerCase() + 'Element'] =
        {
            selector: '.product[data-product-name="' + product +'"]'
        }
    }
);

And the test:

var productSection= browser.page.myPage().section.productSection;
productSection.click('@milkElement');
GrayedFox commented 7 years ago

Just a friendly FYI, missing shorthand selectors and CSS selectors in pageobjects is currently broken. If you're getting allElements.shift() is not a function during test retries, it's probably because of a pageObject function which looks something like the ones found here, i.e.

ourPageObjectFunction(option) {
  return this
    .waitForElementVisible('@shortHandSelector', 'Where are you baby?')
    .click('@shortHandSelector')
    .waitForElementVisible(`.some .css [selector="${option}"]`)
    .somethingElse()
}

Note, this is not about using dynamic selectors (as in the example above) - it's about a bug in the Nightwatch framework forgetting the selector strategy (CSS vs XPATH). Workaround (untested) might be to ensure your pageObject functions that mix shorthand and CSS selectors always pass the selector strategy as the first argument (and ensure to pass 3 args), i.e.

.getText('css', '.some .element', 'Custom message')

illegalnumbers commented 7 years ago

Is there any work planned on this? Could I help anyone if they've started work on something similar?

dhinus commented 7 years ago

1464 looks promising. Given it's not likely it will get merged soon, we ended up implementing the same thing as a helper function and calling it from a section command. Most of the work was done by @federico-pellegatta, I thought it might be useful to post it here.

Helper function:

const util = require('util');
const Section = require('nightwatch/lib/page-object/section');

const dynamicSection = (section, ...selector) => new Section(
  Object.assign({}, section, {
    selector: util.format(section.selector, selector),
    name: `${section.name}:${selector.join('-')}`
  })
);

Page object:

sections: {
  rowWithId: {
    selector: '.row[data-id="%s"]'
  }
}

commands: [{
  rowWithId(id) {
    return dynamicSection(sections.rowWithId, id);
  }
}]

Test:

const row5 = pageObject.rowWithId(5);
TheDelta commented 6 years ago

@dhinus thanks for this great code - be aware: (at least I) couldn't get commands to work on the dynamic Section - so you need to create your commands on the parent and simply get the dynamic section there.

It works like a charm, thanks <3

vibridi commented 6 years ago

Hi everyone, is there any news on this issue? I also tried out @dhinus solution but it appears not to be working on the dynamic section. Any status updates or tips are appreciated!

shanehu13 commented 5 years ago

I want to know how to new a section on the Nightwatch 1.0.19,Can somebody tell me?

phwolf commented 4 years ago

Any news on this issue? Having dynamic selectors in page objects would be so helpful!

frameq commented 3 years ago

working for v 0.9


module.exports = {
    sections: {
        search_result: {
            selector: '[class=list]',
            elements: {
                record: {
                    selector: '//*[@class="record" and normalize-space()="{name}"]',
                    locateStrategy: 'xpath'
                }
            }
        }
    },
    commands: [
        {
            isRecordPresent: function (name) {
                let search_result = this.section.search_result
                search_result.elements.record.selector = search_result.elements.record.selector.replace('{name}', name)
                return search_result.waitForElementVisible('@record')
            }
        }
    ]
}
stale[bot] commented 3 years ago

This issue has been automatically marked as stale because it has not had any recent activity. If possible, please retry using the latest Nightwatch version and update the issue with any relevant details. If no further activity occurs, it will be closed. Thank you for your contribution.