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

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:
openhands committed 2026-10-14 21:19:18 +02:00
1 parent 3fa643828d
commit 640e5a5dd0
12 files changed
+339 -44

No files matched your search

+6 -5
View File
@@ -117,15 +117,15 @@ jobs:
retention-days: 14
# Cross-browser imaging: the deploy gate only runs chromium, so this verifies
# a real render (decode + APNG animation) across every bundled engine against
# the public site. Non-blocking: it hits the live service, so transient
# network/edge hiccups must not fail the pipeline; the blocking contract
# checks live in the deploy gate (e2e/imaging/avatar-endpoint.spec.ts).
# a real render (decode + APNG animation + visual baseline) across every
# bundled engine against the public site. Blocking on purpose: this is the
# signal that avatar imaging works on every system, so a failure here must
# stop the pipeline. IMAGING_EDGE marks the target as edge-fronted, enabling
# the public-routing smoke and skipping the CMS-only /imaging assertions.
tests-imaging-browsers:
needs: check
if: gitea.event_name == 'push' && (gitea.ref_name == 'main' || gitea.ref_name == 'master')
runs-on: ubuntu-latest
continue-on-error: true
steps:
- name: Checkout
uses: actions/checkout@v5
@@ -142,6 +142,7 @@ jobs:
- name: Cross-browser imaging checks
env:
PLAYWRIGHT_BASE_URL: https://epicnabbo.nl
IMAGING_EDGE: "1"
run: pnpm test:imaging
preflight:
+130 -26
View File
@@ -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");
});
+19 -12
View File
@@ -1,15 +1,5 @@
import { expect, test } from "@playwright/test";
// Same production figure as the endpoint spec: includes the trailing `.0`
// segment the client sends and the renderer ignores.
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";
const AVATAR_PATH = `/api/imaging/avatar?${new URLSearchParams({
figure: FIGURE,
img_format: "apng",
effect: "14",
})}`;
import { avatarPath } from "./imaging-target";
/**
* Cross-browser check that a real browser (chromium/firefox/webkit) can decode
@@ -25,8 +15,11 @@ const AVATAR_PATH = `/api/imaging/avatar?${new URLSearchParams({
*
* Run across engines via `pnpm test:imaging` (playwright.imaging.config.ts).
*/
const ANIMATED = avatarPath({ img_format: "apng", effect: "14" });
const STATIC = avatarPath({ img_format: "png" });
test("the avatar decodes and animates in the browser", async ({ page }) => {
const response = await page.goto(AVATAR_PATH);
const response = await page.goto(ANIMATED);
expect(response?.status()).toBe(200);
const image = page.locator("img");
@@ -48,3 +41,17 @@ test("the avatar decodes and animates in the browser", async ({ page }) => {
}
expect(frames.size).toBeGreaterThan(1);
});
test("the static render matches its committed visual baseline", async ({
page,
}) => {
// Pins the exact pixels of a non-animated render so a renderer/asset drift
// (wrong palette, missing part, changed sizing) is caught even when the
// image still decodes. Baselines are per engine and committed under
// e2e/imaging/__screenshots__/.
const response = await page.goto(STATIC);
expect(response?.status()).toBe(200);
await expect(page.locator("img")).toHaveScreenshot("static-avatar.png", {
animations: "disabled",
});
});
Binary file not shown.

After

Width:  |  Height:  |  Size: 3.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.6 KiB

+78
View File
@@ -0,0 +1,78 @@
import { expect, test } from "@playwright/test";
import { countBytes, FIGURE, IS_EDGE, looksLikeImage } from "./imaging-target";
// These assertions only hold when the CMS itself answers `/imaging`. In
// production the edge proxy fronts bare `/imaging` (and `/imaging/*`) to the
// imager container, so the CMS route is shadowed; set `IMAGING_EDGE=1` to skip.
test.skip(
IS_EDGE,
"The CMS /imaging route is shadowed by the edge proxy in production",
);
const imagerPath = (params: Record<string, string> = {}) =>
`/imaging?${new URLSearchParams({ figure: FIGURE, img_format: "png", ...params })}`;
const PNG_MAGIC = Buffer.from("89504e470d0a1a0a", "hex");
test("serves the default render as a PNG", async ({ request }) => {
const response = await request.get(imagerPath());
expect(response.status()).toBe(200);
expect(response.headers()["content-type"]).toContain("image/png");
const body = await response.body();
expect(body.subarray(0, 8).equals(PNG_MAGIC)).toBe(true);
expect(looksLikeImage(body)).toBe(true);
});
test("transcodes to WebP on request", async ({ request }) => {
const response = await request.get(imagerPath({ format: "webp" }));
expect(response.status()).toBe(200);
expect(response.headers()["content-type"]).toContain("image/webp");
const body = await response.body();
expect(body.subarray(0, 4).toString("ascii")).toBe("RIFF");
expect(body.subarray(8, 12).toString("ascii")).toBe("WEBP");
});
test("transcodes to AVIF on request", async ({ request }) => {
const response = await request.get(imagerPath({ format: "avif" }));
expect(response.status()).toBe(200);
expect(response.headers()["content-type"]).toContain("image/avif");
const body = await response.body();
expect(body.subarray(4, 12).toString("ascii")).toBe("ftypavif");
});
test("leaves animated APNG renders untranscoded", async ({ request }) => {
// The renderer emits effected avatars as APNG; libvips here reads only the
// first frame, so the animated original must be served as-is even when a
// transcode is requested — otherwise the avatar would stop animating.
const response = await request.get(
imagerPath({ img_format: "apng", effect: "14", format: "webp" }),
);
expect(response.status()).toBe(200);
expect(response.headers()["content-type"]).toContain("image/png");
const body = await response.body();
expect(body.includes(Buffer.from("acTL"))).toBe(true);
expect(countBytes(body, Buffer.from("fcTL"))).toBeGreaterThan(1);
});
test("advertises long-lived edge caching", async ({ request }) => {
const response = await request.get(imagerPath());
expect(response.status()).toBe(200);
const cacheControl = response.headers()["cache-control"] ?? "";
expect(cacheControl).toContain("public");
expect(cacheControl).toContain("s-maxage");
expect(cacheControl).toContain("stale-while-revalidate");
});
test("rejects a malformed figure with 400", async ({ request }) => {
const response = await request.get(
`/imaging?figure=${encodeURIComponent("../bad")}`,
);
expect(response.status()).toBe(400);
});
test("the imager preflight advertises GET", async ({ request }) => {
const response = await request.fetch("/imaging", { method: "OPTIONS" });
expect(response.status()).toBe(204);
expect(response.headers()["access-control-allow-methods"]).toContain("GET");
});
+51
View File
@@ -0,0 +1,51 @@
/**
* Where the imaging specs are pointed.
*
* - Deploy gate (`playwright.config.ts`): hits the CMS directly, so the CMS's
* own `/imaging` route answers and its transcoding variants are reachable.
* - Cross-browser job (`playwright.imaging.config.ts` against the public
* origin): the edge proxy fronts bare `/imaging` (and `/imaging/*`) to the
* imager container, so the CMS route is shadowed. Only the endpoints that are
* identical in both topologies may be asserted unconditionally.
*
* Set `IMAGING_EDGE=1` when the base URL is behind the edge proxy.
*/
export const IS_EDGE = process.env.IMAGING_EDGE === "1";
// 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.
export 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";
export const avatarPath = (params: Record<string, string> = {}) =>
`/api/imaging/avatar?${new URLSearchParams({ figure: FIGURE, ...params })}`;
/** Public imager endpoint served through the edge proxy (imager container). */
export const IMAGER_PATH = (params: Record<string, string> = {}) =>
`/imaging/avatarimage?${new URLSearchParams({ figure: FIGURE, ...params })}`;
/** Counts non-overlapping occurrences of `needle` inside `haystack`. */
export 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;
}
/** PNG/JPEG/GIF/WebP/AVIF signatures, so a test can prove a body is an image. */
export function looksLikeImage(body: Buffer): boolean {
if (body.length < 12) return false;
if (body.subarray(0, 8).equals(Buffer.from("89504e470d0a1a0a", "hex")))
return true; // PNG / APNG
if (body.subarray(0, 3).toString("ascii") === "GIF") return true;
if (body.subarray(0, 2).toString("hex") === "ffd8") return true; // JPEG
if (body.subarray(0, 4).toString("ascii") === "RIFF") return true; // WebP
if (body.subarray(4, 12).toString("ascii") === "ftypavif") return true; // AVIF
return false;
}
+39
View File
@@ -0,0 +1,39 @@
import { expect, test } from "@playwright/test";
import {
avatarPath,
IMAGER_PATH,
IS_EDGE,
looksLikeImage,
} from "./imaging-target";
// Regression guard for the public topology: the edge proxy must front the
// imager container at `/imaging/*` while `/api/imaging/*` reaches the CMS.
// Mis-routing here blanks avatars for every visitor, and no unit test sees it.
// Only meaningful against the public origin, so it is skipped when the CMS is
// reached directly (set `IMAGING_EDGE=1` for the public origin).
test.skip(
!IS_EDGE,
"Public routing is only asserted against the edge-fronted origin",
);
test("the edge serves the imager endpoint as an image", async ({ request }) => {
const response = await request.get(IMAGER_PATH({ img_format: "png" }));
expect(response.status()).toBe(200);
expect(response.headers()["content-type"]).toContain("image/");
expect(looksLikeImage(await response.body())).toBe(true);
});
test("the CMS avatar endpoint is reachable through the edge", async ({
request,
}) => {
const response = await request.get(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");
expect(response.status()).toBe(200);
expect(response.headers()["content-type"]).toContain("image/");
});
+1
View File
@@ -41,6 +41,7 @@
"test:ui": "bash scripts/with-memory-cap.sh 8g playwright test --config playwright.ui.config.ts",
"test:ui:update": "bash scripts/with-memory-cap.sh 8g playwright test --config playwright.ui.config.ts --update-snapshots",
"test:imaging": "bash scripts/with-memory-cap.sh 8g playwright test --config playwright.imaging.config.ts",
"test:imaging:update": "bash scripts/with-memory-cap.sh 8g playwright test --config playwright.imaging.config.ts --update-snapshots",
"performance:report": "node scripts/performance-report.mjs",
"test:integration": "bash scripts/with-memory-cap.sh 8g vitest run --config vitest.integration.config.ts"
},
+7
View File
@@ -7,6 +7,13 @@ export default defineConfig({
retries: 1,
timeout: 30000,
reporter: [["list"], ["html", { open: "never" }]],
expect: {
toHaveScreenshot: {
animations: "disabled",
caret: "hide",
maxDiffPixelRatio: 0.02,
},
},
use: {
baseURL: process.env.PLAYWRIGHT_BASE_URL || "http://127.0.0.1:3002",
trace: "retain-on-failure",
+8 -1
View File
@@ -15,7 +15,14 @@ export default defineConfig({
workers: 1,
retries: 1,
timeout: 60000,
expect: { timeout: 15000 },
expect: {
timeout: 15000,
toHaveScreenshot: {
animations: "disabled",
caret: "hide",
maxDiffPixelRatio: 0.02,
},
},
reporter: [
["list"],
["html", { outputFolder: "playwright-report/imaging", open: "never" }],