Testing and debugging¶
The tools are built into rospanel:
rospanel plugin test . # run test.js
rospanel plugin dev . # an interactive console that reloads on save
rospanel plugin validate . # check the package the way the panel will
In tests and in dev the plugin runs in the same sandbox as in the panel: the same memory and
database limits, the same SQLite database with your migrations. Its database is created afresh on every test
or dev run.
dev.config.json¶
The settings the plugin runs with in test and dev — what the operator types into the form:
{"settings": {"token": "test-token", "mode": "test", "limit": "100"}}
Values are strings, as in panel.config. With a required field empty, the plugin does not
start: fill in the settings first: token.
test.js¶
test.js runs in a sandbox of its own, beside the plugin. It calls the plugin through
plugin.*, and mock.* intercepts the plugin's requests.
test("test name", () => {
mock.http("https://api.example.com/", { status: 200, body: { ok: true } });
const r = plugin.call("onAction", { key: "sync", user_ids: [] });
assert.equal(r.ok, true);
});
Tests run in the order declared. Mocks are reset before each test; the plugin's database
and kv are not: what one test wrote, the next one sees.
test and assert¶
test(name, fn) |
declare a test; an exception inside fn fails it |
assert.equal(actual, expected, msg?) |
a deep comparison through JSON; the order of object keys does not matter |
assert.ok(value, msg?) |
the value is truthy |
assert.throws(fn, msg?) |
fn throws |
plugin — call the plugin¶
plugin.call(export, arg?) |
call an export and return its answer: plugin.call("payment.create", {...}), plugin.call("userFields", 42), plugin.call("bot.onCommand", {...}) |
plugin.event(name, data) |
deliver an event to onEvent as the panel does: {id, event, created_at: now, data} |
plugin.cron(name) |
run a scheduled job |
plugin.kv.get/set/delete/list |
read and seed the plugin's kv |
plugin.db.exec/query |
read and seed the plugin's database |
An exception in the plugin propagates to the test — assert.throws(() => plugin.event(...))
checks that the plugin failed.
mock — answers to the plugin's requests¶
mock.http(prefix, {status?, headers?, body?}) |
the answer to panel.http.fetch for URLs starting with prefix. body as a string or an object (becomes JSON), status 200 by default |
mock.api(method, path, {status?, body?}) |
the answer to panel.api. path exact (/v1/users/7) or ending in * (/v1/users/*) |
mock.calls() |
what the plugin asked for: [{kind: "http"\|"api", method, url, body}] |
mock.reset() |
forget the mocks and the recorded calls |
- An unmocked
fetchin a test throws (no mock for GET https://… — add mock.http(…)); an unmockedpanel.apianswers501with a hint. A test never goes to the network. netholds for mocks too: a request to a host not in the manifest is refused, as in the panel.- A
formor blob body is recorded inmock.calls()as text (the first MB) — check multipart fields withincludes.
crypto — sign what a test sends the plugin¶
Tests have crypto.hash, crypto.hmac, crypto.sign, crypto.jwt — the same functions as
panel.crypto. A test signs a callback "from the payment system" with them:
test("signed webhook is accepted", () => {
const body = JSON.stringify({ id: "inv-1", status: "paid", amount: 150 });
const sign = crypto.hmac("sha256", "k3y", body);
const r = plugin.call("payment.webhook", { body, headers: { "x-signature": sign } });
assert.equal(r.status, "paid");
});
An example¶
A plugin with a sync job that reads users through the API and remembers how many there are:
test("sync counts the users", () => {
mock.api("GET", "/v1/users*", { body: { data: [{ id: 7 }, { id: 8 }] } });
plugin.cron("sync");
assert.equal(plugin.kv.get("last_sync").count, 2);
assert.equal(mock.calls()[0].url, "/v1/users?limit=1000");
});
test("kv from the previous test is still there", () => {
assert.ok(plugin.kv.get("last_sync"));
});
More in the test.js of every plugin in examples/plugins.
The output:
✓ sync counts the users
✗ kv from the previous test is still there
expected a truthy value, got undefined
1 passed, 1 failed
The exit code is non-zero when any test fails — rospanel plugin test fits a CI.
What tests check, and what they do not¶
As in the panel: the manifest (the permissions points need included), the exports, net for
mocked requests too, the memory limit, the database quota and its refused statements, kv and
blob limits.
Not in tests — check these on a real panel:
panel.apipermissions. A mock answers whatever the plugin asks; with--panelthe key's permissions apply, not the plugin's.- Read-only
panel.apiin decisions,bot.menuandtransformSubscription. - Call times. Every
plugin.callgets 30 s, whatever the export's own limit (0.3 s for a decision). - Pausing. Failed calls never pause the plugin, and a failed surface is not skipped for 30 s.
- Event delivery.
plugin.eventcallsonEventonce: no retries, no loop detection, nochannel.send. onHttpanswers are returned as the plugin made them, without the panel's header rules.- Who sees user fields, buttons and widgets in the admin panel.
dev — the console¶
rospanel plugin dev .
my-plugin is running. Type help for commands.
> event user.created {"id": 1, "name": "Ann"}
> call userFields 1
{"email":"ann@example.com"}
> cron sync
> kv user/
> sql SELECT * FROM orders LIMIT 5
> mock api GET /v1/users/1 {"data": {"id": 1, "name": "Ann"}}
> mock http https://api.example.com/ {"ok": true}
> logs
> reload
> quit
| Command | |
|---|---|
event <name> [json] |
deliver an event to onEvent |
call <export> [json] |
call an export: call payment.create {"order_id": 1, "amount_rub": 100} |
cron <name> |
run a job |
kv [prefix] |
show kv |
sql <query> |
query the plugin's database |
mock api <METHOD> <path> <json> |
an answer for panel.api |
mock http <url-prefix> <json> |
an answer for panel.http.fetch |
logs |
the plugin's log |
reload |
reload now (files are watched anyway) |
help, quit |
How it differs from test:
- The network is real. An unmocked
fetchgoes out to the internet (only to hosts innet) — handy for trying a real service with a token fromdev.config.json. - Reloads. Saving any file reloads the plugin as an update: the database and
kvstay, new migrations apply. If you edited a migration already applied, ordev.config.json, restartdev. - A real panel. With
--paneland--key,panel.apigoes to it:
rospanel plugin dev . --panel https://vpn.example.com/<API path> --key rp_…
The API path and keys are in the panel's Settings → API. Use a key with only the permissions you need: changes through it are real.
validate — before publishing¶
rospanel plugin validate .
rospanel plugin validate my-plugin-1.2.0.zip
ok: my-plugin 1.2.0 — 14 KB, exports onEvent, sync, userFields
validate checks the manifest, the package's contents and the theme's styles, loads the code,
checks the exports, and lists every problem.
Debugging in the panel¶
- Plugins → Log — the last 1000 lines: your
panel.log/console, failed calls with the reason, the plugin being paused. - Plugins → Code — what is installed now.
- The panel's journal — what the plugin changed through
panel.api(actorplugin:<id>), and presses of its buttons. - A paused plugin's card says why and when the panel retries it; press Start again once the cause is fixed.
Common errors and what they mean are in limits and errors.