Skip to content

Repository files navigation

@xinutec/ui-harness — the fleet's shared UI layer

Three parts, one repo:

  • src/ — phone-width layout checks (Playwright). The dynamic layout-measurement layer (L2 of the layout-quality architecture): render a screen at true phone geometry and assert about the painted pixels, not the source. Consumed over npm by twelve Angular frontends.
  • android/ — the WebView app shell (Gradle). The Activity every wrapper app is a WebView around: system-bar insets, page-coloured bars, back through SPA history, restore-on-reopen. Consumed by path, as a composite build.
  • scaffold/ — @xinutec/ui-scaffold, the Angular app frame. The top bar, where up goes from each screen, and sheets and dialogs the back gesture closes. A package of its own, because it has to be built by ng-packagr: see below.

They share a repo because they are the same job seen from two sides — one keeps the web UI honest about phone geometry, the other is the frame that UI is shown in on a phone — and because the alternative was eight hand-maintained copies of one Activity, already drifting.

Neither half can disturb the other's consumers. files: ["dist"] in package.json means android/ never reaches an npm consumer's node_modules, and npm consumers pin a SHA, so an Android commit is invisible to them until they bump. Gradle consumers resolve by path against whatever is checked out, so a change to src/ is invisible to them entirely.

Scaffold: the Angular app frame

