Playwright · 2FA
The code arrives by mail, or it is computed
A two-factor journey blocks a test suite in two places: the code sent by email, which the test cannot read, and the one from an authenticator app, which it cannot generate. Both are solvable, and not in the same way.
The code that arrives by email
Same mechanics as a sign-up: the request goes out before the click and answers when the message lands.
import { expect, test } from '@playwright/test';
const KEY = process.env.FACTEUR_API_KEY!;
const INBOX = 'j3k9x2mq';
/** hasOtp aims at the extracted code, not at text quoting one. */
const waitForCode = (sentTo: string) =>
fetch('https://api.facteur.eu/v1/messages/search', {
method: 'POST',
headers: {
authorization: `Bearer ${KEY}`,
'content-type': 'application/json',
},
body: JSON.stringify({ inbox: INBOX, sentTo, hasOtp: true, wait: 30_000 }),
}).then((response) => response.json());
test('signing in asks for the code sent by email', async ({ page }) => {
const tag = `login-${Date.now()}`;
const arriving = waitForCode(tag);
await page.goto('/login');
await page.getByLabel('Email').fill(`${tag}@${INBOX}.inbox.facteur.eu`);
await page.getByRole('button', { name: 'Continue' }).click();
const { messages } = await arriving;
// Six digits, extracted on reception.
await page.getByLabel('Code').fill(messages[0].otp);
await expect(page.getByText('Welcome')).toBeVisible();
});A criterion that aims at the code, not at the text
Asking for "the message carrying a code" avoids landing on an email that quotes an older code in its history.
The code lives in its own field
Six digits, extracted on reception. Your test compares a value; it does not search HTML for a pattern.
The code from an authenticator app
Nobody can send you that one: it is computed from the secret your enrolment screen shows. Twenty lines of Node, no dependency, and the phone stays in the drawer.
import { createHmac } from 'node:crypto';
const BASE32 = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ234567';
/** The secret your enrolment screen shows, in base32. */
function decode(secret: string): Buffer {
let bits = 0;
let value = 0;
const out: number[] = [];
for (const c of secret.replace(/=+$/, '').toUpperCase()) {
value = (value << 5) | BASE32.indexOf(c);
bits += 5;
if (bits >= 8) {
out.push((value >>> (bits - 8)) & 0xff);
bits -= 8;
}
}
return Buffer.from(out);
}
/** RFC 6238: SHA-1, six digits, a thirty-second step. */
export function totp(secret: string, at = Date.now()): string {
const counter = Math.floor(at / 1000 / 30);
const message = Buffer.alloc(8);
message.writeUInt32BE(Math.floor(counter / 2 ** 32), 0);
message.writeUInt32BE(counter % 2 ** 32, 4);
const digest = createHmac('sha1', decode(secret)).update(message).digest();
const offset = digest[digest.length - 1] & 0x0f;
const binary =
((digest[offset] & 0x7f) << 24) |
(digest[offset + 1] << 16) |
(digest[offset + 2] << 8) |
digest[offset + 3];
return String(binary % 1_000_000).padStart(6, '0');
}What it changes for a suite
The whole journey becomes testable
Enrolment, sign-in, backup codes: none of it depends on a physical device or on somebody’s personal mailbox any more.
The test stays readable
Two calls and an assertion. You do not extract the code, and the waiting is not a loop you maintain.
Parallel runs do not collide
Each test receives at its own address, so the code it reads belongs to its own journey.
What about a Playwright plugin?
Planned, not published. The test above expects nothing from it: it calls the API directly, and keeps working afterwards.
Unblock your 2FA tests
A test mailbox and a read-only key, and the whole journey is testable again. The free plan asks for no card.