test(imaging): broaden coverage across contract, transcode, routing and pixels
CI / check (push) Successful in 32s
CI / tests-imaging-browsers (push) Successful in 1m33s
CI / tests-integration (push) Successful in 2m5s
CI / tests-unit (push) Successful in 2m7s
CI / tests-ui (push) Successful in 2m47s
CI / preflight (push) Skipped
CI / deploy (push) Successful in 3m44s
CI / check (push) Successful in 32s
CI / tests-imaging-browsers (push) Successful in 1m33s
CI / tests-integration (push) Successful in 2m5s
CI / tests-unit (push) Successful in 2m7s
CI / tests-ui (push) Successful in 2m47s
CI / preflight (push) Skipped
CI / deploy (push) Successful in 3m44s
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.
This commit is contained in:
1 parent
3fa643828d
commit
640e5a5dd0
12 files changed
+339
-44
No files matched your search
@@ -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:
|
||||
|
||||
@@ -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 `.<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";
|
||||
// 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<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;
|
||||
}
|
||||
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");
|
||||
});
|
||||
@@ -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",
|
||||
});
|
||||
});
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 3.1 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 11 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 3.6 KiB |
@@ -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<string, string> = {}) =>
|
||||
`/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");
|
||||
});
|
||||
@@ -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 `.<n>` 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<string, string> = {}) =>
|
||||
`/api/imaging/avatar?${new URLSearchParams({ figure: FIGURE, ...params })}`;
|
||||
|
||||
/** Public imager endpoint served through the edge proxy (imager container). */
|
||||
export const IMAGER_PATH = (params: Record<string, string> = {}) =>
|
||||
`/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;
|
||||
}
|
||||
@@ -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/");
|
||||
});
|
||||
@@ -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"
|
||||
},
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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" }],
|
||||
|
||||
Reference in new issue
Block a user