@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.
Maintainers
Readme
@otpmagiclink/playwright
The one-line SDK for testing OTP and magic-link auth flows in Playwright, Cypress, and any Node test runner.
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:
- Hardcode
123456with a backend bypass — ships as a security bug eventually. - Real Gmail + IMAP polling — flaky, slow, blocked by bot detection.
- 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/playwrightGrab 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_hereQuick 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 withsk_).baseUrl— Override the defaulthttps://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.statusproperty.SandboxTimeoutError— Thrown when awaitFor*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.comAdd 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
- Node.js 18+
- An API key from otpmagiclink.com
Docs & guides
- Quick start
- Testing Better Auth in Playwright
- Testing Clerk in Playwright
- The complete magic-link testing guide
- MailSlurp alternative
License
MIT
