Cypress: Using .within() in custom commands/functions to return an inner DOM element found in .within() results in unexpected behavior

Created on 16 Oct 2020  路  13Comments  路  Source: cypress-io/cypress

Current behavior

This command worked in 5.3, but in 5.4 when I chain a .type() command to it. It throws and error

Command:

Cypress.Commands.add('firstCellSearchFilter', () => {
  cy.get('.ag-header-row-floating-filter').within(() => {
    cy.get('.ag-header-cell')
      .first()
      .within(() => {
        cy.get('.ag-text-field-input')
      })
  })
})

Error:
cy.type() can only be called on a single element. Your subject contained 3 elements.

Foo.firstCellSearchFilter().type('bar') fails because Foo.firstCellSearchFilter() returns ALL of the cy.get('.ag-text-field-input') elements. Its like the scope of cy.get('.ag-header-cell').first().within() gets reset or ignored

Desired behavior

I would expect the command to scope to the single input element, like it did in 5.3

Versions

Cypress: 5.4
Mac Mojave
Chrome 86

needs investigating unexpected behavior v5.4.0 馃悰

Most helpful comment

Another example of unexpected behavior from https://github.com/cypress-io/cypress/issues/9064

@sainthkh I do agree that this is working as documented, but I think it may be helpful to think about how to provide the wanted behavior here. People want to traverse within some DOM and return the inner DOM element they've found, so that they can return it to be chained as a command/function.

So, either we can provide an example of how they can do this or we change the behavior of .within()

Maybe the scope of .within() could change if there is a return statement, similar to how what is yielded from .then() changes based on the return statement. (This is just me thinking out loud, I haven't discussed this with the team).

function getTableCell(
  tableId,
  column,
  row,
) {
  return cy
    .get(tableId)
    .find('tbody>tr')
    .eq(row)
    .within(() => {
      cy.get(column);
    });
}

it('test', () => {
  cy.visit('index.html')
  getTableCell("#myTable", "#three", 0).should('have.text', 'foo')
})

5.4.0

Screen Shot 2020-11-03 at 11 31 27 AM

5.5.0

Screen Shot 2020-11-03 at 11 33 48 AM

All 13 comments

I can see a behavior changed in 5.4.0 in an unexpected way, not sure this is exactly your situation. I can recreate this with the following code. cc @sainthkh

index.html

<html>
<body>
  <div class="first">First
      <div class="second">Second
        <input class="third">
      </div>
      <div class="second">Second</div>
  </div>
  <input class="third">
  <input class="third">
</body>
</html>

spec.js

Cypress.Commands.add('getThird', () => {
  cy.get('.first').within(() => {
    cy.get('.second')
      .first()
      .within(() => {
        cy.get('.third')
      })
  })
})

it('test cy.route()', () => {
  cy.visit('index.html')
  cy.getThird().type('foo')
})

5.3.0

Screen Shot 2020-10-19 at 10 32 18 PM

5.4.0

Screen Shot 2020-10-19 at 10 33 50 PM

It's the intended behavior. Before 5.4, cy.within was permanently narrowing scope for the following commands and it's fixed in #8699.

Here're the workarounds:

// Use CSS selector pseudo class
Cypress.Commands.add('getThird2', () => {
  cy.get('.first .second:first-child .third')
})
// Use jQuery commands + cy.get() withinSubject option.
Cypress.Commands.add('getThird3', () => {
  cy.get('.third', {
    withinSubject: Cypress.$('.first').find('.second').first(),
  })
})

I don't think this is really expected behavior. Why should this cause Cypress to type in the element within the first within - .second? I would expect this custom command to return .first or .third, but never .second.

Cypress.Commands.add('getThird', () => {
  cy.get('.first').within(() => {
    cy.get('.second')
      .first()
      .within(() => {
        cy.get('.third')
      })
  })
})

it('test cy.route()', () => {
  cy.visit('index.html')
  cy.getThird().type('foo')
})

@jennifer-shehane I'll look into this more thoroughly tomorrow.

I checked it and concluded that it's the intended behavior.

First of all, I checked should command correctly works with the <div.first>.

cy.visit('fixtures/a.html')
cy.getThird().should('have.class', 'first')

This works fine. I concluded that the > <div class="second a">...</div> message has nothing to do with the bug in cy.within.


But it makes me curious why it shows div.second rather than div.first.

It happened because the node used to throw an error in type.js is not the selected node but the node at the coordinate.

Below is the location where the error is thrown (line 431).

https://github.com/cypress-io/cypress/blob/e02abb178f054427b8ebc4452fcda57d8e046205/packages/driver/src/cy/commands/actions/type.js#L420-L435

And $elToCheck is calculated below (line 359):

https://github.com/cypress-io/cypress/blob/e02abb178f054427b8ebc4452fcda57d8e046205/packages/driver/src/cy/actionability.js#L343-L366

That's why the element at the center of the div.first, the first div.second, was returned.


But this makes me curious about what happens if I extend the first input.third to make it located at the center of the div.first.

And I wanted to be sure if this is a bug or not.

I changed the html code like below:

<html>
<body>
  <div class="first">First
      <div class="second">Second
        <input class="third" style="width: 80%">
      </div>
      <div class="second">Second</div>
  </div>
  <input class="third">
  <input class="third">
</body>
</html>

The only change is to add style="width: 80%".

But in this case, the problematic test passes. This makes me think whether we should call this behavior a bug.

But I concluded that it's not. Because in modern web applications, there are a lot of components like below is possible:

<div class="awesome-component" id="abc">
  <div class="another-div">
    <input />
  </div>
</div>

In this case, not allowing cy.get('#abc').type('foo') and only accepting cy.get('#abc .another-div input').type('foo') is too inconvenient.

In conclusion, this is not a bug and there is nothing to be changed.

It is absolutely a bug. If its not then what is the point of within()? the within() is supposed to narrow the search scope. The BUG is the when you attach cy.get to an element that is being found in the within() is attaches the action, type() or click() to the element that was found at the top level within() scope instead of the element found in the within() scope.

referring to my original comment

Cypress.Commands.add('firstCellSearchFilter', () => {
  cy.get('.ag-header-row-floating-filter').within(() => {
    cy.get('.ag-header-cell')
      .first()
      .within(() => {
        cy.get('.ag-text-field-input')
      })
  })
})

So you're telling me that the intended behavior of Foo.firstCellSearchFilter().type() is to type or click on 'ag-header-row-floating-filter' (the outer scope) and not ag-text-field-input?

This worked in 5.3 and broke in 5.4

Actually, that's what the document says:

.within() yields the same subject it was given from the previous command.

Before 5.4, cy.within() didn't work as documented. And with the old behavior, there are no differences in the 2 code examples below:

cy.get('article').within(() => {
  cy.get('h1').should('contain.text', 'Blog')
})
.should('have.class', 'post')
cy.get('article').within(() => {
  cy.get('h1').should('contain.text', 'Blog').should('have.class', 'post')
})

Another example of unexpected behavior from https://github.com/cypress-io/cypress/issues/9064

@sainthkh I do agree that this is working as documented, but I think it may be helpful to think about how to provide the wanted behavior here. People want to traverse within some DOM and return the inner DOM element they've found, so that they can return it to be chained as a command/function.

So, either we can provide an example of how they can do this or we change the behavior of .within()

Maybe the scope of .within() could change if there is a return statement, similar to how what is yielded from .then() changes based on the return statement. (This is just me thinking out loud, I haven't discussed this with the team).

function getTableCell(
  tableId,
  column,
  row,
) {
  return cy
    .get(tableId)
    .find('tbody>tr')
    .eq(row)
    .within(() => {
      cy.get(column);
    });
}

it('test', () => {
  cy.visit('index.html')
  getTableCell("#myTable", "#three", 0).should('have.text', 'foo')
})

5.4.0

Screen Shot 2020-11-03 at 11 31 27 AM

5.5.0

Screen Shot 2020-11-03 at 11 33 48 AM

@jennifer-shehane Actually, that's what I was thinking, too. I was wondering if I need to open a new issue about that. And you mentioned it.

I think it would meet the two groups of users if the new behavior works like below:

<div id="a">
  <div id="b">Hello</div>
  <div>World</div>
</div>

Old behavior:

cy.get('#a').within(() => cy.get('#b')).should('have.text', 'Hello')

Current behavior:

cy.get('#a').within(() => cy.get('#b')).should('have.text', 'HelloWorld')

Suggested behavior:

cy.get('#a').within(() => { cy.get('#b') }).should('have.text', 'HelloWorld');
cy.get('#a').within(() => { return cy.get('#b') }).should('have.text', 'Hello');

Yeah, this would be a breaking change, but we intend to schedule breaking change releases more often (~ every 12 weeks). We have one scheduled for Nov 23 https://github.com/cypress-io/cypress/pull/8437

I'll try and bring this proposal up with the team and comment back.

I like the idea of using an explicit return to control the downstream subject.

@tripleequal, you can back-flip inside the custom command to work around the recent mod, something like this
(testing with the superb cypress-fiddle)

/// <reference types="@cypress/fiddle" />

Cypress.Commands.add('firstCellSearchFilter', () => {  
  let result;  
  cy.get('.ag-header-row-floating-filter')
    .within(() => {  
      cy.get('.ag-header-cell')  
        .first()  
        .within(() => {  
          cy.get('.ag-text-field-input')
            .invoke('val')
            .then(val => result = val);
        })
    })  
    .then(() => result); 
})  

const firstCellTest = {
  html: `
    <div class="ag-header-row-floating-filter">
      <div class="ag-header-cell">
        <input class="ag-text-field-input" value="1">
      </div>
      <div class="ag-header-cell">
        <input class="ag-text-field-input" value="2">
      </div>
    </div>
  `,
  test: `
    cy.firstCellSearchFilter().then((value) => {
      console.log(value);  // logs "1"
    });
  `
}

it('tests subject change from within()', () => {
  cy.runExample(firstCellTest)
})

I'd also like to see explicit return applied to .each(), which allow it to work like Array.map().
It could also behave like Array.reduce() if an accumulator parameter (updated on each iteration) was provided.

@sainthkh After discussing this with the team, we are most concerned about there being consistency among all of the commands and how they work. So, .then() should work similarly to how .within() or .should() or any other command that accepts a function that includes other cypress comannds within it.

So, we'd like to not see new rules made up specifically for .within() - and make sure the logic matches more closely to how the other commands work.

I first thought of making a new command like inside, inward or simply in for the old within behavior.

But I realized that the problems could be solved with find command. It seems that there is no

I guess we can close this issue by creating a note in within command about searching elements in the narrowed scope with find.

It's short and looks good. Example:

it('test', () => {
  cy.visit('fixtures/dom.html')
  cy.get('#by-name').find('input:first').type('hello world')
})
Was this page helpful?
0 / 5 - 0 ratings