import { expect, test } from "@playwright/test";
import { instrument, isClean, issuesSummary, toolPath } from "./qa-helpers";
// Cross-cutting UI quality sweep: locale, a11y, responsive, dark mode.
// Designed for the QA container at localhost:13499 (auth off).
// Run with --workers=2 to avoid overloading the host.
// ---------------------------------------------------------------------------
// Shared constants
// ---------------------------------------------------------------------------
const ALL_LOCALES = [
{ code: "en", dir: "ltr" },
{ code: "ar", dir: "rtl" },
{ code: "de", dir: "ltr" },
{ code: "es", dir: "ltr" },
{ code: "fr", dir: "ltr" },
{ code: "hi", dir: "ltr" },
{ code: "id", dir: "ltr" },
{ code: "it", dir: "ltr" },
{ code: "ja", dir: "ltr" },
{ code: "ko", dir: "ltr" },
{ code: "nl", dir: "ltr" },
{ code: "pl", dir: "ltr" },
{ code: "pt-BR", dir: "ltr" },
{ code: "ru", dir: "ltr" },
{ code: "sv", dir: "ltr" },
{ code: "th", dir: "ltr" },
{ code: "tr", dir: "ltr" },
{ code: "uk", dir: "ltr" },
{ code: "vi", dir: "ltr" },
{ code: "zh-CN", dir: "ltr" },
{ code: "zh-TW", dir: "ltr" },
] as const;
const RESIZE_PATH = toolPath("resize");
const CONVERT_AUDIO_PATH = toolPath("convert-audio");
// Patterns that indicate a raw i18n key leaked into visible text
const I18N_KEY_PATTERN =
/(?:^|\s)(tools\.\w+\.\w+|common\.\w+|settings\.\w+|categories\.\w+|editor\.\w+|nav\.\w+|errors?\.\w+|auth\.\w+|home\.\w+|general\.\w+|upload\.\w+|dropzone\.\w+)\b/;
/** Set locale via localStorage + reload, matching the app's i18n mechanism. */
async function setLocale(page: import("@playwright/test").Page, code: string) {
await page.evaluate((c) => localStorage.setItem("snapotter-locale", c), code);
await page.reload({ waitUntil: "domcontentloaded" });
// Give the async locale loader time to apply translations
await page.waitForTimeout(1200);
}
/** Click the "Toggle theme" button in the top nav. The button has title="Toggle theme". */
async function clickThemeToggle(page: import("@playwright/test").Page) {
const toggle = page.locator('button[title="Toggle theme"]');
await toggle.waitFor({ state: "visible", timeout: 5000 });
await toggle.click();
await page.waitForTimeout(600);
}
// =========================================================================
// 1) LOCALE
// =========================================================================
test.describe("Locale", () => {
for (const locale of ALL_LOCALES) {
test(`${locale.code}: home + tools render without i18n key leaks or layout breaks`, async ({
page,
}) => {
const issues = instrument(page);
// Navigate to home and set locale
await page.goto("/", { waitUntil: "domcontentloaded" });
await setLocale(page, locale.code);
// (a) Check lang attribute
const lang = await page.getAttribute("html", "lang");
expect(lang, `html lang should be ${locale.code}`).toBe(locale.code);
// (b) Check dir attribute for RTL
if (locale.dir === "rtl") {
const dir = await page.getAttribute("html", "dir");
expect(dir, `html dir should be rtl for ${locale.code}`).toBe("rtl");
}
// (c) No raw i18n keys in home page body
const homeBody = await page.evaluate(() => document.body.innerText);
const homeKeys = homeBody.match(new RegExp(I18N_KEY_PATTERN.source, "gm")) || [];
expect(
homeKeys.length,
`Home (${locale.code}): raw i18n keys found: ${homeKeys.join(", ")}`,
).toBe(0);
// (d) No horizontal overflow on home
const homeOverflow = await page.evaluate(
() => document.documentElement.scrollWidth - window.innerWidth,
);
expect(
homeOverflow,
`Home (${locale.code}): horizontal overflow of ${homeOverflow}px`,
).toBeLessThanOrEqual(4);
// Navigate to /image/resize
await page.goto(RESIZE_PATH, { waitUntil: "domcontentloaded" });
await page.waitForTimeout(800);
const resizeBody = await page.evaluate(() => document.body.innerText);
const resizeKeys = resizeBody.match(new RegExp(I18N_KEY_PATTERN.source, "gm")) || [];
expect(
resizeKeys.length,
`Resize (${locale.code}): raw i18n keys found: ${resizeKeys.join(", ")}`,
).toBe(0);
// Navigate to /audio/convert-audio
await page.goto(CONVERT_AUDIO_PATH, { waitUntil: "domcontentloaded" });
await page.waitForTimeout(800);
const audioBody = await page.evaluate(() => document.body.innerText);
const audioKeys = audioBody.match(new RegExp(I18N_KEY_PATTERN.source, "gm")) || [];
expect(
audioKeys.length,
`ConvertAudio (${locale.code}): raw i18n keys found: ${audioKeys.join(", ")}`,
).toBe(0);
// Console should be clean (no missing-translation warnings, no errors)
expect(isClean(issues), `${locale.code} console issues:\n${issuesSummary(issues)}`).toBe(
true,
);
});
}
});
// =========================================================================
// 2) A11Y (Playwright built-ins only, no axe)
// =========================================================================
test.describe("A11y", () => {
const PAGES = [
{ name: "Home", path: "/" },
{ name: "Resize", path: RESIZE_PATH },
];
for (const pg of PAGES) {
test(`${pg.name}: h1, landmarks, accessible names, alt text`, async ({ page }) => {
const issues = instrument(page);
await page.goto(pg.path, { waitUntil: "domcontentloaded" });
await page.waitForTimeout(600);
// h1: tool pages must have exactly 1; home (catalog) may have 0
const h1Count = await page.locator("h1").count();
if (pg.path !== "/") {
expect(h1Count, `${pg.name}: expected exactly 1
, found ${h1Count}`).toBe(1);
}
// If home has 0 h1, we record it in findings but don't fail (catalog page)
// Landmark: present
const mainCount = await page.locator("main, [role='main']").count();
expect(mainCount, `${pg.name}: no landmark`).toBeGreaterThanOrEqual(1);
// Landmark: