feature: Multi-layer testing foundation — UI (fireEvent) + Electron (ElectronTestbed) test layers#616
Conversation
… for the UI test layer Adds `wvlet.uni.dom.testing` to the published `uni` JS artifact so desktop apps can test their reactive UI the way VSCode tests components: render into a real headless browser DOM, simulate user input, and assert on the reactively re-rendered output. This closes the biggest gap in Uni's testing story: previously uni-dom tests could only `renderTo` and read `textContent` — there was no way to dispatch a click, keystroke, or input event. - `fireEvent` — Testing-Library-style event simulation: click/dblclick/mouse, input/change/setChecked, keyDown/keyUp/keyPress (with modifiers), focus/blur, submit, custom. Bubbling + cancelable, returns the dispatch result. - `Query` / `TextMatcher` — getBy/queryBy/queryAll for CSS selectors, `data-testid`, and element text (String or Regex), returning the deepest matching element. - `mount` / `Mounted` — render an RxElement into a fresh container, query and interact through the handle, `unmount()`/AutoCloseable cleanup. - `DomTestSession` — framework-agnostic mount tracker; `DomTestSupport` (test scope) wires it into `UniTest.after` for automatic per-test cleanup. Tests run in real headless Chromium (Playwright): InteractionTest proves clicks/typing/ keyboard drive Rx state; QueryTest covers the query helpers. Full domTest suite green (387 tests). Part of the multi-layer testing roadmap (plans/2026-06-27-multi-layer-testing.md): unit -> UI (this) -> Electron -> plugin. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
There was a problem hiding this comment.
Code Review
This pull request introduces a comprehensive UI testing toolkit for uni-dom components, enabling real user interaction simulation and Testing-Library-style queries in headless browser environments. It includes event simulation via fireEvent, query helpers, and automated cleanup mechanisms. The review feedback highlights two key improvements: adding a null check for textContent in queryAllByText to prevent potential NullPointerException crashes in Scala.js, and refactoring cleanupMounted to safely pop elements from the collection to avoid concurrent modification issues if lifecycle hooks trigger additional mounts during teardown.
Important
The consumer version of Gemini Code Assist on GitHub is being sunset. Starting June 18, 2026, new organization installations will be blocked, and all code review activity will officially cease on July 17, 2026.
For more details on the timeline and next steps, please review the Help Documentation.
| def queryAllByText(matcher: TextMatcher): Seq[dom.Element] = | ||
| val all = queryAll("*").filter(e => matcher.matches(e.textContent)) | ||
| all.filterNot(e => all.exists(other => (other ne e) && e.contains(other))) |
There was a problem hiding this comment.
In Scala.js, dom.Element.textContent can return null at runtime (e.g., for certain element types or in some headless DOM environments). If it returns null, passing it to matcher.matches will result in a NullPointerException because the default TextMatcher implementations call text.trim. Adding a null check prevents potential test suite crashes.
def queryAllByText(matcher: TextMatcher): Seq[dom.Element] =
val all = queryAll("*").filter { e =>
val txt = e.textContent
txt != null && matcher.matches(txt)
}
all.filterNot(e => all.exists(other => (other ne e) && e.contains(other)))| def cleanupMounted(): Unit = | ||
| mountedElements | ||
| .reverseIterator | ||
| .foreach { m => | ||
| try | ||
| m.unmount() | ||
| catch | ||
| case _: Throwable => | ||
| () // best-effort cleanup; never fail a test on teardown | ||
| } | ||
| mountedElements.clear() |
There was a problem hiding this comment.
Iterating over mountedElements using reverseIterator while calling m.unmount() can be unsafe if any component's lifecycle hooks (like beforeUnmount) trigger additional mounts, which would modify the mountedElements collection during iteration. Popping elements from the end of the ListBuffer in a while loop is safer, highly efficient, and naturally processes them in reverse order.
/** Unmount every element mounted through this session, in reverse order. */
def cleanupMounted(): Unit =
while mountedElements.nonEmpty do
val m = mountedElements.remove(mountedElements.size - 1)
try
m.unmount()
catch
case _: Throwable =>
() // best-effort cleanup; never fail a test on teardown…-IPC services Adds `wvlet.uni.electron.testing.ElectronTestbed` to the published `uni` JS artifact: an in-memory Electron IPC harness that lets desktop apps integration-test their RPC-over-IPC services without a real Electron runtime — the Electron layer of Uni's multi-layer testing story. A testbed plays both sides of the IPC boundary: a fake `ipcMain` handed to `ElectronRPCServer.serve`, and a fake preload `bridge` an `ElectronChannelFactory` calls. Renderer requests route to the served handler on the matching channel, exercising the full marshalling + RPCDispatcher path in a plain Node/headless test. API: `ElectronTestbed.serve(routers*)`, `.bridge(channel)`, `.channelFactory(channel)`, `.newAsyncClient`. - `ElectronRPCTest` now dogfoods the harness (its hand-rolled `fakeBridge` is gone). - `ElectronTestbedTest` adds a stateful `CounterApi` integration test (state persists across IPC calls) and an unserved-channel error case. All electron tests green (7 passed). Part of plans/2026-06-27-multi-layer-testing.md (PR 2 of the unit → UI → Electron → plugin roadmap). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…ely (#617) ## Why Part of making Uni a foundation for **multi-layer testing of rich desktop apps** (see `plans/2026-06-27-multi-layer-testing.md`). VSCode keeps separate test commands per layer (unit / integration / UI / smoke); to do the same here, `UniTest` needs a way to mark which layer a test belongs to and run/skip one layer at a time. This is the mechanism that makes the other layers (UI toolkit, Electron harness in #616) runnable as independent suites in CI. ## What A `TestTag` varargs API on `test(...)` that unifies the old `flaky` boolean and ad-hoc tags: ```scala test("renders the toolbar", UI) { ... } test("hits the network", Electron, Slow) { ... } test("retried under load", Flaky) { ... } // Flaky is itself a tag: failure -> skipped test("smoke check", TestTag("smoke")) { ... } // custom tag (or a bare string) ``` - `trait TestTag { def name: String }`; built-ins `Flaky`, `UI`, `Electron`, `Integration`, `Slow` exported into `UniTest` scope; custom via `TestTag("name")` or a `String` (given `Conversion`). - `test(name: String, tags: TestTag*)(body)` — `isFlaky` derives from the presence of `Flaky`; `TestDef` stays string-based internally so the runner is unchanged. - sbt selection (long options use a `--` prefix per repo convention; short options `-l`/`-t:` keep one hyphen): - `--tags:a,b` — include filter: run only tests carrying any of these tags - `--exclude-tags:a,b` — exclude filter: skip tests carrying any of these tags - exclusion wins over inclusion - Documented in `.github/instructions/unitest.instructions.md`. ```bash ./sbt "coreJVM/testOnly * -- --tags:ui,electron" # ui OR electron ./sbt "coreJVM/testOnly * -- --exclude-tags:slow" # everything except slow ``` ### Breaking change `test(name, flaky = true)` → `test(name, Flaky)`; `test(name, tags = Seq("ui"))` → `test(name, UI)` (or `TestTag("ui")`). Only one in-repo call site used `flaky = true`; it's migrated. No other open branch uses the old params. ## Tests `TagFilterTest` (12) covers arg parsing, `includesTags` semantics, `TestTag` names, the `Flaky`→`isFlaky` mapping, layer-tag registration, and the String conversion. Verified end-to-end via the sbt runner (`--tags:meta` → 1 test, `--exclude-tags:meta` → rest). Full uni-test JVM suite green (69); JS + Native and every JVM test module compile. Roadmap: unit → UI (#616) → Electron (#616) → **tags (this)** → plugin (#618). 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
… (wvlet.uni.plugin) (#618) ## Why Final layer of the multi-layer testing goal: make Uni a foundation for **apps built from pluggable units, testable in isolation** — VSCode's extension/plugin testing layer. Uni had no plugin/extension model; this adds a minimal one plus the harness to test plugins. Since `wvlet.uni.plugin` is a top-level namespace alongside `design`, `rx`, `surface` — and uni is not Electron-specific — the core model must be generic: it hardcodes **no** contribution kinds and has **no** dependency on `wvlet.uni.http.rpc`. See [`adr/2026-07-06-plugin-extension-points.md`](https://github.com/wvlet/uni/blob/feature/uni-plugin-model-and-test-harness/adr/2026-07-06-plugin-extension-points.md). ## What `wvlet.uni.plugin` — a VSCode-style extension model built on **typed extension points**, shipped in the published `uni` artifact (cross-platform: JVM/JS/Native): - **`Plugin`** — `id` + `activate(context)`, mirroring VSCode's `activate(context)`. - **`PluginContext`** — deliberately minimal registration surface: `contribute(point)(value)` + `onDeactivate(hook)`. - **`ExtensionPoint[A]`** — a typed contribution slot, compared by identity (define points as shared `val`s). `ExtensionPoint.keyed(name)(keyOf)` makes contributions unique by key: a duplicate key — within a plugin or across plugins — is rejected at activation, so conflicts surface in tests rather than at runtime. - **`PluginHost`** — activates plugins and owns the per-point registries: `contributions(point)`, `contribution(point, key)`, `deactivate()` (FILO hooks + reset). Re-activating the same plugin id is rejected. Contribution kinds are defined **next to their types**, so dependency arrows point *into* the plugin layer: - **`wvlet.uni.plugin.Command`** — `Command.point` (keyed by command id) + extension methods `registerCommand`, `executeCommand`, `commandIds`, `hasCommand`. - **`wvlet.uni.http.rpc.RPCPlugin`** — `routerPoint` + extension methods `registerRpcRouter`, `rpcRouters`, `rpcDispatcher` (an `RPCDispatcher` over all contributed routers, ready to serve via HTTP or Electron IPC). New kinds (views, menus, `Design` bindings) are added by defining a point — not by editing the core. ```scala import wvlet.uni.plugin.Command.* import wvlet.uni.http.rpc.RPCPlugin.* class NotesPlugin extends Plugin: def id = "notes" def activate(ctx: PluginContext): Unit = ctx.registerRpcRouter(RPCRouter.of[NotesApi](NotesApiImpl())) ctx.registerCommand("notes.new")(_ => createNote()) ctx.onDeactivate(() => flushToDisk()) ``` ## Test harness - **`wvlet.uni.plugin.testing.PluginTestHost`** — `activate(plugin)` / `activateAll(plugins*)` activate plugin(s) against a fresh host through the real activation path and return the host for assertions. Because the host is cross-platform, **plugin tests run on the JVM with no Electron/browser runtime**. ```scala val host = PluginTestHost.activate(NotesPlugin()) host.commandIds shouldContain "notes.new" host.executeCommand("notes.new") shouldBe expectedNote host.rpcRouters.flatMap(_.routes.map(_.path)).exists(_.endsWith("/list")) shouldBe true ``` ## Tests `PluginHostTest` (10 tests, JVM green; compiles on JS + Native): command registration/execution, unknown-command error, RPC-router contribution, app-defined extension points, unkeyed multi-contribution, lifecycle teardown, and conflict rejection (duplicate within a plugin, across plugins, and double-activation). ## Roadmap status unit → UI (#616) → Electron (#616) → tags (#617) → **plugin (this)**. Richer contribution points (views, menus, a typed command API, `Design` bindings), and a JS bridge from `RPCPlugin.rpcDispatcher` into `ElectronTestbed` for full end-to-end plugin-RPC tests, are natural follow-ups. 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Why
Goal: make Uni a foundation for building rich desktop apps testable at every layer, mirroring VSCode's testing strategy (unit → integration → UI/smoke → extension). Full roadmap:
plans/2026-06-27-multi-layer-testing.md.This branch delivers the two missing middle layers of that pyramid. Unit testing (
UniTest) was already strong; this adds UI-interaction testing and Electron integration testing.Layer 1 — UI (component) test toolkit
wvlet.uni.dom.testingPreviously
uni-domtests could onlyrenderToand readtextContent— no way to dispatch a click, keystroke, or input event. New, shipped in the publisheduniJS artifact:fireEvent— Testing-Library-style event simulation:click/dblclick/mouse,input/change/setChecked,keyDown/keyUp/keyPress(with modifiers),focus/blur,submit,custom.Query/TextMatcher—getBy/queryBy/queryAllfor CSS selectors,data-testid, and element text (StringorRegex); text queries return the deepest match.mount/Mounted— render anRxElementinto a fresh container, query/interact via the handle,unmount()/AutoCloseablecleanup.DomTestSession+DomTestSupport(test scope) for automatic per-test cleanup viaUniTest.after.Tests run in real headless Chromium (Playwright):
InteractionTest(clicks/typing/keyboard drive Rx state),QueryTest(query helpers). FulldomTestsuite green: 387 passed.Layer 2 — Electron integration harness
wvlet.uni.electron.testing.ElectronTestbedIn-memory Electron IPC harness so apps can integration-test RPC-over-IPC services without a real Electron runtime. Plays both sides of the IPC boundary (fake
ipcMain+ fake preloadbridge), routing renderer requests to served routers through the full marshalling +RPCDispatcherpath.ElectronTestbed.serve(routers*),.bridge(channel),.channelFactory(channel),.newAsyncClient.ElectronRPCTestnow dogfoods it (hand-rolledfakeBridgeremoved).ElectronTestbedTest— statefulCounterApiintegration test + unserved-channel error case. 7 electron tests passed.Both toolkits ship in the published
uniartifact (no test-framework dependency) so downstream desktop apps reuse them directly; only theUniTest-coupledDomTestSupportmixin is test-scope.Roadmap (follow-up PRs)
UniTesttest tags — select/exclude layers (unit/ui/electron) in CI🤖 Generated with Claude Code