Fix E2E tests

This commit is contained in:
Kalle
2026-07-26 14:03:38 +03:00
parent 329fd82120
commit 14da8034a2
3 changed files with 88 additions and 30 deletions

View File

@@ -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<void>((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<void>) {
const MAX_ATTEMPTS = 3;
const PER_ATTEMPT_TIMEOUT = 10_000;

View File

@@ -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);
});

View File

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