Skip to main content

Module testing

Module testing 

Source
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

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

CallAs 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) (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.

§In the viewers

After the tests pass, run the plugin in both viewers (agent guide).

  • 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). 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.