Introduction

Welcome to the guide on testing Descope flows with Cypress. Whether you are new to end-to-end testing or an experienced tester looking to better understand Descope flows, this guide will provide you with practical knowledge and examples that you can implement straight away. Let's dive in!

Note: This guide covers creating test users manually for testing; however, you can also see our Dynamic Test Users Configuration guide if you would like the ability to create test users dynamically as part of the sign-up-or-in process.

What you'll learn

  • Use custom Cypress commands to:
    • Programmatically authenticate with Descope
    • Authenticate with Descope through the UI
  • Including your tests in continuous integration via Github Actions

Cypress Installation

Make sure you have Cypress already installed within your application. If not, you can do so here.

Descope Application Setup

To get started with Descope, an application needs to be setup within the Descope Console via the following steps:

  1. Visit the Descope Console and create a new project.
  2. Enter the desired name for your application.
  3. Get your projectId found in Settings/Project , and create a managementKey, found in Settings/Company/Management Keys/+ Mangement Key

Setting Descope app credentials in Cypress

To have access to test user credentials within our tests we need to configure Cypress to use the Descope environment variables set in the .env file.
  • cypress.config.js
  • cypress.config.ts
const { defineConfig } = require("cypress");

// Populate process.env with values from .env file
require('dotenv').config()

module.exports = defineConfig({
  e2e: {
    includeShadowDom: true, // Important for interacting with Descope components
    baseUrl: 'http://localhost:3000',
    setupNodeEvents(on, config) {
      // implement node event listeners here
    },
  },
  env: {
    descope_project_id: process.env.REACT_APP_DESCOPE_PROJECT_ID,
    descope_management_key: process.env.REACT_APP_DESCOPE_MANAGEMENT_KEY
  },
});
Make sure the corresponding environment variables exist in your .env file

Custom Command for Descope Authentication

There are two ways you can authenticate to Descope:

  • Login with UI
  • Programmatic Login

For both methods of log in, you will need to include your environment variables and test user first. Then, you can start the login flow.

//
// cypress/support/commands.js
//

const projectId = Cypress.env('descope_project_id')
const managementKey = Cypress.env('descope_management_key')
const descopeAPIDomain = "api.descope.com"

// Define the authorization header
const authHeader = {
    'Authorization': `Bearer ${projectId}:${managementKey}`,
}

// Define the base URL for Descope API
const descopeApiBaseURL = `https://${descopeAPIDomain}/v1`;

const testUserLoginId = "testUser" + Math.floor(1000 + Math.random() * 9000) + "@gmail.com"; // Must match email to pass validation

// Define the test user details
const testUser = {
    loginId: testUserLoginId,
    email: testUserLoginId,
    phone: "+11231231234",
    verifiedEmail: true,
    verifiedPhone: true,
    displayName: "Test User",
    test: true,
}

Descope UI Login

Below is a command to login into Descope, using the Test User Management API and navigating via the user interface, just as a real user would. It should be added in cypress/support/commands.js. The loginViaDescopeUI command will execute the following steps:
  1. Use the Test User Management API to perform the login (create user and generate OTP code).
  2. Then, we enter the user loginId and code that we just generated to log in via the user interface.
Note: Since Descope renders components via the shadow DOM, remember to add the includeShadowDom: true field in your cypress.config.js as noted above or your tests won't be able to interact with the UI. The other option is to use .shadow() in each function call as described in the Cypress documentation but this may make your code harder to read.
//
// cypress/support/commands.js
//

// Add the loginViaDescopeUI command
Cypress.Commands.add('loginViaDescopeUI', () => {
    cy.request({
        method: 'POST',
        url: `${descopeApiBaseURL}/mgmt/user/create`,
        headers: authHeader,
        body: testUser,
    })
        .then(({ body }) => {
            const loginId = body["user"]["loginIds"][0];
            cy.request({
                method: 'POST',
                url: `${descopeApiBaseURL}/mgmt/tests/generate/otp`,
                headers: authHeader,
                body: {
                    "loginId": loginId,
                    "deliveryMethod": "email"
                }
            })
                .then(({ body }) => {
                    const otpCode = body["code"]
                    const loginID = body["loginId"]
                    cy.visit('/login')

                    cy.get('descope-wc')
                        .find('input')
                        .type(loginID)
                    // If you haven't set `includeShadowDom: true` in your config (as recommended above),
                    // you'll have to use `.shadow()` in each function call.
                    // So the previous line would look like this:
                    // cy.get('descope-wc').shadow().find('input').type(loginID)

                    cy.get('descope-wc')
                        .find('descope-button').contains('Continue').click()
                    cy.get('descope-wc').find('.descope-input-wrapper').find('input').should('exist') // Assertion added to wait for the OTP code input to appear
                    let otpCodeArray = Array.from(otpCode); // Convert the OTP code string to an array
                    otpCodeArray.forEach((digit, index) => {
                        cy.get(`descope-text-field[data-id="${index}"]`).then($element => {
                                const input = $element[0].shadowRoot.querySelector('input');
                                input.value = digit;
                                input.dispatchEvent(new Event('change', { bubbles: true }));
                            });
                    });
                    cy.get('descope-wc')
                        .find('descope-button').contains('Submit').click()

										// Customize these steps based on your authentication flow
                })
        })
})

Programmatic Login

Below is a command to programmatically login into Descope, using the Test User Management API and set an item in localStorage with the authenticated users details, which we will use in our application code to verify we are authenticated under test.The loginViaDescopeAPI command will execute the following steps:
  1. Use the Test User Management API to perform the programmatic login (create user, generate OTP code, and verify OTP code).
  2. Finally the refreshTokenand sessionToken items are set in localStorage.
//
// cypress/support/commands.js
//

// Add the loginViaDescopeAPI command
Cypress.Commands.add('loginViaDescopeAPI', () => {
    cy.request({
        method: 'POST',
        url: `${descopeApiBaseURL}/mgmt/user/create`,
        headers: authHeader,
        body: testUser,
    })
        .then(({ body }) => {
            const loginId = body["user"]["loginIds"][0];
            cy.request({
                method: 'POST',
                url: `${descopeApiBaseURL}/mgmt/tests/generate/otp`,
                headers: authHeader,
                body: {
                    "loginId": loginId,
                    "deliveryMethod": "email"
                }
            })
                .then(({ body }) => {
                    const otpCode = body["code"]
                    cy.request({
                        method: 'POST',
                        url: `${descopeApiBaseURL}/auth/otp/verify/email`,
                        headers: authHeader,
                        body: {
                            "loginId": loginId,
                            "code": otpCode
                        }
                    })
                        .then(({ body }) => {
                            const sessionJwt = body["sessionJwt"]
                            const refreshJwt = body["refreshJwt"]
                            // Note that if the refresh token is empty,
                            // you will need to get it from the headers instead.
                            // This is due to whether your Descope project
                            // returns tokens via cookies or response body

                            /** Default name for the session cookie name / local storage key */
                            const SESSION_TOKEN_KEY = 'DS';
                            /** Default name for the refresh local storage key */
                            const REFRESH_TOKEN_KEY = 'DSR';

                            // // Store the JWT in the browser's local storage.
                            cy.window().then((win) => {
                                win.localStorage.setItem(SESSION_TOKEN_KEY, sessionJwt);
                                win.localStorage.setItem(REFRESH_TOKEN_KEY, refreshJwt);
                            });

                            // // Now navigate to the root URL of your application.
                            cy.visit('/')

                        })
                })
        })
})

We’ll also need to clean up the created testing users before starting so we don’t go over the limit. This is done with the deleteAllTestUsers function written in the same file.

//
// cypress/support/commands.js
//

// Add the deleteAllTestUsers command
Cypress.Commands.add('deleteAllTestUsers', () => {
    cy.request({
        method: 'DELETE',
        url: `${descopeApiBaseURL}/mgmt/user/test/delete/all`,
        headers: authHeader,
    })
})

With our Descope app setup properly in the Descope Developer console, necessary environment variables in place, and our loginViaDescopeApi and/or loginViaDescopeUI command(s) implemented, we will be able to authenticate with Descope while our app is under test. Below is a test to login as a user using our loginViaDescopeAPI function and verify the welcome page is showing.

describe('Descope', function () {
  beforeEach(function () {
    cy.deleteAllTestUsers()
    cy.loginViaDescopeAPI()
  })

  it('shows welcome page', function () {
    cy.contains('Welcome').should('be.visible')
    })
})

We can also use our loginViaDescopeUI command in the test. Below is our test to login as a user via Descope and run a basic sanity check.

describe('Descope', function () {
  beforeEach(function () {
    cy.deleteAllTestUsers()
    cy.loginViaDescopeUI()
		cy.visit('/')
  })

  it('shows welcome page', function () {
    cy.contains('Welcome').should('be.visible')
  })
})

Running Tests in Parallel

If you'd like to run tests in parallel, you'll have to delete test users individually after the run. Here's a command to do so called deleteIndividualTestUser. You'll also need to modify your login command to return the loginId of the user or you can store it in a global variable.

