Skip to content

feature: Multi-layer testing foundation — UI (fireEvent) + Electron (ElectronTestbed) test layers#616

Merged
xerial merged 2 commits into
mainfrom
feature/multi-layer-testing-foundation
Jul 6, 2026
Merged

feature: Multi-layer testing foundation — UI (fireEvent) + Electron (ElectronTestbed) test layers#616
xerial merged 2 commits into
mainfrom
feature/multi-layer-testing-foundation

Conversation

@xerial

@xerial xerial commented Jun 27, 2026

Copy link
Copy Markdown
Member

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.testing

Previously uni-dom tests could only renderTo and read textContent — no way to dispatch a click, keystroke, or input event. New, shipped in the published uni JS artifact:

  • fireEvent — Testing-Library-style event simulation: click/dblclick/mouse, input/change/setChecked, keyDown/keyUp/keyPress (with modifiers), focus/blur, submit, custom.
  • Query / TextMatchergetBy/queryBy/queryAll for CSS selectors, data-testid, and element text (String or Regex); text queries return the deepest match.
  • mount / Mounted — render an RxElement into a fresh container, query/interact via the handle, unmount() / AutoCloseable cleanup.
  • DomTestSession + DomTestSupport (test scope) for automatic per-test cleanup via UniTest.after.

Tests run in real headless Chromium (Playwright): InteractionTest (clicks/typing/keyboard drive Rx state), QueryTest (query helpers). Full domTest suite green: 387 passed.

Layer 2 — Electron integration harness wvlet.uni.electron.testing.ElectronTestbed

In-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 preload bridge), routing renderer requests to served routers through the full marshalling + RPCDispatcher path.

  • API: ElectronTestbed.serve(routers*), .bridge(channel), .channelFactory(channel), .newAsyncClient.
  • ElectronRPCTest now dogfoods it (hand-rolled fakeBridge removed).
  • ElectronTestbedTest — stateful CounterApi integration test + unserved-channel error case. 7 electron tests passed.

Both toolkits ship in the published uni artifact (no test-framework dependency) so downstream desktop apps reuse them directly; only the UniTest-coupled DomTestSupport mixin is test-scope.

Roadmap (follow-up PRs)

  1. UI test toolkit ✅ (this PR)
  2. Electron integration harness ✅ (this PR)
  3. UniTest test tags — select/exclude layers (unit/ui/electron) in CI
  4. Plugin model + plugin test harness (needs design)

🤖 Generated with Claude Code

… 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>
@github-actions github-actions Bot added the feature New feature label Jun 27, 2026

@gemini-code-assist gemini-code-assist Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment on lines +57 to +59
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)))

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

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)))

Comment on lines +40 to +50
def cleanupMounted(): Unit =
mountedElements
.reverseIterator
.foreach { m =>
try
m.unmount()
catch
case _: Throwable =>
() // best-effort cleanup; never fail a test on teardown
}
mountedElements.clear()

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

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>
@xerial xerial changed the title feature: uni-dom UI testing toolkit (fireEvent + queries + mount) feature: Multi-layer testing foundation — UI (fireEvent) + Electron (ElectronTestbed) test layers Jun 27, 2026
xerial added a commit that referenced this pull request Jun 27, 2026
…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>
@xerial xerial merged commit f7b174c into main Jul 6, 2026
14 checks passed
@xerial xerial deleted the feature/multi-layer-testing-foundation branch July 6, 2026 21:00
xerial added a commit that referenced this pull request Jul 6, 2026
… (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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

feature New feature

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant