# Testing plugins
`standard_plugin::testing` runs a plugin under ordinary `cargo test`, on the
machine that builds it. `MockHost` checks grants exactly as the viewer does
(both use `standard-plugin-manifest`), keeps values and secrets, records
emitted events, live messages, calls, claims, surface commits, frame
requests and the surfaces the plugin opened. `Harness` drives a `UiPlugin`
as a viewer would; `DaemonHarness` drives a `DaemonPlugin` (drive, events,
calls).
## A testable plugin crate
`standard-plugin new` sets a UI plugin up for this: the crate is
`crate-type = ["cdylib", "rlib"]` (the component, and a library the tests
link) and starts `#![cfg_attr(not(test), no_std)]`, so the plugin is
`no_std` in the component and has `std` under `cargo test`. It starts with
one test; `cargo test` in the plugin's directory runs it.
```rust,ignore
#[cfg(test)]
mod tests {
use super::*;
use standard_plugin::surface::Model;
use standard_plugin::testing::{Harness, MockHost};
use standard_plugin::{KeyCode, Power, PointerKind};
#[test]
fn the_paddle_follows_the_keys() {
let host = MockHost::new("pong").surface("court", Model::Pixels);
let mut harness = Harness::<Pong>::activate(host, "{}");
harness.resize("court", Geometry::cells(40, 12, (8, 16)));
harness.show("court", true);
harness.frame(0, 16, Power::Mains);
harness.key("court", KeyCode::Up);
assert_eq!(harness.take_frame_request(), Some(0), "the key asked for a frame");
harness.pointer("court", PointerKind::Down, 12, 40);
harness.frame(16, 16, Power::Mains);
let last = harness.commits("court").last().cloned().unwrap();
assert!(!last.dirty.is_empty() || last.full);
}
}
```
## What the harness does
| `Harness::activate(host, config)` | Instantiates the plugin with its configuration |
| `resize(surface, geometry)` | Lays a surface out (a pane instance `status@p1` of a declared `status` works too); a zero geometry removes an instance. Declare a `machine.after` or `project.after` surface with `MockHost::machine_surface` or `project_surface` so `view::surface_machine`/`surface_project` name the owner of `row@<id>` |
| `show(surface, visible)` | Shows or hides a surface |
| `frame(now_ms, interval_ms, power)` | Calls `frame()`; returns when the plugin wants the next one |
| `key(surface, code)`, `paste(surface, text)`, `pointer(surface, kind, x, y)` | Input on a surface; `x`/`y` in the surface's units (cells, or pixels) |
| `command(id)` | Runs one of the manifest's commands |
| `set_capabilities(caps)`, `set_theme(theme)`, `set_model(surface, model)` | Changes what the viewer can show, its theme, or a two-model surface's model, with the event that says so |
| `event(event)` | Any other event |
| `take_frame_request()` | The frame the plugin asked for from an event (`Some(0)`: the next one) |
| `commits(surface)`, `host(\|host\| ...)` | What the plugin committed; the host's records (`opened`, `emitted`, `denials`, ...) |
`DaemonHarness` drives a daemon plugin on the same host:
| `DaemonHarness::activate(host, config)` | Instantiates the plugin and starts its run loop |
| `drive(now_ms)` | Delivers what happened on the machine since the last drive (children's output and exits, file changes), then runs the plugin's tasks; returns when it next wants to run |
| `event(event)`, `call(method, request)`, `call_from(method, request, caller)` | An account event; a call from the UI half (or another caller) |
| `output(pid, stderr, bytes)`, `exit(pid, status)` | A running child writes or exits |
| `file_changed(path)` | A change at `path`: every watch that covers it at its depth and outside its `exclude` globs hears it; returns how many |
| `interest(&["card"])` | The account's viewers now show exactly these surfaces (`&[]`: nobody looks); the plugin hears `Event::Interest` on the next drive when it changed ([reading only while someone looks](daemon-plugins.md#reading-only-while-someone-looks)) |
| `plugin(\|plugin\| ...)` | Borrows the plugin to assert on its state |
A drive that returns `None` means the plugin asked for no wake: assert it
for the idle case (nobody looking), so a stray timer shows up as a test
failure. `host.published` and `host.values` count what the plugin wrote, to
measure a minute of traffic.
`MockHost` answers the daemon world as the host does:
- **Children.** `MockHost::script(program, ProcessScript)` (or
`script_args(program, &[args], ...)`) says what a program does when the
plugin spawns it: its stdout and stderr (delivered as `ProcessOutput`
for a piped stream) and its exit status (`ProcessExited`, and what
`wait` answers). Every spawn is recorded in `host.processes()` with its
distinct pid, arguments, the environment it got (the plugin's, set with
`MockHost::env`, less `env_remove`, plus `env`) and its directory (the
command's, else `HOME`). An unscripted child runs until
`DaemonHarness::exit`; `wait` on it answers `Unavailable` rather than
block. `process.exec:` grants and `machine.full` are checked as the host
checks them.
- **Panes.** `panes::create` opens a pane as `standardd` does: in a
directory inside one of `host.account.projects` of this machine (by
whole components after symlinks; `Invalid` elsewhere), listed in
`host.panes` at generation 1, and recorded in `host.pane_launches()`
with its project, directory, command and variables.
- **HTTP.** `daemon::http` works natively too, against the host:
`MockHost::http(method, url_prefix, Response::new(200).with_json(&v)?)`
queues an answer for the next request of that method (`*` for any)
whose URL starts with the prefix (each used once, in order;
`host.queue_http(..., Err(error))` queues a failure, on a running host
too). The `fetch:` grants are checked with the host's own rule (a
refusal is `Error::GrantDenied { grant: "fetch:<host>" }` and counts as
a denial), a body over `max_body` is `Invalid`, and a request nothing
answers is `Unavailable`. `host.http_requests()` lists every request let
through, with its method, URL, headers, body, timeout and cap, and
`host.http_pending()` counts answers not used yet. Plugin code needs no
`cfg(target_arch)` around its requests.
- **Watches.** `watch` is checked with the host's own path rules
(`standard_plugin_manifest::paths`: whole components after resolving
symlinks, `machine.full`) and exclude globs; `host.watches()` lists them.
- **Calls.** Every `call`, `send` and `call_async` is recorded in
`host.calls` with its target and timeout; `call_answers` answers them,
and `Harness::answer_calls()` delivers the `Event::CallResult` of each
async call made so far.
- **Identity.** `MockHost::machine(id)` is the daemon's (or viewer's)
machine; `MockHost::singleton(epoch)` runs a daemon plugin as a singleton
(`Context::lease_epoch`); claims (`claimed_elsewhere`, `claims`) work in
both worlds; `host.account_id`, `host.instance_id` and `MockHost::clock`
set the rest.
A key, a paste, a press and a command are gestures, as in the viewer:
`cx.open(surface)` works while the plugin handles one and fails otherwise.
`Surface::offscreen` draws into a buffer the host never sees, for tests of
drawing code alone. Example: the SDK's [`tests/testing_harness.rs`](../tests/testing_harness.rs).
## In the viewers
After the tests pass, run the plugin in both viewers
([agent guide](agent-guide.md#pre-publish-checklist)).
- **Native.** `STANDARD_PLUGIN_DIRS=$PWD/bundle standard` loads a built
bundle from disk in that one viewer, with its manifest's grants, and
nothing is installed on the account.
- **Browser.** The browser viewer (`standardcode.ai/terminal`) has no way
to load a local bundle. Install the built directory or packed `.tar` on
the account from a file on one of its machines: **Install plugin…**, pick
the machine, name the path ([publishing](publishing.md#a-file-on-another-machine)).
That installs it for every viewer of the account, so use a test
account.
Still to write: simulating several viewers (driving and not) and claims,
and testing a companion and its UI half together in one test.