---
title: Browser check recipes: login, 2FA, checkout and more in Playwright
description: Copy-paste Playwright patterns for browser checks — login, TOTP two-factor codes, checkout, search, API + UI, cookie banners, iframes and Stripe fields, mobile viewports and file downloads.
canonical: https://watchfor.io/docs/monitors/browser-checks/recipes
---

# Browser check recipes: login, 2FA, checkout and more in Playwright

Copy-paste Playwright patterns for browser checks — login, TOTP two-factor codes, checkout, search, API + UI, cookie banners, iframes and Stripe fields, mobile viewports and file downloads.

Every pattern here is plain Playwright and was run on our runtime (Playwright
1.63.0) before it was published. The URLs and selectors are placeholders for
your site's — `npx playwright codegen https://your-site.example` records real
ones for you — and credentials belong in
[variables and secrets](/docs/monitors/browser-checks/variables).

## Login

The **Login** example is the starting point. Sign in with a dedicated test
account and check something only a signed-in user sees:

```ts

test('login', async ({ page }) => {
  await test.step('Open the login page', async () => {
    await page.goto(process.env.TARGET_URL!);
  });

  await test.step('Sign in', async () => {
    await page.getByLabel('Email').fill(process.env.LOGIN_USERNAME!);
    await page.getByLabel('Password').fill(process.env.LOGIN_PASSWORD!);
    await page.getByRole('button', { name: 'Sign in' }).click();
  });

  await test.step('Dashboard is shown', async () => {
    await expect(page).toHaveURL(/\/dashboard/);
    await expect(page.getByRole('button', { name: /account/i })).toBeVisible();
  });
});
```

- Assert on something that is **only** there after a successful sign-in (an
  account menu, the user's name). A redirect alone can also mean "back to the
  login page with an error".
- Every run starts with a fresh browser, so the check signs in every time.
  That is the point: it proves sign-in works.
- If your login sits behind a CAPTCHA or bot protection, allow our browsers
  by user agent (`WatchFor-BrowserCheck/1.0`) for the test account, or monitor a staging
  host without it.

## Two-factor codes (TOTP)

`otpauth` is preinstalled and generates the same six-digit code an
authenticator app shows. Enrol a dedicated test account in 2FA, and when the
site shows the QR code, keep the **base32 secret** behind it (most sites offer
"can't scan? enter this key"). Store it as a secret named `TOTP_SECRET`.

```ts

test('login with 2FA', async ({ page }) => {
  const totp = new TOTP({ secret: process.env.TOTP_SECRET! });

  await test.step('Sign in', async () => {
    await page.goto(process.env.TARGET_URL!);
    await page.getByLabel('Email').fill(process.env.LOGIN_USERNAME!);
    await page.getByLabel('Password').fill(process.env.LOGIN_PASSWORD!);
    await page.getByRole('button', { name: 'Continue' }).click();
  });

  await test.step('Enter the one-time code', async () => {
    // A code about to expire can be stale by the time the form is sent.
    if (totp.remaining() < 5_000) await page.waitForTimeout(totp.remaining() + 500);
    await page.getByLabel(/code/i).fill(totp.generate());
    await page.getByRole('button', { name: /verify/i }).click();
  });

  await test.step('Signed in', async () => {
    await expect(page.getByRole('button', { name: /account/i })).toBeVisible();
  });
});
```

- The runner's clock is synchronised, so codes are valid. If every run fails at
  the code, check the secret (spaces removed, base32) and whether the site uses
  a period other than 30 seconds (`new TOTP({ secret, period: 60 })`).
- Some sites refuse the **same code twice**. The in-run retry can land in the
  same 30-second window; the `remaining()` guard above keeps the two attempts
  apart in most cases.
- Codes sent by **SMS or email** can't be read by a browser check. Use an
  authenticator (TOTP) factor on the test account.

The [Login with 2FA example](/dashboard/organization/monitors/new/playwright?template=login-totp)
runs this flow against a public practice site that publishes its secret.

## Checkout

Go as far as the step before money moves. Pick a product that is always in
stock, and assert on the order summary:

```ts

test('checkout', async ({ page }) => {
  await test.step('Open the product', async () => {
    await page.goto(new URL('/products/gift-card', process.env.TARGET_URL!).toString());
    await expect(page.getByRole('heading', { level: 1 })).toBeVisible();
  });

  await test.step('Add to cart', async () => {
    await page.getByRole('button', { name: /add to (cart|bag|basket)/i }).click();
    await expect(page.getByRole('status')).toContainText(/added/i);
  });

  await test.step('Reach the order summary', async () => {
    await page.getByRole('link', { name: /cart|basket/i }).first().click();
    await page.getByRole('button', { name: /checkout/i }).click();
    await expect(page.getByText(/order summary|total/i).first()).toBeVisible();
  });
});
```

- Never enter a real card. To test payment itself, point the check at a
  staging shop in your provider's test mode (see [iframes and Stripe](#iframes)).
- Checks that create real orders, carts or emails leave a trail on every run.
  Use a test customer your team can filter out, or stop before the order.

## Search

```ts

test('search', async ({ page }) => {
  await test.step('Search', async () => {
    await page.goto(process.env.TARGET_URL!);
    const box = page.getByRole('searchbox').or(page.getByPlaceholder(/search/i)).first();
    await box.fill('gift card');
    await box.press('Enter');
  });

  await test.step('Results are shown', async () => {
    await expect(page.getByRole('main').getByRole('link').first()).toBeVisible();
  });
});
```

Search for something that will always have results. An empty results page is
exactly what the check should catch, so don't make the query clever.

## API + UI

The `request` fixture calls an API in the same run (through the same network
path as the browser), so one check can prove the backend answers and the page
shows its data:

```ts

test('api and page agree', async ({ page, request }) => {
  let name = '';

  await test.step('API answers', async () => {
    const res = await request.get(new URL('/api/products/42', process.env.TARGET_URL!).toString(), {
      headers: { Authorization: `Bearer ${process.env.API_TOKEN}` },
    });
    expect(res.ok()).toBeTruthy();
    name = (await res.json()).name;
  });

  await test.step('Page shows the same product', async () => {
    await page.goto(new URL('/products/42', process.env.TARGET_URL!).toString());
    await expect(page.getByRole('heading', { level: 1 })).toHaveText(name);
  });
});
```

Store `API_TOKEN` as a secret. For API-only journeys without a page, an
[API monitor](/docs/monitors/api) is cheaper and faster.

## Cookie banners and pop-ups

`page.addLocatorHandler` clicks a banner away **whenever** it shows up, before
any action it would block, so you don't have to guess when it appears:

```ts

test('journey with a consent banner', async ({ page }) => {
  await page.addLocatorHandler(
    page.getByRole('button', { name: /accept all|allow all|agree/i }),
    async (button) => {
      await button.click();
    },
  );

  await page.goto(process.env.TARGET_URL!);
  await page.getByRole('link', { name: 'Pricing' }).click();
  await expect(page).toHaveURL(/pricing/);
});
```

Our browsers run with `locale: en-US` and the UTC timezone, and some consent
tools show a different banner (or none) by region, so match the button by a
pattern rather than one exact label. The same works for newsletter pop-ups and
chat widgets.

## Iframes and Stripe fields

Fields inside an iframe (payment forms, embedded widgets) need the frame first:

```ts
const card = page
  .locator('iframe[title="Secure card payment input frame"]')
  .contentFrame();
await card.getByPlaceholder('Card number').fill('4242 4242 4242 4242');
await card.getByPlaceholder('MM / YY').fill('12 / 34');
await card.getByPlaceholder('CVC').fill('123');
```

- `4242 4242 4242 4242` is Stripe's **test** card and works only in test mode.
  Point payment checks at a staging shop using test keys; never type a real
  card into a monitor.
- Payment providers change their iframe titles now and then. Select by the
  `title` or `name` attribute, not by position.

## Mobile viewport

Emulate a phone with Playwright's device list. Browser checks run Chromium, so
drop the device's browser choice and keep its viewport, touch and user agent:

```ts

const { defaultBrowserType: _ignored, ...pixel } = devices['Pixel 7'];
test.use(pixel);

test('mobile menu opens', async ({ page }) => {
  await page.goto(process.env.TARGET_URL!);
  await page.getByRole('button', { name: /menu|navigation/i }).click();
  await expect(page.getByRole('navigation')).toBeVisible();
});
```

Without the first line, an iPhone profile would ask for WebKit, which our
runtime doesn't ship. To only resize, `test.use({ viewport: { width: 390,
height: 844 } })` is enough.

## File download

The cheap, exact check fetches what the link points at. To click it like a
person, allow downloads for that test (they are off by default):

```ts

test.use({ acceptDownloads: true });

test('price list downloads', async ({ page }) => {
  await page.goto(process.env.TARGET_URL!);
  const link = page.getByRole('link', { name: 'Download the price list' });

  await test.step('The file is served', async () => {
    const res = await page.request.get(new URL((await link.getAttribute('href'))!, page.url()).toString());
    expect(res.ok()).toBeTruthy();
    expect(res.headers()['content-type']).toContain('pdf');
  });

  await test.step('Clicking downloads it', async () => {
    const [download] = await Promise.all([page.waitForEvent('download'), link.click()]);
    expect(download.suggestedFilename()).toMatch(/\.pdf$/);
    expect(await download.failure()).toBeNull();
  });
});
```

Downloaded files live only for the run; they aren't kept or shown in results.

---

Canonical page: https://watchfor.io/docs/monitors/browser-checks/recipes · All docs: https://watchfor.io/docs · Site guide: https://watchfor.io/llms.txt
