cypress-io / cypress

Fast, easy and reliable testing for anything that runs in a browser.
https://cypress.io
MIT License
46.72k stars 3.16k forks source link

Proposal: Add a group to commands for nicer command logging #1260

Open NicholasBoll opened 6 years ago

NicholasBoll commented 6 years ago

I've been playing around with ways of defining reusable helpers (blog post coming) and have been using the Cypress log API and passing { log: false } to allow control of the log output of these helpers/commands. This has been useful, but there are some times that I want to dive into that abstraction (most likely on failure) to see what step of the helper failed.

I think a concept of a group would be useful here. Instead of passing { log: false } to every encapsulated command, having a group would help a lot more

Current behavior:

// This will work on https://github.com/cypress-io/cypress-example-todomvc
export const createTodo = (name) => {
  const log = Cypress.log({
    name: 'createTodo',
    message: name,
    consoleProps() {
      return {
        'Inserted Todo': name,
      }
    }
  })
  cy.get('.new-todo', { log: false }).type(`${name}{enter}`, { log: false })

  return cy
    .get('.todo-list li', { log: false })
    .contains('li', name.trim(), { log: false })
    .then(($el) => {
      log.set({ $el }).snapshot().end()
    })
}

// to use this helper:
createTodo('Learn the Cypress Log API')
  .then(console.log) // logs the created todo element from the TodoMVC Cypress example

Screenshot of the above:

screen shot 2018-02-06 at 12 30 46 am

Proposed behavior:

