Skip to content

feature: Plugin model on typed extension points + plugin test harness (wvlet.uni.plugin)#618

Merged
xerial merged 3 commits into
mainfrom
feature/uni-plugin-model-and-test-harness
Jul 6, 2026
Merged

feature: Plugin model on typed extension points + plugin test harness (wvlet.uni.plugin)#618
xerial merged 3 commits into
mainfrom
feature/uni-plugin-model-and-test-harness

Conversation

@xerial

@xerial xerial commented Jun 27, 2026

Copy link
Copy Markdown
Member

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.

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

  • Pluginid + 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 vals). 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.CommandCommand.point (keyed by command id) + extension methods registerCommand, executeCommand, commandIds, hasCommand.
  • wvlet.uni.http.rpc.RPCPluginrouterPoint + 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.

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.PluginTestHostactivate(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.
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

Introduces `wvlet.uni.plugin`, a minimal VSCode-style extension model so Uni desktop apps
can be built from pluggable units and tested in isolation — the plugin layer of Uni's
multi-layer testing story.

Model (mirrors VSCode's activate(context) + contribution points, built on existing Uni
primitives — RPCRouter composition and lifecycle):
- `Plugin` — `id` + `activate(context)`.
- `PluginContext` — registration surface: `registerCommand(id)(handler)`,
  `registerRpcRouter(router)`, `onDeactivate(hook)`.
- `PluginHost` — activates plugins, owns a host-global command registry and the contributed
  RPC routers; `executeCommand`, `dispatcher` (RPCDispatcher over all routers), and
  `deactivate()` (FILO hooks + reset). Duplicate command ids and re-activation are rejected so
  conflicts surface in tests, not at runtime.

Test harness:
- `wvlet.uni.plugin.testing.PluginTestHost.activate(plugin)` / `activateAll(...)` activate a
  plugin against a fresh host through the real activation path, returning the host for
  assertions. Cross-platform — plugin tests run on the JVM with no Electron/browser runtime.

`PluginHostTest` covers command registration/execution, RPC-router contribution, lifecycle
teardown, and conflict rejection (8 tests, JVM green; compiles on JS + Native).

Completes the unit → UI → Electron → plugin roadmap
(plans/2026-06-27-multi-layer-testing.md). Richer contribution points (views, menus, a
typed command API) are follow-ups.

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 plugin system for the Uni desktop application, including the Plugin and PluginContext traits, a PluginHost runtime manager, a PluginTestHost helper for isolated testing, and corresponding unit tests. The feedback suggests improving the robustness of plugin activation by making it atomic to prevent partial registrations if activation fails, logging exceptions caught during deactivation instead of swallowing them silently, and adding a unit test to verify the rollback of partial registrations.

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 +36 to +55
private def contextFor(pluginId: String): PluginContext =
new PluginContext:
override def registerCommand(id: String)(handler: Seq[Any] => Any): Unit =
if commands.contains(id) then
throw IllegalArgumentException(
s"Command '${id}' is already registered (attempted by plugin '${pluginId}')"
)
commands(id) = handler

override def registerRpcRouter(router: RPCRouter): Unit = _routers += router

override def onDeactivate(hook: () => Unit): Unit = deactivateHooks += hook

/** Activate a single plugin. Returns this host for chaining. */
def activate(plugin: Plugin): PluginHost =
if _activated.contains(plugin.id) then
throw IllegalArgumentException(s"Plugin '${plugin.id}' is already activated")
plugin.activate(contextFor(plugin.id))
_activated += plugin.id
this

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.

high

If a plugin throws an exception during its activate method, any commands, RPC routers, or deactivation hooks registered prior to the exception will remain in the host's global registries, while the plugin itself is not marked as activated. This leaves the PluginHost in a corrupted, partially-activated state.

To ensure atomicity, we should accumulate registrations in a temporary context during activation and only commit them to the host's global registries once the plugin's activate method completes successfully.

  /** Activate a single plugin. Returns this host for chaining. */
  def activate(plugin: Plugin): PluginHost = 
    if _activated.contains(plugin.id) then
      throw IllegalArgumentException(s"Plugin '${plugin.id}' is already activated")

    val localCommands = mutable.LinkedHashMap.empty[String, Seq[Any] => Any]
    val localRouters  = mutable.ListBuffer.empty[RPCRouter]
    val localHooks    = mutable.ListBuffer.empty[() => Unit]

    val context = new PluginContext:
      override def registerCommand(id: String)(handler: Seq[Any] => Any): Unit = 
        if commands.contains(id) || localCommands.contains(id) then
          throw IllegalArgumentException(
            s"Command '${id}' is already registered (attempted by plugin '${plugin.id}')"
          )
        localCommands(id) = handler

      override def registerRpcRouter(router: RPCRouter): Unit = 
        localRouters += router

      override def onDeactivate(hook: () => Unit): Unit = 
        localHooks += hook

    plugin.activate(context)

    // Commit contributions only after successful activation
    commands ++= localCommands
    _routers ++= localRouters
    deactivateHooks ++= localHooks
    _activated += plugin.id
    this

Comment on lines +100 to +109
def deactivate(): Unit =
deactivateHooks
.reverseIterator
.foreach { hook =>
try
hook()
catch
case _: Throwable =>
()
}

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

Swallowing exceptions silently during deactivation makes debugging teardown issues extremely difficult. It is highly recommended to log these failures.

To do this, you can make PluginHost extend LogSupport (imported from wvlet.uni.log.LogSupport) and log a warning when a hook fails.

  def deactivate(): Unit = 
    deactivateHooks
      .reverseIterator
      .foreach { hook =>
        try
          hook()
        catch
          case e: Throwable =>
            warn(s"Error running deactivation hook: ${e.getMessage}", e)
      }

Comment on lines +114 to +121
test("activating the same plugin id twice is rejected") {
val host = PluginTestHost.activate(NotesPlugin())
intercept[IllegalArgumentException] {
host.activate(NotesPlugin())
}
}

end PluginHostTest

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

Add a unit test to verify that partial registrations are correctly rolled back and not committed to the host if a plugin fails to activate.

  test("activating the same plugin id twice is rejected") {
    val host = PluginTestHost.activate(NotesPlugin())
    intercept[IllegalArgumentException] {
      host.activate(NotesPlugin())
    }
  }

  test("partial registrations are rolled back if activation fails") {
    val host = new PluginHost()
    val plugin =
      new Plugin:
        override def id: String                             = "failed-plugin"
        override def activate(context: PluginContext): Unit =
          context.registerCommand("partial-cmd")(_ => 1)
          context.registerRpcRouter(RPCRouter.of[NotesApi](NotesApiImpl()))
          throw new RuntimeException("activation failed")

    intercept[RuntimeException] {
      host.activate(plugin)
    }

    host.hasCommand("partial-cmd") shouldBe false
    host.routers shouldBe empty
    host.activatedPlugins shouldBe empty
  }

end PluginHostTest

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 and others added 2 commits July 6, 2026 14:21
Generalize wvlet.uni.plugin so the top-level namespace claim holds: the
core (Plugin, PluginContext, PluginHost) no longer hardcodes commands
and RPC routers, and no longer depends on wvlet.uni.http.rpc.

PluginContext shrinks to contribute(point)(value) + onDeactivate;
PluginHost owns a typed ExtensionPoint registry. Contribution kinds are
defined next to their types — Command.point (keyed by id) in the plugin
package, RPCPlugin.routerPoint in wvlet.uni.http.rpc — so dependency
arrows point into the plugin layer and new kinds are added by defining
a point, not editing the core. Keyed points reject duplicate ids at
activation, keeping the conflicts-surface-in-tests property.

See adr/2026-07-06-plugin-extension-points.md for the rationale.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@xerial xerial changed the title feature: Plugin model + plugin test harness (wvlet.uni.plugin) feature: Plugin model on typed extension points + plugin test harness (wvlet.uni.plugin) Jul 6, 2026
@xerial xerial merged commit 8c3162f into main Jul 6, 2026
15 checks passed
@xerial xerial deleted the feature/uni-plugin-model-and-test-harness branch July 6, 2026 21:26
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