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 00000000..b5051e69 Binary files /dev/null and b/e2e/imaging/avatar-render.spec.ts-snapshots/static-avatar-chromium-linux.png differ 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 00000000..0be5f30d Binary files /dev/null and b/e2e/imaging/avatar-render.spec.ts-snapshots/static-avatar-firefox-linux.png differ diff --git a/e2e/imaging/avatar-render.spec.ts-snapshots/static-avatar-webkit-linux.png b/e2e/imaging/avatar-render.spec.ts-snapshots/static-avatar-webkit-linux.png new file mode 100644 index 00000000..8613986f Binary files /dev/null and b/e2e/imaging/avatar-render.spec.ts-snapshots/static-avatar-webkit-linux.png differ diff --git a/e2e/imaging/cms-imaging.spec.ts b/e2e/imaging/cms-imaging.spec.ts new file mode 100644 index 00000000..0aa15c75 --- /dev/null +++ b/e2e/imaging/cms-imaging.spec.ts @@ -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 = {}) => + `/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" }],