4

I have a sign-in form I am testing using Playwright that has one of two possible states:

  1. If the user already exists, it will only ask for their password and present a "Sign In" button.
  2. If it is a new user, it will ask for their "First & last name" and password and present a "Save" button.

Due to some logic of our authentication system, it's not practical for it to be entirely deterministic so I need to ensure my test handles each of those cases successfully.

One such solution is [1]:

if (await page.getByRole('button', { name: 'Sign In' }).count() > 0) {
  await page.getByLabel('Password').fill('password');
  await page.getByRole('button', { name: 'Sign In' }).click();
} else {
  await page.getByLabel('First & last name').fill('Bob Alice');
  await page.getByLabel('Password').fill('password');
  await page.getByRole('button', { name: 'Save' }).click();
}

However, this requires the page to wait for my timeout before continuing. I could reduce the timeout for the conditional check [2], but this adds more brittleness to the test.

How can I use conditional logic without forced timeouts to ensure one of two code paths in Playwright is executed?

Ryan Saunders
  • 359
  • 1
  • 13

2 Answers2

4

Using Promise.race, you can wait for one of many possible locators to be visible, then return details about which locator succeeded. Here's such a helper function in Typescript:

import { Locator } from '@playwright/test';

type WaitForRes = [ locatorIndex: number, locator: Locator ];

export async function waitForOneOf(
  locators: Locator[],
): Promise<WaitForRes> {
  const res = await Promise.race([
    ...locators.map(async (locator, index): Promise<WaitForRes> => {
      let timedOut = false;
      await locator.waitFor({ state: 'visible' }).catch(() => timedOut = true);
      return [ timedOut ? -1 : index, locator ];
    }),
  ]);
  if (res[0] === -1) {
    throw new Error('no locator visible before timeout');
  }
  return res;
}

And here is how it can be used to solve the problem:

const [ index ] = await waitForOneOf([
  page.getByRole('button', { name: 'Sign In' }),
  page.getByRole('button', { name: 'Save' }),
]);
const isExistingAccount = index === 0;
if (isExistingAccount) {
  await page.getByLabel('Password').fill('password');
  await page.getByRole('button', { name: 'Sign In' }).click();
} else {
  await page.getByLabel('First & last name').fill('Bob Alice');
  await page.getByLabel('Password').fill('password');
  await page.getByRole('button', { name: 'Save' }).click();
}

The waitForOneOf wrapper gives some benefits over simply using Promise.race [1]:

  1. The expectation is that all but one of the locators is going to time out and throw an error. This isn't an issue because the Promise.race will discard those rejections; however, if you are debugging within VS Code (or elsewhere), this thrown error will trigger a breakpoint that pauses the code execution. To mitigate this, we .catch the error instead.
  2. By catching the error, we can also specify its failure and if no locator resolves correctly (if (res[0] === -1)), then we can still throw an error.
  3. By returning the resolved locator and its index, we can have conditional code execution, as demonstrated above.
Ryan Saunders
  • 359
  • 1
  • 13
0

This is the simplest way I've found to determine state based on which locator shows up:

const accountExists = await Promise.any([
    page.getByRole('button', { name: 'Sign In' }).waitFor().then(() => false),
    page.getByRole('button', { name: 'Save' }).waitFor().then(() => true),
]).catch(() => {
    throw "Missing button";
});

Then you can use it however you need:

if (accountExists) {
    await page.getByLabel('Password').fill('password');
    await page.getByRole('button', { name: 'Sign In' }).click();
} else {
    await page.getByLabel('First & last name').fill('Bob Alice');
    await page.getByLabel('Password').fill('password');
    await page.getByRole('button', { name: 'Save' }).click();
}
heygarrett
  • 11
  • 4