Lox Nexus

Class: PluginTestHost<T>

Runs your plugin class in a test, without a Nexus server: use it in every plugin's test suite. It drives the plugin through the same PluginRuntime that runs it in a worker, so config validation, sensors, actuators, catalog, panel, actions and webhooks behave as in production, and it records everything the plugin sends. Always await host.stop() at the end (in finally), or the plugin's timers keep the test process alive.

Example

Start the smart plug plugin against a fake device, read a sensor, switch it, stop:

const clock = new TestClock()
const host = await PluginTestHost.start(SmartPlugPlugin, { config: { address }, clock })
try {
  const power = await host.addSensor("meter", { reading: "power" })
  assert.deepEqual(host.values(power), [Value.number(0)])
  const button = await host.addActuator("switch")
  await host.actuate(button, Value.boolean(true))
  await clock.advance(5000) // the next poll
} finally {
  await host.stop()
}

Type Parameters

T

T extends PluginClass = PluginClass

Properties

sent

readonly sent: ({ type: "hello"; version: number; features?: string[]; } | { type: "ready"; } | { type: "failed"; error: string; } | { type: "emit"; signalId: string; value: Value; causeId?: string; } | { type: "status"; status: "running" | "degraded"; message?: string; } | { type: "log"; level: "error" | "warn" | "info" | "debug"; message: string; data?: unknown; causeId?: string; } | { type: "store.set"; key: string; value: unknown; } | { type: "store.delete"; key: string; } | { type: "config.migrated"; config: unknown; version: number; } | { type: "panel.changed"; } | { type: "action.progress"; requestId: number; text: string; fraction?: number; } | { type: "response"; requestId: number; ok: boolean; result?: unknown; error?: string; } | { type: "signal.error"; signalId: string; error: string; } | { type: "signal.recovered"; signalId: string; } | { type: "watch.ack"; seq: number; } | { type: "pong"; seq: number; } | { type: "stopped"; })[] = []

every message the plugin sent, in order: emits, logs ({ type: "log", level, message, data? }), status changes, store writes and responses. Assert on it for what no other member shows, such as a log line.


store

readonly store: Record<string, unknown>

the store as the host would have persisted it; updated by the plugin's set() and delete(), read it back in assertions

Accessors

migratedConfig

Get Signature

get migratedConfig(): { config: unknown; version: number; } | undefined

the config init migrated and reported to be stored, if it did

Returns

{ config: unknown; version: number; } | undefined


plugin

Get Signature

get plugin(): InstanceType<T>

the plugin instance, typed as its class, for reaching into it from a test

Returns

InstanceType<T>


status

Get Signature

get status(): { status: "running" | "degraded"; message: string | undefined; } | { message?: undefined; status: "running"; }

the last status the plugin reported, "running" if none

Returns

{ status: "running" | "degraded"; message: string | undefined; } | { message?: undefined; status: "running"; }

Methods

start()

static start<T>(type, options?): Promise<PluginTestHost<T>>

Starts the plugin and waits for ready.

Type Parameters

T

T extends PluginClass

Parameters

type

T

options?

TestHostOptions = {}

Returns

Promise<PluginTestHost<T>>

Throws

Error with the plugin's message when init fails (invalid config, start threw)


checkConfig()

static checkConfig(type, config, configVersion?, store?): Promise<ConfigCheckResult>

What a host's settings check answers: issues at their fields, and the (migrated) config to save; the plugin doesn't start. With store, also what the migration wrote to a copy of it.

Parameters

type

PluginClass

config

unknown

configVersion?

number

store?

Record<string, unknown>

Returns

Promise<ConfigCheckResult>


testConfig()

static testConfig(type, config): Promise<string | undefined>

runs the plugin's Test connection with this config;

Parameters

type

PluginClass

config

unknown

Returns

Promise<string | undefined>

Throws

Error with its message, also for an invalid config


discover()

static discover(type, field, config?): Promise<object[]>

runs the plugin's Find on the network for a config field; config is the form as filled in so far.

Parameters

type

PluginClass

field

string

config?

unknown

Returns

Promise<object[]>

Throws

Error with its failure


describe()

static describe(type): Promise<{ config: Record<string, unknown>; sensors: object[]; actuators: object[]; watches?: { label: string; description?: string; }; actions?: object[]; testable?: true; panelPush?: true; discoverable?: string[]; }>

the description the UI gets (config and params as JSON Schema), without starting the plugin; throws for invalid static declarations

Parameters

type

PluginClass

Returns

Promise<{ config: Record<string, unknown>; sensors: object[]; actuators: object[]; watches?: { label: string; description?: string; }; actions?: object[]; testable?: true; panelPush?: true; discoverable?: string[]; }>


addSensor()

addSensor(capability, params?, label?): Promise<string>

Adds and starts a sensor, like the host does when the user configures a signal; the call resolves after the sensor's start(). Ids are generated ("sensor-1", "sensor-2", …).

Parameters

capability

string

params?

unknown = {}

checked against the capability's params schema, defaults applied; default {}

label?

string

the signal's name (this.signal.label); default "" like an older host

Returns

Promise<string>

the new signal's id;

Throws

Error for unknown capabilities, invalid params or a failing start


addActuator()

addActuator(capability, params?, label?): Promise<string>

Adds an actuator like addSensor() does for a sensor.

Parameters

capability

string

params?

unknown = {}

label?

string

Returns

Promise<string>

the new signal's id; throws like addSensor()


remove()

remove(id): Promise<void>

disposes the signal's sensor or actuator, as when the user deletes it; unknown ids are ignored

Parameters

id

string

Returns

Promise<void>


values()

values(id): Value[]

every value the signal emitted so far

Parameters

id

string

Returns

Value[]


next()

next(id, timeoutMs?): Promise<Value>

waits for the next value emitted after this call (earlier ones are in values());

Parameters

id

string

timeoutMs?

number = 2000

Returns

Promise<Value>

Throws

after timeoutMs


actuate()

actuate(id, value, causeId?, replayed?): Promise<void>

runs an actuator;

Parameters

id

string

value

Value

causeId?

string

the bus message it answers; emits and logs while it runs carry it

replayed?

boolean = false

delivered again by the host (a force after a restart), as run() sees in command.replayed

Returns

Promise<void>

Throws

Error with the message run() threw


catalog()

catalog(): Promise<CatalogNode[]>

calls Plugin.catalog()

Returns

Promise<CatalogNode[]>


panel()

panel(): Promise<{ type: "keyValue"; items: object[]; } | { type: "table"; columns: string[]; rows: (string | { text?: string; tone?: "error" | "ok" | "warn" | "info" | "muted"; until?: number; since?: number; at?: number; })[][]; rowActions?: object[][]; rowCatalog?: (string | null)[]; } | { type: "qr"; data: string; caption?: string; } | { type: "json"; data: unknown; } | { type: "tiles"; tiles: object[]; } | { type: "notice"; tone: "error" | "ok" | "warn" | "info" | "muted"; title?: string; text: string; link?: { label: string; href: string; }; } | { type: "sections"; sections: object[]; } | undefined>

calls Plugin.panel(); rejects a panel the host would refuse, e.g. a javascript: link or 13 tiles

Returns

Promise<{ type: "keyValue"; items: object[]; } | { type: "table"; columns: string[]; rows: (string | { text?: string; tone?: "error" | "ok" | "warn" | "info" | "muted"; until?: number; since?: number; at?: number; })[][]; rowActions?: object[][]; rowCatalog?: (string | null)[]; } | { type: "qr"; data: string; caption?: string; } | { type: "json"; data: unknown; } | { type: "tiles"; tiles: object[]; } | { type: "notice"; tone: "error" | "ok" | "warn" | "info" | "muted"; title?: string; text: string; link?: { label: string; href: string; }; } | { type: "sections"; sections: object[]; } | undefined>


action()

action(id, input?): Promise<string | undefined>

runs an action like the Run button does; resolves with its message, rejects with its failure

Parameters

id

string

input?

unknown = {}

Returns

Promise<string | undefined>


actionAnswer()

actionAnswer(id, input?): Promise<string | { message?: string; panel?: { type: "keyValue"; items: object[]; } | { type: "table"; columns: string[]; rows: (string | { text?: string; tone?: "error" | "ok" | "warn" | "info" | "muted"; until?: number; since?: number; at?: number; })[][]; rowActions?: object[][]; rowCatalog?: (string | null)[]; } | { type: "qr"; data: string; caption?: string; } | { type: "json"; data: unknown; } | { type: "tiles"; tiles: object[]; } | { type: "notice"; tone: "error" | "ok" | "warn" | "info" | "muted"; title?: string; text: string; link?: { label: string; href: string; }; }; } | undefined>

like action(), with the whole answer: a text, or a message with its panel part (on hosts with "action.result")

Parameters

id

string

input?

unknown = {}

Returns

Promise<string | { message?: string; panel?: { type: "keyValue"; items: object[]; } | { type: "table"; columns: string[]; rows: (string | { text?: string; tone?: "error" | "ok" | "warn" | "info" | "muted"; until?: number; since?: number; at?: number; })[][]; rowActions?: object[][]; rowCatalog?: (string | null)[]; } | { type: "qr"; data: string; caption?: string; } | { type: "json"; data: unknown; } | { type: "tiles"; tiles: object[]; } | { type: "notice"; tone: "error" | "ok" | "warn" | "info" | "muted"; title?: string; text: string; link?: { label: string; href: string; }; }; } | undefined>


http()

http(request): Promise<Required<{ status: number; headers?: Record<string, string>; body?: string; }>>

Calls a webhook route; the method defaults to POST. An unknown route answers 404, a throwing handler rejects.

Parameters

request

Partial<{ method: string; path: string; query: Record<string, string>; headers: Record<string, string>; body: string; }> & Pick<{ method: string; path: string; query: Record<string, string>; headers: Record<string, string>; body: string; }, "path">

Returns

Promise<Required<{ status: number; headers?: Record<string, string>; body?: string; }>>


unhandledRejection()

unhandledRejection(reason): void

Hands reason to the plugin as the worker would for an unhandled promise rejection. Real ones only reach the plugin with forwardRejections; otherwise node:test keeps handling them.

Parameters

reason

unknown

Returns

void


logLevel()

logLevel(level): Promise<void>

the lowest log level the host keeps, as when the user switches debug logging on or off; lines below it aren't sent

Parameters

level

"error" | "warn" | "info" | "debug"

Returns

Promise<void>


watchSignals()

watchSignals(signals): Promise<void>

the signals the user chose, for a plugin that declares watches; replaces the previous list

Parameters

signals

object[]

Returns

Promise<void>


pushValues()

pushValues(values, dropped?): Promise<void>

Delivers values of watched signals like the host does; resolves once the plugin acknowledged the batch, i.e. its watched() finished. ts and kind default to now and "state".

Parameters

values

(Omit<{ signalId: string; ts: number; kind: "state" | "command"; value: Value; forced?: true; initial?: true; }, "kind" | "ts"> & Partial<Pick<{ signalId: string; ts: number; kind: "state" | "command"; value: Value; forced?: true; initial?: true; }, "kind" | "ts">>)[]

dropped?

number = 0

Returns

Promise<void>


stop()

stop(): Promise<void>

stops the plugin like the host would; resolves when it reported stopped

Returns

Promise<void>