[nexus] add WebAssembly support using Emscripten (#12904)

This commit adds support for building the Nexus simulator for
WebAssembly (WASM) using the Emscripten toolchain. This enables the
simulator to run in a web browser environment with a JavaScript-based
control interface and visualization.

Key implementation details:
- Introduced `nexus_wasm.cpp` which defines Emscripten bindings (using
  Embind) for core simulation controls, including stepping time,
  node creation, topology orchestration, and state manipulation.
- Implemented a `WasmObserver` and a global event queue to capture
  simulation events (node state changes, link updates, packet events)
  and expose them to JavaScript via a polling mechanism (`pollEvent`).
- Updated the CMake build system to support the `EMSCRIPTEN` platform,
  configuring specific linker options for ES6 module export,
  modularization, and memory growth.
- Enhanced `build.sh` to allow targeting WASM via `emcmake`.
- Guarded file-system-dependent operations in `nexus_pcap.cpp` and
  adjusted `nexus_core.cpp` to handle WASM-specific constraints where
  standard I/O or multiple observers might not be applicable.
- Added `test_wasm_bindings.mjs`, a Node.js-based smoke test that
  verifies the integrity of the WASM bindings and event pipeline.
- Integrated `nexus-wasm-tests` into the GitHub Actions workflow to
  ensure continuous verification of the WASM build and functionality.
This commit is contained in:
Jonathan Hui
2026-04-17 13:25:28 -07:00
committed by GitHub
parent c10b4e1da4
commit 27321a2110
14 changed files with 612 additions and 11 deletions
+30
View File
@@ -133,3 +133,33 @@ jobs:
- name: Run GRPC Tests
run: |
cd build/nexus && ./tests/nexus/nexus_grpc
nexus-wasm-tests:
name: nexus-wasm-tests
runs-on: ubuntu-24.04
steps:
- name: Harden Runner
uses: step-security/harden-runner@5ef0c079ce82195b2a36a210272d6b661572d83e # v2.14.2
with:
egress-policy: audit
- uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
with:
submodules: recursive
- name: Bootstrap
run: |
sudo apt-get update
sudo apt-get --no-install-recommends install -y ninja-build
- name: Set up Emscripten
uses: mymindstorm/setup-emsdk@v14
- name: Build Nexus WASM
run: |
mkdir -p build/nexus
top_builddir=build/nexus ./tests/nexus/build.sh wasm
- name: Run WASM Smoke Test
run: |
node tests/nexus/test_wasm_bindings.mjs build/nexus/tests/nexus/nexus_live_demo.js
+23
View File
@@ -84,6 +84,29 @@ ExtAddress::InfoString ExtAddress::ToString(void) const
return string;
}
Error ExtAddress::FromString(const char *aString)
{
Error error = kErrorNone;
uint8_t high;
uint8_t low;
VerifyOrExit(aString != nullptr, error = kErrorInvalidArgs);
for (uint8_t &byte : m8)
{
SuccessOrExit(error = ParseHexDigit(*aString, high));
aString++;
SuccessOrExit(error = ParseHexDigit(*aString, low));
aString++;
byte = static_cast<uint8_t>((high << 4) | low);
}
VerifyOrExit(*aString == kNullChar, error = kErrorParse);
exit:
return error;
}
void ExtAddress::CopyAddress(uint8_t *aDst, const uint8_t *aSrc, CopyByteOrder aByteOrder)
{
switch (aByteOrder)
+14
View File
@@ -232,6 +232,20 @@ public:
*/
InfoString ToString(void) const;
/**
* Parses an Extended Address from a string.
*
* The string must be a hex representation of the address (e.g., "0123456789abcdef").
* The parsing is case-insensitive.
*
* @param[in] aString A pointer to the string to parse.
*
* @retval kErrorNone Successfully parsed the Extended Address.
* @retval kErrorInvalidArgs @p aString is `nullptr`.
* @retval kErrorParse @p aString is not a valid hex string representation of an Extended Address.
*/
Error FromString(const char *aString);
private:
static constexpr uint8_t kGroupFlag = (1 << 0);
static constexpr uint8_t kLocalFlag = (1 << 1);
+29 -5
View File
@@ -49,7 +49,7 @@ set(COMMON_COMPILE_OPTIONS
option(OT_NEXUS_GRPC "Enable Nexus gRPC" OFF)
if(OT_NEXUS_GRPC)
if(OT_NEXUS_GRPC AND NOT EMSCRIPTEN)
find_package(gRPC CONFIG REQUIRED)
find_package(Protobuf REQUIRED)
@@ -116,7 +116,7 @@ add_library(ot-nexus-platform
if(OT_NEXUS_GRPC)
set_source_files_properties(${SIMULATION_PROTO_SRCS} PROPERTIES
COMPILE_FLAGS "-Wno-pedantic -Wno-error"
COMPILE_OPTIONS "-Wno-pedantic;-Wno-error"
)
endif()
@@ -131,6 +131,10 @@ target_compile_options(ot-nexus-platform
${OT_CFLAGS}
)
if(EMSCRIPTEN)
target_link_options(ot-nexus-platform PRIVATE "--bind")
endif()
if(OT_NEXUS_GRPC)
target_compile_definitions(ot-nexus-platform
PRIVATE
@@ -404,14 +408,34 @@ ot_nexus_test(full_network_reset "core;large_network;nexus")
ot_nexus_test(large_network "core;large_network;nexus")
# Live Demo Persistent Server
if(OT_NEXUS_GRPC)
if(EMSCRIPTEN)
set(NEXUS_WASM_LINK_OPTIONS
"--bind"
"-sEXPORT_ES6=1"
"-sMODULARIZE=1"
"-sEXPORTED_RUNTIME_METHODS=['ccall','cwrap']"
"-sSTACK_SIZE=4194304"
"-sALLOW_MEMORY_GROWTH=1"
)
add_executable(nexus_live_demo
platform/nexus_wasm.cpp
)
target_link_options(nexus_live_demo PRIVATE ${NEXUS_WASM_LINK_OPTIONS})
set_target_properties(nexus_live_demo PROPERTIES CXX_STANDARD 17)
elseif(OT_NEXUS_GRPC)
add_executable(nexus_live_demo
platform/nexus_native.cpp
)
endif()
if(TARGET nexus_live_demo)
target_include_directories(nexus_live_demo PRIVATE ${COMMON_INCLUDES})
target_link_libraries(nexus_live_demo PRIVATE ${COMMON_LIBS})
target_compile_options(nexus_live_demo PRIVATE ${COMMON_COMPILE_OPTIONS} ${OT_CFLAGS})
target_compile_definitions(nexus_live_demo PRIVATE OPENTHREAD_NEXUS_CONFIG_GRPC_ENABLE=1)
if(EMSCRIPTEN)
add_test(NAME nexus_wasm_bindings COMMAND node ${CMAKE_CURRENT_SOURCE_DIR}/test_wasm_bindings.mjs ${CMAKE_CURRENT_BINARY_DIR}/nexus_live_demo.js)
elseif(OT_NEXUS_GRPC)
target_compile_definitions(nexus_live_demo PRIVATE OPENTHREAD_NEXUS_CONFIG_GRPC_ENABLE=1)
endif()
endif()
+25 -6
View File
@@ -47,9 +47,15 @@ fi
case $1 in
trel)
fifteenfour=OFF
wasm=OFF
;;
wasm)
fifteenfour=ON
wasm=ON
;;
*)
fifteenfour=ON
wasm=OFF
;;
esac
@@ -57,12 +63,25 @@ echo "==========================================================================
echo "Building OpenThread Nexus test platform"
echo "===================================================================================================="
cd "${top_builddir}" || die "cd failed"
cmake -GNinja -DOT_PLATFORM=nexus -DOT_COMPILE_WARNING_AS_ERROR=ON \
-DOT_THREAD_VERSION=1.4 -DOT_APP_CLI=OFF -DOT_APP_NCP=OFF -DOT_APP_RCP=OFF \
-DOT_15_4=${fifteenfour} \
-DOT_NEXUS_GRPC="${OT_NEXUS_GRPC:-OFF}" \
-DOT_PROJECT_CONFIG="${top_srcdir}/tests/nexus/openthread-core-nexus-config.h" \
"${top_srcdir}" || die
CMAKE_ARGS=(
-GNinja
-DOT_PLATFORM=nexus
-DOT_COMPILE_WARNING_AS_ERROR=ON
-DOT_THREAD_VERSION=1.4
-DOT_APP_CLI=OFF
-DOT_APP_NCP=OFF
-DOT_APP_RCP=OFF
-DOT_15_4="${fifteenfour}"
-DOT_PROJECT_CONFIG="${top_srcdir}/tests/nexus/openthread-core-nexus-config.h"
)
if [ "${wasm}" = "ON" ]; then
emcmake cmake "${CMAKE_ARGS[@]}" "${top_srcdir}" || die
else
cmake "${CMAKE_ARGS[@]}" -DOT_NEXUS_GRPC="${OT_NEXUS_GRPC:-OFF}" "${top_srcdir}" || die
fi
ninja || die
exit 0
+2
View File
@@ -390,6 +390,8 @@ void Core::SetNodeEnabled(uint32_t aNodeId, bool aEnabled)
Node *Core::FindNodeById(uint32_t aNodeId) { return mNodes.FindMatching(aNodeId); }
Node *Core::FindNodeByExtAddress(const Mac::ExtAddress &aExtAddress) { return mNodes.FindMatching(aExtAddress); }
Node &Core::CreateNode(void)
{
Node *node;
+1
View File
@@ -63,6 +63,7 @@ public:
Node &CreateNode(void);
Node *FindNodeById(uint32_t aNodeId);
Node *FindNodeByExtAddress(const Mac::ExtAddress &aExtAddress);
LinkedList<Node> &GetNodes(void) { return mNodes; }
+37
View File
@@ -33,6 +33,43 @@
namespace ot {
namespace Nexus {
const char *Node::GetExtendedRoleString(void) const
{
const char *roleStr;
Mle::DeviceRole role = Get<Mle::Mle>().GetRole();
switch (role)
{
case Mle::kRoleDisabled:
roleStr = "Disabled";
break;
case Mle::kRoleDetached:
roleStr = "Detached";
break;
case Mle::kRoleLeader:
roleStr = "Leader";
break;
case Mle::kRoleRouter:
roleStr = "Router";
break;
case Mle::kRoleChild:
if (Get<Mle::Mle>().IsFullThreadDevice())
{
roleStr = Get<Mle::Mle>().IsRouterRoleAllowed() ? "REED" : "FED";
}
else
{
roleStr = Get<Mle::Mle>().IsRxOnWhenIdle() ? "MED" : "SED";
}
break;
default:
roleStr = "Unknown";
break;
}
return roleStr;
}
void Node::Reset(void)
{
Instance *instance = &GetInstance();
+7
View File
@@ -160,6 +160,13 @@ public:
uint32_t GetId(void) const { return GetInstance().GetId(); }
/**
* Returns the extended role string of the node.
*
* @returns The role string (e.g., "Leader", "Router", "REED", "FED", "MED", "SED", "Disabled").
*/
const char *GetExtendedRoleString(void) const;
static Node &From(otInstance *aInstance)
{
Instance *instance = static_cast<Instance *>(aInstance);
+25
View File
@@ -27,6 +27,7 @@
*/
#include "nexus_pcap.hpp"
#include "nexus_utils.hpp"
#include "common/clearable.hpp"
#include "common/code_utils.hpp"
@@ -35,6 +36,8 @@
namespace ot {
namespace Nexus {
#ifndef __EMSCRIPTEN__
OT_TOOL_PACKED_BEGIN
struct Epb
{
@@ -264,5 +267,27 @@ exit:
return;
}
#else // __EMSCRIPTEN__
Pcap::Pcap(void) {}
Pcap::~Pcap(void) {}
void Pcap::Open(const char *) {}
void Pcap::Close(void) {}
void Pcap::WriteFrame(const otRadioFrame &, uint64_t) {}
void Pcap::WritePacket(const InfraIf::LinkLayerAddress &,
const InfraIf::LinkLayerAddress &,
const uint8_t *,
uint16_t,
uint64_t)
{
}
#endif // __EMSCRIPTEN__
} // namespace Nexus
} // namespace ot
+2
View File
@@ -102,7 +102,9 @@ private:
static constexpr uint16_t kTapChannelLength = 3;
static constexpr uint8_t kTapChannelPage = 0;
#ifndef __EMSCRIPTEN__
FILE *mFile;
#endif
};
} // namespace Nexus
+5
View File
@@ -79,6 +79,11 @@
namespace ot {
namespace Nexus {
/**
* Invalid Node ID.
*/
static constexpr uint32_t kInvalidNodeId = 0xffffffff;
/**
* CSL period constants in units of 10 symbols.
*/
+285
View File
@@ -0,0 +1,285 @@
/*
* Copyright (c) 2026, The OpenThread Authors.
* All rights reserved.
*
* Redistribution and use in source and binary forms, with or without
* modification, are permitted provided that the following conditions are met:
* 1. Redistributions of source code must retain the above copyright
* notice, this list of conditions and the following disclaimer.
* 2. Redistributions in binary form must reproduce the above copyright
* notice, this list of conditions and the following disclaimer in the
* documentation and/or other materials provided with the distribution.
* 3. Neither the name of the copyright holder nor the
* names of its contributors may be used to endorse or promote products
* derived from this software without specific prior written permission.
*
* THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
* AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
* IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
* ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE
* LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
* CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
* SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
* INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
* CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
* ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
* POSSIBILITY OF SUCH DAMAGE.
*/
/**
* @file
* This file defines the WebAssembly bindings for the Nexus simulator.
*/
#include "openthread-core-config.h"
#include <deque>
#include <string>
#include <vector>
#include <emscripten.h>
#include <emscripten/bind.h>
#include <emscripten/val.h>
#include <openthread/thread.h>
#include <openthread/thread_ftd.h>
#include "nexus_core.hpp"
#include "nexus_node.hpp"
#include "mac/mac.hpp"
#include "thread/mle.hpp"
#include "thread/neighbor_table.hpp"
namespace ot {
namespace Nexus {
using namespace emscripten;
/**
* This class manages simulation events and exposes them to the WASM environment.
*
* It implements the Observer interface to capture events from the simulator core.
*/
class WasmManager : public Observer
{
public:
static constexpr char kEventNodeStateChanged[] = "node_state_changed";
static constexpr char kEventLinkUpdate[] = "link_update";
static constexpr char kEventPacketEvent[] = "packet_event";
static constexpr char kEventHeartbeat[] = "heartbeat";
/**
* Represents a simulation event to be consumed by JavaScript.
*/
struct Event
{
std::string mType;
val mData;
};
/**
* Returns the singleton instance of WasmManager.
*
* @returns The WasmManager instance.
*/
static WasmManager &Get(void)
{
static WasmManager sManager;
return sManager;
}
/**
* Initializes the manager and attaches it as an observer to the core.
*
* This method is idempotent and ensures the manager is registered as a simulation observer.
*/
void Init(void)
{
if (!mInitialized)
{
Core::Get().AddObserver(*this);
mInitialized = true;
}
}
/**
* Polls the next simulation event from the internal queue.
*
* The returned event object has the following structure in JavaScript:
* {
* "type": string (e.g., "node_state_changed", "link_update", "packet_event"),
* "data": object (type-specific payload)
* }
*
* @returns The event object or val::null() if the queue is empty.
*/
val PollEvent(void)
{
val eventVal = val::null();
if (!mEventQueue.empty())
{
const Event &event = mEventQueue.front();
eventVal = val::object();
eventVal.set("type", event.mType);
eventVal.set("data", event.mData);
mEventQueue.pop_front();
}
return eventVal;
}
// Observer overrides
void OnNodeStateChanged(Node *aNode) override
{
val data = val::object();
data.set("id", aNode->GetId());
data.set("role", aNode->GetExtendedRoleString());
data.set("x", aNode->GetPositionX());
data.set("y", aNode->GetPositionY());
data.set("rloc16", aNode->Get<Mle::Mle>().GetRloc16());
data.set("extAddress", aNode->Get<Mac::Mac>().GetExtAddress().ToString().AsCString());
PushEvent(kEventNodeStateChanged, data);
}
void OnLinkUpdate(uint32_t aSrcId, uint32_t aDstId, bool aIsActive) override
{
val data = val::object();
data.set("srcId", aSrcId);
data.set("dstId", aDstId);
data.set("isActive", aIsActive);
PushEvent(kEventLinkUpdate, data);
}
void OnPacketEvent(uint32_t aSrcId, uint32_t aDstId, const uint8_t *aFrame, uint16_t aLength) override
{
val data = val::object();
data.set("srcId", aSrcId);
data.set("dstId", aDstId);
data.set("length", aLength);
data.set("frame", val(typed_memory_view(aLength, aFrame)).call<val>("slice"));
PushEvent(kEventPacketEvent, data);
}
void OnHeartbeat(uint64_t aTimestampUs) override
{
val data = val::object();
data.set("timestampUs", aTimestampUs);
PushEvent(kEventHeartbeat, data);
}
void OnClearEvents(void) override { mEventQueue.clear(); }
/**
* WasmManager does not participate in state dumping as events are streamed in real-time.
*/
void DumpState(void) override {}
bool IsConnected(void) const override { return true; }
private:
static constexpr size_t kMaxQueueSize = 1000;
void PushEvent(const std::string &aType, val aData)
{
if (mEventQueue.size() >= kMaxQueueSize)
{
mEventQueue.pop_front();
}
mEventQueue.push_back({aType, aData});
}
std::deque<Event> mEventQueue; // Internal event queue (should be polled regularly by JS to prevent growth)
bool mInitialized = false;
};
// The global simulator core instance.
Core gWasmCore;
} // namespace Nexus
} // namespace ot
// Embind definitions
EMSCRIPTEN_BINDINGS(nexus_simulator)
{
using namespace ot::Nexus;
// Initialize WasmManager during module binding
WasmManager::Get().Init();
enum_<Node::JoinMode>("JoinMode")
.value("AsFtd", Node::kAsFtd)
.value("AsFed", Node::kAsFed)
.value("AsMed", Node::kAsMed)
.value("AsSed", Node::kAsSed)
.value("AsSedWithFullNetData", Node::kAsSedWithFullNetData);
function("pollEvent", optional_override([]() -> val { return WasmManager::Get().PollEvent(); }));
function("getNow", optional_override([]() -> uint32_t { return Core::Get().GetNow().GetValue(); }));
function("stepSimulation", optional_override([](uint32_t aElapsedMs) { Core::Get().AdvanceTime(aElapsedMs); }));
function("createNode", optional_override([](float aX, float aY) -> uint32_t {
Node &node = Core::Get().CreateNode();
node.SetPosition(aX, aY);
WasmManager::Get().OnNodeStateChanged(&node);
return node.GetId();
}));
function("setNodePosition", optional_override([](uint32_t aNodeId, float aX, float aY) {
Node *node = Core::Get().FindNodeById(aNodeId);
if (node != nullptr)
{
node->SetPosition(aX, aY);
}
}));
function("getNodeId", optional_override([](std::string aExtAddress) -> uint32_t {
ot::Mac::ExtAddress extAddr;
uint32_t id = kInvalidNodeId;
if (extAddr.FromString(aExtAddress.c_str()) == ot::kErrorNone)
{
Node *node = Core::Get().GetNodes().FindMatching(extAddr);
if (node != nullptr)
{
id = node->GetId();
}
}
return id;
}));
function("setNodeEnabled",
optional_override([](uint32_t aNodeId, bool aEnabled) { Core::Get().SetNodeEnabled(aNodeId, aEnabled); }));
function("formNetwork", optional_override([](uint32_t aNodeId) {
Node *node = Core::Get().FindNodeById(aNodeId);
if (node != nullptr)
{
node->Form();
}
}));
function("joinNetwork", optional_override([](uint32_t aNodeId, uint32_t aTargetNodeId, Node::JoinMode aJoinMode) {
Core &core = Core::Get();
Node *node = core.FindNodeById(aNodeId);
Node *target = core.FindNodeById(aTargetNodeId);
if (node != nullptr && target != nullptr)
{
node->Join(*target, aJoinMode);
}
}));
}
+127
View File
@@ -0,0 +1,127 @@
import path from 'path';
import { fileURLToPath } from 'url';
async function createTopology(Nexus) {
console.log("Creating Topology in JavaScript...");
const leaderId = Nexus.createNode(350, 100);
const fedId = Nexus.createNode(100, 500);
const sedId = Nexus.createNode(350, 500);
const medId = Nexus.createNode(600, 500);
const router1Id = Nexus.createNode(200, 300);
const router2Id = Nexus.createNode(500, 300);
Nexus.formNetwork(leaderId);
// Use the exported JoinMode enum
Nexus.joinNetwork(fedId, leaderId, Nexus.JoinMode.AsFed);
Nexus.joinNetwork(sedId, leaderId, Nexus.JoinMode.AsSed);
Nexus.joinNetwork(medId, leaderId, Nexus.JoinMode.AsMed);
Nexus.joinNetwork(router1Id, leaderId, Nexus.JoinMode.AsFtd);
Nexus.joinNetwork(router2Id, leaderId, Nexus.JoinMode.AsFtd);
return { leaderId, fedId, sedId, medId, router1Id, router2Id };
}
async function runTest(modulePath) {
const absolutePath = path.resolve(modulePath);
console.log(`Loading Nexus WASM module from: ${absolutePath}`);
// Load the Emscripten-generated modularized ES6 module
const { default: createNexusModule } = await import('file://' + absolutePath);
// Initialize the module
const Nexus = await createNexusModule();
await createTopology(Nexus);
console.log("Initial simulation time:", Nexus.getNow());
console.log("Stepping Simulation...");
let nodeStateChangedCount = 0;
let linkUpdateCount = 0;
let packetEventCount = 0;
// Advance simulation and check for events
for (let i = 0; i < 50; i++) {
Nexus.stepSimulation(100);
let event;
while ((event = Nexus.pollEvent()) !== null) {
const type = event.type;
const data = event.data;
if (type === "node_state_changed") {
nodeStateChangedCount++;
if (nodeStateChangedCount < 10) {
console.log(`[Event] Node ${data.id} state: ${data.role} at (${data.x}, ${data.y}) RLOC16: ${data.rloc16} ExtAddr: ${data.extAddress}`);
}
if (!data.extAddress) {
throw new Error(`Node ${data.id} state change event missing extAddress`);
}
} else if (type === "link_update") {
linkUpdateCount++;
} else if (type === "packet_event") {
packetEventCount++;
if (packetEventCount < 5) {
console.log(`[Event] Packet from ${data.srcId} to ${data.dstId} (length: ${data.length}) frame: ${data.frame.length} bytes`);
}
if (!data.frame || data.frame.length !== data.length) {
throw new Error(`Packet event from ${data.srcId} to ${data.dstId} has invalid frame data`);
}
}
}
}
console.log(`Received ${nodeStateChangedCount} node_state_changed events`);
console.log(`Received ${linkUpdateCount} link_update events`);
console.log(`Received ${packetEventCount} packet_event events`);
if (nodeStateChangedCount === 0) {
throw new Error("Expected node_state_changed events were not received");
}
// Verify getNodeId
let event;
// We need to find an event that has extAddress to test getNodeId
// Since we've already polled all events, we should have some node state changed events.
// Let's assume the first few events we printed have what we need, or just use the last polled data if available.
// Actually, let's just create a new node and test it.
console.log("Creating a manual node...");
const nodeId = Nexus.createNode(100, 200);
console.log("Created node ID:", nodeId);
// Step to trigger state change event to get its extAddress
Nexus.stepSimulation(1);
let foundExtAddr = null;
while ((event = Nexus.pollEvent()) !== null) {
if (event.type === "node_state_changed" && event.data.id === nodeId) {
foundExtAddr = event.data.extAddress;
}
}
if (foundExtAddr) {
const resolvedId = Nexus.getNodeId(foundExtAddr);
console.log(`Resolved ExtAddr ${foundExtAddr} to ID: ${resolvedId}`);
if (resolvedId !== nodeId) {
throw new Error(`getNodeId failed: expected ${nodeId}, got ${resolvedId}`);
}
}
Nexus.setNodePosition(nodeId, 150, 250);
Nexus.setNodeEnabled(nodeId, false);
Nexus.setNodeEnabled(nodeId, true);
console.log("Test completed successfully!");
}
const modulePath = process.argv[2];
if (!modulePath) {
console.error("Usage: node test_wasm_bindings.mjs <path-to-nexus_live_demo.js>");
process.exit(1);
}
runTest(modulePath).catch(err => {
console.error("Test failed:", err);
process.exit(1);
});