diff --git a/e2e/admin.spec.ts b/e2e/admin.spec.ts index a8e51d272..2aa30c63b 100644 --- a/e2e/admin.spec.ts +++ b/e2e/admin.spec.ts @@ -35,7 +35,7 @@ const FRIEND_CODE = "0123-4567-8901"; const LINKED_PLAYER_ID = 1; test.describe("Admin panel", () => { - test("grants roles, friend code, API access and patron status to a user", async ({ + test("grants roles, friend code and API access to a user", async ({ page, factories, }) => { @@ -45,13 +45,10 @@ test.describe("Admin panel", () => { }); const api = new ApiPage(page); - const topRightButtons = new TopRightButtons(page); await impersonate(page, target.id); await api.goto(); await expect(api.locators.noAccessMessage).toBeVisible(); - await navigate({ page, url: "/" }); - await expect(topRightButtons.locators.supportLink).toBeVisible(); await impersonate(page, ADMIN_ID); const adminActions = new AdminActionsPage(page); @@ -81,13 +78,25 @@ test.describe("Admin panel", () => { await isNotVisible(api.locators.noAccessMessage); const token = await api.generateToken("read"); expect(token.length).toBeGreaterThan(0); + }); + + test("grants tournament organizer role and patron status to a user", async ({ + page, + factories, + }) => { + const target = await factories.UserFactory.create(ROLE_TARGET); - // not a tournament organizer yet const newOrganization = new NewOrganizationPage(page); + const topRightButtons = new TopRightButtons(page); + + await impersonate(page, target.id); await newOrganization.goto(); await expect(newOrganization.locators.noPermissionsAlert).toBeVisible(); + await navigate({ page, url: "/" }); + await expect(topRightButtons.locators.supportLink).toBeVisible(); await impersonate(page, ADMIN_ID); + const adminActions = new AdminActionsPage(page); await adminActions.goto(); await adminActions.giveTournamentOrganizer(ROLE_TARGET.discordName); // tier one so the tournament organizer grant above stays the only source of that role diff --git a/e2e/helpers/playwright.ts b/e2e/helpers/playwright.ts index af52fac07..d6ad0b4b8 100644 --- a/e2e/helpers/playwright.ts +++ b/e2e/helpers/playwright.ts @@ -21,6 +21,17 @@ try { } export const E2E_BASE_PORT = Number(process.env.PORT || 5173) + 500; +interface RouterProbe { + wentBusy: boolean; + observer: MutationObserver; +} + +declare global { + interface Window { + __routerProbe?: RouterProbe; + } +} + export const MOBILE_VIEWPORT = { width: 375, height: 667 }; export const TABLET_VIEWPORT = { width: 768, height: 1024 }; @@ -348,21 +359,7 @@ export async function waitForPOSTResponse(page: Page, cb: () => Promise) { const MAX_ATTEMPTS = 3; const PER_ATTEMPT_TIMEOUT = 10_000; - // The busy marker only appears once React commits the submission, which on a - // loaded machine lands well after the POST left the browser. Watching for it - // from before the click keeps the idle of the *previous* render from being - // read as the action having settled below. - const routerWentBusy = page - .waitForFunction( - () => - document - .querySelector('[data-testid="hydrated"]') - ?.getAttribute("data-router-idle") !== "true", - undefined, - { timeout: PER_ATTEMPT_TIMEOUT }, - ) - // a POST that no fetcher or navigation drives never turns the router busy - .catch(() => {}); + await armRouterProbe(page); // React Aria buttons fire their handler on press end. Occasionally a click // registers the press start (the button goes `:active`) but the press never @@ -384,15 +381,65 @@ export async function waitForPOSTResponse(page: Page, cb: () => Promise) { } } + // React commits the submission before the POST leaves the browser, but on a + // loaded machine it can lag behind the response; without waiting for it the + // idle of the *previous* render reads as the action having settled. + if (!(await routerWentBusy(page))) { + await page + .waitForFunction( + () => window.__routerProbe?.wentBusy !== false, + undefined, + { + timeout: 2_000, + polling: 50, + }, + ) + // a POST that no fetcher or navigation drives never turns the router busy + .catch(() => {}); + } + // The POST's revalidation (and any redirect it drives) is still in flight; // an interaction landing mid-flight aborts it, and routes that opt out of // revalidation on navigation (e.g. to.$id) then keep the stale data. - await routerWentBusy; await expectRouterIdle(page); return response!; } +/** + * Starts recording whether the router turns busy. A fast action holds the busy + * marker for a frame or two, which a polled wait misses outright; the flag a + * `MutationObserver` sets survives the marker flipping back. + */ +async function armRouterProbe(page: Page) { + await page.evaluate(() => { + window.__routerProbe?.observer.disconnect(); + + const marker = document.querySelector('[data-testid="hydrated"]'); + if (!marker) return; + + const probe: RouterProbe = { + wentBusy: false, + observer: new MutationObserver(() => { + if (marker.getAttribute("data-router-idle") !== "true") { + probe.wentBusy = true; + } + }), + }; + probe.observer.observe(marker, { + attributes: true, + attributeFilter: ["data-router-idle"], + }); + + window.__routerProbe = probe; + }); +} + +/** A missing probe means a document navigation wiped it, which only a busy router does. */ +function routerWentBusy(page: Page) { + return page.evaluate(() => window.__routerProbe?.wentBusy !== false); +} + /** Waits until no navigation, revalidation or fetcher is in flight. */ async function expectRouterIdle(page: Page) { // A submit's redirect plus the target page's loaders can exceed the default