April 17, 2024

Strategies for Handling Login with Playwright

Testing Authentication Flows with Playwright: Strategies for Handling Login, Logout, and Session Management.

;

In today's era of web applications, robust login, logout, and session management are essential for both security and user experience . Playwright, with its powerful automation capabilities, provides a solid foundation for testing and implementing these important functionalities. In this blog we will explore some practical strategies and real world examples for handling login , logout and session management with playwright.

Automating Login with Playwright

Login functionality is the gateway to the application for accessing the restricted area of the application. Automating login with Playwright involves simulating the user interactions like entering the credentials and submitting the login form . Let's explore some examples.

import { test as setup, expect } from "@playwright/test";
 
const authFile = "playwright/.auth/user.json";
 
setup("authenticate", async ({ page }) => {
  // Perform authentication steps. Replace these actions with your own.
  await page.goto("https://github.com/login");
  await page.getByLabel("Username or email address").fill("username");
  await page.getByLabel("Password").fill("password");
  await page.getByRole("button", { name: "Sign in" }).click();
  // Wait until the page receives the cookies.
  //
  // Sometimes login flow sets cookies in the process of several redirects.
  // Wait for the final URL to ensure that the cookies are actually set.
  await page.waitForURL("https://github.com/");
  // Alternatively, you can wait until the page reaches a state where all cookies are set.
  await expect(
    page.getByRole("button", { name: "View profile and more" }),
  ).toBeVisible();
 
  // End of authentication steps.
 
  await page.context().storageState({ path: authFile });
});

now let's see How to Handle auth in E2E testing with Playwright

To make it work, we should use globalsetup (opens in a new tab) . it's just a function and a property. in the playwright configuration file. Here is sample configuration file

import { devices } from "@playwright/test";
 
const config = {
  globalSetup: "./e2e/globalSetup",
  use: {
    baseURL: process.env.BASE_URL,
    storageState: `./e2e/state.json`,
  },
  projects: [
    {
      name: "chromium",
      use: { ...devices["Desktop Chrome"] },
    },
  ],
  testDir: "./src",
  testMatch: "**/*.spec.js",
};
 
export default config;

after that next step is we have to implement the globalSetup function

import { chromium } from "@playwright/test";
import { AuthPage } from "./AuthPage";
 
async function globalSetup(config) {
  const [project] = config.projects;
  const { storageState, baseURL } = project.use;
 
  const browser = await chromium.launch();
  const page = await browser.newPage();
 
  const auth = new AuthPage(page, baseURL);
  await auth.login();
 
  await page.context().storageState({
    path: storageState,
  });
 
  await browser.close();
}
 
export default globalSetup;

In general, we've passed the configuration , successfully launched the browser and new page is created page.context() that stores the browser context (cookies,local storage and so on) to the path we've provided in the configuration

And then, let's check the authentication

const userCredentials = {
  username: process.env.USERNAME ?? "",
  password: process.env.PASSWORD ?? "",
};
 
export class AuthPage {
  constructor(page, baseUrl = "") {
    this.page = page;
    this.baseUrl = baseUrl;
  }
 
  async submitLoginForm() {
    await this.page.click('input[type="email"]');
    await this.page.fill('input[type="email"]', userCredentials.username);
    await this.page.click('input[type="password"]');
    await this.page.fill('input[type="password"]', userCredentials.password);
    await this.page.click('text="Sign In"');
  }
 
  async login() {
    await Promise.all([
      this.page.goto(this.baseUrl + "/login"),
      this.page.waitForNavigation(),
    ]);
 
    await this.submitLoginForm();
 
    // if there's a redirect back to main page
    await this.page.waitForURL((url) => url.origin === this.baseUrl, {
      waitUntil: "networkidle",
    });
  }
}

Basic functionality has been implemented here . Finished with logic implementation , connected to the test runners and it is executed initially at the beginning of the application flow.

Depending on the logic of the backend , the state of the auth might be reused several times . for example in this case, the token was valid for a couple of hours, so it makes no sense to run e2e auth logic again and again.

Let's make some improvements to the globalSetup function

Let's change the playwright configuration file and implement the function that will generate the state file name concatenated with a timestamp. Otherwise, if an hour does not pass , it will return the old one:

import { devices } from "@playwright/test";
 
const STORAGE_STATE_FILE_PREFIX = "state";
const STORAGE_STATE_PATH = "./e2e";
const EXPIRES_IN_MINUTES = 60;
 
const getStorageStateFileName = () => {
  const lastFile = fs
    .readdirSync(STORAGE_STATE_PATH)
    .filter((name) => name.startsWith(STORAGE_STATE_FILE_PREFIX))
    .pop();
  const currentTime = Date.now();
 
  if (lastFile) {
    const [, lastTimestamp] = lastFile.split(".");
    const dateDiffInMinutes = Math.floor(
      (currentTime - parseInt(lastTimestamp)) / 1000 / 60,
    );
 
    return dateDiffInMinutes > EXPIRES_IN_MINUTES ? currentTime : lastTimestamp;
  }
 
  return currentTime;
};
 
const config = {
  globalSetup: "./e2e/globalSetup",
  use: {
    baseURL: process.env.BASE_URL,
    // generates new filename concatenated with timestamp or returns old one (for example, state.1645720991712.json)
    storageState: `${STORAGE_STATE_PATH}/state.${getStorageStateFileName()}.json`,
  },
  projects: [
    {
      name: "chromium",
      use: { ...devices["Desktop Chrome"] },
    },
  ],
  testDir: "./src",
  testMatch: "**/*.spec.js",
};
 
export default config;

Handling Logout with Playwright

For logging out let's see this example (opens in a new tab)

here we are following the steps for automating this test

  1. visit link (opens in a new tab)

  2. Once the page is loaded completely, log in with username as tomsmith and password as SuperSecretPassword!

  3. Assert that the login was successful

  4. Log out and assert the success of the logout.

import { test, expect } from "@playwright/test";
 
test("Example to demonstrate text input and basic assertions", async ({
  page,
}) => {
  await page.goto("https://the-internet.herokuapp.com/login");
 
  await expect(page.locator("#username")).toBeVisible({ timeout: 2000 });
 
  await page.fill("#username", "tomsmith");
 
  await page.fill("#password", "SuperSecretPassword!");
 
  await page.click('button[type="submit"]');
 
  await expect(page.locator("div#flash")).toContainText(
    "You logged into a secure area!",
  );
 
  await page.click('a[href="/logout"]');
 
  await expect(page.locator("#username")).toBeVisible({ timeout: 2000 });
 
  await expect(page.locator("div#flash")).toContainText(
    "You logged out of the secure area!",
  );
});

Advanced Strategies for Handling Login with Playwright

Beyond the basic login and logout functionality, Playwright support advance techniques for handling more complex cases. These includes multi-factor auth , OAUTH flows, and handling CAPTCHA challenges and many more third party auth like clerk , magic link e.t.c.

1. Auth clerk with Playwright

Set the ENV variables as follows:

# Playwright
PLAYWRIGHT_E2E_USER_ID="user_***"
PLAYWRIGHT_E2E_USER_EMAIL="name@example.com"
PLAYWRIGHT_E2E_USER_PASSWORD="***"

Next you want to edit your global.auth.ts

 
import { chromium, expect, test as setup } from '@playwright/test';
import { config as cfg } from '../config';
import Clerk from '@clerk/clerk-js';
import { test } from '@playwright/test';
 
type ClerkType = typeof Clerk;
 
const authFile = 'state.json';
 
setup('Setup Auth', async ({ page, context }) => {
 
  await page.goto("https://your.url")
 
  // some quick check to see if the dom has loaded
  const logo = await page.getByRole('img', { name: 'Logo' })
  await expect(logo).toBeVisible();
 
  // ENV variables generated using The Clerk Dashboard
  const data = {
    userId: process.env.PLAYWRIGHT_E2E_USER_ID || '',
    loginPayload: {
      strategy: 'password',
      identifier: process.env.PLAYWRIGHT_E2E_USER_EMAIL || '',
      password: process.env.PLAYWRIGHT_E2E_USER_PASSWORD || '',
    }
  }
 
  //here is where the magic happens
  const result = await page.evaluate(async data => {
 
    // wait function as promise
    const wait = (ms: number) => new Promise(resolve => setTimeout(resolve, ms));
 
    const wdw = window as Window & typeof globalThis & { Clerk: ClerkType };
 
    /** clear the cookies */
    document.cookie.split(";").forEach(function(c) { document.cookie = c.replace(/^ +/, "").replace(/=.*/, "=;expires=" + new Date().toUTCString() + ";path=/"); });
 
    const clerkIsReady = (window: Window & typeof globalThis & { Clerk: ClerkType }) => {
      return wdw.Clerk && wdw.Clerk.isReady();
    }
 
    while (!clerkIsReady(wdw)) {
      await wait(100);
    }
 
    /** if the session is still valid just return true */
    if (wdw.Clerk.session?.expireAt && wdw.Clerk.session.expireAt > new Date()) {
      return true;
    }
 
    /** if its a different user currently logged in sign out */
    if (wdw.Clerk.user?.id !== data.userId) {
      await wdw.Clerk.signOut();
    }
 
    /**
     * otherwise signin
     */
    const res = await wdw.Clerk.client?.signIn.create(data.loginPayload);
 
    if (!res) {
      return false
    }
 
    /** set the session as active */
    await wdw.Clerk.setActive({
      session: res.createdSessionId,
    });
 
    return true
 
  }, data);
 
  if (!result) {
    throw new Error('Failed to sign in');
  }
 
  const pageContext = await page.context();
 
  let cookies = await pageContext.cookies();
 
  // clerk polls the session cookie, so we have to set a wait
  while (!cookies.some(c => c.name === '__session')) {
    cookies = await pageContext.cookies();
  }
 
  // store the cookies in the state.json
  await pageContext.storageState({ path: authFile });
 
})
 

The following code is the key to loading the session . Clerk makes a seperate request as a part of handshake after setting the session to Active. This could technically be an infinite loop, however playwright has timeout restrictions if there is an issue requesting the cookie

while (!cookies.some((c) => c.name === "__session")) {
  cookies = await pageContext.cookies();
}

2. Bypass CAPTCHA with Base Playwright and 2Captcha

Method 1 : Use the PLaywright and 2captcha plugin

We'll begin by installing the plugin

npm install playwright 2captcha

In your code editor, import the both the libraries and create async function that launches the headless Chrome browser (with headless: true, as in production).

// Call ReCaptcha Website
const websiteUrl = "https://patrickhlauke.github.io/recaptcha/";
await page.goto(websiteUrl);
 
// Wait for the CAPTCHA element to load
const captchaFrame = await page.waitForSelector(
  "iframe[src*='recaptcha/api2']",
);
 
// Switch to the CAPTCHA iframe
const captchaFrameContent = await captchaFrame.contentFrame();
 
// Wait for the CAPTCHA checkbox to appear
const captchaCheckbox =
  await captchaFrameContent.waitForSelector("#recaptcha-anchor");
 
// Click the CAPTCHA checkbox
await captchaCheckbox.click();

to retrieve the answer , invoke the solver.recaptcha() method to send a request to 2Captcha's API and fetch the response string containing the correct answer. Here, it's crucial to pass the data-sitekey parameter (i.e., loremipsum) from the CAPTCHA

 // Wait for the CAPTCHA challenge to be solved by 2Captcha
  const captchaResponse = await solver.recaptcha("loremipsum", websiteUrl);
 
  // Fill in the CAPTCHA response and submit the form
  const captchaInput = await captchaFrameContent.waitForSelector("#g-recaptcha-response");
  await captchaInput.evaluate((input, captchaResponse) => {
    input.value = captchaResponse;
  }, captchaResponse);
  await captchaFrameContent.waitForSelector("button[type='submit']").then((button) => button.click());
 
  // Wait for the page to navigate to the next page
  await page.waitForNavigation();
 
  console.log("CAPTCHA solved successfully!");
 
  await browser.close();
 
})();

3. supabase magic login in CI with Playwright

Here we will see how to handle authentication in CI tests using github actions (opens in a new tab) . This approach allows to authenticate and test app easily.

Requirements : We just need Supabase app using magic login for authentication. Here we are using Supabase CLI to start the database in CI rather than connecting the database

Supabase local developement

For reference you can refer the supbase docs here (opens in a new tab)

npx supabase start
Started supabase local development setup.
         API URL: http://localhost:54321
          DB URL: postgresql://postgres:postgres@localhost:54322/postgres
      Studio URL: http://localhost:54323
    Inbucket URL: http://localhost:54324 # keep a note of this URL!
      JWT secret: xxx
        anon key: xxx
service_role key: xxx

Now install Playwright package

npm init playwright@latest

Configuring Playwright

Playwright configuration file is created in the root directory of the project. Here is the sample configuration file

import dotenv from 'dotenv'
import type { PlaywrightTestConfig } from '@playwright/test'
 
dotenv.config()
 
const config: PlaywrightTestConfig = {
  webServer: {
    command: 'npm run build && npm run preview',
    port: 4173,
  },
  testDir: 'tests',
  // we will be creating this file shortly
  globalSetup: './tests/global-setup.ts',
  use: {
    // this is where we will cache the user's session across tests
    storageState: 'storage-state.json',
  },
}
 
export default config

Writing the global setup function

one of the main benifits of using magic login links is user don't have to remember the password or enter it on your app.

Supabase magic login will email the user login link, optionally with OTP . we need to retrive these details and use them in our setup function. to complete the login process.

Here is the function which allow us to select the email address field , enter the value and submit the form.

import { chromium, type FullConfig } from '@playwright/test'
 
async function globalSetup(config: FullConfig) {
  const browser = await chromium.launch()
  const page = await browser.newPage()
  // replace this URL with the address your local app runs on
  await page.goto(`http://localhost:${config.webServer?.port}`)
  /**
   * My app automatically signs users in when they don't exist.
   * You'll need to seed a user and update this username if
   * you have a separate registration process.
   */
  await page
    .getByRole('textbox', { name: 'Your email address' })
    .type('user@example.com')
  await page.getByRole('button', { name: 'Email me a login link' }).click()
  await browser.close()
}
 
export default globalSetup

we need to write a function that will allow us to fetch the latest message for a user and extract the login link and OTP .

import { chromium, request, type FullConfig } from '@playwright/test'
 
const getLoginMessage = async (username: string) => {
  const requestContext = await request.newContext()
  const messages = await requestContext
    .get(`${process.env.INBUCKET_URL}/api/v1/mailbox/${username}`)
    .then((res) => res.json())
    // InBucket doesn't have any params for sorting, so here
    // we're sorting the messages by date
    .then((items) =>
      [...items].sort((a, b) => {
        if (a.date < b.date) {
          return 1
        }
 
        if (a.date > b.date) {
          return -1
        }
 
        return 0
      })
    )
 
  // As we've sorted the messages by date, the first message in
  // the `messages` array will be the latest one
  const latestMessageId = messages[0]?.id
 
  if (latestMessageId) {
    const message = await requestContext
      .get(
        `${process.env.INBUCKET_URL}/api/v1/mailbox/${username}/${latestMessageId}`
      )
      .then((res) => res.json())
 
    // We've got the latest email. We're going to use regular
    // expressions to match the bits we need.
    const token = message.body.text.match(/enter the code: ([0-9]+)/)[1]
    const url = message.body.text.match(/Log In \( (.+) \)/)[1]
 
    return { token, url }
  }
 
  return {
    token: '',
    url: '',
  }
}
 

To finish, we’ll also save the current page context to storage-state.json - this means Playwright can use this login session across multiple tests.

async function globalSetup(config: FullConfig) {
  // ...beginning of function
  await page.getByRole('button', { name: 'Email me a login link' }).click()
  const { token } = await getLoginMessage('user')
  await page.getByRole('textbox', { name: 'One-time password' }).type(token)
  await page
    .getByRole('button', { name: 'Login with one-time password' })
    .click()
  await page.getByText('Protected content for user@example.com').waitFor()
  await page.context().storageState({ path: 'storage-state.json' })
  await browser.close()
}

Supabase sends the email asyncrhronously , there's a tiny delay , and we can't gurantee the time between triggering the email and when we try to finish logging in

A workaround for this might be hardcoding a dealy of a few hundred ms . to avoid this situation we need to wrote a function that'll wait for new email before continuing .

const waitForNewToken = async (oldToken: string, username: string) => {
  let triesLeft = 5
  return new Promise<Awaited<ReturnType<typeof getLoginMessage>>>(
    (resolve, reject) => {
      const interval = setInterval(async () => {
        const check = await getLoginMessage(username)
        if (check.token !== oldToken) {
          resolve(check)
          clearInterval(interval)
        } else if (triesLeft <= 1) {
          reject()
          clearInterval(interval)
        }
        triesLeft--
      }, 100)
    }
  )
}

Essentially, this will poll InBucket every 100ms (with a retry limit of 5) until it receives a new token. The oldToken is retrieved at the start of the setup function and will either be the default empty string (if the mailbox is empty) or the token from the last login attempt.

We can use waitForNewToken in our setup function and completed, it looks like this:

async function globalSetup(config: FullConfig) {
  const { token: oldToken } = await getLoginMessage()
  const browser = await chromium.launch()
  const page = await browser.newPage()
  await page.goto(`http://localhost:${config.webServer?.port}`)
  await page
    .getByRole('textbox', { name: 'Your email address' })
    .type('user@example.com')
  await page.getByRole('button', { name: 'Email me a login link' }).click()
  const { token } = await waitForNewToken(oldToken)
  await page.getByRole('textbox', { name: 'One-time password' }).type(token)
  await page
    .getByRole('button', { name: 'Login with one-time password' })
    .click()
  await page.getByText('Protected content for user@example.com').waitFor()
  await page.context().storageState({ path: 'storage-state.json' })
  await browser.close()
}

let's run a simple test case

import { expect, test } from "@playwright/test";
 
test("can login", async ({ page }) => {
  await page.goto("/");
  await expect(
    page.getByText("Protected content for user@example.com"),
  ).toBeVisible();
});

Running the tests in Continuous Integration (CI)

playwright:
  name: Playwright
  runs-on: ubuntu-latest
  strategy:
    fail-fast: false
    matrix:
      shardIndex: [1, 2]
      shardTotal: [2]
 
  steps:
    - name:  Cancel Previous Runs
      uses: styfle/cancel-workflow-action@0.11.0
      with:
        access_token: ${{ github.token }}
 
    - name:  Checkout repo
      uses: actions/checkout@v3
 
    - name:  Copy test env vars
      run: cp .env.example .env
 
    - name:  Setup node
      uses: actions/setup-node@v3
      with:
        node-version: 16
 
    - name:  Download deps
      uses: bahmutov/npm-install@v1
 
    - name:  Install Playwright browsers
      run: npx playwright install --with-deps
 
    - name:  Setup Supabase CLI
      uses: supabase/setup-cli@v1
      with:
        version: latest
 
    - name:  Start Supabase
      run: supabase start && supabase db reset
 
    - name:  Playwright
      env:
        INBUCKET_URL: http://localhost:54324
      run: npx playwright test --shard=${{ matrix.shardIndex }}/${{ matrix.shardTotal }}

Session management

Session management involves maintaining user sessions securely and efficiently. with the help of playwright we can automate the session management by storing the session data in the cookies and local storage.

for example

// Simulate session expiration by manipulating cookies
await page.context().addCookies([
  {
    name: "session",
    value: "expired",
    expires: Date.now(), // Set expiration to past time
    url: "https://abc.com",
  },
]);
 
// Navigate to a page requiring authentication
await page.goto("https://abc.com/dashboard");
 
// Verify redirection to login page
// Insert verification logic here

For more details on session management with playwright , you can refer to the Advanced Browser Manipulation with Playwright (opens in a new tab)

Best Practices for Handling Login with Playwright

  • Use element selectors resilient to changes.

  • Implement wait strategies to handle asynchronous behavior.

  • Encrypt sensitive data like passwords during test execution.

  • Regularly update test scripts to accommodate application changes.

  • Incorporate logging and error handling for better debugging and reporting.

Conclusion

Effective login, logout, and session management are indispensable for modern web applications. With Playwright, automating these functionalities becomes not only feasible but also efficient. By following the strategies and examples outlined in this guide, you can ensure the reliability and security of your application's authentication and session handling mechanisms.

By combining Playwright's automation capabilities with these practical strategies, you can streamline testing and enhance the overall quality of your web application.