0045. UI behaviour that can hurt a cluster is tested as pure functions in ui/src/lib, not through rendered pages

Status: Accepted · Date: 2026-10-26 · Design section: UI, Roadmap phase 3 Decided: Test the UI's dangerous decisions (may this approval proceed, may this patch be sent, how is an error shown) as pure functions in ui/src/lib/ under node --test, with no component test framework and no ui/src/gen import in those files; the page screens stay untested by unit tests

Context

ui/src/pages/ had no unit tests; only resumableStream and patchTarget were covered. The screens that can do real damage are the approval dialog (ADR 0021) and the patch editor (ADR 0039).

The UI is preactcn-only, with no React and no preact/compat. The usual component-testing libraries assume React, so they cannot be added. A DOM harness for Preact would be a new dependency, and node --test runs with no bundler. Generated-client imports without a file extension already fail there with ERR_MODULE_NOT_FOUND.

Decision

Decisions go in ui/src/lib/ as pure functions and are tested there. The page calls them and does nothing else with the result.

  • canApprove (identity.ts): approval needs a strong identity (uuid, serial, a MAC) and a chosen role. The dialog shows identity, hardware and role before this gate opens.
  • buildPatchTarget and canSavePatch (patchTarget.ts): malformed selector JSON throws, so nothing is sent. Only the chosen target field is populated. The cluster layer ignores the target.
  • describeError (errors.ts): the apiserver's validation message is shown verbatim, under a headline chosen by status code.

Files in lib/ that take generated types use import type only, or take the enum reading as a boolean from the caller.

Not covered, on purpose: the screens' rendering, and the delete confirmation. Delete only runs from the button inside the confirmation dialog, so no code path skips it. Testing that needs a DOM. A browser end-to-end test is the right tool and a separate decision.

Consequences

  • describeError used ConnectError.message, which carries a [code] prefix, so the apiserver text was never shown verbatim. It now uses rawMessage. A test pins this.
  • Wiring mistakes inside a page (a gate not called, wrong prop) are not caught. Only the rule itself is.
  • New dangerous UI decisions are extracted into lib/ first.

Search the docs

Search pages and headings