Skip to content
Merged
Show file tree
Hide file tree
Changes from 3 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,10 @@
"types": "./types/index.d.ts",
"default": "./index.js"
},
"./web": {
"types": "./web/index.d.ts",
"default": "./web/index.js"
},
"./web/server": {
"types": "./lib/runtime/index.d.ts",
"default": "./lib/runtime/index.js"
Expand Down
165 changes: 165 additions & 0 deletions test/test-web.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,165 @@
// Copyright (c) 2026 RobotWebTools Contributors. All rights reserved.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0

'use strict';

// SDK tests for `rclnodejs/web`. Runtime / wire protocol coverage
// lives in test/test-runtime.js.

const assert = require('assert');
const rclnodejs = require('../index.js');
const { createRuntime, WebSocketTransport } = require('../lib/runtime');

// `web/` is ESM; await import() lets us pull it in from this CJS file.
let connect;
before(async function () {
({ connect } = await import('../web/index.js'));
});

describe('rclnodejs/web (Browser SDK)', function () {
this.timeout(60 * 1000);

let node;
let runtime;
let endpoint;

before(async function () {
await rclnodejs.init();
node = rclnodejs.createNode('runtime_test_node');
rclnodejs.spin(node);

runtime = createRuntime({
node,
transport: new WebSocketTransport({ port: 0, host: '127.0.0.1' }),
});
runtime.expose({
call: { '/rt_add': 'example_interfaces/srv/AddTwoInts' },
publish: { '/rt_chatter': 'std_msgs/msg/String' },
subscribe: { '/rt_chatter': 'std_msgs/msg/String' },
});
await runtime.start();
const port = runtime.transports[0].port;
endpoint = `ws://127.0.0.1:${port}/capability`;
});

after(async function () {
if (runtime) await runtime.stop();
rclnodejs.shutdown();
});

it('round-trips a service call through the runtime', async function () {
const svc = node.createService(
'example_interfaces/srv/AddTwoInts',
'/rt_add',
(request, response) => {
const reply = response.template;
reply.sum = request.a + request.b;
response.send(reply);
}
);
const ros = await connect(endpoint);
try {
// BigInts arrive over the wire as the toJSONSafe "Nn" convention.
const reply = await ros.call('/rt_add', { a: '2n', b: '40n' });
assert.strictEqual(reply.sum, '42n');
} finally {
await ros.close();
node.destroyService(svc);
}
});

it('publish + subscribe round-trip through the runtime', async function () {
const ros = await connect(endpoint);
let timer;
try {
let received;
const sub = await ros.subscribe('/rt_chatter', (msg) => {
if (!received) received = msg && msg.data;
});

// Retry until subscription discovery completes.
timer = setInterval(() => {
ros.publish('/rt_chatter', { data: 'hello-runtime' }).catch(() => {});
}, 100);

await waitFor(() => received === 'hello-runtime', 8000);
await sub.close();
} finally {
if (timer) clearInterval(timer);
await ros.close();
}
});

it('surfaces not_exposed errors with a structured `code` field', async function () {
// Asserts the SDK propagates `code:` onto the rejected Promise.
const ros = await connect(endpoint);
try {
await assertRejectsWithCode(
ros.call('/never_exposed', {}),
'not_exposed'
);
await assertRejectsWithCode(
ros.publish('/never_exposed', {}),
'not_exposed'
);
await assertRejectsWithCode(
ros.subscribe('/never_exposed', () => {}),
'not_exposed'
);
} finally {
await ros.close();
}
});

it('assigns distinct UUID v4 ids for repeated subscribes', async function () {
const ros = await connect(endpoint);
try {
const sub1 = await ros.subscribe('/rt_chatter', () => {});
const sub2 = await ros.subscribe('/rt_chatter', () => {});
assert.notStrictEqual(sub1.subId, sub2.subId);
assert.match(
sub1.subId,
/^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[0-9a-f]{4}-[0-9a-f]{12}$/i,
'subId should be a v4 UUID'
);
await sub1.close();
await sub2.close();
} finally {
await ros.close();
}
});
});

function waitFor(predicate, timeoutMs) {
const started = Date.now();
return new Promise((resolve, reject) => {
const tick = () => {
if (predicate()) return resolve();
if (Date.now() - started > timeoutMs) {
return reject(new Error('waitFor: timeout'));
}
setTimeout(tick, 50);
};
tick();
});
}

async function assertRejectsWithCode(promise, expectedCode) {
let err;
try {
await promise;
} catch (e) {
err = e;
}
assert.ok(err, 'expected rejection');
assert.strictEqual(
err.code,
expectedCode,
`expected error code ${expectedCode}, got ${err.code}: ${err.message}`
);
}
Loading
Loading