test(imaging): broaden coverage across contract, transcode, routing and pixels
CI / check (push) Successful in 32s
CI / tests-imaging-browsers (push) Successful in 1m33s
CI / tests-integration (push) Successful in 2m5s
CI / tests-unit (push) Successful in 2m7s
CI / tests-ui (push) Successful in 2m47s
CI / preflight (push) Skipped
CI / deploy (push) Successful in 3m44s
CI / check (push) Successful in 32s
CI / tests-imaging-browsers (push) Successful in 1m33s
CI / tests-integration (push) Successful in 2m5s
CI / tests-unit (push) Successful in 2m7s
CI / tests-ui (push) Successful in 2m47s
CI / preflight (push) Skipped
CI / deploy (push) Successful in 3m44s
Environment-independent contract tests (cache headers + source, CORS, OPTIONS preflight, negative figure/badge tables, concurrency burst) run in both the deploy gate and the cross-browser job. CMS-only /imaging assertions (webp/avif transcode, animated-APNG passthrough, s-maxage edge caching) live in cms-imaging.spec.ts and are skipped behind the edge proxy; the public topology has its own public-routing.spec.ts smoke that fails if the edge ever stops fronting the imager or starts shadowing the CMS endpoints. A committed per-engine visual baseline pins the static render. The cross-browser imaging job is now blocking (IMAGING_EDGE=1), so avatar imaging on chromium/firefox/webkit is a required signal.
This commit is contained in:
1 parent
3fa643828d
commit
640e5a5dd0
12 files changed
+339
-44
No files matched your search
@@ -1,25 +1,18 @@
|
||||
import { expect, test } from "@playwright/test";
|
||||
import { avatarPath, countBytes, looksLikeImage } from "./imaging-target";
|
||||
|
||||
// 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.
|
||||
const FIGURE =
|
||||
"hr-11782-40-40.hd-180-7-14.ch-11592-66.lg-10726-79-1408.sh-11764-1408.ha-11958-70-1408.wa-2007-0.0";
|
||||
// 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
|
||||
// in the cross-browser job. Topology-specific checks live in
|
||||
// `cms-imaging.spec.ts` (direct only) and `public-routing.spec.ts` (edge only).
|
||||
|
||||
const avatarPath = (params: Record<string, string> = {}) =>
|
||||
`/api/imaging/avatar?${new URLSearchParams({ figure: FIGURE, ...params })}`;
|
||||
|
||||
function countBytes(haystack: Buffer, needle: Buffer): number {
|
||||
let count = 0;
|
||||
for (
|
||||
let index = haystack.indexOf(needle);
|
||||
index !== -1;
|
||||
index = haystack.indexOf(needle, index + needle.length)
|
||||
) {
|
||||
count += 1;
|
||||
}
|
||||
return count;
|
||||
}
|
||||
const IMAGE_SOURCES = new Set([
|
||||
"primary",
|
||||
"fallback",
|
||||
"cache",
|
||||
"cache-stale",
|
||||
"unavailable",
|
||||
]);
|
||||
|
||||
test("renders an animated APNG for a figure with an effect", async ({
|
||||
request,
|
||||
@@ -49,6 +42,26 @@ test("serves a static PNG when no animation is requested", async ({
|
||||
expect(body.subarray(1, 4).toString("ascii")).toBe("PNG");
|
||||
});
|
||||
|
||||
test("advertises a cacheable response and a known render source", async ({
|
||||
request,
|
||||
}) => {
|
||||
const response = await request.get(avatarPath({ img_format: "png" }));
|
||||
expect(response.status()).toBe(200);
|
||||
|
||||
const source = response.headers()["x-imager-source"];
|
||||
expect(source, "x-imager-source header").toBeTruthy();
|
||||
expect(IMAGE_SOURCES.has(source as string)).toBe(true);
|
||||
|
||||
const cacheControl = response.headers()["cache-control"];
|
||||
expect(cacheControl, "cache-control header").toContain("public");
|
||||
expect(cacheControl).toMatch(/max-age=\d+/);
|
||||
|
||||
// Render sources other than the graceful placeholder are worth caching.
|
||||
if (source !== "unavailable") {
|
||||
expect(cacheControl).not.toContain("no-cache");
|
||||
}
|
||||
});
|
||||
|
||||
test("answers conditional requests with 304", async ({ request }) => {
|
||||
const first = await request.get(avatarPath({ img_format: "png" }));
|
||||
expect(first.status()).toBe(200);
|
||||
@@ -62,15 +75,70 @@ test("answers conditional requests with 304", async ({ request }) => {
|
||||
expect(second.headers()["etag"]).toBe(etag);
|
||||
});
|
||||
|
||||
test("rejects a malformed figure with 400", async ({ request }) => {
|
||||
const response = await request.get(avatarPath({ figure: "../bad" }));
|
||||
expect(response.status()).toBe(400);
|
||||
test("serves the rendered avatar behind permissive CORS", async ({
|
||||
request,
|
||||
}) => {
|
||||
const response = await request.get(avatarPath({ img_format: "png" }));
|
||||
expect(response.status()).toBe(200);
|
||||
expect(response.headers()["access-control-allow-origin"]).toBe("*");
|
||||
});
|
||||
|
||||
// NB: the sibling `/imaging` CMS route is intentionally fronted to the imager
|
||||
// container by the edge proxy in production, so it is not asserted here — it
|
||||
// would only pass when the CMS is reached directly. Its behaviour is covered by
|
||||
// src/lib/imaging-route.test.ts.
|
||||
test("preflight requests advertise GET and revalidation", async ({
|
||||
request,
|
||||
}) => {
|
||||
const response = await request.fetch(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");
|
||||
expect(response.headers()["access-control-allow-headers"]).toContain(
|
||||
"If-None-Match",
|
||||
);
|
||||
});
|
||||
|
||||
test.describe("rejects malformed figures", () => {
|
||||
const cases: Array<{ name: string; query: string }> = [
|
||||
{ name: "missing figure", query: "" },
|
||||
{ name: "empty figure", query: "?figure=" },
|
||||
{ name: "path traversal", query: "?figure=../bad" },
|
||||
{ name: "separator injection", query: "?figure=hd-180-1/../x" },
|
||||
{
|
||||
name: "over-long figure",
|
||||
query: `?figure=hd-180-${"x".repeat(600)}`,
|
||||
},
|
||||
{
|
||||
name: "too many parts",
|
||||
query: `?figure=hd-180${".a-1".repeat(30)}`,
|
||||
},
|
||||
];
|
||||
|
||||
for (const { name, query } of cases) {
|
||||
test(name, async ({ request }) => {
|
||||
const response = await request.get(`/api/imaging/avatar${query}`);
|
||||
expect(response.status()).toBe(400);
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
test("collapses a burst of concurrent requests for one figure", async ({
|
||||
request,
|
||||
}) => {
|
||||
// Vary the key so this can hit a cold (or at least distinct) render while a
|
||||
// page-load style burst is in flight. Every response must still be a real
|
||||
// image — single-flight must not turn contention into 5xx/placeholders.
|
||||
const path = avatarPath({
|
||||
img_format: "png",
|
||||
direction: "6",
|
||||
head_direction: "1",
|
||||
});
|
||||
const responses = await Promise.all(
|
||||
Array.from({ length: 6 }, () => request.get(path)),
|
||||
);
|
||||
for (const response of responses) {
|
||||
expect(response.status()).toBe(200);
|
||||
expect(response.headers()["content-type"]).toContain("image/");
|
||||
expect(looksLikeImage(await response.body())).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
test("the badge endpoint serves an image and revalidates", async ({
|
||||
request,
|
||||
@@ -78,6 +146,7 @@ test("the badge endpoint serves an image and revalidates", async ({
|
||||
const first = await request.get("/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();
|
||||
|
||||
@@ -86,3 +155,38 @@ test("the badge endpoint serves an image and revalidates", async ({
|
||||
});
|
||||
expect(second.status()).toBe(304);
|
||||
});
|
||||
|
||||
test("the badge endpoint can resolve from a local source", async ({
|
||||
request,
|
||||
}) => {
|
||||
const response = await request.get(
|
||||
"/api/imaging/badge?code=ADM&source=local",
|
||||
);
|
||||
expect(response.status()).toBe(200);
|
||||
expect(response.headers()["content-type"]).toContain("image/");
|
||||
expect(response.headers()["x-imager-source"]).toBeTruthy();
|
||||
});
|
||||
|
||||
test.describe("rejects malformed badge codes", () => {
|
||||
const cases = [
|
||||
{ name: "missing code", query: "" },
|
||||
{ name: "empty code", query: "?code=" },
|
||||
{ name: "path traversal", query: "?code=../x" },
|
||||
{ name: "too long", query: `?code=${"A".repeat(51)}` },
|
||||
];
|
||||
|
||||
for (const { name, query } of cases) {
|
||||
test(name, async ({ request }) => {
|
||||
const response = await request.get(`/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",
|
||||
});
|
||||
expect(response.status()).toBe(204);
|
||||
expect(response.headers()["access-control-allow-methods"]).toContain("GET");
|
||||
});
|
||||
Reference in new issue
Block a user