jest-mock-xapi
v1.0.4
Published
A jest mock xAPI module validating and testing Cisco Collaboration Device macros
Readme
Jest Mock xAPI
Run Cisco RoomOS JavaScript macro tests in Node.js while preserving the normal import xapi from "xapi" developer experience.
This project provides a Jest-compatible mock of the RoomOS xapi module so JavaScript macros for Cisco RoomOS devices can be tested locally in a standard Node environment. It exists so macro developers can validate macro behavior without having to deploy to a device for every change or lose the familiar RoomOS import pattern in their source files. It is intended for developers building and maintaining Cisco RoomOS macros who want repeatable automated tests around xAPI commands, status reads, configuration changes, and emitted events.
Overview
The package exposes a mocked xapi module that mirrors the top-level Command, Status, Config, and Event areas that RoomOS macro developers already use. Internally, it uses a schema-backed proxy so only valid xAPI paths are available, and each path resolves to a Jest mock function that can be inspected with normal Jest matchers. Commands resolve as promises so command handlers look like the real RoomOS async API, while status, configuration, and event paths support setting values, subscribing to changes, and emitting updates from tests. In practice, a macro test imports the macro, uses the mock xAPI to seed state or emit events, and then asserts that the macro called the expected xAPI command or updated the expected path in response.
Setup
Prerequisites & Dependencies:
- Node.js 20 or later is recommended for local development and testing.
- A Jest-based test setup is expected in the macro project that consumes this package.
- The macro under test should import
xapiexactly as it would on a Cisco RoomOS device:import xapi from "xapi";. - This package is intended for local macro testing and assumes the macro developer is comfortable writing JavaScript or TypeScript unit tests with Jest.
Installation Steps:
Install
jest-mock-xapiand Jest in your macro project.npm install --save-dev jest jest-mock-xapiChoose one Jest integration option.
Option 1 (recommended): Map
xapidirectly tojest-mock-xapiwithmoduleNameMapper.{ "jest": { "moduleNameMapper": { "^xapi$": "jest-mock-xapi" } } }Option 2: Register the virtual
xapimodule through the package's setup entrypoint if you prefer a setup-file workflow.{ "jest": { "setupFiles": ["jest-mock-xapi/register"] } }Add Jest test scripts to your macro project's
package.json.{ "scripts": { "test": "NODE_OPTIONS=--experimental-vm-modules jest --runInBand", "test:watch": "NODE_OPTIONS=--experimental-vm-modules jest --watchAll --runInBand" } }With option 1 in place, write tests that import your macro, set xAPI values or emit xAPI changes, and then assert the macro responded correctly.
import { beforeEach, describe, expect, it, jest } from "@jest/globals"; describe("my roomos macro", () => { beforeEach(() => { jest.resetModules(); }); it("dials when a panel event is triggered", async () => { const { default: xapi } = await import("xapi"); jest.clearAllMocks(); xapi.removeAllListeners(); await import("./my-macro.js"); xapi.Event.UserInterface.Extensions.Panel.Clicked.emit({ PanelId: "speed-dial-panel", }); expect(xapi.Command.Dial).toHaveBeenCalledWith({ Number: "[email protected]", }); }); });Run the tests from your macro project.
Run your tests once with:
npm testRun your tests continuously upon macro/test code changes with:
npm run test:watch
The package now has a split API:
jest-mock-xapiexports the mock object itself and is the recommended target formoduleNameMapper.jest-mock-xapi/registerregisters a virtualxapimodule for Jest setup-file workflows.
Usage
The expected development flow is that a macro developer installs jest-mock-xapi, keeps production macro code written for the native RoomOS runtime, and uses Jest to control mock device state from tests. In practice, a test imports the macro, seeds status or config values, emits events or updates, and then asserts the macro reacted with the expected xAPI calls.
Reset state between tests
Most test suites should clear mocks and listeners before each test so one scenario does not leak into the next.
import { beforeEach, jest } from "@jest/globals";
beforeEach(async () => {
jest.resetModules();
const { default: xapi } = await import("xapi");
jest.clearAllMocks();
xapi.removeAllListeners();
});Assert xCommand calls
Commands are Jest mocks, so you can assert on them with normal Jest matchers.
import { expect, it } from "@jest/globals";
it("dials the requested destination", async () => {
const { default: xapi } = await import("xapi");
await someMacroFunction();
expect(xapi.Command.Dial).toHaveBeenCalledWith({
Number: "[email protected]",
});
});Use RoomOS runtime globals
The mock installs the RoomOS _main_module_name() global when jest-mock-xapi is loaded. It returns the name of the calling macro file without the source extension, matching the RoomOS behavior used by self-managing macros.
import xapi from "xapi";
const macroName = _main_module_name();
xapi.Command.Macros.Macro.Deactivate({ Name: macroName });For example, calling _main_module_name() from self-deactivating-macro.js returns "self-deactivating-macro".
Seed leaf status and config values
Use set() to prepare mock device state before importing a macro or invoking a handler.
import { expect, it } from "@jest/globals";
it("reads the prepared default volume", async () => {
const { default: xapi } = await import("xapi");
xapi.Config.Audio.DefaultVolume.set(40);
xapi.Status.Audio.Volume.set(20);
expect(xapi.Config.Audio.DefaultVolume.get()).toBe(40);
expect(xapi.Status.Audio.Volume.get()).toBe(20);
});Select a RoomOS product
Set Status.SystemUnit.ProductPlatform to a public product name when a test should enforce product-specific xAPI availability. Once set to a known product, the mock rejects xAPI paths that are not available on that product and validates product-specific configuration values.
import { expect, it } from "@jest/globals";
it("handles Desk Pro xAPI differences", async () => {
const { default: xapi } = await import("xapi");
xapi.Status.SystemUnit.ProductPlatform.set("Desk Pro");
xapi.Config.Video.Output.Connector[1].MonitorRole.set("Auto");
await expect(
xapi.Config.Video.Output.Connector[3].MonitorRole.set("Auto"),
).rejects.toEqual({
code: -32602,
message: "No match on Path argument",
});
await expect(
xapi.Config.Video.Output.Connector[1].MonitorRole.set("PresentationOnly"),
).rejects.toEqual({
code: -32602,
message: "Bad usage: Missing or invalid parameter(s).",
});
});If no known product platform has been set, the mock keeps the broad schema-backed behavior and does not apply product-specific filtering.
Read full status or config branches
The mock supports aggregate get() calls on root paths and indexed branches, similar to the real xAPI module.
import { expect, it } from "@jest/globals";
it("returns full config branches", async () => {
const { default: xapi } = await import("xapi");
xapi.Config.Cameras.Camera[1].Brightness.Mode.set("Manual");
xapi.Config.Cameras.Camera[2].Brightness.Mode.set("Auto");
expect(xapi.Config.get()).toHaveProperty("Audio");
expect(xapi.Config.Cameras.Camera[1].get()).toEqual({
Brightness: {
Mode: "Manual",
},
});
expect(xapi.Config.Cameras.Camera.get()).toEqual([
{ Brightness: { Mode: "Manual" } },
{ Brightness: { Mode: "Auto" } },
]);
expect(xapi.Config.Cameras.Camera["*"].get()).toEqual([
{ Brightness: { Mode: "Manual" } },
{ Brightness: { Mode: "Auto" } },
]);
});Emit xEvent payloads
Use emit() on event paths to simulate the same payloads a RoomOS device would send to a macro.
import { expect, it } from "@jest/globals";
it("reacts to a panel press", async () => {
const { default: xapi } = await import("xapi");
await import("./my-macro.js");
xapi.Event.UserInterface.Extensions.Panel.Clicked.emit({
PanelId: "speed-dial-panel",
});
expect(xapi.Command.Dial).toHaveBeenCalledWith({
Number: "[email protected]",
});
});Subscribe to leaf updates
Leaf subscriptions behave like the real macro API and receive the updated value directly.
import { expect, it, jest } from "@jest/globals";
it("notifies leaf status listeners", async () => {
const { default: xapi } = await import("xapi");
const handler = jest.fn();
xapi.Status.Audio.Volume.on(handler);
xapi.Status.Audio.Volume.set(55);
expect(handler).toHaveBeenCalledWith(55);
});Subscribe to root or branch updates
Root and branch listeners receive a nested payload scoped to the changed branch.
import { expect, it, jest } from "@jest/globals";
it("notifies root listeners with relative path payloads", async () => {
const { default: xapi } = await import("xapi");
const statusHandler = jest.fn();
const configHandler = jest.fn();
const eventHandler = jest.fn();
xapi.Status.on(statusHandler);
xapi.Config.on(configHandler);
xapi.Event.on(eventHandler);
xapi.Status.Audio.Volume.set(55);
xapi.Config.Audio.DefaultVolume.set(35);
xapi.Event.UserInterface.Extensions.Panel.Clicked.emit({
PanelId: "speed-dial-panel",
});
expect(statusHandler).toHaveBeenCalledWith({
Audio: {
Volume: 55,
},
});
expect(configHandler).toHaveBeenCalledWith({
Audio: {
DefaultVolume: 35,
},
});
expect(eventHandler).toHaveBeenCalledWith({
UserInterface: {
Extensions: {
Panel: {
Clicked: {
PanelId: "speed-dial-panel",
},
},
},
},
});
});Track indexed status branches such as calls
Indexed collection listeners such as xapi.Status.Call.on(...) receive the full branch snapshot plus the branch id.
import { expect, it, jest } from "@jest/globals";
it("notifies call listeners as a call branch changes", async () => {
const { default: xapi } = await import("xapi");
const handler = jest.fn();
xapi.Status.Call.on(handler);
xapi.Status.Call[42].Direction.set("Outgoing");
xapi.Status.Call[42].Status.set("Connected");
expect(handler).toHaveBeenNthCalledWith(1, {
Direction: "Outgoing",
id: "42",
});
expect(handler).toHaveBeenNthCalledWith(2, {
Direction: "Outgoing",
Status: "Connected",
id: "42",
});
});Remove indexed status branches
Use removeStatus() to simulate an indexed status branch disappearing, such as a call ending.
import { expect, it, jest } from "@jest/globals";
it("emits a ghost payload when a call ends", async () => {
const { default: xapi } = await import("xapi");
const handler = jest.fn();
xapi.Status.Call.on(handler);
xapi.Status.Call[7].Direction.set("Incoming");
xapi.removeStatus("Call.7");
expect(handler).toHaveBeenLastCalledWith({
ghost: "true",
id: "7",
});
});Demo
For complete examples, see the speed-dial-macro demo, the self-deactivating-macro demo, and the monitor-role-changer demo.
*For more demos & PoCs like this, check out our Webex Labs site.
License
All contents are licensed under the MIT license. Please see license for details.
Disclaimer
Everything included is for demo and Proof of Concept purposes only. Use of the site is solely at your own risk. This site may contain links to third party content, which we do not warrant, endorse, or assume liability for. These demos are for Cisco Webex usecases, but are not Official Cisco Webex Branded demos.
Questions
Please contact the WXSD team at [email protected] for questions. Or, if you're a Cisco internal employee, reach out to us on the Webex App via our bot ([email protected]). In the "Engagement Type" field, choose the "API/SDK Proof of Concept Integration Development" option to make sure you reach our team.
