mirror of
https://github.com/bulwarkmail/webmail.git
synced 2026-11-04 16:14:07 +00:00
test: check that no text is cut off at common window sizes and zoom levels
This commit is contained in:
@@ -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<string, string> {
|
||||
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<Page> {
|
||||
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<void> {
|
||||
await page.waitForLoadState('networkidle', { timeout: 5000 }).catch(() => {});
|
||||
await page.waitForTimeout(800);
|
||||
}
|
||||
|
||||
async function check(page: Page, scenario: Scenario, screen: string, found: Map<string, CutOff[]>): Promise<void> {
|
||||
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, CutOff[]>): 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<string, CutOff[]>();
|
||||
|
||||
// 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());
|
||||
});
|
||||
}
|
||||
@@ -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<CutOff[]> {
|
||||
return page.evaluate(() => {
|
||||
const out: { where: string; text: string; px: number }[] = [];
|
||||
const seen = new Set<Element>();
|
||||
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;
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user