export const createTodo = (name) => {
  const group = Cypress.group({
    name: 'createTodo',
    message: name,
    consoleProps() {
      return {
        'Inserted Todo': name,
      }
    }
  })

  cy.get('.new-todo').type(`${name}{enter}`)

  return cy
    .get('.todo-list')
    .contains('li', name.trim())
    .then($el => { group.set({ $el }).snapshot().end() })

Matching the log API seems like a logical choice. In the UI it would show createTodo with the logging the same as the Current behavior example, but would have an arrow that would expand to the grouped commands. This would both simplify creating custom command output (don't have to pass { log: false } to everything) as well as make it easier to understand that's going on under the hood of a custom command. The group would be collapsed by default and expanded if a nested command failed.

NicholasBoll commented 6 years ago

This proposal is conceptually similar to the Web Console log's group: https://developer.mozilla.org/en-US/docs/Web/API/Console/group

kamituel commented 6 years ago

I'm surprised this didn't get more votes yet.

I'd love to be able to group several commands into one, nicely labelled, group that is collapsed by default, and autoxpands on a failure.

Also note this could be useful when having a larger test (which we are encouraged to do) to group sections of a single test case for better readability.

NicholasBoll commented 5 years ago

I was thinking about this more. Basically as test suites get larger, people look to ways to "macro" commands. There are a few ways to do this:

  1. Cypress Custom Commands

    // definition
    Cypress.Commands.add('fillForm', (first, last) => {
    cy.get('#first').type(first)
    cy.get('#last').type(last)
    }
    
    // usage
    cy.fillForm('John', 'Doe')
    
    // log
    GET  #first
    TYPE John
    GET  #last
    TYPE Doe
  2. Regular functions

    // definition
    function fillForm(first, last) {
    cy.get('#first').type(first)
    cy.get('#last').type(last)
    }
    
    // usage
    fillForm('John', 'Doe')
    
    // log
    GET  #first
    TYPE John
    GET  #last
    TYPE Doe
  3. Functional composition (.then)

    // definition
    const fillForm = (first, last) => ($form) => {
    cy.wrap($form).find('#first').type(first)
    cy.wrap($form).find('#last').type(last)
    }
    
    // usage
    cy
    .get('#form')
    .then(fillForm('John', 'Doe'))
    
    // log
    GET  #form
    THEN
    WRAP <form>
    FIND #first
    TYPE John
    WRAP <form>
    FIND #first
    TYPE Doe
  4. Functional composition (.pipe)

    // definition
    const fillForm = (first, last) => function fillForm($form) {
    cy.wrap($form).find('#first').type(first)
    cy.wrap($form).find('#last').type(last)
    }
    
    // usage (.pipe)
    cy
    .get('#form')
    .pipe(fillForm('John', 'Doe'))
    
    // log (.pipe)
    GET  #form
    PIPE fillForm
    WRAP <form>
    FIND #first
    TYPE John
    WRAP <form>
    FIND #first
    TYPE Doe
  5. Page Objects

    // definitions
    class Page {
    fillForm(first, last) {
      cy.get('#first').type(first)
      cy.get('#last').type(last)
    }
    }
    
    // usage
    const page = new Page()
    
    page.fillForm('John', 'Doe')
    
    // log
    GET  #first
    TYPE John
    GET  #last
    TYPE Doe

The function name fillForm isn't shown in the log output of all the examples except cypress-pipe. Even cypress-pipe is confusing, because I only see cy.get('#form').pipe(fillForm('John', 'Doe')) in my test, but a bunch more commands logged in the Command Log. Grouping would make this much easier to understand the relationship between my test code and the log.

The following could be modifications to get grouping to work:

  1. Cypress Custom Commands

    // definition
    Cypress.Commands.add('fillForm', (first, last) => {
    cy.group('fillForm', () => {
      cy.get('#first').type(first)
      cy.get('#last').type(last)
    })
    }
    
    // usage
    cy.fillForm('John', 'Doe')
    
    // log
    GROUP fillForm
    - GET  #first
    - TYPE John
    - GET  #last
    - TYPE Doe
  2. Regular functions

    // definition
    import { group } from '@cypress/group' // Higher-order function/class decorator
    
    const fillForm = group('fillForm', (first, last) => {
    cy.get('#first').type(first)
    cy.get('#last').type(last)
    })
    
    // usage
    fillForm('John', 'Doe')
    
    // log
    GROUP fillForm
    - GET  #first
    - TYPE John
    - GET  #last
    - TYPE Doe
  3. Functional composition (.then)

    // definition
    const fillForm = (first, last) => ($form) => {
    cy.group('fillForm', () => {
      cy.wrap($form).find('#first').type(first)
      cy.wrap($form).find('#last').type(last)
    })
    }
    
    // usage
    cy
    .get('#form')
    .then(fillForm('John', 'Doe'))
    
    // log
    GET  #form
    THEN
    GROUP fillForm
    - WRAP <form>
    - FIND #first
    - TYPE John
    - WRAP <form>
    - FIND #first
    - TYPE Doe
  4. Functional composition (.pipe) (no changes actually needed)

    // definition
    const fillForm = (first, last) => function fillForm($form) {
    cy.wrap($form).find('#first').type(first)
    cy.wrap($form).find('#last').type(last)
    }
    
    // usage
    cy
    .get('#form')
    .pipe(fillForm('John', 'Doe'))
    
    // log
    GET  #form
    PIPE fillForm
    - WRAP <form>
    - FIND #first
    - TYPE John
    - WRAP <form>
    - FIND #first
    - TYPE Doe
  5. Page Objects

    // definitions
    import { group } from '@cypress/group' // Higher-order function/class decorator
    class Page {
    @group('fillForm') // optional if name can't be inferred
    fillForm(first, last) {
      cy.get('#first').type(first)
      cy.get('#last').type(last)
    }
    }
    
    // usage
    const page = new Page()
    
    page.fillForm('John', 'Doe')
    
    // log
    GROUP fillForm
    - GET  #first
    - TYPE John
    - GET  #last
    - TYPE Doe
NicholasBoll commented 5 years ago

I spent some time looking into this over the weekend. I don't think group can be added without changes to the Cypress runner code. The React UI renders a list, but will need to render a tree. cy.within would be easier to understand as well in the Command Log if it also created a group. Right now you can understand what is inside a cy.within while debugging by clicking on Commands inside a within by the debug output, but not part of the Video or screenshots.

Next step would be to evaluate how much code needs to change to support grouping. cy.within required changes to all commands within querying.coffee to know if querying APIs should target the root element or the withinSubject. Grouping doesn't seem to need that level of interaction with other commands, but does require cooperation with the UI.

WillsB3 commented 1 year ago

I too would love to be able to utilise grouping of logs for custom commands as some of the built in commands (e.g. cy.within) do.

Screenshot 2022-10-04 at 17 28 13

It now looks like cy.within uses logGroup internally to allow commands executed inside the within block to be collapsed in the runner UI, but I assume logGroup is not exported or otherwise available to end users authoring tests?

jogelin commented 1 year ago

https://j1000.github.io/blog/2022/10/27/enhanced_cypress_logging.html

BloodyRain2k commented 1 year ago

It seems that the parameters outlined in that blog post aren't yet in the TypeScript definitions: image

VSCode is complaining but Cypress is fine.

And while it's nice to be able to rather simply create groups this way, it would be nice to be able to close them as easily too.

Maybe an additional option for Cypress.log({ groupEnd: true, groupCollapse: true }) to be used in conjunction with groupEnd?

iomedico-beyer commented 1 year ago

see also please expose groupStart and groupEnd

jchatard commented 9 months ago

In our use case, it would be very nice to have this implemented. Because our test suites and tests are so long that we have to split them by it(). That’s the only way we can survive. But this has 2 main drawbacks:

As Cypress Cloud is billed on an it() usage basis, it’s very expensive for us to run tests.

So we would love to have this implemented :-)

jchatard commented 7 months ago

As of now, we have implemented this workaround:

index.d.ts

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

declare namespace Cypress {
    interface Chainable<Subject = any> {
        group(label: string, fn: () => void): void;
        skip(label: string, fn: () => void): void;
    }
}

commands.ts

Cypress.Commands.add("group", { prevSubject: false }, (label, fn) => {
    const log = Cypress.log({
        name: "group",
        displayName: label,
        message: [],
        autoEnd: true,
        // @ts-ignore
        groupStart: true
    });

    return cy
        .window({ log: false })
        .then(() => {
            fn();
        })
        .then(() => {
            // @ts-ignore
            log.endGroup();
        });
});

Cypress.Commands.add("skip", { prevSubject: false }, (label, fn) => {
    Cypress.log({
        name: "skip",
        displayName: `⏹ ${label}`,
        message: ["_skipped_"],
        autoEnd: true,
        // @ts-ignore
        groupStart: false,
    });
});

Usage:

describe('My describe', () => {
    it('My test should group', () => {
        cy.visit('/test.html')
        cy.get('h1').should('contain.text', 'Testing')

        cy.group("Label", () => {
            cy.get('h1').should('contain.text', 'Lorem')

            cy.skip("More deeply grouping", () => {
                cy.get('h1').should('contain.text', 'ipsum')
            })
        })

        cy.get('p').should('contain.text', 'Lorem ipsum')

    })
})
mirobo commented 7 months ago

As of now, we have implemented this workaround: commands.ts


Cypress.Commands.add("group", { prevSubject: false }, (label, fn) => {
    const log = Cypress.log({
        name: "group",
        displayName: label,
        message: [],
        autoEnd: true,
        // @ts-ignore
        groupStart: true
    });

    return cy
        .window({ log: false })
        .then(() => {
            fn();
        })
        .then(() => {
            // @ts-ignore
            log.endGroup();
        });
});

Do you also have the problem that this spinner always shows? image

And the group is not collapsed. Would be really helpful if group collapsing and the never-ending spinner could be "fixed" somehow. But also the group logging mechanism should not add to the overall execution time of the test. I'd like to add group logging on a per-UI-component basis (i.e. by default I want to hide the specifics of how to select a value from an Angular Material Dropdown). At least Cypress does not mess with the order of logs as Playwright does ;-) (inconsistent order of logs 🤔)

jchatard commented 7 months ago

@mirobo

Do you also have the problem that this spinner always shows?

No

And the group is not collapsed.

No, they are not collapsed, but I added a CSS rule to see them more easily.

BloodyRain2k commented 7 months ago

Do you also have the problem that this spinner always shows?

I have that with own version occasionally, but it's more often with 3rd party requests that just "died".

And the group is not collapsed.

I dug through the UI to get mine to do that, works most of the time, but with very fast tests it's possible that the UI updates faster than the code can keep up with, in which case groups to be closed stay open.

Cypress.Commands.add("group", { prevSubject: false }, (label, fn) => {
  const group_id = `grp_${new Date().getTime()}`;

  Cypress.log({
    id: group_id,
    displayName: label,
    message: [],
    // @ts-ignore
    groupStart: true,
  });

  const chain = fn(group_id);

  return cy.wait(1, { log: false })
    .then(() => {
      Cypress.log({ groupEnd: true, emitOnly: true });

      const loggedCommands = Array.from(window?.top?.document
        ?.querySelectorAll("ul.commands-container > li.command") || []) as HTMLElement[];
      // @ts-ignore
      if (loggedCommands.length > 0) {
        // "li.command" => .__reactInternalInstance*.return.key == group_id
        for (const command of loggedCommands.reverse()) {
          const internal = Object.keys(command).find(key => key.startsWith("__reactInternalInstance"));
          if (!internal) { continue; }
          if (command[internal].return.key == group_id) {
            // cy.log("command 1st child:", group_id, command.firstChild)
            const expander = Array.from(command.firstChild?.childNodes || [])
              .find(node => (node as HTMLElement).className == "command-expander-column")
              ?.firstChild as HTMLElement;
            if (!expander) {
              cy.log("couldn't find '> .command-expander-column'")
              continue;
            }
            if (expander.classList.contains("command-expander-is-open")) {
              // @ts-ignore
              expander.parentNode.click();
              break;
            };
          }
        }
      }

      return chain;
    })
});

The way it works is that it generates a unique group_id for each call and assigns it to Cypress.log({ id }), because this was the ONLY thing I could find that would manage to make it into the UI somehow and be findable. Disassembling React is always such a fun task. Anyways, after I found out how to find the group_id set that way, I just had to dig through the list to find it, then back out to find the folding toggle and click it.

It's also likely that this might not work right away if you directly copy it into your setup, because this is just a part of my version. The entire thing is at this point so complicated, because I've added various other features like (wonky) auto retry if something fails within the group and custom function call if something writes an error to the console, that I myself needed to take several looks over it again to even understand how the hell the damn thing worked. And I'm the one who built it...