import { test as base, expect, type Locator, type Page, } from "@playwright/test"; import { ADMIN_ID } from "~/features/admin/admin-constants"; import type { SeedVariation } from "~/features/api-private/routes/seed"; import { tournamentBracketsPage } from "~/utils/urls"; try { process.loadEnvFile(); } catch { // .env is optional; in CI env vars come from the host (e2e-tests.yml creates none) } export const E2E_BASE_PORT = Number(process.env.PORT || 5173) + 500; type WorkerFixtures = { workerPort: number; workerBaseURL: string; }; export const test = base.extend({ workerPort: [ // biome-ignore lint/correctness/noEmptyPattern: Playwright requires object destructuring async ({}, use, workerInfo) => { const port = E2E_BASE_PORT + workerInfo.parallelIndex; await use(port); }, { scope: "worker" }, ], workerBaseURL: [ async ({ workerPort }, use) => { await use(`http://localhost:${workerPort}`); }, { scope: "worker" }, ], baseURL: async ({ workerBaseURL }, use) => { await use(workerBaseURL); }, }); export { expect }; export async function selectWeapon({ page, name, testId = "weapon-select", }: { page: Page; name: string; testId?: string; }) { await page.getByTestId(testId).click(); await page.getByPlaceholder("Search weapons...").fill(name); await page .getByRole("listbox", { name: "Suggestions" }) .getByTestId(`weapon-select-option-${name}`) .click(); } export async function selectStage({ page, name, testId = "stage-select", nth, }: { page: Page; name: string; testId?: string; nth?: number; }) { const select = nth !== undefined ? page.getByTestId(testId).nth(nth) : page.getByTestId(testId); await select.click(); await page.getByPlaceholder("Search stages...").fill(name); await page.getByTestId(`stage-select-option-${name}`).click(); } export async function selectUser({ page, userName, labelName, exact = false, }: { page: Page; userName: string; labelName: string; exact?: boolean; }) { const comboboxButton = page.getByLabel(labelName, { exact }); const searchInput = page.getByTestId("user-search-input"); const option = page.getByTestId("user-search-item").first(); await expect(comboboxButton).not.toBeDisabled(); await comboboxButton.click(); await searchInput.fill(userName); await expect(option).toBeVisible(); await page.keyboard.press("Enter"); } export async function selectTournament({ page, query, }: { page: Page; query: string; }) { const item = page.getByTestId("tournament-search-item"); await page.getByRole("button", { name: /Tournament search/i }).click(); await page.getByTestId("tournament-search-input").fill(query); await expect(item.first()).toBeVisible(); await item.first().click(); } /** page.goto that waits for the page to be hydrated before proceeding */ export async function navigate({ page, url }: { page: Page; url: string }) { // Rewrite absolute URLs with localhost to use the worker's baseURL // This handles invite links and other URLs embedded with VITE_SITE_DOMAIN let targetUrl = url; if (url.startsWith("http://localhost:")) { const urlObj = new URL(url); // Extract just the path and search params, let Playwright use the correct baseURL targetUrl = urlObj.pathname + urlObj.search; } await page.goto(targetUrl); await expectIsHydrated(page); } /** Waits and expects the page to be hydrated (click handlers etc. ready for testing) */ export async function expectIsHydrated(page: Page) { await expect(page.getByTestId("hydrated")).toHaveCount(1); } export async function seed(page: Page, variation?: SeedVariation) { return retryPost(page, "seed", "/seed", { form: { variation: variation ?? "DEFAULT", source: "e2e" }, }); } export function impersonate(page: Page, userId = ADMIN_ID) { return retryPost(page, "impersonate", `/auth/impersonate?id=${userId}`); } /** * Direct (non-browser) POST that retries on transient network failures such as * "socket hang up", which the dev server can produce intermittently under load. * Only safe for idempotent endpoints. */ async function retryPost( page: Page, name: string, url: string, options?: Parameters[1], ) { const MAX_ATTEMPTS = 3; for (let attempt = 1; attempt <= MAX_ATTEMPTS; attempt++) { try { return await page.request.post(url, { timeout: 7_500, ...options }); } catch (error) { if (attempt === MAX_ATTEMPTS) throw error; } } throw new Error(`${name}: unreachable`); } export async function submit(page: Page, testId?: string) { const postPromise = page.waitForResponse( (res) => res.request().method() === "POST", ); await page.getByTestId(testId ?? "submit-button").click(); const postRes = await postPromise; // Remix returns 202 from action endpoints when the action threw/returned a // redirect. The fetcher then drives a client-side navigation and, once // that completes, fires a GET against the new route's data. If we return // before that GET fires, a subsequent Link click can be aborted mid-flight // by the queued navigation (ERR_ABORTED on the new route's .data fetch), // leaving the test on the old page. if (postRes.status() === 202) { await page.waitForResponse( (res) => res.request().method() === "GET" && res.url().includes(".data"), ); // Toast flash params are stripped right after via a replace navigation // (without revalidation); wait for it so it can't abort a later click. await expect(page).not.toHaveURL(/__(?:success|error)=/); } } export async function waitForPOSTResponse(page: Page, cb: () => Promise) { const MAX_ATTEMPTS = 3; const PER_ATTEMPT_TIMEOUT = 10_000; // React Aria buttons fire their handler on press end. Occasionally a click // registers the press start (the button goes `:active`) but the press never // completes into a submit, so no POST fires. Re-issue the action when the // expected POST doesn't arrive within the per-attempt window. for (let attempt = 1; attempt <= MAX_ATTEMPTS; attempt++) { const responsePromise = page.waitForResponse( (res) => res.request().method() === "POST", { timeout: PER_ATTEMPT_TIMEOUT }, ); await cb(); try { await responsePromise; return; } catch (error) { if (attempt === MAX_ATTEMPTS) throw error; } } } export function isNotVisible(locator: Locator) { return expect(locator).toHaveCount(0); } export function modalClickConfirmButton(page: Page) { return submit(page, "confirm-button"); } /** * Clicks a tournament nav tab by its testId, opening the overflow ("More") menu * first when the tab has collapsed into it on the current viewport. */ export async function clickNavTab(page: Page, testId: string) { const visibleTab = page.locator(`[data-testid="${testId}"]:visible`); if ((await visibleTab.count()) === 0) { await page.getByRole("button", { name: "More" }).click(); } await visibleTab.click(); } export const startBracket = async (page: Page, tournamentId = 2) => { await seed(page); await impersonate(page); await navigate({ page, url: tournamentBracketsPage({ tournamentId }), }); await page.getByTestId("finalize-bracket-button").click(); await submit(page, "confirm-finalize-bracket-button"); };