diff --git a/integration/tests/25-do-layout-fit.spec.ts b/integration/tests/25-do-layout-fit.spec.ts new file mode 100644 index 000000000..77ec19577 --- /dev/null +++ b/integration/tests/25-do-layout-fit.spec.ts @@ -0,0 +1,149 @@ +/** + * Nothing cut off at common window sizes: the main screens at 1280×720 and 1366×768, + * and at 125 % and 150 % zoom (the same windows in CSS pixels), in English, German + * (long words) and Arabic (right to left). Each screen fails on a line of text that + * runs past the edge of the box it sits in or hides behind a scroll bar + * (helpers/layout-fit.ts). The message details panel also has to give the sender's + * address room and show its dates on one line. + * + * Runs as dso-menus@example.org on Stalwart 1.0: check or create the account on the + * VM first (~/desktop-offline/dso_menus_provision.py stw-v1, then + * dso_menus_authmail.py stw-v1 for the message with long sign-in results), then + * desktop-offline-rig/run-spec.sh with IT_DSO_PASSWORD from the vault item + * "Stalwart test – dso-* throwaway accounts". Reads only; changes nothing. + * IT_LAYOUT_SHOTS: a folder for a screenshot of each screen. IT_LAYOUT_SCREENS=mail: + * the mail screens only. + */ +import { test, expect, type Browser, type Page } from '@playwright/test'; +import { existsSync, mkdirSync, readFileSync } from 'node:fs'; +import path from 'node:path'; +import { requireEntEnv } from './helpers/ent'; +import { accountSwitcher, neutralizeDevOverlay } from './helpers/app'; +import { WEBMAIL_URL } from './helpers/config'; +import { findCutOffText, type CutOff } from './helpers/layout-fit'; + +requireEntEnv(); +test.setTimeout(600_000); + +const USER = 'dso-menus@example.org'; +const PASSWORD = process.env.IT_DSO_PASSWORD ?? ''; +if (!PASSWORD) throw new Error('Set IT_DSO_PASSWORD, and check the account on the VM first: see the top of this file'); +const AUTH_SUBJECT = 'Your weekly account summary is ready for review'; +const SHOTS = process.env.IT_LAYOUT_SHOTS ?? ''; +const MAIL_ONLY = process.env.IT_LAYOUT_SCREENS === 'mail'; + +const SETTINGS_TABS = ['account', 'security', 'notifications', 'general', 'appearance', 'accessibility', 'reading', + 'composing', 'organize', 'filters', 'vacation', 'calendar', 'contacts', 'files', 'backup']; + +interface Scenario { name: string; locale: string; language: string; width: number; height: number } + +const SCENARIOS: Scenario[] = [ + { name: '1280x720-en', locale: 'en-US', language: 'en', width: 1280, height: 720 }, + { name: '1366x768-de', locale: 'de-DE', language: 'de', width: 1366, height: 768 }, + // 1280×720 at 125 % and 1366×768 at 150 %: the same window in CSS pixels. + { name: '1280x720-125-de', locale: 'de-DE', language: 'de', width: 1024, height: 576 }, + { name: '1366x768-150-de', locale: 'de-DE', language: 'de', width: 911, height: 512 }, + { name: '1280x720-ar', locale: 'ar', language: 'ar', width: 1280, height: 720 }, +]; + +function viewerText(language: string): Record { + const file = path.join(__dirname, '..', '..', 'locales', language, 'common.json'); + const viewer = JSON.parse(readFileSync(file, 'utf-8')).email_viewer; + return { showDetails: viewer.show_details, routing: viewer.details.recipients_routing }; +} + +async function signIn(browser: Browser, scenario: Scenario): Promise { + const context = await browser.newContext({ + baseURL: WEBMAIL_URL, + locale: scenario.locale, + viewport: { width: scenario.width, height: scenario.height }, + }); + const page = await context.newPage(); + await neutralizeDevOverlay(page); + await page.goto('/'); + await page.fill('#username', USER); + await page.fill('#password', PASSWORD); + await page.click('button[type="submit"]'); + await accountSwitcher(page).waitFor({ state: 'visible', timeout: 60000 }); + return page; +} + +async function settle(page: Page): Promise { + await page.waitForLoadState('networkidle', { timeout: 5000 }).catch(() => {}); + await page.waitForTimeout(800); +} + +async function check(page: Page, scenario: Scenario, screen: string, found: Map): Promise { + await settle(page); + if (SHOTS) { + if (!existsSync(SHOTS)) mkdirSync(SHOTS, { recursive: true }); + await page.screenshot({ path: path.join(SHOTS, `${screen}-${scenario.name}.png`) }); + } + const cut = await findCutOffText(page); + if (cut.length) found.set(screen, cut); +} + +function report(found: Map): string { + return [...found].map(([screen, list]) => `${screen}:\n${list.map((c) => ` ${c.px}px ${c.where} "${c.text}"`).join('\n')}`).join('\n'); +} + +for (const scenario of SCENARIOS) { + test(`nothing is cut off at ${scenario.name}`, async ({ browser }) => { + const page = await signIn(browser, scenario); + const text = viewerText(scenario.language); + const found = new Map(); + + // Mail: the list, a message with long sign-in results and its details open. + const item = page.locator('[data-testid="email-list-item"]', { hasText: AUTH_SUBJECT }).first(); + await item.waitFor({ state: 'visible', timeout: 60000 }); + await check(page, scenario, 'mail-list', found); + await item.click(); + await page.getByRole('button', { name: text.showDetails }).first().click(); + const routing = page.locator('section', { has: page.getByText(text.routing, { exact: true }) }).first(); + await expect(routing).toBeVisible(); + await check(page, scenario, 'mail-details', found); + + // The sender's address gets a column of its own width, not a sliver, and a date + // takes one line. Rows: From, To, Sent, Received. + const fit = await routing.evaluate((section) => { + const values = Array.from(section.querySelectorAll('dl > dd')); + const from = values[0].getBoundingClientRect(); + const sent = values[values.length - 2]; + const lineHeight = parseFloat(getComputedStyle(sent).lineHeight) || 20; + return { fromWidth: Math.round(from.width), sentLines: Math.round(sent.getBoundingClientRect().height / lineHeight) }; + }); + expect.soft(fit.fromWidth, 'width of the From value in the details panel').toBeGreaterThanOrEqual(220); + expect.soft(fit.sentLines, 'lines the Sent date takes in the details panel').toBeLessThanOrEqual(1); + + if (scenario.language === 'ar' || MAIL_ONLY) { + expect(found, report(found)).toEqual(new Map()); + return; + } + + // Composer. + await page.keyboard.press('Escape'); + await page.keyboard.press('c'); + await page.locator('[data-testid="email-composer"]').waitFor({ state: 'visible', timeout: 15000 }); + await check(page, scenario, 'composer', found); + await page.keyboard.press('Escape'); + + // Calendar, contacts, files. + await page.goto('/calendar'); + await page.waitForTimeout(1500); + await page.keyboard.press('w'); + await check(page, scenario, 'calendar-week', found); + await page.keyboard.press('m'); + await check(page, scenario, 'calendar-month', found); + await page.goto('/contacts'); + await check(page, scenario, 'contacts', found); + await page.goto('/files'); + await check(page, scenario, 'files', found); + + for (const tab of SETTINGS_TABS) { + await page.goto(`/settings/${tab}`); + await check(page, scenario, `settings-${tab}`, found); + } + + expect(found, report(found)).toEqual(new Map()); + }); +} diff --git a/integration/tests/helpers/layout-fit.ts b/integration/tests/helpers/layout-fit.ts new file mode 100644 index 000000000..127b2d785 --- /dev/null +++ b/integration/tests/helpers/layout-fit.ts @@ -0,0 +1,80 @@ +/** + * Finds text that's cut off at the side: a line of text that runs past the edge of + * the nearest box that clips it (overflow other than visible), or past the window + * when nothing clips it. A box's vertical scroll bar counts as its edge, so text + * hidden behind a scroll bar is found too. + * + * Not counted: text that's meant to be cut (text-overflow: ellipsis, line clamp), + * text inside a box that scrolls only sideways, text that's wholly outside its box (a + * closed drawer, a slide), text in a box 1-2px wide (visually hidden), screen-reader-only text, and anything marked + * data-layout-fit-ignore. Frames (a message body) aren't looked into. + */ +import type { Page } from '@playwright/test'; + +export interface CutOff { + /** Tag, test id and a few classes of the element holding the text. */ + where: string; + text: string; + /** How many CSS pixels are cut off. */ + px: number; +} + +export async function findCutOffText(page: Page): Promise { + return page.evaluate(() => { + const out: { where: string; text: string; px: number }[] = []; + const seen = new Set(); + const viewport = { left: 0, right: document.documentElement.clientWidth }; + const clipsX = (s: CSSStyleDeclaration) => s.overflowX !== 'visible'; + // A sideways scroller on purpose: one that scrolls sideways only. A box that + // scrolls up and down computes overflow-x: auto as well, and text running off + // its side is still cut off for anyone who doesn't think to scroll sideways. + const scrollsX = (s: CSSStyleDeclaration) => + (s.overflowX === 'auto' || s.overflowX === 'scroll') && s.overflowY !== 'auto' && s.overflowY !== 'scroll'; + const describe = (el: Element) => { + const id = el.getAttribute('data-testid'); + const cls = (el.getAttribute('class') ?? '').split(/\s+/).filter(Boolean).slice(0, 4).join('.'); + return `${el.tagName.toLowerCase()}${id ? `[data-testid=${id}]` : ''}${cls ? `.${cls}` : ''}`; + }; + const walker = document.createTreeWalker(document.body, NodeFilter.SHOW_TEXT); + for (let node = walker.nextNode(); node; node = walker.nextNode()) { + const text = (node.textContent ?? '').replace(/\s+/g, ' ').trim(); + const el = node.parentElement; + if (!text || !el || seen.has(el)) continue; + if (el.closest('script, style, noscript, [aria-hidden="true"], .sr-only, [data-layout-fit-ignore]')) continue; + const own = getComputedStyle(el); + if (own.visibility === 'hidden' || own.display === 'none' || Number(own.opacity) === 0) continue; + const range = document.createRange(); + range.selectNodeContents(node); + const rects = Array.from(range.getClientRects()).filter((r) => r.width > 0.5 && r.height > 0.5); + if (rects.length === 0) continue; + const textLeft = Math.min(...rects.map((r) => r.left)); + const textRight = Math.max(...rects.map((r) => r.right)); + + let meant = false; + let box: { left: number; right: number } | null = null; + for (let at: Element | null = el; at && at !== document.documentElement; at = at.parentElement) { + const s = getComputedStyle(at); + if (s.textOverflow === 'ellipsis' || (s.webkitLineClamp && s.webkitLineClamp !== 'none')) meant = true; + if (Number(s.opacity) === 0 || s.visibility === 'hidden') { meant = true; break; } + if (!clipsX(s)) continue; + if (scrollsX(s)) { meant = true; break; } + // A 1px box is the visually-hidden pattern: read out, not shown. + if (at.clientWidth <= 2) { meant = true; break; } + const r = at.getBoundingClientRect(); + const left = r.left + at.clientLeft; + box = { left, right: left + at.clientWidth }; + break; + } + if (meant) continue; + const edge = box ?? viewport; + // Wholly outside: not cut, put away. + if (textRight <= edge.left || textLeft >= edge.right) continue; + const px = Math.max(textRight - edge.right, edge.left - textLeft); + if (px > 1.5) { + seen.add(el); + out.push({ where: describe(el), text: text.slice(0, 60), px: Math.round(px) }); + } + } + return out; + }); +}