test(imaging): add endpoint contract e2e and cross-browser render checks
CI / check (push) Successful in 40s
CI / tests-imaging-browsers (push) Failing after 1m20s
CI / tests-unit (push) Successful in 2m12s
CI / tests-integration (push) Successful in 2m24s
CI / tests-ui (push) Successful in 2m55s
CI / preflight (push) Skipped
CI / deploy (push) Successful in 2m38s

The deploy gate only installs chromium, so imaging had no browser-level
coverage. Add two Playwright specs:

- e2e/imaging/avatar-endpoint.spec.ts: HTTP contract for the avatar
  (/api/imaging/avatar), default (/imaging) and badge endpoints — animated
  APNG for a figure with an effect, the trailing-`.0` regression figure,
  ETag/304 revalidation, and a 400 for a malformed figure. Runs in the
  deploy gate automatically via the default Playwright config.
- e2e/imaging/avatar-render.spec.ts: loads the avatar in a real browser and
  asserts it decodes at a non-zero size and animates. Navigates to the image
  URL directly so production CSP `upgrade-insecure-requests` cannot rewrite
  the request over a plain-HTTP host.

Add playwright.imaging.config.ts running both across chromium, firefox and
webkit, a `test:imaging` script, and a non-blocking CI job that installs all
three engines and checks the live site.
This commit is contained in:
openhands committed 2026-10-14 21:00:27 +02:00
1 parent f3407ebfdf
commit 888765c47f
5 files changed
+204

No files matched your search

+28
View File
@@ -116,6 +116,34 @@ jobs:
test-results/ui/ test-results/ui/
retention-days: 14 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: preflight:
needs: [tests-unit, tests-integration, tests-ui] needs: [tests-unit, tests-integration, tests-ui]
if: gitea.event_name == 'pull_request' || (gitea.event_name == 'push' && startsWith(gitea.ref, 'refs/heads/codex/')) if: gitea.event_name == 'pull_request' || (gitea.event_name == 'push' && startsWith(gitea.ref, 'refs/heads/codex/'))
+91
View File
@@ -0,0 +1,91 @@
import { expect, test } from "@playwright/test";
// 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";
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;
}
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);
});
+50
View File
@@ -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<string>();
for (let attempt = 0; attempt < 8; attempt += 1) {
frames.add((await image.screenshot()).toString("base64"));
await page.waitForTimeout(180);
}
expect(frames.size).toBeGreaterThan(1);
});
+1
View File
@@ -40,6 +40,7 @@
"test:news:real": "bash scripts/with-memory-cap.sh 8g node --import tsx e2e/news-real/run.ts", "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": "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: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", "performance:report": "node scripts/performance-report.mjs",
"test:integration": "bash scripts/with-memory-cap.sh 8g vitest run --config vitest.integration.config.ts" "test:integration": "bash scripts/with-memory-cap.sh 8g vitest run --config vitest.integration.config.ts"
}, },
+34
View File
@@ -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"] } },
],
});