Testing & review

Every contribution lands with a failing test first (red), then the implementation (green), then a PR that keeps the gates green on bothg++ and clang++.

The four test layers

  • Unit: capabilities against a fake PluginServices; assert what you contribute and that removal is total.
  • Hermetic integration: register your plugin in a PluginRuntime, drive a turn with the fake LLM client (tests/fake_llm.h), assert observable effects. No network, ever.
  • Dialect tests (providers), pure body/parse/stream fixtures, mirroring tests/dialect_gemini_test.cpp.
  • Regression: make test; your contributions must not leak into other tests. The ledger test exists to catch exactly this.
flowchart TB
    unit["1 · Unit<br/>a capability against a fake PluginServices"] --> integ
    integ["2 · Hermetic integration<br/>PluginRuntime + fake LLM client<br/>no network, ever"] --> dialect
    dialect["3 · Dialect, for providers<br/>pure request / parse / stream fixtures"] --> reg
    reg["4 · Regression: make test<br/>contributions must not leak into other tests"]

    guard["any test that activates a plugin redirects<br/>XDG_CONFIG_HOME to a scratch directory"] -.-> integ
The four layers a plugin contribution has to survive, and the redirection guard.

Redirection and hermeticity

Plugin state is persisted under ~/.config/amber/plugins/<id>/. A test that activates a plugin must redirect XDG_CONFIG_HOME to a scratch directory so it never touches the developer's real config. The reference test does this with a small RAII guard.

The reference test

// tests/example_plugin_test.cpp, the reference test for the hello plugin.
TEST(example_hello_plugin_contributes_and_unwinds_a_tool) {
    ScratchConfig scratch("hello");       // redirects XDG_CONFIG_HOME
    Fixture f;                            // ToolRegistry + Config + Workspace
    PluginRuntime runtime(f.tools, f.cfg, f.ws);
    runtime.add(std::make_shared<examples::HelloPlugin>());

    ASSERT_FALSE((bool)f.tools.find("greet"));   // registered, not started

    runtime.start();
    ASSERT_TRUE(runtime.status("hello").enabled);
    auto tool = f.tools.find("greet");
    ASSERT_TRUE((bool)tool);
    ASSERT_EQ(tool->execute(json{{"name", "amber"}}).output,
              std::string("Hello, amber!"));

    ASSERT_TRUE(runtime.set_state("hello", false));   // disable
    ASSERT_FALSE((bool)f.tools.find("greet"));        // ledger unwound
}

This is the shape to copy: register, assert nothing is installed, start, assert the contribution is live, disable, assert it is gone. It is compiled from plugins/_template/ by make test, so it cannot rot.

Doc-as-test

tests/plugin_docs_test.cpp asserts that everyCapabilityKind, every event payload struct, and every bundled plugin id is documented in the developer guide. Add a capability or an event without updating the guide and the suite fails, documentation drift is caught in CI, not in review.

The gates

make clean && make && make test && make lint && make analyze && make check
  • make test: the unit suite (hermetic).
  • make check: build-hygiene invariants.
  • make lint: clang-tidy; make analyze: cppcheck. Both must be clean.

Review checklist

- [ ] Plugin depends only on the public plugin API, no tui/ includes.
- [ ] Every contribution goes through a capability; nothing registered
      in initialize outside the ledger.
- [ ] Render callables are pure; no I/O, no blocking, no mutation.
- [ ] No blocking in event handlers.
- [ ] Tests: contribution, removal, and one hermetic end-to-end path.
- [ ] The availability table is updated if a capability's status changed.
- [ ] Zero new clang-tidy/cppcheck findings; class <= 200 lines,
      method <= 10 lines.

Prove the seam held

A provider or tool plugin's diff must not touchlib/http_transport.cpp, the agent loop, or the TUI. If it does, the framework, not your plugin, is missing an extension point. Say so in the PR instead of working around it.

amber 0.5.8Apache 2.0C++17
utf-8ncursesJacek Trefon www.trefon.com2026