npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@otpmagiclink/playwright

v0.1.0

Published

The one-line SDK for testing OTP and magic-link auth flows in Playwright, Cypress, or any Node test runner. Real emails, real codes, no mocks.

Readme

@otpmagiclink/playwright

The one-line SDK for testing OTP and magic-link auth flows in Playwright, Cypress, and any Node test runner.

npm license

const otp = await sandbox.waitForOtp('[email protected]');

That's it. No inbox setup, no regex parsing, no cleanup step, no third-party mail account.

Works with Better Auth, Clerk, Auth.js (NextAuth), Supabase, or any custom auth flow that sends an email.


Why?

Every E2E test suite that touches auth hits the same wall: how do you read the OTP or magic link your app just emailed? Most teams end up with one of three bad options:

  1. Hardcode 123456 with a backend bypass — ships as a security bug eventually.
  2. Real Gmail + IMAP polling — flaky, slow, blocked by bot detection.
  3. MailSlurp / Mailosaur / Mailtrap — 30 minutes of setup, custom regex per email template, $49/mo minimum.

@otpmagiclink/playwright is a fourth option: a purpose-built OTP sandbox that returns the code, not the message. One line of test code, real production auth flow.


Install

npm install --save-dev @otpmagiclink/playwright
# or
pnpm add -D @otpmagiclink/playwright
# or
yarn add -D @otpmagiclink/playwright

Grab a free API key at otpmagiclink.com — free forever for 1 project, no credit card. Add it to your CI secrets and local .env.test:

OTP_API_KEY=sk_sandbox_your_key_here

Quick start

import { test, expect } from '@playwright/test';
import { SandboxClient } from '@otpmagiclink/playwright';

const sandbox = new SandboxClient({
  apiKey: process.env.OTP_API_KEY!,
});

test('sign up with email OTP', async ({ page }) => {
  await page.goto('/signup');
  await page.fill('[name=email]', '[email protected]');
  await page.click('button[type=submit]');

  const otp = await sandbox.waitForOtp('[email protected]');

  await page.fill('[name=otp]', otp);
  await expect(page).toHaveURL('/dashboard');
});

Magic links

test('sign in with magic link', async ({ page }) => {
  await page.goto('/signin');
  await page.fill('[name=email]', '[email protected]');
  await page.click('text=Send magic link');

  const link = await sandbox.waitForMagicLink('[email protected]');
  await page.goto(link);

  await expect(page).toHaveURL('/dashboard');
});

Or use the convenience helper:

await sandbox.followMagicLink(page, '[email protected]');

Time-travel for expiry testing

Fast-forward the sandbox clock instead of waiting an hour:

test('magic link expires after 1 hour', async ({ page }) => {
  await page.goto('/signin');
  await page.fill('[name=email]', '[email protected]');
  await page.click('text=Send magic link');

  await sandbox.advanceClock(3_600); // +1 hour

  const link = await sandbox.waitForMagicLink('[email protected]');
  await page.goto(link);

  await expect(page.getByText(/expired/i)).toBeVisible();
});

test.afterEach(async () => {
  await sandbox.resetClock();
});

API reference

new SandboxClient({ apiKey, baseUrl? })

Create a client bound to your sandbox project.

  • apiKey — Your sandbox project's API key (required, starts with sk_).
  • baseUrl — Override the default https://otpmagiclink.com (useful for self-hosted deployments).

waitForOtp(identifier, options?)

Polls the sandbox until an OTP for identifier arrives. Returns the 6-digit code as a string. Throws SandboxTimeoutError if no OTP arrives within the timeout.

const otp = await sandbox.waitForOtp('[email protected]', {
  timeout: 15_000,     // default: 10s
  pollInterval: 250,   // default: 250ms
});

waitForMagicLink(identifier, options?)

Polls until a magic-link URL arrives and returns it as a string.

const link = await sandbox.waitForMagicLink('[email protected]');
await page.goto(link);

followMagicLink(page, identifier, options?)

Convenience helper — waits for the link and navigates page to it. Accepts any object with a goto() method (no hard Playwright dependency).

await sandbox.followMagicLink(page, '[email protected]');

getInbox(identifier, limit?)

Returns the current message list for one identifier (newest first). Non-polling — useful for assertions on subject or body content.

const messages = await sandbox.getInbox('[email protected]');
expect(messages[0].subject).toContain('Welcome');

advanceClock(seconds) / resetClock()

Fast-forward the sandbox virtual clock, or reset to real time. Always call resetClock() in afterEach to prevent leakage between tests.

await sandbox.advanceClock(3_600);
// ... test expiry behaviour ...
await sandbox.resetClock();

Errors

Two error classes are exported:

  • SandboxError — Thrown on non-2xx responses. Has a .status property.
  • SandboxTimeoutError — Thrown when a waitFor* call times out.
import { SandboxError, SandboxTimeoutError } from '@otpmagiclink/playwright';

try {
  const otp = await sandbox.waitForOtp('[email protected]');
} catch (err) {
  if (err instanceof SandboxTimeoutError) {
    console.error('No OTP arrived in time — did your auth adapter route to the sandbox?');
  }
  throw err;
}

Routing your auth library at the sandbox

The pattern is always the same — swap the OTP/link-sending destination in test mode. Full auth-stack recipes are on our docs page.

Better Auth:

emailOTP({
  async sendVerificationOTP({ email, otp }) {
    if (process.env.NODE_ENV === 'test') {
      await fetch(`${process.env.OTP_SANDBOX_URL}/api/v1/verifications`, {
        method: 'POST',
        headers: {
          Authorization: `Bearer ${process.env.OTP_API_KEY}`,
          'Content-Type': 'application/json',
        },
        body: JSON.stringify({
          identifier: email,
          channel: 'EMAIL',
          kind: 'OTP',
        }),
      });
      return;
    }
    // production Resend/SendGrid send
  },
})

Auth.js (NextAuth):

Resend({
  async sendVerificationRequest({ identifier, url }) {
    if (process.env.NODE_ENV === 'test') {
      await fetch(`${process.env.OTP_SANDBOX_URL}/api/v1/verifications`, {
        method: 'POST',
        headers: { Authorization: `Bearer ${process.env.OTP_API_KEY}` },
        body: JSON.stringify({
          identifier,
          channel: 'EMAIL',
          kind: 'MAGIC_LINK',
          redirectUrl: url,
        }),
      });
      return;
    }
    // production send
  },
})

Clerk / Supabase / custom SMTP — see the full auth stack recipes.


CI

# .github/workflows/e2e.yml
name: E2E
on: [push, pull_request]

jobs:
  test:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: 20
      - run: npm ci
      - run: npx playwright install --with-deps

      - run: npx playwright test
        env:
          NODE_ENV: test
          OTP_API_KEY: ${{ secrets.OTP_API_KEY }}
          OTP_SANDBOX_URL: https://otpmagiclink.com

Add OTP_API_KEY to your repo secrets. That's the whole CI setup — no inbox provisioning, no cleanup step, no per-test mail account.


Also works with Cypress, Vitest, and vanilla Node

The SandboxClient is a plain HTTP client with no Playwright dependency. The @playwright/test peer dep is optional — install it if you want the followMagicLink(page, ...) convenience helper's TypeScript types to line up perfectly.

// Works anywhere:
import { SandboxClient } from '@otpmagiclink/playwright';

const sandbox = new SandboxClient({ apiKey: process.env.OTP_API_KEY! });
const otp = await sandbox.waitForOtp('[email protected]');

Requirements


Docs & guides


License

MIT