Every Angular app in the fleet draws the same top bar and wires its overlays into history the same way, from here. The console in memview is where it was written.

  • <ui-scaffold title="…" [menu]="…">, once, above the router. The root screen and any other screen without up (a top-level destination the menu reaches) get a leading menu (when the app passes one) and the screen's own name, else the app's; a screen whose route declares up gets arrow_back and its own name. What the element holds is drawn at the end on every screen; a page adds its own actions after it with <ng-template scaffoldActions>.
  • A count on the menu button: [menuBadge]="n" draws Material's badge on it (hidden at 0), and [menuLabel] says it in words, for example 'Menu — 2 sync conflicts need attention'.
  • Every route but the root declares data.up or data.top: true (dev-lint #1793 flags one with neither). top is a peer main screen that keeps the menu, for an app with several equal ones: a destination the menu lists, as a navigation drawer's are. A utility screen (settings) is drilled in and declares up. TOP and UP name the keys.
  • Up is declared on the route, never set by a page: { path: 's/:id/w/:run', data: { up: '/s/:id' } }. The long form, { path: '/s/:id', keep: ['task'], label: 'the session' }, carries query parameters over and names the arrow for a screen reader. Up is the parent screen, not history, and a declaration is what a lint can check without running the app.
  • A screen with several parents (a detail many lists open, a settings screen reached from anywhere) adds opener: true: up returns to the screen that opened it, as back does, and goes to path only when nothing in the app did — Android's "Up vs Back" for a screen with multiple entry points.
  • scaffoldTitle(() => …) in a page's constructor names the screen for as long as the page is on view.
  • Sheets.open(…) and Dialogs.open(…) in place of MatBottomSheet and MatDialog: each overlay gets a history entry, so a phone's back gesture closes the overlay and only the overlay.

Why a second package. The harness is built by plain tsc, and an Angular decorator leaving it "carries metadata an AOT build cannot instantiate" (see src/awake.ts), which is why its runtime pieces are plain classes with a thin adapter in each app. A bar is a template, so it needs Angular's own library build: ng-packagr, in partial compilation mode.

Its build is committed (scaffold/dist/), not run on install. Built by an app's install, it failed in every app's CI: pnpm builds a git dependency inside its store, pnpm/action-setup puts the store under a node_modules directory, and TypeScript does not emit a file whose path runs through node_modules, so only the entry file was compiled (memview CI, 2026-09-27; reproduced by building under any node_modules path, and ng-packagr overrides a tsconfig that lists the roots). Shipped built, an install also skips the ~190 packages the build needs. The gate rebuilds it and fails if the result differs from what is committed; the build is byte-for-byte reproducible.

Installing it names the directory: pnpm add "github:xinutec/ui-harness#<sha>&path:/scaffold". It runs nothing on install, so it needs no allowBuilds entry. Its own lockfile is in scaffold/; it is not a workspace member of this root.

Web: why it's a package (and why it builds to JS on install)

Extracted from the life app's e2e harness after it caught, in one week: a 497px toggle row in a 380px sheet, nested scrollers that broke swipe, and a suite that had silently run at 1280×720 while claiming 390px.

The measurement code imports only types from @playwright/test (erased at compile), so the built JS pulls in no copy of the runner — load-bearing, because two @playwright/test instances make every suite die with "No tests found". The consuming app resolves the one real @playwright/test from its own node_modules (declared here as a peerDependency).

Consumers load compiled JS + .d.ts from dist/, not TypeScript source: Playwright only transpiles TS outside node_modules, so a TS-source package would be unimportable from an installed dependency. dist/ is gitignored; the prepare script builds it at install time (tsc), so a plain git clone install produces a ready-to-load package. (This replaces the old in-monorepo mechanism of importing src/ui-harness.ts by relative path — that only worked because both lived in the same tree.)

files therefore ships src/ and tsconfig.build.json as well as dist/, so the package can be rebuilt from what it publishes. That matters for consumers that install with --ignore-scripts, which is every pure Nix build: prepare never runs there, so dist/ is absent, and with files: ["dist"] alone the installed package was just a package.json — nothing to import and nothing to compile, which is how thoth's packaged build failed (2026-08-01). Shipping the sources costs a few kB and makes "install without running my code, then build it yourself" a supported path rather than a dead end.

For the same reason @types/node is a real dependency, not a devDependency: src/serve.ts uses process and node: imports, so it is needed to COMPILE the package, and a consumer building from source gets nothing from a dev-only declaration. @playwright/test stays a peer — only types are imported from it, and a second copy of the runner makes every suite report "No tests found".

Consuming (per app)

Installed as a public git dependency — anonymous https clone, no registry, no token, no .npmrc:

pnpm add -D github:xinutec/ui-harness   # @playwright/test is a peer — apps already have it
// frontend/package.json
"devDependencies": { "@xinutec/ui-harness": "github:xinutec/ui-harness" }

This package imposes no package manager on you. dist/ is not committed, so your install clones the repo and runs its prepare — and prepare is tsc -p tsconfig.build.json, naming no package manager at all rather than npm run build as it did until 2026-08-09. Every consumer is on pnpm and so is this repo, but nothing here would break one that was not.

In a Docker build on node:alpine, add git so the install can clone the dep: RUN apk add --no-cache git ca-certificates && npm install -g pnpm && pnpm install --frozen-lockfile (what life/Dockerfile does).

// frontend/e2e/ui-pages.spec.ts
import { expectNoTextOverlaps, expectNoHorizontalOverflow, expectViewportIsPhone } from '@xinutec/ui-harness';

The config and the server

An app no longer writes a Playwright config; it says what it is and gets one.

// frontend/e2e/harness.mjs — the app-specific half, read by BOTH the config
// and the static server, so they cannot disagree.
/** @type {import('@xinutec/ui-harness/config').HarnessSpec} */
export default {
  app: 'life',                      // must be in the APPS table (it IS the port)
  dist: 'dist/life-web/browser',    // the built bundle to serve
  api: { '/api/me': { userId: 'test' } },  // fallback for unrouted /api/ calls
};
// frontend/playwright.config.ts
import { defineConfig, devices } from '@playwright/test';
import { phoneConfig } from '@xinutec/ui-harness/config';
import harness from './e2e/harness.mjs';

export default defineConfig(phoneConfig(harness, devices, { goldens: true }));

The port is an allocation, so it is allocated. APPS in src/config.ts is an ordered list and a port is an index into it — two apps cannot share one, and an app the list does not name gets a loud error rather than a number someone guessed. This replaces two fleet lint rules that read eleven hand-written configs looking for collisions after the fact. They found real ones: recall and utterance both on 4293 (recall's suite went 8/8 pass to 8/8 fail purely by running at the same time), health and memview both on 4273, and three servers that defaulted to the port of the app they had been copied from.

devices is passed in rather than imported here. That keeps this module free of a runtime @playwright/test (see above), and it means an app hands over its device table without getting to choose from it — devices['Desktop Chrome'] in a phone-width suite is not expressible. For the same reason HarnessOptions has no use or projects: overriding those is the failure, so it is a type error rather than a convention. What an app may still set: testMatch, timeout, goldens.

webServer.command is generated and runs dist/serve.js, the fleet's one static server for a production bundle — SPA fallback, the content types the service worker needs, containment above the bundle, and the /api/ stub from the spec. To serve a build by hand, from frontend/:

node node_modules/@xinutec/ui-harness/dist/serve.js   # no arguments: the spec has them

An app that serves its own bundle (thoth's Swift binary is the thing under test) sets server: { command: (port) => …, cwd, host, reuseExistingServer } and still takes the allocated port.

Every app's suite includes one viewport self-guard spec:

test('the suite really runs at phone geometry', async ({ page }) => {
  await page.goto('/');
  await expectViewportIsPhone(page);
});

API

  • expectNoTextOverlaps(page, testInfo, rootSel?, tol?) — no two pieces of painted text share pixels. Glyph-level (Range.getClientRects()), rects clipped to every overflow-clipping ancestor; same-node fragment pairs skipped.
  • expectNoHorizontalOverflow(page, testInfo, rootSel?, allow?, tol?) — nothing escapes sideways, on EITHER edge; intended horizontal scrollers are an explicit allow-list (computed overflow-x is a trap). The left edge matters more than it sounds: a right-hand spill announces itself by scrolling the page, while content pushed off the left is silent — LTR gives no scroll room left of the origin, so it is simply unreadable, and a right-edge-only check stays green. (This check was right-edge-only until life's wellbeing chart shipped with its axis words off the screen and three passing tests.)
  • expectNoOccludedControls(page, sels, rootSel?) — interactive controls aren't hidden behind other paint (a FAB sunk under the bottom nav).
  • expectViewportIsPhone(page, width?) — the checker-checker: fails loudly if device emulation ever silently drops.
  • expectNoClippedText(page, testInfo, rootSel?, minPx?) — no visible text is permanently sheared by an overflow-clipping ancestor. The scroll test keeps it honest: text scrolled out of a scroller comes back, so only a clip the container cannot scroll away counts.
  • expectIconFontLoaded(page, family?) — the icon font actually loaded (no tofu boxes for Material Icons).
  • expectNoClippedIcons(page, testInfo, rootSel?, minPx?) — every icon has room for its glyph. mat-icon carries overflow: hidden, which voids the min-width: auto floor that stops a flex item collapsing below its content, so an icon beside a long text sibling absorbs the row's shrink and is clipped rather than scaled — recall's status banner painted 9.6px of a 24px hourglass while the shorter sentence beside it lost 0.7px and looked fine. No other check sees it: shrinking is what avoids overflow, nothing overlaps, nothing is occluded, and expectNoClippedText skips icon ligatures by design. Measures the painted box against font-size, not scrollWidth, so an unloaded icon font (whose content is the literal ligature word) cannot flag every icon in the app — that failure belongs to expectIconFontLoaded, which is worth calling beside it.
  • expectCanvasLegible(page, testInfo, sel?, minRatio?, minPainted?) — a canvas's marks are actually visible against the page behind them. Canvas is the one place the stylesheet does not reach: an unparseable colour assigned to fillStyle is ignored in silence, leaving the previous value (black, on a fresh context). Material's system tokens compute to light-dark(#…, #…), which no canvas can parse, so passing one straight through paints black on a dark background with nothing anywhere reporting a problem — and nothing else here can see it, since the layout checks measure geometry, unit tests never rasterise, and the page stays valid. Solidly-painted pixels only (alpha > 200), scored against the nearest opaque ancestor rather than document.body, which several fleet apps leave transparent. Call it under page.emulateMedia({ colorScheme }) for BOTH schemes — the classic form of this bug is invisible in light mode. It catches marks that are illegible, not merely wrong-coloured; dev-lint's DL-CANVAS-SYSTEM-TOKEN covers the known cause statically.
  • expectUpInTheBar(page) — on a screen whose route declares up, the scaffold's bar leads with arrow_back and its heading names the screen. Call it on every detail screen the suite visits: the scaffold's own tests cannot see which routes an app declared.
  • expectBackClosesOverlay(page, open) — open opens a sheet or dialog; back closes it and leaves the URL alone. An overlay opened past Sheets/Dialogs has no history entry, so back leaves the screen and the check names that.
  • MISSING_BUNDLE_RECOVERY and expectRecoversFromMissingBundle(page, url, ready) — a service worker can serve an index naming a main-*.js a later deploy removed, and the app never starts; its own recovery is inside that bundle. Every service-worker app pastes MISSING_BUNDLE_RECOVERY into index.html's <head>, before any bundle: when the bundle fails to load it unregisters the worker, deletes its ngsw: caches and reloads, at most once a minute. The check refuses the bundle once and expects ready after the reload; it fails rather than passes if it never saw the bundle requested.
  • swipeUp(page, opts?) — a real CDP touch flick, not a scrollTop shortcut.
  • expectReachableByScroll(page, locator, scrollerSel) — swipe until the target is on-screen; fails if a nested-scroller fight keeps it unreachable.

Android: the WebView app shell

android/ is a Gradle build publishing one library, org.xinutec:shell — the Activity the fleet's eight WebView wrappers are. An app declares its URL and its opt-ins; the shell owns system-bar insets (including the IME), bars painted with the page's own colour, restore-on-reopen filtered so a spent login callback can't strand the app, and back through SPA history on the modern dispatcher.

Consumed as a composite build, resolved by path, with no version anywhere:

// the app's android/settings.gradle.kts
includeBuild("../../ui-harness/android") {
    dependencySubstitution {
        substitute(module("org.xinutec:shell")).using(project(":main"))
    }
}

Full API, the manifest attributes an app still owns, and how to build it: android/README.md.

Developing the harness

pnpm install --frozen-lockfile && pnpm test runs the web half's own specs. Three kinds: tests/measurement.spec.ts — page.setContent DOM fixtures for the layout checks (ellipsis-phantom, clip-model and icon-glyph-vs-badge false positives, real overlap/overflow detection, the allow-list); tests/config.spec.ts — the port allocation and the shape of the config it hands out; tests/serve.spec.ts — the static server against a real bundle-shaped directory. pnpm run build compiles src/ → dist/.

Twelve frontends consume this (coach, fleetwatch, gamepads, health, home, life, memview, messages, observe, recall, thoth, utterance) and all of them now take their config from it, so a change here lands everywhere at once — run their ui-check after anything that touches config.ts or serve.ts.

gate.dhall covers both halves and is what the pre-commit hook runs — twelve named checks: the pnpm install, build and specs, then the shell's unit tests and an assembleDebug of life against it. Run it with nix run ../dev-lint#gate -- . gate.json; each row names the dev shell it needs, so there is no wrapper. Eight apps ride on the Android half, so a red run there is a real regression in every one of them.

The life consumer build is unconditional: it used to print SKIPPED when life was not checked out beside this repo, and a green run that skipped it is not the same green as one that did it.

About

@xinutec/ui-harness — shared phone-width layout checks for the fleet's Angular frontends (Playwright)

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages