The Playwright API you were reaching for is .evaluate(), not .fill().
Every OTP guide on the first page of Google shows the same line:page.fill("#otpCode", code). That works against a single input. It breaks, silently, against Clerk, Shadcn InputOTP, MUI, Stripe verification, and every other six-field component real apps actually ship. This page is the part the SERP skips: which Playwright API to call, why the obvious one loses, and the 12-line page.evaluate dispatch that wins, pulled straight from the open source Assrt runner.
The anchor fact
Assrt bumps the Playwright MCP per-call timeout to 120 seconds on purpose.
The SDK default is 60 seconds. The default OTP poll window is 60 seconds. If you do not bump the transport, your test fails the exact moment the email arrives. Source: assrt-mcp/src/core/browser.ts:381, constant TOOL_TIMEOUT_MS = 120_000. The same constant governs every navigate, type, click, and evaluate call.
Why the Playwright API you reach for first does not work
A reasonable Playwright engineer writing an OTP test for the first time reaches for page.getByRole or page.locator, finds each digit field, and calls .fill() on each one. That is the pattern every MailSlurp, Mailosaur, and testmail.app tutorial shows. It works perfectly against a single <input id="otpCode">. It fails, intermittently, against every modern split-digit component. The failure mode is not loud. The test flakes. You chase it for an afternoon, add a wait, add a retry, and end up with the wrong mental model of your own app.
The Playwright OTP divide
// What every SERP guide shows
import { test, expect } from "@playwright/test";
test("signup with OTP", async ({ page }) => {
await page.goto("/signup");
await page.getByLabel(/email/i).fill("t+u9x@inbox.test");
await page.getByRole("button", { name: /sign up/i }).click();
const code = await fetchOtpFromInbox();
// Looks correct. Works against <input id="otpCode">.
// Fails against every modern OTP component.
for (let i = 0; i < 6; i++) {
await page
.locator('input[maxlength="1"]')
.nth(i)
.fill(code[i]);
}
await page.getByRole("button", { name: /verify/i }).click();
});The difference is which events the DOM actually sees. A .fill() sets the value and fires input and change. It never fires paste. The component author, sensibly, installed one onPaste handler on the parent fieldset and made it responsible for splitting a 6-character string across 6 children. Your test never triggers that handler. The moment you dispatch a real ClipboardEvent, the component does exactly what it was designed to do: split the string, focus the last field, and dispatch one change.
“If the code input is split across multiple single-character fields (common OTP pattern), you MUST use evaluate to paste all digits at once. Do NOT type into each field one by one.”
assrt-mcp/src/core/agent.ts:234
The components you already ship that all break the same way
This is not an edge case. Every OTP library built after React 17 binds to onPaste by default, because users want to paste the code from their SMS app in one action. The handler shape is consistent enough that the same page.evaluate expression fills every one of these components without changes.
The pipeline your test actually flows through
Inputs come in from the left: the user flow you want to cover, a disposable inbox minted on demand, and an email body that arrives three to eight seconds later. Assrt is the hub. Playwright MCP is the transport. Outputs on the right are what you keep: a committable .spec.ts, a passing run in under 15 seconds, a video of the scenario, and a scenario file you can re-run in CI.
Inputs to Assrt to Playwright
What happens when you run it
The trace below is a real scenario run against a local Next.js dev server with a Shadcn InputOTP form. Look for three things: the regex pattern that matched (labelled code: won over the bare 6-digit fallback), the snapshot that detected six split inputs, and the single browser_evaluate call that filled all of them.
Six steps, one round trip per step
From signup click to passing assertion
Your test calls create_temp_email
One HTTP POST to api.internal.temp-mail.io/api/v3/email/new returns a fresh mailbox. No API key. No account. The response is { email, token } and Assrt stores both on the DisposableEmail instance for this run.
Playwright drives the signup form
Assrt runs browser_navigate, browser_type, browser_click through the Playwright MCP stdio transport. Each MCP tool call has a 120_000 ms per-call timeout (browser.ts:381) — double the SDK default, because the next step polls for up to 60 seconds.
wait_for_verification_code polls the inbox
Default 60s window, 3s interval (email.ts:67). The 3s interval is not arbitrary: temp-mail.io rate-limits harder below that. If the email provider (Resend, Postmark, SendGrid) takes more than 60s, pass timeout_seconds: 120 and the transport timeout still covers it.
The 7-regex cascade extracts the code
email.ts:101-109. Most specific first: code: / verification: / OTP: / pin: labels, then bare 6-digit, 4-digit, 8-digit runs. body_html is stripped and entity-decoded before matching, so codes wrapped in <strong> tags or split across quoted-printable lines still resolve.
browser_evaluate dispatches one ClipboardEvent
When the DOM snapshot shows more than one input[maxlength="1"], the system prompt (agent.ts:234-235) forces a single evaluate call. It constructs a real DataTransfer and dispatches a ClipboardEvent on the parent container. All six digits land in one round trip.
The scenario becomes a committable .spec.ts
Every step above is a real Playwright primitive. You can run assrt-mcp as a live agent, or transcribe the passing scenario into tests/e2e/*.spec.ts with role+name locators and the same evaluate expression. No YAML, no vendor DSL, no lock-in.
Numbers that come from the source
Every number below is pulled from a real line in the Assrt source tree. None of them are benchmarks; they are constants the code actually uses.
browser.ts:381. Double the MCP SDK default so a 60s OTP poll has headroom before the transport kills it.
email.ts:101-109. Labelled forms win over bare digit runs, so an order number cannot be mistaken for the OTP.
email.ts:67. Most transactional providers (Resend, Postmark) deliver under 8 seconds; 60 is the cold-start budget.
email.ts:67. Not arbitrary; temp-mail.io rate-limits harder below 3 seconds between GETs against the same address.
Four things the SERP guides leave out
Split-digit forms break .fill() loops
Shadcn InputOTP, Clerk, Stripe verification, Supabase Auth, MUI, Chakra PinInput. Every one binds its splitter to onPaste, not onChange. A per-field .fill() fights the component's own focus logic and lands only the first digit intermittently.
One HTTP POST, zero keys
temp-mail.io v3 internal endpoint. Not a paid dashboard. Not a vendor account. Assrt asks for a 10-character local part so addresses do not collide across concurrent runs.
Per-call MCP timeout: 120s
Assrt bumps the Playwright MCP tool timeout to 120_000 ms in browser.ts:381. The SDK default is 60s. Without the bump, a 60s OTP poll plus the round trip overhead can hit the transport timeout mid-call and the test fails for the wrong reason.
Same context, consume once
Magic links are consume-on-GET. Clicking them in an email client opens a second browser context and loses the Playwright session. The fix: regex the URL out of body, page.goto() in the same context, retry above the email wait so each retry gets a fresh token.
What a committable Playwright spec looks like
A passing Assrt scenario graduates into a .spec.ts file you check into the same repo as everything else. Runtime [ref=eN] references resolve into getByRole and getByLabel queries; the OTP dispatcher inlines as a single page.evaluate. The full file, with the disposable inbox and the 7-regex cascade inlined, is below.
Magic links: do not click through the email
A magic link is a second-order OTP. The token is the URL, the URL is consume-on-GET, and the failure modes are different from a numeric code. Two things go wrong in naive setups. First, clicking the link inside the email client (or even inside temp-mail.io's web UI) opens a separate browser context and the Playwright session is gone. Second, a retry (network flake, slow assertion) invalidates the session because the token has already been spent. The fix is to pull the URL out of body (the decoded plain-text copy Assrt returns, up to 5000 chars) and call page.goto() in the same Playwright page.
The URL regex above is deliberately permissive about the host and path, but it requires one of verify, callback, or magic in the path. That filters out unsubscribe URLs, marketing pixels, and CDN-hosted inline images that otherwise match a greedy https?:\/\/\S+.
What this page is not trying to be
This is not a reviewed list of email-testing SaaS. Mailosaur and MailSlurp both have fine products; their blog posts show the single-input case well. This is the gap between what those guides show and what your app actually ships. The split-digit onPaste pattern is not Clerk-specific or Stripe-specific. It is a convention every well-written OTP component follows, because it is what users do with their hands. Your test has to do the same thing, through the same event. Playwright gives you the tool (page.evaluate). The SERP just never points at it.
Get your Playwright OTP test green in one call
Twenty minutes, one real signup flow. We run it live against your app, hand you the scenario file, and show the .spec.ts it graduates into. No slides.
Book a call →Questions Playwright users ask on Reddit
Why does page.locator('input[maxlength="1"]').nth(i).fill(code[i]) fail on Shadcn InputOTP and Clerk?
Because those components do not hook onChange. They install an onPaste handler on the parent container that splits the pasted string across the children and manages focus between them. Playwright's .fill() API sets the value of the input and fires input/change events, but does not dispatch a ClipboardEvent. Meanwhile the component's own focus management sees each .fill() as an edit and tries to advance focus to the next field, fighting the test. The visible failure mode is intermittent: sometimes only the first digit lands, sometimes all six end up in field 0, and sometimes the test flakes based on React commit timing. Assrt's agent.ts:234-235 hard-codes the correct approach: one page.evaluate call with a DataTransfer and a ClipboardEvent dispatched on the parent.
Why bump the Playwright MCP per-call timeout to 120 seconds?
The Model Context Protocol SDK ships with a 60s default per-tool timeout. Assrt's wait_for_verification_code tool is a single MCP call that internally polls the inbox for up to 60 seconds before returning. With the SDK default, the call would hit the transport timeout at the exact moment the OTP arrives, and the test would fail for an unrelated reason. browser.ts:381 sets TOOL_TIMEOUT_MS to 120_000 so the poll always completes with headroom. If you raise timeout_seconds on the tool (for slower providers), you should raise the transport timeout in the same ratio.
Do I need temp-mail.io specifically, or can I use my own inbox?
temp-mail.io is Assrt's default because it requires no account, no key, and exposes a v3 endpoint that returns JSON directly. Assrt calls POST https://api.internal.temp-mail.io/api/v3/email/new and GET /api/v3/email/{address}/messages. The implementation is a single file at assrt-mcp/src/core/email.ts, roughly 130 lines. If you prefer MailSlurp, Mailosaur, testmail.app, or a self-hosted Maildev/InBucket instance, replace the fetch URLs with your provider's equivalents. The split-digit ClipboardEvent and the 7-regex cascade are completely decoupled from the inbox source.
What does Assrt do differently from proprietary Playwright alternatives that cost $7,500 a month?
Proprietary runners lock the test output behind a YAML DSL or a hosted UI. You cannot check the test into your repo, run it offline, or re-use the step under a different framework. Assrt runs on top of @playwright/mcp, which is itself real Playwright. Every scenario is a sequence of browser_navigate, browser_click, browser_type, browser_evaluate calls that a junior engineer can transcribe into a .spec.ts file. The disposable email logic is a 130-line file. The OTP paste trick is 12 lines. It is all open source, self-hosted, and free.
How do I handle a magic link without losing my Playwright browser context?
Do not click the link inside the email client. The email client opens a new browser context (or worse, launches the OS default browser), and the Playwright session you started in is gone. Instead, let waitForVerificationCode return the full email (the body field is included, up to 5000 chars). Run a URL regex against body, not body_html, because body_html has been stripped and entity-decoded. Then call page.goto(url) in the same Playwright page you were already on. For consume-on-GET links (Auth0 passwordless, Supabase magic link), this is load-bearing: a second GET invalidates the token, so any retry logic must wrap the entire wait-plus-visit, not just the visit.
How does Assrt detect whether the form is split-digit or a single input?
browser_snapshot returns an accessibility tree that lists every input. The system prompt in agent.ts:234 instructs the agent: if the snapshot contains more than one input[maxlength="1"], use the evaluate+DataTransfer pattern; otherwise, use a normal browser_type into the single field. This matches the real world: 2FA forms from Stripe, Clerk, and Supabase are almost always split-digit, while custom forms built with Tailwind + react-hook-form usually keep one input with an inputmode of numeric. Assrt picks the right strategy automatically.
Can I run assrt_test against a production environment, or only local dev?
Either. The agent accepts a URL (browser_navigate) so http://localhost:3000 and https://staging.example.com work the same. For production OTP tests, provision a real inbox under your domain and replace the temp-mail calls in the graduated .spec.ts with your provider's API. Do not run assrt_test against production directly unless the signup flow has a dedicated @e2e test path, because the disposable inbox means the user you create is real in your database and you will either have to clean up or accept the drift.
Comments (••)
Leave a comment to see what others are saying.Public and anonymous. No signup.