Expand description
§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.
#[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
| Call | As the viewer |
|---|---|
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:
| Call | As standardd |
|---|---|
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) |
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)(orscript_args(program, &[args], ...)) says what a program does when the plugin spawns it: its stdout and stderr (delivered asProcessOutputfor a piped stream) and its exit status (ProcessExited, and whatwaitanswers). Every spawn is recorded inhost.processes()with its distinct pid, arguments, the environment it got (the plugin’s, set withMockHost::env, lessenv_remove, plusenv) and its directory (the command’s, elseHOME). An unscripted child runs untilDaemonHarness::exit;waiton it answersUnavailablerather than block.process.exec:grants andmachine.fullare checked as the host checks them. - Panes.
panes::createopens a pane asstandardddoes: in a directory inside one ofhost.account.projectsof this machine (by whole components after symlinks;Invalidelsewhere), listed inhost.panesat generation 1, and recorded inhost.pane_launches()with its project, directory, command and variables. - HTTP.
daemon::httpworks 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). Thefetch:grants are checked with the host’s own rule (a refusal isError::GrantDenied { grant: "fetch:<host>" }and counts as a denial), a body overmax_bodyisInvalid, and a request nothing answers isUnavailable.host.http_requests()lists every request let through, with its method, URL, headers, body, timeout and cap, andhost.http_pending()counts answers not used yet. Plugin code needs nocfg(target_arch)around its requests. - Watches.
watchis 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,sendandcall_asyncis recorded inhost.callswith its target and timeout;call_answersanswers them, andHarness::answer_calls()delivers theEvent::CallResultof 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_idandMockHost::clockset 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.
§In the viewers
After the tests pass, run the plugin in both viewers (agent guide).
- Native.
STANDARD_PLUGIN_DIRS=$PWD/bundle standardloads 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.taron the account from a file on one of its machines: Install plugin…, pick the machine, name the path (publishing). 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.