From 640e5a5dd03f717b006b0e8f09d7c4e3815f94d3 Mon Sep 17 00:00:00 2001 From: openhands Date: Wed, 14 Oct 2026 21:19:18 +0200 Subject: [PATCH] test(imaging): broaden coverage across contract, transcode, routing and pixels 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. --- .gitea/workflows/ci.yaml | 11 +- e2e/imaging/avatar-endpoint.spec.ts | 156 +++++++++++++++--- e2e/imaging/avatar-render.spec.ts | 31 ++-- .../static-avatar-chromium-linux.png | Bin 0 -> 3213 bytes .../static-avatar-firefox-linux.png | Bin 0 -> 11088 bytes .../static-avatar-webkit-linux.png | Bin 0 -> 3707 bytes e2e/imaging/cms-imaging.spec.ts | 78 +++++++++ e2e/imaging/imaging-target.ts | 51 ++++++ e2e/imaging/public-routing.spec.ts | 39 +++++ package.json | 1 + playwright.config.ts | 7 + playwright.imaging.config.ts | 9 +- 12 files changed, 339 insertions(+), 44 deletions(-) create mode 100644 e2e/imaging/avatar-render.spec.ts-snapshots/static-avatar-chromium-linux.png create mode 100644 e2e/imaging/avatar-render.spec.ts-snapshots/static-avatar-firefox-linux.png create mode 100644 e2e/imaging/avatar-render.spec.ts-snapshots/static-avatar-webkit-linux.png create mode 100644 e2e/imaging/cms-imaging.spec.ts create mode 100644 e2e/imaging/imaging-target.ts create mode 100644 e2e/imaging/public-routing.spec.ts diff --git a/.gitea/workflows/ci.yaml b/.gitea/workflows/ci.yaml index ad436cb2..bec8efc1 100644 --- a/.gitea/workflows/ci.yaml +++ b/.gitea/workflows/ci.yaml @@ -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: diff --git a/e2e/imaging/avatar-endpoint.spec.ts b/e2e/imaging/avatar-endpoint.spec.ts index 085cfd60..16a8e875 100644 --- a/e2e/imaging/avatar-endpoint.spec.ts +++ b/e2e/imaging/avatar-endpoint.spec.ts @@ -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 `.` 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 = {}) => - `/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"); +}); diff --git a/e2e/imaging/avatar-render.spec.ts b/e2e/imaging/avatar-render.spec.ts index 88ab67ca..6a004b32 100644 --- a/e2e/imaging/avatar-render.spec.ts +++ b/e2e/imaging/avatar-render.spec.ts @@ -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", + }); +}); diff --git a/e2e/imaging/avatar-render.spec.ts-snapshots/static-avatar-chromium-linux.png b/e2e/imaging/avatar-render.spec.ts-snapshots/static-avatar-chromium-linux.png new file mode 100644 index 0000000000000000000000000000000000000000..b5051e692d4d463ae9079b1ca9a9196f418a8a3c GIT binary patch literal 3213 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 Rzx;J*bn!;G8V$Sf{{jgE=w$!^ literal 0 HcmV?d00001 diff --git a/e2e/imaging/avatar-render.spec.ts-snapshots/static-avatar-firefox-linux.png b/e2e/imaging/avatar-render.spec.ts-snapshots/static-avatar-firefox-linux.png new file mode 100644 index 0000000000000000000000000000000000000000..0be5f30d3fb79eb538edb4f7eb1d68c403c98d43 GIT binary patch literal 11088 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 We)lepV0J?Q0000mSJ1|;+0dBfwYN4k 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 d|2ur0h*qZA__FJ{lWEf = {}) => + `/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"); +}); diff --git a/e2e/imaging/imaging-target.ts b/e2e/imaging/imaging-target.ts new file mode 100644 index 00000000..afe04a5a --- /dev/null +++ b/e2e/imaging/imaging-target.ts @@ -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 `.` 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 = {}) => + `/api/imaging/avatar?${new URLSearchParams({ figure: FIGURE, ...params })}`; + +/** Public imager endpoint served through the edge proxy (imager container). */ +export const IMAGER_PATH = (params: Record = {}) => + `/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; +} diff --git a/e2e/imaging/public-routing.spec.ts b/e2e/imaging/public-routing.spec.ts new file mode 100644 index 00000000..cc28e379 --- /dev/null +++ b/e2e/imaging/public-routing.spec.ts @@ -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/"); +}); diff --git a/package.json b/package.json index 9e315bf4..e29b2a85 100644 --- a/package.json +++ b/package.json @@ -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" }, diff --git a/playwright.config.ts b/playwright.config.ts index 9655e58e..9c753f8b 100644 --- a/playwright.config.ts +++ b/playwright.config.ts @@ -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", diff --git a/playwright.imaging.config.ts b/playwright.imaging.config.ts index 9e220bc9..51719144 100644 --- a/playwright.imaging.config.ts +++ b/playwright.imaging.config.ts @@ -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" }],