SHA256
feat(android): align native preview with measured PWA design
This commit is contained in:
1 parent
bd0e4cc4fb
commit
7ac9882307
30 files changed
+2793
-750
No files matched your search
@@ -0,0 +1,105 @@
|
||||
// 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();
|
||||
}
|
||||
Reference in new issue
Block a user