import { test, expect, type Page } from '@playwright/test'; import path from 'node:path'; import { fileURLToPath } from 'node:url'; import fs from 'node:fs'; const __dirname = path.dirname(fileURLToPath(import.meta.url)); const ARTIFACTS = path.resolve(__dirname, 'artifacts'); const shot = async (page: Page, name: string) => { fs.mkdirSync(ARTIFACTS, { recursive: true }); await page.screenshot({ path: path.join(ARTIFACTS, name), fullPage: true, }); }; /** * WCAG contrast helpers (in-page evaluate). Binary observable: ratio < 4.5:1 fails. * Matches visual-qa color check patterns for luminance/contrast. */ async function assertReadableContrast( page: Page, selectors: string[], label: string ): Promise { const minRatio = await page.evaluate((sels: string[]) => { function luminance(r: number, g: number, b: number): number { const a = [r, g, b].map((v) => { v /= 255; return v <= 0.03928 ? v / 12.92 : Math.pow((v + 0.055) / 1.055, 2.4); }); return a[0] * 0.2126 + a[1] * 0.7152 + a[2] * 0.0722; } function contrastRatio(c1: number[], c2: number[]): number { const l1 = luminance(c1[0], c1[1], c1[2]); const l2 = luminance(c2[0], c2[1], c2[2]); const hi = Math.max(l1, l2); const lo = Math.min(l1, l2); return (hi + 0.05) / (lo + 0.05); } function parse(c: string): [number, number, number] { const m = c.match(/rgba?\((\d+),\s*(\d+),\s*(\d+)/i); return m ? [parseInt(m[1], 10), parseInt(m[2], 10), parseInt(m[3], 10)] : [255, 255, 255]; } let minR = 21; for (const sel of sels) { const els = document.querySelectorAll(sel); for (const el of Array.from(els)) { if (!(el instanceof HTMLElement)) continue; const fg = parse(window.getComputedStyle(el).color); // collect ALL non-transparent bgs in ancestor chain; pick darkest (min lum) as the surface const bgs: [number, number, number][] = []; let p: HTMLElement | null = el; while (p) { const pst = window.getComputedStyle(p); const bstr = pst.backgroundColor; if (!bstr.includes('0)')) { const b = parse(bstr); // avoid pure black default if transparent resolved if (!(b[0] === 0 && b[1] === 0 && b[2] === 0)) { bgs.push(b); } } p = p.parentElement; } // also include body/html const bodyB = parse(window.getComputedStyle(document.body).backgroundColor); if (!(bodyB[0] === 0 && bodyB[1] === 0 && bodyB[2] === 0)) bgs.push(bodyB); const htmlB = parse(window.getComputedStyle(document.documentElement).backgroundColor); if (!(htmlB[0] === 0 && htmlB[1] === 0 && htmlB[2] === 0)) bgs.push(htmlB); let surface = [255, 255, 255] as [number, number, number]; let minLum = 1; for (const b of bgs) { const l = luminance(b[0], b[1], b[2]); if (l < minLum) { minLum = l; surface = b; } } const r = contrastRatio(fg, surface); if (r < minR) minR = r; } } return minR; }, selectors); expect(minRatio, `contrast < 4.5:1 (${label})`).toBeGreaterThanOrEqual(4.5); } test.describe('Settings contrast (Gitea bug #75 black-on-black)', () => { test('Happy: Settings form labels/inputs/selects readable (contrast ≥4.5:1)', async ({ page }) => { await page.goto('/'); await page.getByRole('button', { name: 'Settings (dev)' }).click(); await shot(page, 'settings-happy.png'); // labels, inputs, selects on the form await assertReadableContrast(page, ['label', 'input', 'select'], 'happy-settings-form'); }); test('Edge: