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: