28

Loop index (i) is not what I'm expecting when I use Protractor within a loop.

Symptoms:

Failed: Index out of bound. Trying to access element at index:'x', but there are only 'x' elements

or

Index is static and always equal to the last value

My code

for (var i = 0; i < MAX; ++i) {
  getPromise().then(function() {
    someArray[i] // 'i' always takes the value of 'MAX'
  })
}

For example:

var expected = ['expect1', 'expect2', 'expect3'];
var els = element.all(by.css('selector'));
for (var i = 0; i < expected.length; ++i) {
  els.get(i).getText().then(function(text) {
    expect(text).toEqual(expected[i]); // Error: `i` is always 3. 
  })
}

or

var els = element.all(by.css('selector'));
for (var i = 0; i < 3; ++i) {
  els.get(i).getText().then(function(text) {
    if (text === 'should click') {
      els.get(i).click(); // fails with "Failed: Index out of bound. Trying to access element at index:3, but there are only 3 elements"
    }
  })
}

or

var els = element.all(by.css('selector'));
els.then(function(rawelements) {
  for (var i = 0; i < rawelements.length; ++i) {
    rawelements[i].getText().then(function(text) {
      if (text === 'should click') {
        rawelements[i].click(); // fails with "Failed: Index out of bound. Trying to access element at index:'rawelements.length', but there are only 'rawelements.length' elements"
      }
    })
  }
})
hankduan
  • 5,994
  • 1
  • 29
  • 43
  • 1
    Thanks for the effort - but this is the classic closure-loop problem. – Benjamin Gruenbaum Jan 12 '15 at 22:21
  • @BenjaminGruenbaum Yes this is the classic closure-loop problem, and I do reference http://stackoverflow.com/questions/750486/javascript-closure-inside-loops-simple-practical-example in the answer. However, I opened this for two reasons. 1) many people do not realize the correlation between the two because some people dont' understand elementFinders return promises and 2) closure isn't the best solution for protractor as there are protractor-specific solutions for this -- see answer – hankduan Jan 12 '15 at 22:24
  • 2
    The suspense is killing me! What two reasons? – Benjamin Gruenbaum Jan 12 '15 at 22:25
  • Sorry hit enter too soon. edited first response. – hankduan Jan 12 '15 at 22:26
  • 2
    Using .filter (or .map or .forEach) on the array is actually how I would do this in general in JS (assuming no "let"). So I wouldn't call it protractor specific. The fact people don't know it's a correlation between the two is exactly why duplicates are typically not deleted - so they can find this question using the relevant keywords and then reach the general one. I appreciate the effort you put into these (and god knows we could use more canonicals in the promise tag) but I'm not sure this is a good fit since there is a similar canonical. If you'd like we can ask in meta. What do you think? – Benjamin Gruenbaum Jan 12 '15 at 22:30
  • I've reopened it in the meanwhile so we can discuss what we're doing about it in more neutral terms. – Benjamin Gruenbaum Jan 12 '15 at 22:31
  • If you think this is enough of a duplicate, I don't mind that you mark as duplicate. I think the key thing I want to get out of this question/answer is so that I can refer to it when I answer such questions (and marking it as duplicate doesn't stop me from doing that =)). – hankduan Jan 12 '15 at 22:39
  • With regards to your comment "Using .filter (or .map or .forEach) on the array is actually how I would do this in general in JS" -> http://stackoverflow.com/questions/750486/javascript-closure-inside-loops-simple-practical-example doesn't actually talk about this. Also, with regards to correlation, most people asking such questions are beginners to protractor and don't even realize elementFinders return promises (and ask questions like why they can't do this: `element.all(...).length` when `element.all()` returns a promise. – hankduan Jan 12 '15 at 22:40
  • Fair enough, let's leave this open for now and see what happens in the following days. – Benjamin Gruenbaum Jan 12 '15 at 22:41

4 Answers4

39

The reason this is happening is because protractor uses promises.

Read https://github.com/angular/protractor/blob/master/docs/control-flow.md

Promises (i.e. element(by...), element.all(by...)) execute their then functions when the underlying value becomes ready. What this means is that all the promises are first scheduled and then the then functions are run as the results become ready.

When you run something like this:

for (var i = 0; i < 3; ++i) {
  console.log('1) i is: ', i);
  getPromise().then(function() {
    console.log('2) i is: ', i);
    someArray[i] // 'i' always takes the value of 3
  })
}
console.log('*  finished looping. i is: ', i);

What happens is that getPromise().then(function() {...}) returns immediately, before the promise is ready and without executing the function inside the then. So first the loop runs through 3 times, scheduling all the getPromise() calls. Then, as the promises resolve, the corresponding thens are run.

The console would look something like this:

1) i is: 0 // schedules first `getPromise()`
1) i is: 1 // schedules second `getPromise()`
1) i is: 2 // schedules third `getPromise()`
*  finished looping. i is: 3
2) i is: 3 // first `then` function runs, but i is already 3 now.
2) i is: 3 // second `then` function runs, but i is already 3 now.
2) i is: 3 // third `then` function runs, but i is already 3 now.

So, how do you run protractor in loops? The general solution is closure. See JavaScript closure inside loops – simple practical example

for (var i = 0; i < 3; ++i) {
  console.log('1) i is: ', i);
  var func = (function() {
    var j = i; 
    return function() {
      console.log('2) j is: ', j);
      someArray[j] // 'j' takes the values of 0..2
    }
  })();
  getPromise().then(func);
}
console.log('*  finished looping. i is: ', i);

But this is not that nice to read. Fortunately, you can also use protractor functions filter(fn), get(i), first(), last(), and the fact that expect is patched to take promises, to deal with this.

Going back to the examples provided earlier. The first example can be rewritten as:

var expected = ['expect1', 'expect2', 'expect3'];
var els = element.all(by.css('selector'));
for (var i = 0; i < expected.length; ++i) {
  expect(els.get(i).getText()).toEqual(expected[i]); // note, the i is no longer in a `then` function and take the correct values.
}

The second and third example can be rewritten as:

var els = element.all(by.css('selector'));
els.filter(function(elem) {
  return elem.getText().then(function(text) {
    return text === 'should click';
  });
}).click(); 
// note here we first used a 'filter' to select the appropriate elements, and used the fact that actions like `click` can act on an array to click all matching elements. The result is that we can stop using a for loop altogether. 

In other words, protractor has many ways to iterate or access element i so that you don't need to use for loops and i. But if you must use for loops and i, you can use the closure solution.

Community
  • 1
  • 1
hankduan
  • 5,994
  • 1
  • 29
  • 43
  • 2
    Have seen this problem several times here, thank you for clearing things up! Now we can refer to this post. – alecxe Jan 12 '15 at 20:40
  • Yup, I've seen this exact problem twice in the past week and a lot of other promise-related questions. Hopefully it'll help people to understand promises in general a bit more. – hankduan Jan 12 '15 at 20:48
  • 1
    The "loop counter" issue isn't specific to promises. *Any* function defined in a for loop will be victim of the counter's terminal value whether it's a promise callback or not. Consider for example an event handler - same deal. – Roamer-1888 Jan 13 '15 at 03:31
  • @Roamer-1888 Yes you're right, but in context of Protractor, promise is one of the largest obstacles that new people trip up on. While this can happen using loop with any other callbacks, it's not as relevant to protractor as it's not as common a use case as this is. – hankduan Jan 13 '15 at 08:18
  • @hankduan: Is there a way to BREAK from the loop when a CONDITION is met? I can't use a 'filter' since I need to iterate multiple pages with similar content(instead of the same being present on a single page). Something like a DONE() function or something on meeting a condition!@hankduan: Is there a way to BREAK from the loop when a CONDITION is met? I can't use a 'filter' since I need to iterate multiple pages with similar content – Sakshi Singla Feb 18 '15 at 06:56
  • Not sure what you mean. Please open a new question with an example (with some code) indicating what you want, and @ me here – hankduan Feb 18 '15 at 19:13
  • As noted elsewhere, you may want [`.map`](http://www.protractortest.org/#/api?view=ElementArrayFinder.prototype.map) – Nate Anderson Jul 30 '17 at 18:56
3

Hank did a great job on answering this.
I wanted to also note another quick and dirty way to handle this. Just move the promise stuff to some external function and pass it the index.

For example if you want to log all the list items on the page at their respective index (from ElementArrayFinder) you could do something like this:

  var log_at_index = function (matcher, index) {
    return $$(matcher).get(index).getText().then(function (item_txt) {
      return console.log('item[' + index + '] = ' + item_txt);
    });
  };

  var css_match = 'li';
  it('should log all items found with their index and displayed text', function () {
    $$(css_match).count().then(function (total) {
      for(var i = 0; i < total; i++)
        log_at_index(css_match, i); // move promises to external function
    });
  });

This comes in handy when you need to do some fast debugging & easy to tweak for your own use.

willko747
  • 517
  • 1
  • 6
  • 13
0

I am NOT arguing with the logic or wisdom of the far more learned people discussing above. I write to point out that in the current version of Protractor within a function declared as async, a for loop like the below (which I was writing in typeScript, incorporating flowLog from @hetznercloud/protractor-test-helper, though I believe console.log would also work here) acts like what one might naively expect.

let inputFields = await element.all(by.tagName('input'));
let i: number;
flowLog('count = '+ inputFields.length);
for (i=0; i < inputFields.length; i++){
  flowLog(i+' '+await inputFields[i].getAttribute('id')+' '+await inputFields[i].getAttribute('value'));
}

producing output like

    count = 44
0 7f7ac149-749f-47fd-a871-e989a5bd378e 1
1 7f7ac149-749f-47fd-a871-e989a5bd3781 2
2 7f7ac149-749f-47fd-a871-e989a5bd3782 3
3 7f7ac149-749f-47fd-a871-e989a5bd3783 4
4 7f7ac149-749f-47fd-a871-e989a5bd3784 5
5 7f7ac149-749f-47fd-a871-e989a5bd3785 6

...

42 7f7ac149-749f-47fd-a871-e989a5bd376a 1
43 7f7ac149-749f-47fd-a871-e989a5bd376b 2

As I understand it, the await is key here, forcing the array to be resolved up front (so count is right) and the awaits within the loop cause each promise to be resolved before i is allowed to be incremented.

My intent here is to give readers options, not to question the above.

Jeremy Kahan
  • 3,796
  • 1
  • 10
  • 23
0

The easier way for doing this these days

it('test case', async () => {
  let elems = element.all(selector)

  for (let i=0; i < await elems.count(); i++) {
    console.log(await elems.get(i).getText())
  }
});
Sergey Pleshakov
  • 7,964
  • 2
  • 17
  • 40