diff --git a/.gitea/workflows/ci.yaml b/.gitea/workflows/ci.yaml index 15466d4d..ad436cb2 100644 --- a/.gitea/workflows/ci.yaml +++ b/.gitea/workflows/ci.yaml @@ -116,6 +116,34 @@ jobs: test-results/ui/ 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). + 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 + with: + repository: ${{ gitea.repository }} + token: ${{ gitea.token }} + + - name: Install dependencies + run: pnpm install --frozen-lockfile + + - name: Install cross-browser engines + run: pnpm exec playwright install --with-deps chromium firefox webkit + + - name: Cross-browser imaging checks + env: + PLAYWRIGHT_BASE_URL: https://epicnabbo.nl + run: pnpm test:imaging + preflight: needs: [tests-unit, tests-integration, tests-ui] if: gitea.event_name == 'pull_request' || (gitea.event_name == 'push' && startsWith(gitea.ref, 'refs/heads/codex/')) diff --git a/e2e/imaging/avatar-endpoint.spec.ts b/e2e/imaging/avatar-endpoint.spec.ts new file mode 100644 index 00000000..99dbc361 --- /dev/null +++ b/e2e/imaging/avatar-endpoint.spec.ts @@ -0,0 +1,91 @@ +import { expect, test } from "@playwright/test"; + +// 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"; + +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; +} + +test("renders an animated APNG for a figure with an effect", async ({ + request, +}) => { + const response = await request.get( + avatarPath({ img_format: "apng", effect: "14" }), + ); + expect(response.status()).toBe(200); + expect(response.headers()["content-type"]).toContain("image/"); + // `unavailable` means the placeholder was served, i.e. no real render. + expect(response.headers()["x-imager-source"]).not.toBe("unavailable"); + + const body = await response.body(); + expect(body.length).toBeGreaterThan(1000); + // APNG signature chunks: `acTL` announces the animation, `fcTL` marks frames. + expect(body.includes(Buffer.from("acTL"))).toBe(true); + expect(countBytes(body, Buffer.from("fcTL"))).toBeGreaterThan(1); +}); + +test("serves a static PNG when no animation is requested", async ({ + request, +}) => { + const response = await request.get(avatarPath({ img_format: "png" })); + expect(response.status()).toBe(200); + expect(response.headers()["content-type"]).toContain("image/png"); + const body = await response.body(); + expect(body.subarray(1, 4).toString("ascii")).toBe("PNG"); +}); + +test("answers conditional requests with 304", async ({ request }) => { + const first = await request.get(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 }, + }); + expect(second.status()).toBe(304); + 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("the default /imaging endpoint serves a PNG", async ({ request }) => { + const response = await request.get( + `/imaging?${new URLSearchParams({ figure: FIGURE, img_format: "png" })}`, + ); + expect(response.status()).toBe(200); + expect(response.headers()["content-type"]).toContain("image/png"); +}); + +test("the badge endpoint serves an image and revalidates", async ({ + request, +}) => { + const first = await request.get("/api/imaging/badge?code=ADM"); + expect(first.status()).toBe(200); + expect(first.headers()["content-type"]).toContain("image/"); + const etag = first.headers()["etag"]; + expect(etag).toBeTruthy(); + + const second = await request.get("/api/imaging/badge?code=ADM", { + headers: { "if-none-match": etag as string }, + }); + expect(second.status()).toBe(304); +}); diff --git a/e2e/imaging/avatar-render.spec.ts b/e2e/imaging/avatar-render.spec.ts new file mode 100644 index 00000000..88ab67ca --- /dev/null +++ b/e2e/imaging/avatar-render.spec.ts @@ -0,0 +1,50 @@ +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", +})}`; + +/** + * Cross-browser check that a real browser (chromium/firefox/webkit) can decode + * the avatar the imager produces and that it actually animates. The endpoint + * spec covers the HTTP contract; this one covers the client-side decode, where + * an unsupported image format would surface as a blank/broken avatar. + * + * Navigates straight to the image URL (a top-level navigation, which CSP + * `upgrade-insecure-requests` does not rewrite) rather than embedding it in a + * page: over a plain-HTTP host the upgrade directive would otherwise send the + * subresource request to https and break the load, without exercising anything + * about the image itself. + * + * Run across engines via `pnpm test:imaging` (playwright.imaging.config.ts). + */ +test("the avatar decodes and animates in the browser", async ({ page }) => { + const response = await page.goto(AVATAR_PATH); + expect(response?.status()).toBe(200); + + const image = page.locator("img"); + await expect(image).toBeVisible(); + const decoded = await image.evaluate((element: HTMLImageElement) => ({ + width: element.naturalWidth, + height: element.naturalHeight, + })); + expect(decoded.width).toBeGreaterThan(0); + expect(decoded.height).toBeGreaterThan(0); + + // An animated APNG keeps producing new frames; a static/undecodable one + // yields the same pixels every time. Sampling screenshots over a second is + // enough to tell them apart without depending on exact frame timing. + const frames = new Set(); + for (let attempt = 0; attempt < 8; attempt += 1) { + frames.add((await image.screenshot()).toString("base64")); + await page.waitForTimeout(180); + } + expect(frames.size).toBeGreaterThan(1); +}); diff --git a/package.json b/package.json index 2c75c930..9e315bf4 100644 --- a/package.json +++ b/package.json @@ -40,6 +40,7 @@ "test:news:real": "bash scripts/with-memory-cap.sh 8g node --import tsx e2e/news-real/run.ts", "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", "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.imaging.config.ts b/playwright.imaging.config.ts new file mode 100644 index 00000000..9e220bc9 --- /dev/null +++ b/playwright.imaging.config.ts @@ -0,0 +1,34 @@ +import { defineConfig, devices } from "@playwright/test"; + +// Cross-browser imaging checks. Unlike the main e2e config (chromium only, +// used as the deploy gate), this runs the same specs across every bundled +// engine so an avatar format that only one browser can decode is caught. +// +// Point it at a running CMS with PLAYWRIGHT_BASE_URL (defaults to the local +// host port). Requires all three engines: `pnpm exec playwright install +// chromium firefox webkit`. +const baseURL = process.env.PLAYWRIGHT_BASE_URL || "http://127.0.0.1:3002"; + +export default defineConfig({ + testDir: "./e2e/imaging", + fullyParallel: false, + workers: 1, + retries: 1, + timeout: 60000, + expect: { timeout: 15000 }, + reporter: [ + ["list"], + ["html", { outputFolder: "playwright-report/imaging", open: "never" }], + ], + outputDir: "test-results/imaging", + use: { + baseURL, + trace: "retain-on-failure", + screenshot: "only-on-failure", + }, + projects: [ + { name: "chromium", use: { ...devices["Desktop Chrome"] } }, + { name: "firefox", use: { ...devices["Desktop Firefox"] } }, + { name: "webkit", use: { ...devices["Desktop Safari"] } }, + ], +});