// Browser automation is a reference-measurement tool, never an application renderer. import fs from 'node:fs'; import path from 'node:path'; import { createRequire } from 'node:module'; const [playwrightModule, destination] = process.argv.slice(2); const require = createRequire(import.meta.url); const { chromium } = require(playwrightModule); const origin = process.env.YUVOMI_TEST_URL; if (!/^https:\/\/127\.0\.0\.1:\d+$/.test(origin)) throw new Error('Only the temporary loopback fixture is allowed'); const browser = await chromium.launch({ headless: true, args: process.getuid?.() === 0 ? ['--no-sandbox'] : [] }); const width = Number(process.env.YUVOMI_VISUAL_WIDTH || 360); const height = Number(process.env.YUVOMI_VISUAL_HEIGHT || 640); const measurements = { upstreamRevision: '10bfef7686ed7303b4c74c7fcc55a5eb16356c9d', browser: browser.version(), androidFontConfiguration: Boolean(process.env.FONTCONFIG_FILE), fixture: { widgets: ['tasks', 'calendar', 'shopping', 'notes'], mobileFavorites: ['tasks', 'kitchen', 'notes'] }, screens: [], }; try { const context = await browser.newContext({ viewport: { width, height }, deviceScaleFactor: 2, isMobile: true, hasTouch: true, reducedMotion: 'reduce', locale: 'de-DE', timezoneId: 'Europe/Berlin', ignoreHTTPSErrors: true, userAgent: 'Mozilla/5.0 (Linux; Android 15) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/140.0.0.0 Mobile Safari/537.36', serviceWorkers: 'block', }); await context.route('**/*', route => new URL(route.request().url()).origin === origin ? route.continue() : route.abort()); const api = context.request; const password = process.env.YUVOMI_TEST_PASSWORD; let csrf = ''; async function request(route, data) { const response = await api.fetch(origin + '/api/v1' + route, { method: data === undefined ? 'GET' : (route === '/preferences' ? 'PUT' : 'POST'), data, headers: { 'X-CSRF-Token': csrf }, }); if (!response.ok()) throw new Error(`Fixture API failed at ${route}: HTTP ${response.status()}`); const result = await response.json(); if (result.csrfToken) csrf = result.csrfToken; return result; } await request('/auth/setup', { username: 'mobiletest', display_name: 'Mobile Test', password }); await request('/auth/login', { username: 'mobiletest', password }); await request('/auth/onboarding-seen', {}); await request('/auth/changelog-seen', {}); const today = new Intl.DateTimeFormat('sv-SE', { timeZone: 'Europe/Berlin' }).format(new Date()); await request('/tasks', { title: 'Gemeinsam den Alltag planen', priority: 'high', due_date: today }); await request('/notes', { title: 'Willkommen zuhause', content: 'Native Oberfläche, eigener Server.', pinned: 1 }); const list = await request('/shopping', { name: 'Wochenmarkt' }); await request(`/shopping/${list.data.id}/items`, { name: 'Äpfel', quantity: '4' }); // Match the implemented native slice, not a fabricated full-module acceptance. await request('/preferences', { dashboard_widgets: ['tasks', 'calendar', 'shopping', 'notes'].map((id, order) => ({ id, visible: true, order, size: '1x1' })), dashboard_today_glance: false, mobile_nav_order: ['tasks', 'kitchen', 'notes'], timezone: 'Europe/Berlin', timezone_hint_dismissed: true, }); await request('/auth/logout', {}); const page = await context.newPage(); await page.addInitScript(() => { localStorage.setItem('yuvomi-language', 'de'); localStorage.setItem('yuvomi-lang', 'de'); }); const selectors = ['.auth-page', '.auth-hero', '.auth-hero__mark', '.auth-hero__title', '.auth-hero__tagline', '.label', '.input', '.btn--primary', '.dashboard-overview__date', '.dashboard-overview__title', '.widget', '.widget__header', '.widget__title', '.widget__body', '.nav-bottom__items', '.nav-bottom .nav-item', '.page-toolbar', '.page-toolbar__title', '.task-card', '.task-card__title', '.note-card', '.list-row']; async function capture(name) { await page.evaluate(() => document.fonts.ready); // Compare neutral states, not a keyboard focus ring left by fixture navigation. await page.evaluate(() => document.activeElement?.blur?.()); await page.evaluate(() => new Promise(resolve => requestAnimationFrame(() => requestAnimationFrame(resolve)))); await page.screenshot({ path: path.join(destination, `${name}.png`), animations: 'disabled' }); measurements.screens.push({ name, viewport: page.viewportSize(), elements: await page.evaluate(selectors => selectors.flatMap(selector => [...document.querySelectorAll(selector)].slice(0, 5).map(element => { const bounds = element.getBoundingClientRect(); const style = getComputedStyle(element); return { selector, text: element.matches('input') ? '' : element.textContent.trim().slice(0, 80), x: bounds.x, y: bounds.y, width: bounds.width, height: bounds.height, font: style.fontFamily, fontSize: style.fontSize, fontWeight: style.fontWeight, lineHeight: style.lineHeight, letterSpacing: style.letterSpacing, color: style.color, background: style.backgroundColor, radius: style.borderRadius, padding: style.padding }; })), selectors) }); } for (const theme of ['light', 'dark']) { await page.emulateMedia({ colorScheme: theme, reducedMotion: 'reduce' }); await page.goto(origin + '/login'); await page.evaluate(value => localStorage.setItem('yuvomi-theme', value), theme); await page.reload(); await page.locator('#auth-btn').waitFor(); await capture(`login-${theme}-${width}`); await page.locator('#username').fill('mobiletest'); await page.locator('#password').fill(password); await page.locator('#auth-btn').click(); await page.locator('.dashboard-overview__title').waitFor(); await page.locator('.widget--tasks .widget__body').waitFor(); await capture(`dashboard-${theme}-${width}`); for (const [route, selector, name] of [['/tasks', '.task-card', 'tasks'], ['/notes', '.note-card', 'notes'], ['/shopping', '.shopping-item', 'shopping']]) { await page.goto(origin + route); await page.locator(selector).first().waitFor(); await capture(`${name}-${theme}-${width}`); } await request('/auth/me'); await request('/auth/logout', {}); } await context.close(); } finally { fs.writeFileSync(path.join(destination, 'measurements.json'), JSON.stringify(measurements, null, 2) + '\n'); await browser.close(); }