feature: Plugin model on typed extension points + plugin test harness (wvlet.uni.plugin)#618
Conversation
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>
There was a problem hiding this comment.
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.
| 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 |
There was a problem hiding this comment.
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| def deactivate(): Unit = | ||
| deactivateHooks | ||
| .reverseIterator | ||
| .foreach { hook => | ||
| try | ||
| hook() | ||
| catch | ||
| case _: Throwable => | ||
| () | ||
| } |
There was a problem hiding this comment.
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)
}| test("activating the same plugin id twice is rejected") { | ||
| val host = PluginTestHost.activate(NotesPlugin()) | ||
| intercept[IllegalArgumentException] { | ||
| host.activate(NotesPlugin()) | ||
| } | ||
| } | ||
|
|
||
| end PluginHostTest |
There was a problem hiding this comment.
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…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>
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>
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.pluginis a top-level namespace alongsidedesign,rx,surface— and uni is not Electron-specific — the core model must be generic: it hardcodes no contribution kinds and has no dependency onwvlet.uni.http.rpc. Seeadr/2026-07-06-plugin-extension-points.md.What
wvlet.uni.plugin— a VSCode-style extension model built on typed extension points, shipped in the publisheduniartifact (cross-platform: JVM/JS/Native):Plugin—id+activate(context), mirroring VSCode'sactivate(context).PluginContext— deliberately minimal registration surface:contribute(point)(value)+onDeactivate(hook).ExtensionPoint[A]— a typed contribution slot, compared by identity (define points as sharedvals).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 methodsregisterCommand,executeCommand,commandIds,hasCommand.wvlet.uni.http.rpc.RPCPlugin—routerPoint+ extension methodsregisterRpcRouter,rpcRouters,rpcDispatcher(anRPCDispatcherover all contributed routers, ready to serve via HTTP or Electron IPC).New kinds (views, menus,
Designbindings) are added by defining a point — not by editing the core.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.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,
Designbindings), and a JS bridge fromRPCPlugin.rpcDispatcherintoElectronTestbedfor full end-to-end plugin-RPC tests, are natural follow-ups.🤖 Generated with Claude Code