Files
HealthLog/e2e/setup-flow-locale-overflow.spec.ts
Marc-André Bombeck 764794496c feat(i18n): register Brazilian Portuguese as a locale
Add pt-BR to the locale list and everything that hangs off it: the
client loader, Intl and date-fns mapping, the reply-language footers of
the Coach and insights prompts (English body, pt-BR footer, the same
path Korean and Dutch take), the citation-coverage normative words, the
no-key fallback copy, the AUDIT-C standard-drink line, country labels,
period search, CSV yes/no cells and the Telegram bare-word aliases.

The locale enums in the extract, managed-profile and record-settings
schemas now derive from the locale list instead of repeating it, and
the five notification and login-alert routes share one isLocale guard
instead of hand-rolled chains, so a later locale is one edit there.
A language tag is matched exactly and case-insensitively before its
primary subtag, so pt-BR and pt-PT browsers land on the Portuguese
bundle rather than English, and AUDIT-C no longer cuts a region tag to
two letters.

PHQ-9, GAD-7 and WHO-5 are not marked validated for pt-BR: the wording
has not been read against the published Brazilian forms, so readers get
the English items plus the existing note.
2026-10-22 14:50:00 +02:00

250 lines
9.5 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* v1.39 — every setup screen, in every locale, with 30% longer strings.
*
* The design spec asks for 30–50% string headroom in every layout, and the
* seven bundles are the floor, not the ceiling: a German label that fits
* today is one revision from not fitting. So this spec does what
* `locale-switch.spec.ts` does (set the locale cookie, render, refuse raw
* keys) and then what `dialog-horizontal-overflow.spec.ts` does (measure
* real overflow in the page), with one step between them: every text node
* on the screen is lengthened by 30% in place before the measurement, so
* the check is against the headroom rather than against today's copy.
*
* Three claims per screen, locale and width: the page does not scroll
* sideways, nothing that clips its text (`overflow: hidden`) is actually
* clipping, and every control sits inside the viewport. The state is driven
* through the API once per test and every screen is visited by URL, which
* the step machine allows for a flow that reached the end.
*/
import type { Page } from "@playwright/test";
import {
E2E_SETUP_LOCALE,
SETUP_LOCALE_STORAGE_STATE_PATH,
} from "./setup/global-setup";
import { resetSetupFlow } from "./setup/setup-flow-fixture";
import { expect, test } from "./setup/test";
import {
answer,
answerEverything,
complete,
expectScreen,
SHELL,
type SetupScreen,
} from "./setup-flow-helpers";
const LOCALES = [
"en",
"de",
"es",
"fr",
"it",
"pl",
"ko",
"nl",
"pt-BR",
] as const;
const VIEWPORTS = [
{ label: "desktop 1280", width: 1280, height: 800 },
{ label: "phone 390", width: 390, height: 844 },
];
const SCREENS: Array<{ screen: SetupScreen; path: string }> = [
{ screen: "who", path: "/onboarding/who" },
{ screen: "areas", path: "/onboarding/areas" },
{ screen: "medication", path: "/onboarding/medication" },
{ screen: "sources", path: "/onboarding/sources" },
{ screen: "visit", path: "/onboarding/visit" },
{ screen: "units", path: "/onboarding/units" },
{ screen: "confirm", path: "/onboarding/confirm" },
{ screen: "first-result", path: "/onboarding/first-result" },
{ screen: "done", path: "/onboarding/done" },
];
/**
* A rendered i18n key is a lookup that fell through. The flow's keys are
* three or more dotted segments (`onboarding.flow.who.options.me`,
* `onboarding.steps.first-result`), lowercase and kebab as often as camel —
* so the test is the dotted shape itself, not a camelCase segment inside it
* (which the locale-switch smoke test requires and which most of these keys
* do not have). No sentence on these screens carries such a run.
*/
const RAW_KEY = /\b[a-z]+(?:\.[a-zA-Z][\w-]*){2,}\b/;
/**
* Lengthen every text node under the shell by 30%, then measure. Padding
* lengthens every WORD by 30%, so the longest unbreakable run grows in the
* same proportion as the sentence — which is what a longer translation does.
* Appending the whole surplus to one word would manufacture an unbreakable
* run no locale produces; adding only spaces would wrap for free and prove
* nothing.
*/
async function padAndMeasure(page: Page) {
return page.evaluate((shellSelector) => {
const shell = document.querySelector<HTMLElement>(shellSelector);
if (!shell) return null;
const walker = document.createTreeWalker(shell, NodeFilter.SHOW_TEXT);
let node: Node | null = walker.nextNode();
let padded = 0;
while (node) {
const text = node.textContent ?? "";
const letters = text.replace(/\s+/g, "").length;
if (
letters >= 3 &&
node.parentElement?.closest("script,style") === null
) {
node.textContent = text.replace(/\S+/g, (word) =>
word.length >= 2
? word + "x".repeat(Math.ceil(word.length * 0.3))
: word,
);
padded += 1;
}
node = walker.nextNode();
}
const doc = document.documentElement;
const clippers = [...shell.querySelectorAll<HTMLElement>("*")]
.filter((el) => {
// Screen-reader-only text is a 1 px clipped box on purpose; it is
// read, not seen, and cannot truncate.
if (el.classList.contains("sr-only") || el.clientWidth <= 1) {
return false;
}
// A select's value ellipsises by design (shadcn `SelectValue`, app
// wide): the full text is the option in the list and the control's
// accessible name, and a trigger that grew with its longest option
// would move every neighbour. Not a layout that lost headroom.
if (el.dataset.slot === "select-value") return false;
const overflowX = getComputedStyle(el).overflowX;
return overflowX === "hidden" || overflowX === "clip";
})
.filter((el) => el.scrollWidth > el.clientWidth + 1)
.map((el) => ({
slot: el.dataset.slot ?? el.tagName.toLowerCase(),
text: (el.textContent ?? "").trim().slice(0, 40),
scrollWidth: el.scrollWidth,
clientWidth: el.clientWidth,
}));
/**
* A name for the box that overflowed, so a red run says WHAT rather than
* only that the page grew: tag, `data-slot`, the first classes, the text
* it starts with, and its edges. The CI runner's fallback fonts render a
* pixel or two wider than a Mac's, so the culprit is often invisible
* locally and the message is the whole diagnosis.
*/
const describe = (el: HTMLElement, r: DOMRect) => ({
tag: el.tagName.toLowerCase(),
slot: el.dataset.slot ?? null,
classes: el.className.toString().split(/\s+/).slice(0, 4).join(" "),
text: (el.textContent ?? "").trim().slice(0, 40),
left: Math.round(r.left),
right: Math.round(r.right),
width: Math.round(r.width),
});
const shellRight = shell.getBoundingClientRect().right;
// Every box that reaches past the shell's own right edge, widest first,
// deepest first among equals (the leaf is the culprit, its ancestors
// merely grew with it).
const widest = [...shell.querySelectorAll<HTMLElement>("*")]
.map((el) => ({ el, r: el.getBoundingClientRect() }))
.filter(({ r }) => r.width > 0 && r.right > shellRight + 1)
.sort(
(a, b) =>
b.r.right - a.r.right || b.el.children.length - a.el.children.length,
)
.reverse()
.slice(0, 6)
.map(({ el, r }) => describe(el, r));
const outside = [
...shell.querySelectorAll<HTMLElement>(
"button, input, select, textarea, a[href], [tabindex]:not([tabindex='-1'])",
),
]
.map((el) => ({ el, r: el.getBoundingClientRect() }))
.filter(({ r }) => r.width > 0 && r.height > 0)
.filter(({ r }) => r.right > window.innerWidth + 1 || r.left < -1)
.map(({ el, r }) => describe(el, r));
return {
padded,
pageScrollWidth: doc.scrollWidth,
pageClientWidth: doc.clientWidth,
shellScrollWidth: shell.scrollWidth,
shellClientWidth: shell.clientWidth,
widest,
clippers,
outside,
};
}, SHELL);
}
// Serial: every test here drives the ONE account through the API, and two
// workers doing that at once would each find the other's answers.
test.describe.configure({ mode: "serial" });
test.describe("setup flow — every locale at 30% longer strings", () => {
test.use({ storageState: SETUP_LOCALE_STORAGE_STATE_PATH });
for (const locale of LOCALES) {
for (const vp of VIEWPORTS) {
test(`${locale} fits at ${vp.label}`, async ({ page, context }) => {
test.setTimeout(180_000);
await resetSetupFlow(E2E_SETUP_LOCALE.username);
await answerEverything(page);
await answer(page, {
step: "units",
units: { glucoseUnit: "mg/dL", unitPreference: "metric" },
});
await complete(page);
await answer(page, { step: "first-result", status: "skipped" });
const baseURL =
test.info().project.use.baseURL ?? "http://localhost:3000";
await context.addCookies([
{ name: "healthlog-locale", value: locale, url: baseURL },
]);
await page.setViewportSize({ width: vp.width, height: vp.height });
for (const item of SCREENS) {
await page.goto(item.path, { waitUntil: "domcontentloaded" });
await expectScreen(page, item.screen);
await expect(page.locator("h1")).toBeVisible();
// No raw key before the padding touches the text.
const body = await page.locator(SHELL).innerText();
const match = body.match(RAW_KEY);
expect(
match?.[0] ?? null,
`raw i18n key on ${item.screen} in ${locale}`,
).toBeNull();
const result = await padAndMeasure(page);
const label = `${item.screen} ${locale} @${vp.label}`;
expect(result, `${label}: shell not found`).not.toBeNull();
expect(
result!.padded,
`${label}: nothing was padded, so this measured nothing`,
).toBeGreaterThan(3);
const culprits = JSON.stringify(result!.widest);
expect(
result!.pageScrollWidth,
`${label}: the page scrolls sideways; past the shell's edge: ${culprits}`,
).toBeLessThanOrEqual(result!.pageClientWidth + 1);
expect(
result!.shellScrollWidth,
`${label}: the shell scrolls sideways; past the shell's edge: ${culprits}`,
).toBeLessThanOrEqual(result!.shellClientWidth + 1);
expect(result!.clippers, `${label}: text is clipped`).toEqual([]);
expect(
result!.outside,
`${label}: control(s) outside the viewport`,
).toEqual([]);
}
});
}
}
});