diff --git a/e2e/helpers/playwright.ts b/e2e/helpers/playwright.ts index 9298e8ba1..8c02ea22e 100644 --- a/e2e/helpers/playwright.ts +++ b/e2e/helpers/playwright.ts @@ -3,6 +3,7 @@ import { expect, type Locator, type Page, + type Response, } from "@playwright/test"; import { ADMIN_ID } from "~/features/admin/admin-constants"; import type { SeedVariation } from "~/features/api-private/routes/seed"; @@ -171,26 +172,82 @@ async function retryPost( } export async function submit(page: Page, testId?: string) { - const postRes = await waitForPOSTResponse(page, async () => { - await page.getByTestId(testId ?? "submit-button").click(); - }); + // Started before the click because the data GET can land before awaiting the + // POST response hands control back to us, and `page.waitForResponse` only + // sees responses arriving after it is called. + const dataGet = watchForDataGetAfterPOST(page); - // 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"), - ); + try { + const postRes = await waitForPOSTResponse(page, async () => { + await page.getByTestId(testId ?? "submit-button").click(); + }); + + // 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) return; + + await dataGet.fired; // 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)=/); + } finally { + dataGet.stop(); } } +/** + * Resolves once a route data GET follows the POST, without missing one that + * arrives while the caller is still awaiting the POST response. + */ +function watchForDataGetAfterPOST(page: Page) { + const TIMEOUT = 15_000; + + let postSeen = false; + let resolveFired: () => void = () => {}; + let rejectFired: (error: Error) => void = () => {}; + + const fired = new Promise((resolve, reject) => { + resolveFired = resolve; + rejectFired = reject; + }); + + const onResponse = (res: Response) => { + if (!postSeen) { + postSeen = res.request().method() === "POST"; + return; + } + + if (res.request().method() === "GET" && res.url().includes(".data")) { + resolveFired(); + } + }; + page.on("response", onResponse); + + const timeout = setTimeout( + () => + rejectFired( + new Error( + `submit: no route data GET followed the redirecting POST within ${TIMEOUT}ms`, + ), + ), + TIMEOUT, + ); + + return { + fired, + stop: () => { + clearTimeout(timeout); + page.off("response", onResponse); + // Nothing awaits `fired` when the POST wasn't a redirect + resolveFired(); + }, + }; +} + export async function waitForPOSTResponse(page: Page, cb: () => Promise) { const MAX_ATTEMPTS = 3; const PER_ATTEMPT_TIMEOUT = 10_000; diff --git a/e2e/sendouq-match.spec.ts b/e2e/sendouq-match.spec.ts index d7aa863f8..3022d3314 100644 --- a/e2e/sendouq-match.spec.ts +++ b/e2e/sendouq-match.spec.ts @@ -195,7 +195,9 @@ test.describe("SendouQ match page", () => { await voteNo(page); await expect(page.getByText("You declined to continue")).toBeVisible(); - await page.getByRole("button", { name: "Rejoin queue" }).click(); + await waitForPOSTResponse(page, async () => { + await page.getByRole("button", { name: "Rejoin queue" }).click(); + }); await expect(page).toHaveURL(SENDOUQ_LOOKING_PAGE); }); diff --git a/e2e/vods.spec.ts b/e2e/vods.spec.ts index c4651157a..6e6569396 100644 --- a/e2e/vods.spec.ts +++ b/e2e/vods.spec.ts @@ -66,20 +66,19 @@ test.describe("VoDs page", () => { await submit(page); - const now = new Date(); - const formattedDate = `${now.getMonth() + 1}/${now.getDate()}/${now.getFullYear()}`; - await page.getByText(formattedDate).isVisible(); - await page.getByTestId("weapon-img-4001").isVisible(); - await page.getByTestId("weapon-img-6010").isVisible(); + const formattedDate = `${VIDEO_DATE.getMonth() + 1}/${VIDEO_DATE.getDate()}/${VIDEO_DATE.getFullYear()}`; + await expect(page.getByText(formattedDate)).toBeVisible(); + await expect(page.getByTestId("weapon-img-4001")).toBeVisible(); + await expect(page.getByTestId("weapon-img-6010")).toBeVisible(); await page.getByTestId("copy-timestamps-button").click(); - await page.getByText("0:00 Intro").isVisible(); - await page - .getByText("0:20 Zink Mini Splatling / TC Hammerhead Bridge") - .isVisible(); - await page - .getByText("5:55 Tenta Brella / RM Museum d'Alfonsino") - .isVisible(); + await expect(page.getByRole("dialog").getByRole("textbox")).toHaveValue( + [ + "0:00 Intro", + "0:20 Zink Mini Splatling / TC Hammerhead Bridge", + "5:55 Tenta Brella / RM Museum d'Alfonsino", + ].join("\n"), + ); }); test("adds video (cast)", async ({ page }) => { @@ -129,9 +128,9 @@ test.describe("VoDs page", () => { await submit(page); for (let i = 0; i < 8; i++) { - await page - .getByTestId(`weapon-img-${i < 4 ? 200 : 6010}-${i}`) - .isVisible(); + await expect( + page.getByTestId(`weapon-img-${i < 4 ? 200 : 6010}-${i}`), + ).toBeVisible(); } }); @@ -155,7 +154,7 @@ test.describe("VoDs page", () => { await expect(page).toHaveURL(vodVideoPage(1)); - await page.getByTestId("weapon-img-200-4").isVisible(); + await expect(page.getByTestId("weapon-img-200")).toBeVisible(); }); test("operates vod filters", async ({ page }) => {