feat(android): align native preview with measured PWA design
Android validation / emulator (37.0) (push) Failing after 3s
Android validation / emulator (35) (push) Failing after 4s
iOS validation / simulator (push) Successful in 59s
Android validation / build (push) Successful in 5m30s

This commit is contained in:
Yuvomi Mobile Contributors committed 2026-10-21 10:45:16 +02:00
1 parent bd0e4cc4fb
commit 7ac9882307
30 files changed
+2793 -750

No files matched your search

+105
View File
@@ -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();
}
+65
View File
@@ -0,0 +1,65 @@
#!/usr/bin/env python3
"""Capture the unmodified pinned PWA with synthetic data, never a user's server."""
import argparse
import json
import os
from pathlib import Path
import secrets
import subprocess
import tempfile
import time
ROOT = Path(__file__).resolve().parents[1]
REVISION = "10bfef7686ed7303b4c74c7fcc55a5eb16356c9d"
def run(upstream, playwright_module, destination, width=360, height=640):
revision = subprocess.check_output(["git", "-C", str(upstream), "rev-parse", "HEAD"], text=True).strip()
assert revision == REVISION, "Visual reference must use pinned unmodified upstream"
assert not subprocess.check_output(["git", "-C", str(upstream), "diff", "HEAD", "--name-only"], text=True).strip()
destination.mkdir(parents=True, exist_ok=True)
base = "/tmp/opencode" if Path("/tmp/opencode").exists() else None
with tempfile.TemporaryDirectory(prefix="yuvomi-visual-", dir=base) as temporary:
directory = Path(temporary)
subprocess.run([
"openssl", "req", "-x509", "-newkey", "rsa:2048", "-nodes", "-days", "1",
"-keyout", str(directory / "key.pem"), "-out", str(directory / "cert.pem"),
"-subj", "/CN=Yuvomi visual fixture",
"-addext", "subjectAltName=IP:127.0.0.1,DNS:localhost",
], check=True, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
env = {**os.environ, "NODE_EXTRA_CA_CERTS": str(directory / "cert.pem"),
"YUVOMI_TEST_DISABLE_OIDC": "true", "YUVOMI_TEST_PASSWORD": secrets.token_urlsafe(24),
"YUVOMI_VISUAL_WIDTH": str(width), "YUVOMI_VISUAL_HEIGHT": str(height)}
with (directory / "server.log").open("w") as log:
server = subprocess.Popen(["node", str(ROOT / "scripts/integration-server.mjs"), str(upstream), temporary],
cwd=upstream, env=env, stdout=log, stderr=log)
try:
for _ in range(200):
if (directory / "ready.json").exists():
break
if server.poll() is not None:
raise RuntimeError("Visual fixture failed to start; no private logs exported")
time.sleep(0.1)
ready = json.loads((directory / "ready.json").read_text())
env["YUVOMI_TEST_URL"] = f"https://127.0.0.1:{ready['port']}"
subprocess.run(["node", str(ROOT / "scripts/capture-pwa-reference.mjs"),
str(playwright_module), str(destination)], env=env, check=True)
finally:
server.terminate()
try:
server.wait(timeout=10)
except subprocess.TimeoutExpired:
server.kill()
server.wait()
if __name__ == "__main__":
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("upstream", type=Path)
parser.add_argument("--playwright-module", type=Path, required=True)
parser.add_argument("--output", type=Path, default=ROOT / "artifacts/visual/pwa")
parser.add_argument("--width", type=int, default=360)
parser.add_argument("--height", type=int, default=640)
args = parser.parse_args()
assert 320 <= args.width <= 1600 and 320 <= args.height <= 1800, "Invalid reference viewport"
run(args.upstream.resolve(), args.playwright_module.resolve(), args.output.resolve(), args.width, args.height)
+53
View File
@@ -0,0 +1,53 @@
// Development-only side-by-side evidence. This deliberately asserts no pixel parity.
import fs from 'node:fs';
import path from 'node:path';
import { createRequire } from 'node:module';
import { createHash } from 'node:crypto';
const [modulePath, nativeDirectory, pwaDirectory, destination] = process.argv.slice(2);
if (!destination) throw new Error('Usage: node compare-native-pwa.mjs PLAYWRIGHT_MODULE NATIVE_DIR PWA_DIR OUTPUT_DIR');
const { chromium } = createRequire(import.meta.url)(path.resolve(modulePath));
const viewport = JSON.parse(fs.readFileSync(path.join(nativeDirectory, 'viewport.json')));
if (viewport.fontScale !== 1) throw new Error('This CSS reference comparison requires fontScale 1');
const cssWidth = viewport.widthPx / viewport.density;
const cssHeight = viewport.heightPx / viewport.density;
const reference = JSON.parse(fs.readFileSync(path.join(pwaDirectory, 'measurements.json')));
if (!reference.screens.every(screen => screen.viewport.width === cssWidth && screen.viewport.height === cssHeight)) {
throw new Error('PWA and native content viewports do not match');
}
function image(directory, name) {
const bytes = fs.readFileSync(path.join(directory, name));
if (bytes.subarray(0, 8).toString('hex') !== '89504e470d0a1a0a' ||
bytes.readUInt32BE(16) !== viewport.widthPx || bytes.readUInt32BE(20) !== viewport.heightPx) {
throw new Error(`Invalid or mismatched PNG: ${name}`);
}
return { uri: `data:image/png;base64,${bytes.toString('base64')}`, sha256: createHash('sha256').update(bytes).digest('hex') };
}
fs.mkdirSync(destination, { recursive: true });
const browser = await chromium.launch({ headless: true, args: process.getuid?.() === 0 ? ['--no-sandbox'] : [] });
const records = [];
try {
const page = await browser.newPage({ viewport: { width: viewport.widthPx * 2, height: viewport.heightPx + 48 }, deviceScaleFactor: 1 });
await page.route('**/*', route => route.abort());
for (const name of ['login', 'dashboard', 'tasks', 'notes', 'shopping']) {
for (const theme of ['light', 'dark']) {
const native = image(nativeDirectory, `${name}-${theme}.png`);
const pwa = image(pwaDirectory, `${name}-${theme}-${cssWidth}.png`);
await page.setContent(`<meta http-equiv="Content-Security-Policy" content="default-src 'none'; img-src data:; style-src 'unsafe-inline'">
<style>body{margin:0;background:#eee;font:20px sans-serif}main{display:flex}section{width:${viewport.widthPx}px}
header{height:48px;display:grid;place-items:center}img{display:block;width:100%}</style>
<main><section><header>PWA 2.78.0 · ${name} · ${theme}</header><img src="${pwa.uri}"></section>
<section><header>Android nativ · ${name} · ${theme} · keine 1:1-Abnahme</header><img src="${native.uri}"></section></main>`);
await page.locator('img').evaluateAll(images => Promise.all(images.map(image => image.decode())));
const file = `${name}-${theme}-comparison.png`;
await page.screenshot({ path: path.join(destination, file) });
records.push({ file, nativeSha256: native.sha256, pwaSha256: pwa.sha256 });
}
}
} finally {
await browser.close();
}
fs.writeFileSync(path.join(destination, 'comparison.json'), JSON.stringify({
status: 'Side-by-side evidence only; no visual acceptance', viewport, upstreamRevision: reference.upstreamRevision, records,
}, null, 2) + '\n');
console.log(`Wrote ${records.length} matched-viewport comparisons; no parity assertion.`);
+16 -8
View File
@@ -21,10 +21,16 @@ process.env.DB_ENCRYPTION_KEY = '';
process.env.NODE_ENV = 'test';
process.env.LOG_LEVEL = 'warn';
const tls = { key: fs.readFileSync(path.join(directory, 'key.pem')), cert: fs.readFileSync(path.join(directory, 'cert.pem')) };
const idp = await startIdentityProvider(tls);
process.env.OIDC_ISSUER = idp.issuer;
process.env.OIDC_CLIENT_ID = idp.clientId;
process.env.OIDC_CLIENT_SECRET = idp.secret;
const idp = process.env.YUVOMI_TEST_DISABLE_OIDC === 'true' ? null : await startIdentityProvider(tls);
if (idp) {
process.env.OIDC_ISSUER = idp.issuer;
process.env.OIDC_CLIENT_ID = idp.clientId;
process.env.OIDC_CLIENT_SECRET = idp.secret;
} else {
delete process.env.OIDC_ISSUER;
delete process.env.OIDC_CLIENT_ID;
delete process.env.OIDC_CLIENT_SECRET;
}
const { server } = await import(pathToFileURL(path.join(upstream, 'server/index.js')));
if (!server.listening) await once(server, 'listening');
let networkAvailable = true;
@@ -50,7 +56,9 @@ const proxy = https.createServer(tls, (request, response) => {
});
proxy.listen(0, '127.0.0.1');
await once(proxy, 'listening');
process.env.OIDC_REDIRECT_URI = `https://127.0.0.1:${proxy.address().port}/api/v1/auth/oidc/callback`;
idp.setRedirect(process.env.OIDC_REDIRECT_URI);
fs.writeFileSync(path.join(directory, 'ready.json'), JSON.stringify({ port: proxy.address().port, idpPort: idp.port }));
process.on('SIGTERM', () => { idp.close(); proxy.close(); server.close(); process.exit(0); });
if (idp) {
process.env.OIDC_REDIRECT_URI = `https://127.0.0.1:${proxy.address().port}/api/v1/auth/oidc/callback`;
idp.setRedirect(process.env.OIDC_REDIRECT_URI);
}
fs.writeFileSync(path.join(directory, 'ready.json'), JSON.stringify({ port: proxy.address().port, idpPort: idp?.port ?? null }));
process.on('SIGTERM', () => { idp?.close(); proxy.close(); server.close(); process.exit(0); });
+37
View File
@@ -2,6 +2,7 @@
"""Regressions for AGP screenshot output collection, not visual parity tests."""
import base64
import importlib.util
import json
from pathlib import Path
import tempfile
import unittest
@@ -57,6 +58,42 @@ class ScreenshotEvidenceTests(unittest.TestCase):
self.assertFalse((self.screenshots / image.name).exists())
self.assertEqual(unrelated.read_text(), "preserve")
def gallery(self):
parent = self.fixture().parent
for name in HARNESS.SCREENSHOTS:
(parent / name).write_bytes(PNG)
(parent / "viewport.json").write_text(json.dumps({"widthPx": 720, "heightPx": 1184, "density": 2}))
return parent
def test_collects_complete_allowlisted_gallery(self):
parent = self.gallery()
(parent / "private.txt").write_text("not exported")
HARNESS.collect_native_screenshot(self.additional, self.screenshots, gallery=True)
self.assertEqual({p.name for p in self.screenshots.iterdir()}, set(HARNESS.VISUAL_OUTPUTS))
def test_partial_gallery_is_not_exported(self):
parent = self.gallery()
(parent / "notes-dark.png").unlink()
with self.assertRaisesRegex(AssertionError, "Missing or ambiguous"):
HARNESS.collect_native_screenshot(self.additional, self.screenshots, gallery=True)
self.assertFalse(self.screenshots.exists())
def test_clears_every_known_gallery_file_but_preserves_unrelated(self):
parent = self.gallery()
HARNESS.collect_native_screenshot(self.additional, self.screenshots, gallery=True)
(parent / "other.png").write_bytes(PNG)
HARNESS.prepare_android_screenshots(self.output)
self.assertEqual([p.name for p in parent.iterdir()], ["other.png"])
self.assertEqual(list(self.screenshots.iterdir()), [])
def test_rejects_gallery_split_across_devices(self):
parent = self.gallery()
other = parent.with_name("second")
other.mkdir()
(parent / "notes-dark.png").rename(other / "notes-dark.png")
with self.assertRaisesRegex(AssertionError, "Mixed-device"):
HARNESS.collect_native_screenshot(self.additional, self.screenshots, gallery=True)
if __name__ == "__main__":
unittest.main()
+27 -11
View File
@@ -20,25 +20,41 @@ import urllib.request
ROOT = Path(__file__).resolve().parents[1]
REVISION = "10bfef7686ed7303b4c74c7fcc55a5eb16356c9d"
SCREENSHOTS = ("native-dashboard.png",) + tuple(
f"{page}-{theme}.png" for page in ("login", "dashboard", "tasks", "notes", "shopping") for theme in ("light", "dark"))
VISUAL_OUTPUTS = SCREENSHOTS + ("viewport.json",)
def prepare_android_screenshots(app_output):
additional = app_output / "connected_android_test_additional_output/integrationAndroidTest"
screenshots = app_output / "native-screenshots"
# Never accept a previous run's screenshot as current evidence.
for stale in additional.rglob("native-dashboard.png"):
stale.unlink()
(screenshots / "native-dashboard.png").unlink(missing_ok=True)
for name in VISUAL_OUTPUTS:
for stale in additional.rglob(name):
stale.unlink()
(screenshots / name).unlink(missing_ok=True)
return additional, screenshots
def collect_native_screenshot(additional, screenshots):
captures = list(additional.rglob("native-dashboard.png"))
assert len(captures) == 1, "Missing or ambiguous native fixture screenshot"
capture = captures[0].read_bytes()
assert capture.startswith(b"\x89PNG\r\n\x1a\n"), "Invalid native fixture screenshot"
def collect_native_screenshot(additional, screenshots, gallery=False):
names = VISUAL_OUTPUTS if gallery else ("native-dashboard.png",)
validated = {}
parents = set()
for name in names:
captures = list(additional.rglob(name))
assert len(captures) == 1, f"Missing or ambiguous native fixture screenshot: {name}"
capture = captures[0].read_bytes()
if name.endswith(".png"):
assert capture.startswith(b"\x89PNG\r\n\x1a\n"), "Invalid native fixture screenshot"
else:
viewport = json.loads(capture)
assert viewport["widthPx"] > 0 and viewport["heightPx"] > 0 and viewport["density"] > 0
parents.add(captures[0].parent)
validated[name] = capture
assert len(parents) == 1, "Mixed-device native gallery evidence"
screenshots.mkdir(parents=True, exist_ok=True)
(screenshots / "native-dashboard.png").write_bytes(capture)
for name, capture in validated.items():
(screenshots / name).write_bytes(capture)
def run(upstream, android, cookie_without_secure=False):
@@ -152,8 +168,8 @@ def run(upstream, android, cookie_without_secure=False):
raise RuntimeError("Android integration tests failed. Inspect the Gradle test report.")
reports = app_output / "androidTest-results/connected/integration"
subprocess.run(["python3", str(ROOT / "scripts/verify-test-results.py"),
str(reports), "--minimum", "7"], check=True)
collect_native_screenshot(additional, screenshots)
str(reports), "--minimum", "10"], check=True)
collect_native_screenshot(additional, screenshots, gallery=True)
finally:
subprocess.run([str(adb), "reverse", "--remove", f"tcp:{port}"], check=False)
subprocess.run([str(adb), "reverse", "--remove", f"tcp:{ready['idpPort']}"], check=False)