standard-plugin-sdk 0.1.1

Write Standard Code plugins in Rust: wasm components against standard:plugin@2.0.0
Documentation
# 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

| 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]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.