This recipe is the smallest end-to-end Playwright test that signs a seeded customer into QA Shop and proves the session is live.
It walks the public login surface — load the page, fill credentials with accessible-label selectors, click submit, and assert the authenticated-only header menu after the redirect. Run it on your laptop in about five seconds.
It is the canonical first test to write whenever you need to drive a real session into your suite, and it is the foundation that every authenticated QA Shop recipe (checkout, account settings, order history) builds on top of.
§ 01 · PREREQUISITES
Prerequisites
You need a project with @playwright/test installed, a Playwright config that points at QA Shop, and a seeded customer to log in as.
Bootstrap with npm init playwright@latest if you don't already have a project — it scaffolds the runner, the config, and an example spec.
Set to http://localhost:3002 for a local checkout, or https://automationtestingplatform.com for the hosted demo.
The seeded recipe-verifier@automationtestingplatform.com account ships with the QA Shop database. Locally it is provisioned by supabase/seed.sql; in CI both values live in GitHub repo secrets.
The recipe assumes the server is already up. The test does not start it for you.
§ 02 · THE SPEC
The full spec
Save this file as tests/recipes/playwright/login-flow-test.spec.ts and run it with npx playwright test --grep login-flow-test.
// tests/recipes/playwright/login-flow-test.spec.ts
import { test, expect } from "@playwright/test";
test("a seeded customer can log in and land authenticated", async ({ page }) => {
const email = process.env.RECIPE_AUTH_EMAIL;
const password = process.env.RECIPE_AUTH_PASSWORD;
if (!email || !password) {
throw new Error(
"RECIPE_AUTH_EMAIL and RECIPE_AUTH_PASSWORD must be set. " +
"See .env.example. In CI these come from GitHub repo secrets; " +
"locally they come from your .env.local.",
);
}
// 1. Land on the login page. The visible heading is "Welcome back!" but the
// submit button reads "Log in" — the button is the stable on-page anchor.
await page.goto("/auth/login");
const submitBtn = page.getByRole("button", { name: /log ?in/i });
await expect(submitBtn).toBeVisible();
// 2. Fill the credentials via accessible-label selectors. QA Shop randomizes
// auth-form input names as a honeypot defense, so a11y selectors are the
// only stable surface — never `data-testid` here.
await page.getByLabel("Email", { exact: true }).fill(email);
await page.getByLabel("Password", { exact: true }).fill(password);
// 3. Submit. Turnstile is not on /auth/login (scoped to principal register
// + forgot-password only), so the button is enabled on render.
await submitBtn.click();
// 4. The post-login redirect lands somewhere outside /auth/*.
await page.waitForURL((url) => !url.pathname.startsWith("/auth/"));
// 5. The header user menu only renders for authenticated sessions.
await expect(page.getByTestId("header-user-menu")).toBeVisible();
});§ 03 · WALKTHROUGH
Walkthrough
The first navigation to /auth/login is plain page.goto — no readiness flag yet.
The first locator is the submit button, captured with getByRole("button", { name: /log ?in/i }). The visible heading on the page is Welcome back!, not Log in, so anchoring on the button (rendered text Log in) is the right call: it is the same element the user will eventually click, and asserting it is visible doubles as a "form is mounted and ready" readiness check.
Filling the email and password uses page.getByLabel("Email", { exact: true }) and page.getByLabel("Password", { exact: true }). Both inputs preserve their aria-label, <label>, and autocomplete="email" / "current-password" attributes regardless of name randomization, so password managers, screen readers, and the recipe all share one selector contract. The exact: true option is the small but load-bearing detail on the password line — the password input ships with a sibling aria-label="Show password" toggle button, and without exact the locator matches both elements (strict-mode violation).
The submit click runs immediately after filling the form. QA Shop scopes Cloudflare Turnstile to principal-account register + forgot-password only, so /auth/login renders with an enabled submit button — no token wait, no widget mount, no headless-browser flake. The four remaining defenses on the login path (rate-limit, honeypot, IP block-list, and the abuse-block check in proxy.ts) handle credential stuffing without adding interactive friction.
The post-click page.waitForURL((url) => !url.pathname.startsWith("/auth/")) is a function-form URL predicate1. Function form is the right idiom here because the redirect target is variable: a guest who navigated to /auth/login directly lands on /, but a guest sent to /auth/login?redirect=%2Fcheckout lands on /checkout. The predicate accepts both without needing a brittle regex listing every legal landing page.
The final assertion is page.getByTestId("header-user-menu"). The header user menu is one of the few public testids QA Shop guarantees to be stable: it only renders for authenticated sessions, it lives in the global header on every route, and the testid is plain (no UUID, no order_number) because it is a UI affordance rather than a data row. It is the canonical "session is live" signal across the entire app.
§ 04 · EXTEND IT
Things to extend
Three obvious variants build on this skeleton.
Wrap the body of this test in a Playwright setup project (see tests/recipes/playwright/setup/auth.setup.ts), call page.context().storageState({ path: "...customer.json" }) after the assertion, and load the stored state in dependent specs via use: { storageState: "...customer.json" }. Every authenticated test then skips the login dance entirely.
Combine this recipe with the add-to-cart-checkout flow: log in first, then add to cart, then click checkout — the auth gate that recipe stops at unlocks into the shipping form, the test card panel, and the order confirmation page.
Submit a wrong password and assert the toast surfaces an error rather than redirecting. Submit five wrong passwords from the same IP and assert the rate limiter blocks subsequent attempts. Both are small specs that double as regression nets for the security layer.
A fourth variant worth scripting eventually is the OAuth path — the login form ships with a GitHub OAuth button alongside the credential form. That spec needs a stubbed identity provider and is a noticeable jump in setup cost, but it is the right next test once the credential flow is solid.
Related
For the full testid catalog and the security model behind the honeypot naming inversion, see the QA Shop testing guide. For the broader checkout flow this recipe unlocks, see Add to cart and reach checkout.
The Selenium, Cypress, and WebdriverIO login counterparts are coming soon — placeholder slugs are /recipes/selenium/login-flow-test, /recipes/cypress/login-flow-test, and /recipes/webdriverio/login-flow-test. The /recipes index lists them as they ship.
Footnotes
-
Playwright page.waitForURL — https://playwright.dev/docs/api/class-page#page-wait-for-url (verified 2026-05-03, Playwright 1.58) ↩
Frequently asked questions
Last verified: