test(imaging): retry transient edge failures instead of flaking the suite
CI / check (push) Successful in 37s
CI / tests-integration (push) Successful in 2m3s
CI / tests-unit (push) Successful in 2m8s
CI / tests-imaging-browsers (push) Successful in 2m15s
CI / tests-ui (push) Successful in 2m54s
CI / preflight (push) Skipped
CI / tests-quality (push) Successful in 1m14s
CI / deploy (push) Successful in 3m37s
CI / check (push) Successful in 37s
CI / tests-integration (push) Successful in 2m3s
CI / tests-unit (push) Successful in 2m8s
CI / tests-imaging-browsers (push) Successful in 2m15s
CI / tests-ui (push) Successful in 2m54s
CI / preflight (push) Skipped
CI / tests-quality (push) Successful in 1m14s
CI / deploy (push) Successful in 3m37s
The cross-browser imaging job runs against the public origin, so every request goes through Cloudflare. One chromium run of "rejects malformed figures › missing figure" came back 525 (Cloudflare: TLS handshake to the origin failed) after a 40s stall — the request never reached the CMS, so a 400 was impossible to observe and the assertion was effectively testing the network. It passed on both firefox and webkit, which is the signature of an edge hiccup rather than a regression. `e2e/imaging/imaging-target.ts` now owns `getThroughEdge` / `fetchThroughEdge`, which retry on the statuses that mean "the app was never reached" (502/503/504 plus Cloudflare's 520-527) with a short backoff, and both edge-fronted specs use them. A genuinely broken edge still exhausts the retries and fails the run, so nothing is masked. Verified: 84 passed / 21 skipped / 0 failed against epicnabbo.nl with IMAGING_EDGE=1 across chromium, firefox and webkit.
This commit is contained in:
1 parent
77a0d1010c
commit
480343d5a5
3 files changed
+117
-22
No files matched your search
@@ -1,5 +1,11 @@
|
||||
import { expect, test } from "@playwright/test";
|
||||
import { avatarPath, countBytes, looksLikeImage } from "./imaging-target";
|
||||
import {
|
||||
avatarPath,
|
||||
countBytes,
|
||||
fetchThroughEdge,
|
||||
getThroughEdge,
|
||||
looksLikeImage,
|
||||
} from "./imaging-target";
|
||||
|
||||
// Endpoints exercised here are identical whether the CMS is reached directly
|
||||
// or through the edge proxy, so this spec runs unchanged in the deploy gate and
|
||||
@@ -17,7 +23,8 @@ const IMAGE_SOURCES = new Set([
|
||||
test("renders an animated APNG for a figure with an effect", async ({
|
||||
request,
|
||||
}) => {
|
||||
const response = await request.get(
|
||||
const response = await getThroughEdge(
|
||||
request,
|
||||
avatarPath({ img_format: "apng", effect: "14" }),
|
||||
);
|
||||
expect(response.status()).toBe(200);
|
||||
@@ -35,7 +42,10 @@ test("renders an animated APNG for a figure with an effect", async ({
|
||||
test("serves a static PNG when no animation is requested", async ({
|
||||
request,
|
||||
}) => {
|
||||
const response = await request.get(avatarPath({ img_format: "png" }));
|
||||
const response = await getThroughEdge(
|
||||
request,
|
||||
avatarPath({ img_format: "png" }),
|
||||
);
|
||||
expect(response.status()).toBe(200);
|
||||
expect(response.headers()["content-type"]).toContain("image/png");
|
||||
const body = await response.body();
|
||||
@@ -45,7 +55,10 @@ test("serves a static PNG when no animation is requested", async ({
|
||||
test("advertises a cacheable response and a known render source", async ({
|
||||
request,
|
||||
}) => {
|
||||
const response = await request.get(avatarPath({ img_format: "png" }));
|
||||
const response = await getThroughEdge(
|
||||
request,
|
||||
avatarPath({ img_format: "png" }),
|
||||
);
|
||||
expect(response.status()).toBe(200);
|
||||
|
||||
const source = response.headers()["x-imager-source"];
|
||||
@@ -63,14 +76,21 @@ test("advertises a cacheable response and a known render source", async ({
|
||||
});
|
||||
|
||||
test("answers conditional requests with 304", async ({ request }) => {
|
||||
const first = await request.get(avatarPath({ img_format: "png" }));
|
||||
const first = await getThroughEdge(
|
||||
request,
|
||||
avatarPath({ img_format: "png" }),
|
||||
);
|
||||
expect(first.status()).toBe(200);
|
||||
const etag = first.headers()["etag"];
|
||||
expect(etag).toBeTruthy();
|
||||
|
||||
const second = await request.get(avatarPath({ img_format: "png" }), {
|
||||
headers: { "if-none-match": etag as string },
|
||||
});
|
||||
const second = await getThroughEdge(
|
||||
request,
|
||||
avatarPath({ img_format: "png" }),
|
||||
{
|
||||
headers: { "if-none-match": etag as string },
|
||||
},
|
||||
);
|
||||
expect(second.status()).toBe(304);
|
||||
expect(second.headers()["etag"]).toBe(etag);
|
||||
});
|
||||
@@ -78,7 +98,10 @@ test("answers conditional requests with 304", async ({ request }) => {
|
||||
test("serves the rendered avatar behind permissive CORS", async ({
|
||||
request,
|
||||
}) => {
|
||||
const response = await request.get(avatarPath({ img_format: "png" }));
|
||||
const response = await getThroughEdge(
|
||||
request,
|
||||
avatarPath({ img_format: "png" }),
|
||||
);
|
||||
expect(response.status()).toBe(200);
|
||||
expect(response.headers()["access-control-allow-origin"]).toBe("*");
|
||||
});
|
||||
@@ -86,7 +109,9 @@ test("serves the rendered avatar behind permissive CORS", async ({
|
||||
test("preflight requests advertise GET and revalidation", async ({
|
||||
request,
|
||||
}) => {
|
||||
const response = await request.fetch(avatarPath(), { method: "OPTIONS" });
|
||||
const response = await fetchThroughEdge(request, avatarPath(), {
|
||||
method: "OPTIONS",
|
||||
});
|
||||
expect(response.status()).toBe(204);
|
||||
expect(response.headers()["access-control-allow-origin"]).toBe("*");
|
||||
expect(response.headers()["access-control-allow-methods"]).toContain("GET");
|
||||
@@ -113,7 +138,10 @@ test.describe("rejects malformed figures", () => {
|
||||
|
||||
for (const { name, query } of cases) {
|
||||
test(name, async ({ request }) => {
|
||||
const response = await request.get(`/api/imaging/avatar${query}`);
|
||||
const response = await getThroughEdge(
|
||||
request,
|
||||
`/api/imaging/avatar${query}`,
|
||||
);
|
||||
expect(response.status()).toBe(400);
|
||||
});
|
||||
}
|
||||
@@ -131,7 +159,7 @@ test("collapses a burst of concurrent requests for one figure", async ({
|
||||
head_direction: "1",
|
||||
});
|
||||
const responses = await Promise.all(
|
||||
Array.from({ length: 6 }, () => request.get(path)),
|
||||
Array.from({ length: 6 }, () => getThroughEdge(request, path)),
|
||||
);
|
||||
for (const response of responses) {
|
||||
expect(response.status()).toBe(200);
|
||||
@@ -143,14 +171,14 @@ test("collapses a burst of concurrent requests for one figure", async ({
|
||||
test("the badge endpoint serves an image and revalidates", async ({
|
||||
request,
|
||||
}) => {
|
||||
const first = await request.get("/api/imaging/badge?code=ADM");
|
||||
const first = await getThroughEdge(request, "/api/imaging/badge?code=ADM");
|
||||
expect(first.status()).toBe(200);
|
||||
expect(first.headers()["content-type"]).toContain("image/");
|
||||
expect(first.headers()["access-control-allow-origin"]).toBe("*");
|
||||
const etag = first.headers()["etag"];
|
||||
expect(etag).toBeTruthy();
|
||||
|
||||
const second = await request.get("/api/imaging/badge?code=ADM", {
|
||||
const second = await getThroughEdge(request, "/api/imaging/badge?code=ADM", {
|
||||
headers: { "if-none-match": etag as string },
|
||||
});
|
||||
expect(second.status()).toBe(304);
|
||||
@@ -159,7 +187,8 @@ test("the badge endpoint serves an image and revalidates", async ({
|
||||
test("the badge endpoint can resolve from a local source", async ({
|
||||
request,
|
||||
}) => {
|
||||
const response = await request.get(
|
||||
const response = await getThroughEdge(
|
||||
request,
|
||||
"/api/imaging/badge?code=ADM&source=local",
|
||||
);
|
||||
expect(response.status()).toBe(200);
|
||||
@@ -177,16 +206,23 @@ test.describe("rejects malformed badge codes", () => {
|
||||
|
||||
for (const { name, query } of cases) {
|
||||
test(name, async ({ request }) => {
|
||||
const response = await request.get(`/api/imaging/badge${query}`);
|
||||
const response = await getThroughEdge(
|
||||
request,
|
||||
`/api/imaging/badge${query}`,
|
||||
);
|
||||
expect(response.status()).toBe(400);
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
test("the badge preflight advertises GET", async ({ request }) => {
|
||||
const response = await request.fetch("/api/imaging/badge?code=ADM", {
|
||||
method: "OPTIONS",
|
||||
});
|
||||
const response = await fetchThroughEdge(
|
||||
request,
|
||||
"/api/imaging/badge?code=ADM",
|
||||
{
|
||||
method: "OPTIONS",
|
||||
},
|
||||
);
|
||||
expect(response.status()).toBe(204);
|
||||
expect(response.headers()["access-control-allow-methods"]).toContain("GET");
|
||||
});
|
||||
@@ -1,3 +1,5 @@
|
||||
import type { APIRequestContext, APIResponse } from "@playwright/test";
|
||||
|
||||
/**
|
||||
* Where the imaging specs are pointed.
|
||||
*
|
||||
@@ -12,6 +14,56 @@
|
||||
*/
|
||||
export const IS_EDGE = process.env.IMAGING_EDGE === "1";
|
||||
|
||||
/**
|
||||
* Failures that mean "the request never reached the app".
|
||||
*
|
||||
* Cloudflare answers the 520-527 family when its own handshake to the origin
|
||||
* fails, and gateways add 502/503/504. Retrying those is not papering over a
|
||||
* bug: the assertion under test is about the CMS, and a 525 says nothing about
|
||||
* it. A genuinely broken edge still exhausts the retries and fails the test.
|
||||
*/
|
||||
const TRANSIENT_EDGE_STATUSES = new Set([
|
||||
502, 503, 504, 520, 521, 522, 523, 524, 525, 526, 527,
|
||||
]);
|
||||
const EDGE_RETRY_ATTEMPTS = 3;
|
||||
const EDGE_RETRY_DELAY_MS = 500;
|
||||
|
||||
async function withEdgeRetry(
|
||||
send: () => Promise<APIResponse>,
|
||||
): Promise<APIResponse> {
|
||||
let response = await send();
|
||||
for (
|
||||
let attempt = 1;
|
||||
attempt < EDGE_RETRY_ATTEMPTS &&
|
||||
TRANSIENT_EDGE_STATUSES.has(response.status());
|
||||
attempt += 1
|
||||
) {
|
||||
await new Promise((resolve) =>
|
||||
setTimeout(resolve, EDGE_RETRY_DELAY_MS * attempt),
|
||||
);
|
||||
response = await send();
|
||||
}
|
||||
return response;
|
||||
}
|
||||
|
||||
/** GET that survives a transient edge/transport failure. */
|
||||
export function getThroughEdge(
|
||||
request: APIRequestContext,
|
||||
url: string,
|
||||
options: Parameters<APIRequestContext["get"]>[1] = {},
|
||||
): Promise<APIResponse> {
|
||||
return withEdgeRetry(() => request.get(url, options));
|
||||
}
|
||||
|
||||
/** POST/OPTIONS/… counterpart of `getThroughEdge`. */
|
||||
export function fetchThroughEdge(
|
||||
request: APIRequestContext,
|
||||
url: string,
|
||||
options: Parameters<APIRequestContext["fetch"]>[1] = {},
|
||||
): Promise<APIResponse> {
|
||||
return withEdgeRetry(() => request.fetch(url, options));
|
||||
}
|
||||
|
||||
// The exact figure that regressed in production: the game client appends a
|
||||
// trailing `.<n>` segment that the renderer ignores, but the previous
|
||||
// validation regex rejected with a 400.
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { expect, test } from "@playwright/test";
|
||||
import {
|
||||
avatarPath,
|
||||
getThroughEdge,
|
||||
IMAGER_PATH,
|
||||
IS_EDGE,
|
||||
looksLikeImage,
|
||||
@@ -17,7 +18,10 @@ test.skip(
|
||||
);
|
||||
|
||||
test("the edge serves the imager endpoint as an image", async ({ request }) => {
|
||||
const response = await request.get(IMAGER_PATH({ img_format: "png" }));
|
||||
const response = await getThroughEdge(
|
||||
request,
|
||||
IMAGER_PATH({ img_format: "png" }),
|
||||
);
|
||||
expect(response.status()).toBe(200);
|
||||
expect(response.headers()["content-type"]).toContain("image/");
|
||||
expect(looksLikeImage(await response.body())).toBe(true);
|
||||
@@ -26,14 +30,17 @@ test("the edge serves the imager endpoint as an image", async ({ request }) => {
|
||||
test("the CMS avatar endpoint is reachable through the edge", async ({
|
||||
request,
|
||||
}) => {
|
||||
const response = await request.get(avatarPath({ img_format: "png" }));
|
||||
const response = await getThroughEdge(
|
||||
request,
|
||||
avatarPath({ img_format: "png" }),
|
||||
);
|
||||
expect(response.status()).toBe(200);
|
||||
expect(response.headers()["content-type"]).toContain("image/");
|
||||
expect(looksLikeImage(await response.body())).toBe(true);
|
||||
});
|
||||
|
||||
test("the edge does not shadow the CMS badge endpoint", async ({ request }) => {
|
||||
const response = await request.get("/api/imaging/badge?code=ADM");
|
||||
const response = await getThroughEdge(request, "/api/imaging/badge?code=ADM");
|
||||
expect(response.status()).toBe(200);
|
||||
expect(response.headers()["content-type"]).toContain("image/");
|
||||
});
|
||||
Reference in new issue
Block a user