Try a different E2E test optimization

This commit is contained in:
Kalle
2026-08-22 17:27:29 +03:00
parent e28e1c0619
commit f322faec4d
2 changed files with 77 additions and 21 deletions

View File

@@ -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

View File

@@ -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<void>) {
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<void>) {
}
}
// 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