PluginsEarly
Test and debug
Check a plugin, test it on fixtures with fake time, draw its views as text, work on it live, and read its logs and status.
A plugin is checked and tested without the app, and without a network.
Everything on this page runs on your machine with gr.
Check
gr plugin check .
gr plugin check reads the folder, the manifest and the script, and names
each problem with its file, line and code:
plugin.toml:6:1: error api.newer: events[0]: the event "files.changed" arrived in api 0.2; this plugin says api = "0.1" (set api = "0.2")
It finds a misspelt key, an id that does not start with the plugin’s name, a
key bound to a command the plugin does not have, a handler for an event the
manifest does not list, a method whose request is missing from [allow], and
a script that uses a global plugins do not have (fetch, process,
require). --json prints the same as data, with the folder’s hash and every
handler the script registers. Errors lists
every code.
Tests
A plugin’s tests are the *.test.ts files beside its plugin.toml. Run them
with the checks:
gr plugin check . --test
ok plugin.test.ts: hello says hello (5 ms)
ok plugin.test.ts: the panel lists Markdown files only (5 ms)
ok plugin.test.ts: Enter opens the chosen file (6 ms)
the tests used: fs:read:workspace
greeter passes (hash 6f5b8e0a0cb9)
gr plugin test . does the same. Each test gets a fresh copy of the plugin in
a pretend host:
- Time is fake. It starts at 2026-01-01 and moves only when the test says.
- There is no network and no program to run. A file, request or program the test’s world does not answer for is refused.
- The workspace is the world you give it, starting from the folder’s
fixtures/.
The last line before the result says which requests the tests used, so you can see a test reach for something you did not expect.
The world a test runs in
fixtures/files/ is the pretend workspace: every file in it is a file of the
project. fixtures/world.json holds the rest. A test lays more over both with
t.fixtures:
t.fixtures({
files: { "README.md": "# Hi", "docs/guide.md": "Guide" },
settings: { max_notes: 10 },
fetch: { "https://api.github.com/repos/o/r": { body: '{"stars": 3}' } },
exec: { "rg": { stdout: "a.go:1:TODO\n" } },
});
files adds to fixtures/files/; every other key replaces what the folder
had. A world can also hold the map’s nodes and edges, your agents and
what they said, the state and store the plugin keeps, open docs,
worktrees, git changes and what a language server reports.
Test API has every key and its shape.
Writing tests
t drives the plugin as a person and the app would:
import { test } from "graphide/test";
test("Enter opens the chosen file", async (t) => {
t.fixtures({ files: { "a.md": "A", "b.md": "B" } });
const v = await t.mount("greeter.notes");
await v.keys("j", "<CR>");
t.expect(t.commands()).toEqual([{ cmd: "open.code", path: "b.md" }]);
});
| To | Use |
|---|---|
| Run a command | await t.command("name", ...args) |
| Show a view | const v = await t.mount("id", { size: [w, h] }) |
| Read a view | v.text(), the view as text; v.tree(), its elements; v.selected(key) |
| Press keys in it | await v.keys("j", "<CR>"), in Vim’s notation |
| Type in its input | await v.keys("/"), then await v.type("words") |
| Open a picker, choose | await t.pick("id", "query"), await t.choose("id", entry) |
| Send any event | await t.event("files.changed", { ... }) |
| Call a tool as an agent | await t.tool("name", { ... }) |
| Move time on | await t.advance(ms) |
| Answer a question it asked | t.asks(), then t.answer(value) |
| See what it did | t.commands(), t.messages(), t.status(id), t.badges(id), t.state(key), t.logs() |
t.commands() is everything the plugin asked the app to do, in order. Opening
b.md above needed no handler: the item’s open did it, and the test sees it
all the same.
t.expect(value) takes toBe, toEqual, toContain, toMatch,
toHaveLength, toBeGreaterThan, toBeLessThan, toBeTruthy, toBeFalsy,
toBeDefined, toBeUndefined and toBeNull, each also after .not.
Draw a view without the app
gr plugin render . --view greeter.notes
Markdown in this project
> notes.md
This is the same text v.text() returns and the review shows before anyone
approves. --state world.json draws it over another world:
echo '{ "files": { "docs/guide.md": "Guide", "CHANGELOG.md": "Changes" } }' > world.json
gr plugin render . --view greeter.notes --state world.json
Markdown in this project
> CHANGELOG.md
docs/guide.md
notes.md
--png shot.png also draws a picture, --size 400x600 sets its size and
--theme ink or --theme marble its colours.
Work on it live
An installed plugin runs the version you approved. Each change to its files is a new version, and waits for approval. While you work on it, run it as it stands:
gr plugin dev .
The plugin loads now, and again each time you save. Dev mode grants nothing new: the plugin may do what you approved, and nothing if you never approved it. A save that asks for more runs without it until you approve. A save that does not load leaves the last good version running.
Dev mode ends with gr plugin dev --off, after eight hours, or when the daemon
stops. Only a person can switch it on, on their own terminal.
When it goes wrong in the app
gr plugin status greeter # its process, its handlers' times, its last errors
gr plugin status greeter --logs # with its log lines
gr plugin logs greeter --since 2026-10-08T12:00:00Z
gr plugin reload greeter # load it afresh, and give it another go
console.log and the other console methods write to the plugin’s log, with
the file and line, and so does a handler that throws. gr plugin status with no name lists every
plugin’s state.
A plugin that keeps failing, runs past its time or memory, or crashes over and
over is switched off for the session. Fix it, then gr plugin reload gives it
another go. When this happens within an hour of approving a new version, the
version approved before it comes back by itself; gr plugin rollback does the
same by hand.
These docs describe Graphide 0.2.16, the latest release. Something wrong or missing? Tell us onDiscord or at hello@graphide.com.