Cypress.Commands.add('deleteIndividualTestUser', (loginId) => {
    cy.request({
        method: 'DELETE',
        url: `${descopeApiBaseURL}/v1/mgmt/user/delete`,
        headers: authHeader,
        body: {
            "loginId": loginId
        }
    })
})

Here's an example of a test that runs in parallel and deletes the user afterwards.

describe('Descope', function () {
  const testUserLoginId = "testuser" + Math.floor(1000 + Math.random() * 9000) + "@gmail.com"; // LoginId match email to pass validation
  before(function () {
    cy.loginViaDescopeUI(testUserLoginId) // Modify this function to accept a loginId
    cy.visit('/')
  })

  it('shows welcome page', function () {
    cy.contains('Welcome').should('be.visible')
  })
  after(function () {
    cy.deleteIndividualTestUser(testUserLoginId)
  })
})

Adding Continuous Integration with Github Actions

  1. Add .env variables to your Github repository
    1. Navigate to: Settings → Secrets & Variables → Actions → Add “New Repository Secret”
      1. REACT_APP_DESCOPE_PROJECT_ID="ProjectID"
      2. REACT_APP_DESCOPE_MANAGEMENT_KEY="ManagementKey"
  2. Create a main.yml file in .github/workflows
name: E2E Tests
on: [push]
jobs:
  cypress-run:
    name: Cypress E2E Tests
    runs-on: ubuntu-latest
    steps:
      - name: Checkout
        uses: actions/checkout@v3

      - name: Install dependencies
        run: yarn install --frozen-lockfile

      - name: Build
        run:  yarn build

      - name: Cypress run
        uses: cypress-io/github-action@v5
        env:
          REACT_APP_DESCOPE_PROJECT_ID: ${{ secrets.REACT_APP_DESCOPE_PROJECT_ID }}
          REACT_APP_DESCOPE_MANAGEMENT_KEY: ${{ secrets.REACT_APP_DESCOPE_MANAGEMENT_KEY }}
        with:
          start: yarn start
          wait-on: "http://localhost:3000"
          wait-on-timeout: 120
          browser: chrome

  1. Github Action, completed!
    1. You’ve added a configuration file for a GitHub Actions workflow. It is written in YAML and is designed to perform End-to-End (E2E) testing using Cypress on a repository's code whenever a push event is triggered.

Descope Rate Limiting Logins

Be aware of the rate limit statement in the Descope documentation:

Descope SDKs and API endpoints are rate-limited to maintain stable performance and provide a good experience for all users. Descope may limit requests if it detects an unusual spike in requests or abnormal activity in a specific project or across projects. For example, this can happen in a denial-of-service attack. To avoid hitting the rate limit, ensure your app is optimized to make the least number of requests possible based on the need. If a request breaches the rate limit - an error will be returned, together with Retry-After header, which will specify when to retry the request.

As the size of a test suite increases and parallelized runs are utilized to expedite test run duration, this limit can be reached.


Examples

Sample App

To view an example of setting up E2E tests with Cypress and Descope, please view our sample app on Github.

Other Authentication Methods

Magic Link

Setting up E2E tests via magic link requires a few more steps than the previous methods. First, you'll need to create a custom Cypress command to login via magic link. Then, you'll need to manually go through the flow in your browser to get the host, execution ID, and step ID. Finally, you'll need to visit the magic link in your test and submit the form.

Cypress.Commands.add('loginViaUIMagicLink', () => {
    cy.request({
        method: 'POST',
        url: `${descopeApiBaseURL}/mgmt/user/create`,
        headers: authHeader,
        body: testUser,
    })
        .then(({ body }) => {
            const loginId = body["user"]["loginIds"][0];
            cy.request({
                method: 'POST',
                url: `${descopeApiBaseURL}/mgmt/tests/generate/magiclink`,
                headers: authHeader,
                body: {
                    "loginId": loginId,
                    "deliveryMethod": "email"
                }
            })
                .then(({ body }) => {
                    const loginID = body["loginId"]
                    const token = body["link"].split('t=')[1]

                    // Get the host, execution ID, and step ID from manually going through the flow in your browser
                    // http://localhost:3000/?descope-login-flow=sign-up-or-in%7C%23%722bBWEu4PXO3974IVnTDuczfkkLE_7.end&t=e4cbb953227ee2816a9f801c953b391253b2cb6fb8c520aefa3728745db253587

                    const host = 'http://localhost:3000/'
                    const executionId = 'sign-up-or-in%7C%23%7C2bBVRwGbJkRVQ6k7N3GqklXHb9v_'
                    const stepId = '7.end'

                    const magicLink = host + '?descope-login-flow=' +  executionId + stepId + '&t=' + token;
                    cy.visit('/')

                    cy.get('descope-wc')
                        .find('input')
                        .type(loginID)

                    cy.get('descope-wc')
                        .find('.descope-button').contains('Continue').click()


                    cy.visit(magicLink)
                    cy.get('descope-wc')
                        .find('.descope-button').contains('Submit').click()
                })
        })
})

Enchanted Link

Similar to Magic Link, setting up E2E tests via enchanted link requires a few more steps than the previous methods. First, you'll need to create a custom Cypress command to login via enchanted link. Then, you'll need to manually go through the flow in your browser to get the host, execution ID, and step ID. Finally, you'll need to visit the enchanted link in your test and submit the form.

Cypress.Commands.add('loginViaDescopeUIEnchantedLink', () => {
    cy.request({
        method: 'POST',
        url: `${descopeApiBaseURL}/mgmt/user/create`,
        headers: authHeader,
        body: testUser,
    })
        .then(({ body }) => {
            const loginId = body["user"]["loginIds"][0];
            cy.request({
                method: 'POST',
                url: `${descopeApiBaseURL}/mgmt/tests/generate/embedded`,
                headers: authHeader,
                body: {
                    "loginId": loginId,
                    "deliveryMethod": "email"
                }
            })
                .then(({ body }) => {
                    const token = body["link"].split('t=')[1]
                    const pendingRef = body["pendingRef"]

                    // Get the host, execution ID, and step ID from manually going through the flow in your browser
                    // http://localhost:3000/?descope-login-flow=sign-up-or-in%7C%23%7C2bBtkd7nNhiEsbDLafLyMMKLgz7_14.end-2bBtkZSoriDJyqk6GVAee7MGSKJ&t=eb58e881ab4e22d3b534ae498c012699f11e2c3a6989e564e7d641c117141bdc

                    const executionId = "sign-up-or-in%7C%23%7C2bBtkd334hiEWbDLaMLyMMKLgz7"
                    const stepId = "14.end"

                    const host = 'http://localhost:3000/'
                    const enchantedLink = host + '?descope-login-flow=' + executionId + "_" + stepId + "-" + pendingRef + '&t=' + token;

                    cy.visit('/')

                    cy.get('descope-wc')
                        .find('input')
                        .type(loginId)

                    cy.get('descope-wc')
                        .find('.descope-button').contains('Sign in with email').click()

                    cy.visit(enchantedLink)
                })
        })
})

Embedded OTP or Link

Similar to Magic Link, setting up E2E tests via embedded OTP requires a few more steps than the previous methods. The same can be done for embedded link, but the token will simply need to be parsed from the link instead of used directly.

Cypress.Commands.add('loginViaDescopeUIEmbeddedOTP', () => {
    cy.request({
        method: 'POST',
        url: `${descopeApiBaseURL}/mgmt/user/create`,
        headers: authHeader,
        body: testUser,
    })
        .then(({ body }) => {
            const loginId = body["user"]["loginIds"][0];
            cy.request({
                method: 'POST',
                url: `${descopeApiBaseURL}/mgmt/tests/generate/otp`,
                headers: authHeader,
                body: {
                    "loginId": loginId,
                    "deliveryMethod": "Embedded"
                }
            })
                .then(({ body }) => {
                    const otpCode = body["code"]
                    const loginID = body["loginId"]

                    // Navigate UI and input code as shown above

                })
        })
})

Passwords

For typing codes into a passwords input, you can simply get the component then type in the code as shown.

    const code = '123456';
    const passcode = cy.get('descope-passcode', { includeShadowDom: true });

    code
      .split('')
      .reverse()
      .forEach((digit, index, arr) => {
        passcode
          .get('input', { includeShadowDom: true })
          .eq(arr.length - index - 1)
          .type(digit);
      });

Phone Number Input

For typing in a phone number, you can get the DOM element that targets a <descope-text-field> element with the specific attributes (type="tel" and placeholder="Phone"). You can then get the resulting jQuery object, access the first (and likely only) DOM element in the jQuery object, access the shadow DOM (which is how Descope renders components), and get the input element. Then, you can directly set the value to the phone number. We add the bubble event to make the event more realistic.
cy.get('descope-text-field[type="tel"][placeholder="Phone"]').then($element => {
    const input = $element[0].shadowRoot.querySelector('input');
    input.value = "1231231234"; // Phone number
    input.dispatchEvent(new Event('change', { bubbles: true }));
})