> ## Documentation Index
> Fetch the complete documentation index at: https://docs.devhelm.io/llms.txt
> Use this file to discover all available pages before exploring further.

# Test a sign-up email flow with Playwright

> A Playwright test that signs up with a fresh address, waits for the verification email, reads the OTP or link, and finishes sign-up

A Playwright test signs up with a fresh testing address, waits for the real email your app sends, reads the code or link, and completes sign-up. There is no DevHelm Playwright package: the test calls `@devhelm/sdk` (or the Python `devhelm` package) directly.

<Accordion title="Prerequisites">
  * A Playwright project, and the app under test running at a URL the runner can reach
  * A mail provider in the test environment that delivers to outside addresses. See [mail provider notes](#mail-provider-notes)
  * An API key and workspace ID:

  The testing API needs an API key and a workspace ID. Export the key, look up the workspace, then export its ID:

  ```bash theme={null}
  export DEVHELM_API_TOKEN=dh_live_xxxxxxxx
  curl https://api.devhelm.io/api/v1/workspaces -H "Authorization: Bearer $DEVHELM_API_TOKEN"
  export DEVHELM_WORKSPACE_ID=<workspace-id>
  ```

  The CLI and Python SDK read both variables. The TypeScript SDK reads `DEVHELM_WORKSPACE_ID` and takes the key in its constructor. With curl, send the workspace as a header:

  ```bash theme={null}
  -H "Authorization: Bearer $DEVHELM_API_TOKEN" \
  -H "x-phelm-workspace-id: $DEVHELM_WORKSPACE_ID"
  ```
</Accordion>

## Write the test

<Steps>
  <Step title="Install the packages">
    ```bash theme={null}
    npm install --save-dev @playwright/test @devhelm/sdk
    npx playwright install chromium
    ```

    The SDK is ESM only. Set `"type": "module"` in the test project's `package.json`.
  </Step>

  <Step title="Set the base URL and test timeout">
    ```typescript playwright.config.ts theme={null}
    import { defineConfig } from "@playwright/test";

    export default defineConfig({
      timeout: 90_000, // above the 60 s email wait
      use: { baseURL: process.env.APP_URL ?? "http://localhost:3000" },
    });
    ```

    The runner timeout must be longer than the wait's `timeoutMs`. A longer runner timeout does not extend the wait call.
  </Step>

  <Step title="Write the sign-up test">
    ```typescript tests/signup.spec.ts theme={null}
    import { Devhelm, type EmailMessageDto } from "@devhelm/sdk";
    import { expect, test } from "@playwright/test";

    const client = new Devhelm({ token: process.env.DEVHELM_API_TOKEN! });

    test("sign-up emails a code that verifies the account", async ({ page }) => {
      const mailbox = await client.email.address({ label: "signup" });
      try {
        await page.goto("/signup");
        await page.getByLabel("Email").fill(mailbox.email);
        await page.getByLabel("Password").fill("correct-horse-battery-staple");

        const since = new Date().toISOString(); // before the click that sends the email
        await page.getByRole("button", { name: "Create account" }).click();

        const message = await mailbox.wait({ receivedAfter: since, subjectContains: "verify", timeoutMs: 60_000 });
        // The 1.9.0 types need this cast.
        const mail = message as unknown as EmailMessageDto & { text?: string | null; html?: string | null };

        const code = mail.otp?.[0]?.value ?? "";
        expect(code, "no code found in the email").toMatch(/^\d{6}$/);

        await page.getByLabel("Verification code").fill(code);
        await page.getByRole("button", { name: "Verify" }).click();
        await expect(page.getByRole("heading", { name: "Welcome" })).toBeVisible();
      } finally {
        await mailbox.clear();
      }
    });
    ```

    `client.email.address()` returns a new address on the workspace's assigned domain, creating the domain on first use. Any local part is an inbox, and the random suffix keeps parallel tests and retries apart. `since` is taken before the click: wait returns the oldest message received at or after it, and without it the SDK uses the time of the wait call, so an email that arrived first is missed. `subjectContains` is a case-insensitive substring match. Replace the labels, button names, and heading with your app's.
  </Step>

  <Step title="Run it">
    ```bash theme={null}
    APP_URL=https://staging.example.com npx playwright test tests/signup.spec.ts
    ```
  </Step>
</Steps>

### Verify with a link instead of a code

After the wait, pick the link by its visible text and open it in the same page:

```typescript theme={null}
import type { EmailMessageDto } from "@devhelm/sdk";

const mail = message as unknown as EmailMessageDto & { text?: string | null; html?: string | null };
const link = mail.links?.find((l) => l.text?.includes("Verify"));
expect(link, "no Verify link in the email").toBeDefined();

// links[].href keeps HTML-escaped ampersands today; decode before you open it
await page.goto(link!.href.replaceAll("&amp;", "&"));
await expect(page.getByText("Email verified")).toBeVisible();
```

`links[]` holds `{href, text}`, one entry per distinct `href`, kept exactly as written. To match on the destination instead, compare hosts: `new URL(l.href).host === "app.example.com"`. Several codes and the body fallback are on [Read OTP codes and links](/testing/email/otp-and-links).

## pytest-playwright variant

`Devhelm()` reads `DEVHELM_API_TOKEN` and `DEVHELM_WORKSPACE_ID`. Install with `pip install pytest-playwright devhelm` and run `pytest tests/test_signup.py --base-url https://staging.example.com`.

```python tests/test_signup.py theme={null}
from datetime import datetime, timezone

import pytest
from devhelm import Devhelm
from playwright.sync_api import Page, expect

client = Devhelm()


@pytest.fixture
def mailbox():
    address = client.email.address(label="signup")
    yield address
    address.clear()


def test_signup_emails_a_working_code(page: Page, mailbox):
    page.goto("/signup")
    page.get_by_label("Email").fill(mailbox.email)
    page.get_by_label("Password").fill("correct-horse-battery-staple")

    since = datetime.now(timezone.utc)  # before the click that sends the email
    page.get_by_role("button", name="Create account").click()

    message = mailbox.wait(received_after=since, subject_contains="verify", timeout_ms=60_000)

    assert message.otp, "no code found in the email"
    page.get_by_label("Verification code").fill(message.otp[0].value)
    page.get_by_role("button", name="Verify").click()
    expect(page.get_by_role("heading", name="Welcome")).to_be_visible()
```

For a link, decode with `link.href.replace("&amp;", "&")`. The Python message has no `text` or `html`; read the body with `message.source()`.

## Mail provider notes

The test proves delivery only when the wait returns. When your app reports the email as sent and the wait still times out, start at the provider.

* **A 250 or 202 from your provider is not delivery.** It means the provider queued the message. Look the recipient up in the provider's event log: delivered, deferred, bounced, dropped, or suppressed.
* **Sandbox and test modes do not deliver to outside addresses:**

| Provider mode | What happens to mail sent to a testing address | What to do |
| - | - | - |
| Amazon SES sandbox | Only verified addresses and domains receive mail | Move the staging sending account out of the sandbox |
| SendGrid sandbox mode (`mail_settings.sandbox_mode`) | The request is validated and nothing is sent | Turn sandbox mode off for this flow |
| Postmark test token (`POSTMARK_API_TEST`) | Accepted and not sent | Use a real server token, such as a dedicated staging server |
| Mailgun sandbox domain | Only authorized recipients receive mail | Send from a verified domain |

* **Click tracking rewrites links** through the provider's tracking host. DevHelm stores the tracked URL as written and does not unwrap it. Turn click tracking off in the test environment, or open the tracked link with `page.goto()` and assert on the final page.
* **Suppression lists block an address after a bounce.** A fresh address per test is never on one.
* **Over the monthly event cap, mail is not stored**, and the wait times out. See [Limits, quotas, and errors](/testing/limits).

<Warning>
  A testing address receives any mail sent to it, and every member of the workspace can read it. Do not point production sending at it, and do not use it for real accounts or customer data.
</Warning>

## Without a mail provider

Inject puts a message in the mailbox through the API and runs the same parsing as real mail, so `otp[]` and `links[]` come out the same. It returns `202` (accepted, not yet stored) and counts toward monthly events. Two ways to use it:

* **Your app's test-mode mailer posts to inject** (`POST /api/v1/email/domains/<domain>/messages/inject` with `to`, `from`, `subject`, `text`, `html`) instead of the provider. The browser test above runs unchanged.
* **The test injects the email itself.** This checks the wait, extraction, and assertions with no app in the loop:

```typescript tests/signup-dry.spec.ts theme={null}
import { Devhelm, type EmailMessageDto } from "@devhelm/sdk";
import { expect, test } from "@playwright/test";

const client = new Devhelm({ token: process.env.DEVHELM_API_TOKEN! });

test("the wait and extraction handle a sign-up email", async () => {
  const mailbox = await client.email.address({ label: "signup-dry" });
  try {
    const since = new Date().toISOString();
    await mailbox.receive({
      from: "no-reply@example.com",
      subject: "Verify your email",
      text: "Your verification code is 482913.",
      html: '<a href="https://app.example.com/verify?token=abc&amp;step=2">Verify email</a>',
    });

    const message = await mailbox.wait({ receivedAfter: since, subjectContains: "verify", timeoutMs: 30_000 });
    // The 1.9.0 types need this cast.
    const mail = message as unknown as EmailMessageDto & { text?: string | null; html?: string | null };

    expect(mail.otp?.[0]?.value).toBe("482913");
    const link = mail.links?.find((l) => l.text === "Verify email");
    expect(link?.href.replaceAll("&amp;", "&")).toBe("https://app.example.com/verify?token=abc&step=2");
  } finally {
    await mailbox.clear();
  }
});
```

## Troubleshooting

<AccordionGroup>
  <Accordion title="The wait times out (408 WAIT_TIMEOUT)">
    A timeout is a `DevhelmApiError` with `status` 408 and `code` `WAIT_TIMEOUT`, never an empty result. Check in order:

    * `since` was taken after the click, or not passed. Take it before the action that sends.
    * The provider did not deliver. Check its log for the recipient, then the [provider notes](#mail-provider-notes).
    * `subjectContains` does not match. List what arrived with `mailbox.messages.list()` and read `subject`.
    * The domain is disabled. Wait on a disabled domain times out instead of failing.
    * The organization hit its monthly event cap.

    If Playwright reports "Test timeout exceeded" instead, the runner timeout is shorter than `timeoutMs`.
  </Accordion>

  <Accordion title="The test read the wrong email">
    Wait returns the oldest match and does not consume it. Call `client.email.address()` inside each test, not once per file. When the flow sends two emails, use a distinct `subjectContains`, or wait again with `receivedAfter` set to the previous message's `receivedAt` plus 1 ms. With several codes in one email, `otp[]` order is not guaranteed, so match on the value.
  </Accordion>

  <Accordion title="The link is broken">
    * The query string has `amp;`: decode `&amp;` before you open the link.
    * The link goes to a tracking host: turn off click tracking, or follow the redirect with `page.goto()`.
    * The token was already used: open the link once, in the test's browser, and do not fetch it from test code first.
  </Accordion>

  <Accordion title="429 when tests run in parallel">
    Every wait call in the organization shares 40 per minute, across email and webhook waits and every workspace. Back off on `DevhelmRateLimitError` using `retryAfter`, and size parallel workers to the budget. See [Testing in CI](/guides/testing-in-ci).
  </Accordion>
</AccordionGroup>

## Next steps

<CardGroup cols={2}>
  <Card title="Wait for an email in a test" icon="hourglass" href="/testing/email/wait">
    Matching, repeat sends, and timeouts.
  </Card>

  <Card title="Read OTP codes and links" icon="key" href="/testing/email/otp-and-links">
    Several codes, picking links, and reading the body.
  </Card>

  <Card title="Testing in CI" icon="github" href="/guides/testing-in-ci">
    Secrets, per-job isolation, cleanup, and the shared wait budget.
  </Card>

  <Card title="Email testing overview" icon="envelope" href="/testing/email/overview">
    Domains, addresses, inject vs real mail, and stored fields.
  </Card>
</CardGroup>


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.