import { expect } from '@playwright/test' // Matched to e2e/seed.py, which prints them when it runs. Overridable the same // way, for a stack somebody wants to leave up and poke at. export const EDUCATOR = { email: process.env.E2E_EDUCATOR_EMAIL || 'educator@e2e.example.com', password: process.env.E2E_EDUCATOR_PASSWORD || 'e2e-educator-password', } export const LEARNER = { email: process.env.E2E_LEARNER_EMAIL || 'learner@e2e.example.com', password: process.env.E2E_LEARNER_PASSWORD || 'e2e-learner-password', } /** * A learner of this worker's own. * * The seed makes one per parallel worker. They share a database, and a session * remembers where it was left — so two workers sitting the same session as the * same person tread on each other, and the failure looks like a product bug * rather than a fixture one. Educators can share: nothing they do here writes * per-person state. */ export function learnerFor(testInfo) { const index = (testInfo?.parallelIndex ?? 0) % 4 return { ...LEARNER, email: LEARNER.email.replace('@', `+w${index}@`) } } // One sign-in per account per worker. The limiter allows ten attempts from an // address in fifteen minutes, and the whole suite arrives from one address — so // a test that signs in afresh each time spends the budget and the rest of the // run fails on 429s that have nothing to do with what is being tested. const tokens = new Map() export async function tokenFor(request, who) { if (!tokens.has(who.email)) { const response = await request.post('/api/v1/auth/login', { data: who }) expect(response.ok(), `sign-in failed: ${await response.text()}`).toBeTruthy() tokens.set(who.email, (await response.json()).access_token) } return tokens.get(who.email) } /** * Sign in and hand the browser the token. * * Not through the form: every test would then be a test of the login form, and * when that breaks the whole suite goes red at once instead of one test. The * form has its own test, which does use the form. */ export async function signIn(page, who = LEARNER) { const token = await tokenFor(page.request, who) // The origin has to exist before localStorage does. await page.goto('/') await page.evaluate(value => localStorage.setItem('token', value), token) return token } /** Past the "what are you studying for?" gate, which every page shows first. */ export async function chooseObjective(page) { const picker = page.getByRole('heading', { name: /what are you studying for/i }) if (await picker.isVisible().catch(() => false)) { await page.getByText(/Pediatrics Boards|Respiratory|E2E/).first().click() await expect(picker).toBeHidden() } } /** * Open a session and be sitting it, however the player decides to start. * * A saved session shows an overview first; one opened with nothing saved goes * straight in. Both are correct, and a test that insists on the button is * testing which of the two happened rather than the session itself. */ export async function startSession(page, id = 1) { // `restart=1` every time. These tests share one account, and a session // remembers where it was left — so without this the second test to run finds // itself on question three of a session the first one was half-way through. await page.goto(`/study/${id}?restart=1`) await chooseObjective(page) const start = page.getByRole('button', { name: /start session/i }) if (await start.isVisible().catch(() => false)) await start.click() await expect(page.locator('.question-card').first()).toBeVisible() } /** * Nothing on the page may be reachable only by scrolling sideways. * * Polled rather than sampled once. A page mid-layout — a figure that has not * finished loading, a font still swapping — is momentarily wider than the * window and then is not, and a single measurement catches whichever moment it * happened to land in. A page that really does overflow stays wide, so it * still fails, just a second later. */ export async function expectNoHorizontalOverflow(page) { await page.evaluate(() => document.fonts?.ready).catch(() => {}) await expect.poll(() => page.evaluate(() => { const doc = document.documentElement // One pixel of slack for sub-pixel rounding on a scaled device. return doc.scrollWidth - doc.clientWidth <= 1 }), { message: 'the page scrolls sideways', timeout: 5000 }).toBe(true) }