|
| 1 | +import { readFileSync } from "node:fs"; |
| 2 | +import { randomUUID } from "node:crypto"; |
| 3 | +import type { Page } from "@playwright/test"; |
| 4 | +import { admin, expect, test } from "./fixtures"; |
| 5 | + |
| 6 | +// Feature map: features/localization.md (T12: IN-01, SE-01, IN-03, IN-04, IN-11, IN-14). |
| 7 | + |
| 8 | +const catalog = (language: string) => JSON.parse(readFileSync(`src/locales/${language}.json`, "utf8")); |
| 9 | +const en = catalog("en"); |
| 10 | +const es = catalog("es"); |
| 11 | +const hu = catalog("hu"); |
| 12 | +const BRAND = "DebateLab by Pi-Stack"; |
| 13 | +const LANGUAGE_NAMES = { en: "English", es: "Español", hu: "Magyar" } as const; |
| 14 | +/** The switcher's own name in whichever language is showing. */ |
| 15 | +const SWITCHER_NAME = new RegExp(`^(${[en, es, hu].map((strings) => strings.langSwitcher.label).join("|")})$`); |
| 16 | + |
| 17 | +const APP_ROUTES = ["/app", "/app/generate", "/app/attack", "/app/history", "/app/settings"]; |
| 18 | +const PUBLIC_ROUTES = ["/terms", "/privacy", "/tools", "/no-such-page"]; |
| 19 | + |
| 20 | +/** Phones at 375 px, the width the QA report used; desktop keeps the project's 1280. */ |
| 21 | +test.beforeEach(async ({ page }, testInfo) => { |
| 22 | + if (testInfo.project.name === "mobile-chromium") await page.setViewportSize({ width: 375, height: page.viewportSize()!.height }); |
| 23 | +}); |
| 24 | + |
| 25 | +async function chooseLanguage(page: Page, language: keyof typeof LANGUAGE_NAMES) { |
| 26 | + await page.getByRole("button", { name: SWITCHER_NAME }).first().click(); |
| 27 | + await page.getByRole("radiogroup").first().getByRole("radio", { name: LANGUAGE_NAMES[language] }).click(); |
| 28 | + await expect(page.locator("html")).toHaveAttribute("lang", language); |
| 29 | +} |
| 30 | + |
| 31 | +/** The tab title once the page has set its own (the shell's is just "DebateLab"). */ |
| 32 | +async function pageTitle(page: Page, route: string) { |
| 33 | + await page.goto(route); |
| 34 | + await expect.poll(() => page.title()).not.toBe("DebateLab"); |
| 35 | + return page.title(); |
| 36 | +} |
| 37 | + |
| 38 | +test("loc-titles: every route's tab title is its own and follows the interface language", async ({ signedIn: page, evidence }) => { |
| 39 | + const titles: Record<string, Record<string, string>> = {}; |
| 40 | + for (const language of ["en", "hu", "es"] as const) { |
| 41 | + await page.goto(`/app?lang=${language}`); |
| 42 | + await expect(page.locator("html")).toHaveAttribute("lang", language); |
| 43 | + titles[language] = {}; |
| 44 | + for (const route of [...APP_ROUTES, ...PUBLIC_ROUTES]) titles[language][route] = await pageTitle(page, route); |
| 45 | + await evidence.step(`Recorded document.title on ${APP_ROUTES.length + PUBLIC_ROUTES.length} routes in ${language}`); |
| 46 | + } |
| 47 | + await evidence.json("01-document-titles", titles); |
| 48 | + |
| 49 | + for (const route of APP_ROUTES) { |
| 50 | + for (const language of ["en", "hu", "es"]) expect(titles[language][route]).toMatch(/ · DebateLab$/); |
| 51 | + } |
| 52 | + for (const route of PUBLIC_ROUTES) { |
| 53 | + for (const language of ["en", "hu", "es"]) expect(titles[language][route]).toMatch(new RegExp(`\\| ${BRAND}$`)); |
| 54 | + } |
| 55 | + expect(titles.en["/app/generate"]).toBe(`${en.nav.generate} · DebateLab`); |
| 56 | + expect(titles.hu["/app/generate"]).toBe(`${hu.nav.generate} · DebateLab`); |
| 57 | + expect(titles.hu["/no-such-page"]).toBe(hu.pageHead.notFound.title.replace("{{brand}}", BRAND)); |
| 58 | + // Each language names every page in its own words (the brand aside). |
| 59 | + for (const route of [...APP_ROUTES, ...PUBLIC_ROUTES]) { |
| 60 | + expect(titles.hu[route], route).not.toBe(titles.en[route]); |
| 61 | + } |
| 62 | + |
| 63 | + // A switch retitles the page on screen, with no reload. |
| 64 | + await page.goto("/app/attack"); |
| 65 | + await chooseLanguage(page, "en"); |
| 66 | + await expect(page).toHaveTitle(`${en.nav.attack} · DebateLab`); |
| 67 | + await chooseLanguage(page, "hu"); |
| 68 | + await expect(page).toHaveTitle(`${hu.nav.attack} · DebateLab`); |
| 69 | + await evidence.shot("02-attack-title-hu"); |
| 70 | +}); |
| 71 | + |
| 72 | +test("loc-validation: an empty Attack shows its required-field error in the interface language, only after submit", async ({ signedIn: page, evidence }) => { |
| 73 | + await page.goto("/app/attack?lang=hu"); |
| 74 | + const caseText = page.getByRole("textbox", { name: new RegExp(hu.attackForm.argument) }); |
| 75 | + await expect(caseText).toBeVisible(); |
| 76 | + await expect(page.getByText(hu.validation.caseTextRequired)).toHaveCount(0); |
| 77 | + await evidence.shot("01-attack-hu-before-submit"); |
| 78 | + await evidence.aria("01-attack-hu-before-submit", page.getByRole("main")); |
| 79 | + |
| 80 | + await page.getByRole("button", { name: hu.attackForm.submit }).click(); |
| 81 | + await expect(page.getByText(hu.validation.caseTextRequired)).toBeVisible(); |
| 82 | + await expect(page.getByText(en.validation.caseTextRequired)).toHaveCount(0); |
| 83 | + await evidence.step("Submitted an empty Attack in Hungarian"); |
| 84 | + await evidence.shot("02-attack-hu-required"); |
| 85 | + |
| 86 | + await chooseLanguage(page, "es"); |
| 87 | + await expect(page.getByText(es.validation.caseTextRequired)).toBeVisible(); |
| 88 | + await evidence.shot("03-attack-es-required"); |
| 89 | +}); |
| 90 | + |
| 91 | +test("loc-switcher: sign-in and not-found pages carry the language switcher", async ({ page, evidence }) => { |
| 92 | + await page.goto("/login"); |
| 93 | + await expect(page.getByRole("heading", { level: 1, name: en.login.title })).toBeVisible(); |
| 94 | + await evidence.shot("01-login-en"); |
| 95 | + await chooseLanguage(page, "hu"); |
| 96 | + await expect(page.getByRole("heading", { level: 1, name: hu.login.title })).toBeVisible(); |
| 97 | + // "Bejelentkezés a DebateLab-ba" already names the app, so it gets no suffix. |
| 98 | + await expect(page).toHaveTitle(hu.login.title); |
| 99 | + await evidence.shot("02-login-hu"); |
| 100 | + |
| 101 | + await page.goto("/no-such-page"); |
| 102 | + await expect(page.getByRole("heading", { level: 1, name: hu.notFound.title })).toBeVisible(); |
| 103 | + await expect(page).toHaveTitle(hu.pageHead.notFound.title.replace("{{brand}}", BRAND)); |
| 104 | + await evidence.shot("03-not-found-hu"); |
| 105 | + await chooseLanguage(page, "es"); |
| 106 | + await expect(page).toHaveTitle(es.pageHead.notFound.title.replace("{{brand}}", BRAND)); |
| 107 | + await expect(page.getByText(es.notFound.message)).toBeVisible(); |
| 108 | + await evidence.shot("04-not-found-es"); |
| 109 | +}); |
| 110 | + |
| 111 | +test("loc-links: ?lang= and the /es and /hu home pages open the landing page in that language", async ({ page, evidence }) => { |
| 112 | + await page.goto("/?lang=hu"); |
| 113 | + await expect(page.locator("html")).toHaveAttribute("lang", "hu"); |
| 114 | + await expect(page).toHaveTitle(hu.pageHead.home.title.replace("{{brand}}", BRAND)); |
| 115 | + // The choice is kept and the address drops the parameter. |
| 116 | + await expect.poll(() => new URL(page.url()).search).toBe(""); |
| 117 | + await evidence.shot("01-landing-lang-hu"); |
| 118 | + |
| 119 | + await page.goto("/es"); |
| 120 | + await expect.poll(() => new URL(page.url()).pathname).toBe("/"); |
| 121 | + await expect(page.locator("html")).toHaveAttribute("lang", "es"); |
| 122 | + await expect(page).toHaveTitle(es.pageHead.home.title.replace("{{brand}}", BRAND)); |
| 123 | + await evidence.shot("02-landing-es"); |
| 124 | + |
| 125 | + await page.goto("/hu"); |
| 126 | + await expect.poll(() => new URL(page.url()).pathname).toBe("/"); |
| 127 | + await expect(page.locator("html")).toHaveAttribute("lang", "hu"); |
| 128 | + await page.reload(); |
| 129 | + await expect(page.locator("html")).toHaveAttribute("lang", "hu"); |
| 130 | + await evidence.shot("03-landing-hu-after-reload"); |
| 131 | +}); |
| 132 | + |
| 133 | +test("loc-server-error: a refused promo code reads in the interface language", async ({ signedIn: page, user, evidence }) => { |
| 134 | + const userId = await user.userId(); |
| 135 | + // The promo field shows for an account that would start a Checkout. |
| 136 | + await admin.from("user_settings") |
| 137 | + .update({ subscription_tier: "expired", trial_ends_at: null, grace_period_ends_at: null, daily_limit_override: null }) |
| 138 | + .eq("user_id", userId); |
| 139 | + await page.goto("/app/settings?lang=hu"); |
| 140 | + await page.getByRole("button", { name: hu.billing.upgradeToPro }).first().click(); |
| 141 | + await page.getByRole("button", { name: hu.pricingModal.promoCode }).click(); |
| 142 | + await page.getByPlaceholder(hu.pricingModal.promoPlaceholder).fill(`NOPE${randomUUID().slice(0, 6).toUpperCase()}`); |
| 143 | + const answer = page.waitForResponse((response) => response.url().includes("/functions/v1/redeem-promo")); |
| 144 | + await page.getByRole("button", { name: hu.pricingModal.apply, exact: true }).click(); |
| 145 | + const body = await (await answer).json(); |
| 146 | + await evidence.json("01-redeem-promo-answer", body); |
| 147 | + // The server still sends English beside the code; the screen shows the translation. |
| 148 | + expect(body).toMatchObject({ success: false, code: "promo_invalid", message: en.serverErrors.promoInvalid }); |
| 149 | + await expect(page.getByText(hu.serverErrors.promoInvalid).first()).toBeVisible(); |
| 150 | + await expect(page.getByText(en.serverErrors.promoInvalid)).toHaveCount(0); |
| 151 | + await evidence.shot("02-promo-refused-hu"); |
| 152 | +}); |
0 commit comments