6. Tests, build and install
In this last step you write tests that run your plugin against the fake plug, build it into a single file, and install it from a ZIP file the way another person would. You see green tests, and your plugin on the Installed tab.
Tests
A plugin that talks to a device breaks when the device changes or when you change the plugin, and you only notice when the lamp doesn't switch. Tests catch that in seconds. The SDK runs your plugin in-process through the same runtime that runs it in a worker, so you test the real code paths without a Nexus server and without a thread. See Testing plugins for everything the test host offers.
The tests talk to a real fake plug, started on a free port for each test, rather than to a mocked
fetch. Copy the fake into your plugin:
mkdir -p plugins/smart-plug/src/fake
cp examples/smart-plug/src/fake/FakePlug.ts plugins/smart-plug/src/fake/
Then create plugins/smart-plug/src/SmartPlugPlugin.test.ts. It uses node:test and
node:assert/strict, and sits next to the code it tests.
import assert from "node:assert/strict"
import http from "node:http"
import type { AddressInfo } from "node:net"
import { afterEach, beforeEach, test } from "node:test"
import { PluginTestHost, TestClock } from "@lox-nexus/plugin-sdk/testing"
import { Value } from "@lox-nexus/plugin-sdk/values"
import { FakePlug } from "./fake/FakePlug.ts"
import { SmartPlugPlugin } from "./SmartPlugPlugin.ts"
First, a helper. Every test gets a fresh plug, and started() starts the plugin against it:
let plug: FakePlug
let address: string
// every test gets its own plug on a free port, so tests can't see each other's switching
beforeEach(async () => {
plug = new FakePlug()
address = await plug.start()
})
afterEach(() => plug.stop())
/** starts the plugin against the fake plug and waits for its first reading */
async function started(clock = new TestClock()) {
const host = await PluginTestHost.start(SmartPlugPlugin, { config: { address }, clock })
await host.plugin.poll()
return host
}
PluginTestHost.start(SmartPlugPlugin, { config, clock })does what a real start does: it validates the config, builds the plugin and runsstart(). Theconfigis what the user would type; defaults are applied.TestClockis the plugin's clock. Time only moves when you say so, so a test never sleeps.
The connection test
test("Test connection names the plug, and says what is wrong when it can't reach it", async () => {
assert.equal(await PluginTestHost.testConfig(SmartPlugPlugin, { address }), "Connected to \"Desk lamp\", which is off")
const unplugged = new FakePlug()
const nowhere = await unplugged.start()
await unplugged.stop()
await assert.rejects(PluginTestHost.testConfig(SmartPlugPlugin, { address: nowhere }), /can't reach the plug at http:\/\/127\.0\.0\.1:\d+/)
})
PluginTestHost.testConfig() runs your static test() without starting the plugin, exactly as the
Test connection button does. The second half starts a plug, stops it, and checks that the error
is the readable one.
An answer that isn't a plug's
test("an answer that isn't a plug's is refused with a message, not read as values", async () => {
const other = http.createServer((_request, response) => void response.end("<html>router login</html>"))
await new Promise<void>(resolve => other.listen(0, "127.0.0.1", resolve))
const { port } = other.address() as AddressInfo
try {
await assert.rejects(PluginTestHost.testConfig(SmartPlugPlugin, { address: `http://127.0.0.1:${port}` }), /answered something unexpected/)
} finally {
await new Promise(resolve => other.close(resolve))
}
})
This is the check from the client step, tested: a server that answers something else is refused with a message that names the address.
Sensors
test("the sensors send the plug's readings, and again after each poll", async () => {
const clock = new TestClock()
const host = await started(clock)
try {
const power = await host.addSensor("meter", { reading: "power" })
const on = await host.addSensor("switched-on")
assert.deepEqual([host.values(power), host.values(on)], [[Value.number(0)], [Value.boolean(false)]])
plug.on = true
const next = host.next(on)
await clock.advance(5000)
assert.deepEqual(await next, Value.boolean(true))
assert.deepEqual(host.values(power).at(-1), Value.number(60))
} finally {
await host.stop()
}
})
host.addSensor("meter", { reading: "power" }) adds a signal like the user does, by capability
id and params. host.values(signal) is what it emitted so far. clock.advance(5000) moves
time forward and runs the poll timer. host.next(signal) waits for the next value, and is called
before the time moves, so the value can't slip past.
The actuator
test("the switch switches the plug, and Switched on follows at once", async () => {
const host = await started()
try {
const on = await host.addSensor("switched-on")
const button = await host.addActuator("switch")
await host.actuate(button, Value.boolean(true))
assert.equal(plug.on, true)
assert.deepEqual(host.values(on), [Value.boolean(false), Value.boolean(true)])
} finally {
await host.stop()
}
})
host.actuate(button, value) sends a command and resolves when run() has finished. A plug that
switched on and a sensor that followed is the whole feature.
The plug that goes away
test("an unplugged plug degrades the instance, which recovers when it is back", async () => {
const host = await started()
try {
const port = Number(new URL(address).port)
await plug.stop()
await host.plugin.poll()
assert.equal(host.status.status, "degraded")
assert.match(host.status.message ?? "", /can't reach the plug/)
plug = new FakePlug()
await plug.start(port)
await host.plugin.poll()
assert.equal(host.status.status, "running")
} finally {
await host.stop()
}
})
host.status is the instance's reported status. The test stops the fake, polls, checks
degraded, brings a plug back on the same port and checks running.
Catalog and panel
test("the catalog offers every signal with its value now, and the panel shows the plug", async () => {
plug.on = true
const host = await started()
try {
const [node] = await host.catalog()
assert.equal(node?.label, "Desk lamp")
assert.deepEqual(node?.entries?.map(entry => [entry.label, entry.value]), [
["Power", Value.number(60)], ["Energy", Value.number(0)], ["Switched on", Value.boolean(true)], ["Switch", undefined]
])
const panel = await host.panel()
assert.equal(panel?.type, "keyValue")
assert.deepEqual(panel.type === "keyValue" && panel.items.map(item => item.value), [
"Desk lamp", { text: "on", tone: "ok" }, "60 W", "0 kWh", { at: 0 }
])
} finally {
await host.stop()
}
})
Always await host.stop() in finally. It disposes the plugin, which clears its timers. A test
that leaves a timer behind keeps node --test from exiting.
Run them
For this file alone, from the repository root:
node --conditions=@lox-nexus/source --import tsx --test plugins/smart-plug/src/SmartPlugPlugin.test.ts
ℹ tests 6
ℹ pass 6
ℹ fail 0
npm test runs every test of the repository, yours included, because it picks up
plugins/*/src/**/*.test.ts. So do npm run lint and npm run typecheck; run all three before you
call a change done.
Build
Nexus runs your plugin from src/ while you develop. To hand it to someone, build one
self-contained file:
npm run build -w @lox-nexus/plugin-smart-plug
This writes plugins/smart-plug/dist/plugin.js, which is what the manifest's entry names. It
holds your code and everything it imports, including the SDK and zod, so it is large (hundreds of
kilobytes) and needs no node_modules next to it. Installing never runs npm. Native modules can't be
bundled; see Publishing a plugin.
Install it as a ZIP file
A ZIP file needs package.json and the bundle named by entry, nothing else. Pack them:
cd plugins/smart-plug
zip -r ../../smart-plug-0.1.0.zip package.json dist
Without zip, Python can do it (also on Windows):
python3 -m zipfile -c ../../smart-plug-0.1.0.zip package.json dist
or PowerShell: Compress-Archive -Path package.json, dist -DestinationPath ..\..\smart-plug-0.1.0.zip.
Windows PowerShell 5 may write backslashes into the entry names, which the installer refuses; use
the Python command or WSL then.
The layout is what matters: package.json at the root of the archive, and the bundle at the path
entry names (dist/plugin.js). A README.md next to package.json is shown on the plugin's card
as Read me.
Your development copy and the ZIP have the same plugin id, and a development copy takes precedence. To see the installed one, restart the server with the development directory switched off:
NEXUS_DEV_PLUGINS_DIR= npm run dev
Then, in the web interface:
- Open Plugins and the tab Installed, and press Install plugin.
- Choose the tab ZIP file, pick
smart-plug-0.1.0.zip, tick I trust this source, and press Fetch and check. Trusting matters: checking starts the plugin's code once in a throwaway worker to learn which sensors and actuators it offers. - The review shows the plugin's name and version, where it comes from (the file name and its sha256), the bundle's sha256, and what changes for existing instances. Press Install. (The review may list New required settings: address for an instance that already has one. That is a known quirk when no version was installed before; ignore it, the install works.)
The card Smart plug appears on the Installed tab, and Add instance offers it. Everything you built runs from the installed copy now.
To release a new version, raise version in package.json, build, pack and install again: running
instances restart on it, and the version before stays for a rollback. The same version with a
different bundle is refused, so raise it whenever the bundle changes. Other ways to hand out a
plugin (a git repository), what a README should say, and the license are on the
Publishing page.
Where to go from here
You have used a settings form, a test, a shared poll, sensors, an actuator, a status, a catalog and a panel. The plugin guide has a page for each, and for what the tutorial left out:
- Webhooks, when the other system pushes to you.
- Buttons on the instance page, for pairing a device or opening a window.
- Watching signals, to write an exporter.
- Settings: secrets, tokens, a Find button, migrating settings between versions.
- Lifetimes, timers and time zones and Logging, store and status.
- Values: the types a signal can carry.
- SDK at a glance when you need a signature.
The first-party plugins, such as Fronius and Home Assistant, are built from the same pieces.