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
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:
1 parent
f3407ebfdf
commit
888765c47f
5 files changed
+204
No files matched your search
@@ -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);
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
Reference in new issue
Block a user