Both themes set `fontFamily: Inter`, and Inter carries no Ge'ez glyphs. So every Amharic string in a bilingual system — 45-73 KB of locale data per app — has been rendering in whatever font the OS happened to substitute: different on Windows, macOS and Android, and matching neither the design nor each other. Noto Sans Ethiopic goes directly after Inter in one stack rather than being swapped in under `[lang='am']`. Browsers fall back per glyph, not per element, so a single stack renders Latin in Inter and Ge'ez in Noto automatically — including within one string. That is the case that matters here: a registry is full of mixed-script lines like an Amharic name beside a Latin IMO number, and a language-scoped swap renders half of those in the wrong face. Heading line-heights go up a step at the same time. Ge'ez has taller ascenders and deeper descenders than Latin, so headings set to Inter's natural leading clip once Ethiopic is actually being rendered. This was deliberately held back from the theme refactor so that change could prove it altered nothing. The gallery gains Ge'ez headings and a mixed-script line, so the baselines cover the case rather than only Latin. Follow-up: both fonts still load from Google Fonts via a render-blocking CSS @import. Self-hosting with a subset and preload links is worth doing — government networks frequently make fonts.googleapis.com slow or unreachable, and silent Amharic fallback is precisely the failure this commit fixes. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
End-to-end browser tests
Playwright, driving the portal and the backoffice in a real browser against a real API and a real database.
# from emaui/
npx nx e2e @ema-platform/e2e # or: npx playwright test --config apps/e2e/playwright.config.ts
npx playwright test --config apps/e2e/playwright.config.ts --headed --debug
npx playwright show-report ../../dist/e2e-report
What it runs against
Nothing shared with your own dev stack. The suite starts its own servers, on its own ports, against its own database:
| port | notes | |
|---|---|---|
| API | 3011 | node dist/main.js with explicit env |
| Portal | 4302 | vite build --mode e2e then vite preview |
| Backoffice | 4303 | same |
| Database | — | ema_e2e |
This is deliberate. A developer's stack is usually already up on 3000/4200/4201,
and dev/start.sh rewrites emaapi/apps/server/emaapi/.env and the apps'
.env.local on every run — a suite that read those files would point at
whichever stack was started last. The API is launched with DATABASE_NAME,
PORT and the payment flags passed directly, and the frontends are built with
--mode e2e, which picks up apps/*/.env.e2e.local (higher precedence than
.env.local, so your own config is left alone).
vite preview on a production build rather than nx serve, because Nx
serialises serve targets per project: with your dev portal already running,
a second nx serve @ema-platform/portal waits forever on the first.
First-time setup
The E2E database has to exist and be bootstrapped once:
DB_NAME=ema_e2e ./dev/start.sh # creates it, migrates, seeds, then starts a stack you can Ctrl-C
Afterwards the suite manages its own servers; you do not need start.sh again.
Note that this run leaves the shared .env files pointing at the E2E database
and its ports — rerun ./dev/start.sh plain to put them back.
How it gets its data
- Accounts are created through the UI. Every spec signs up its own applicant
with a timestamped email, and deletes it in
afterEach(deleteApplicant). Nothing is shared between specs, so specs can run in any order and the second run of the suite behaves exactly like the first. - The one-time code is scraped from the API log. There is no local SMS
gateway,
iam.user_verifications.verification_codeis argon2-hashed, and the notification rows it is delivered through carry an empty body — the log line is the only plaintext. The API is therefore started with its output teed to/tmp/ema-e2e-api.log, andsupport/api-log.tsreads codes written after a recorded offset. The test still types the real code into the real screen. Because the log line names no recipient, matching is by position, which is sound only while the suite runs single-worker — henceworkers: 1. - Database reads use
psql, shelled out fromsupport/db.ts, rather than adding apgdependency to the frontend workspace for the sake of a few assertions.
Conventions
- One worker, serial. The officer queue is global state and the log-offset trick needs ordering; determinism is worth the wall-clock.
- Assertions are on what the user sees — headings, badges, buttons — with database checks only where the point is that something was persisted.
- No
waitForTimeout. Wait for a condition. - Traces, screenshots and video are retained for failures only.
Debugging a failure
Playwright writes test-results/<test>/error-context.md with a full
accessibility snapshot of the page at the moment it failed — usually enough on
its own. Otherwise npx playwright show-trace test-results/<test>/trace.zip.
Status
Implemented: signup → OTP → operations gate → dashboard, and the empty-state
dashboard. See the parent prompt (dev/prompts/e2e-browser-tests-prompt.md) for
the flows still to be written: catalogue filtering, the full application
lifecycle per licence type, officer review, payment bypass, certificate
download, expiry, renewal and reminders.