diff --git a/.github/workflows/linux-x64-build-and-test.yml b/.github/workflows/linux-x64-build-and-test.yml index c57f8eda..81bed305 100644 --- a/.github/workflows/linux-x64-build-and-test.yml +++ b/.github/workflows/linux-x64-build-and-test.yml @@ -76,21 +76,8 @@ jobs: npm run test-idl npm run clean - - name: Coveralls Parallel + - name: Coveralls if: ${{ matrix.ros_distribution == 'rolling' && matrix['node-version'] == '24.X' }} uses: coverallsapp/github-action@v2 with: github-token: ${{ secrets.GITHUB_TOKEN }} - flag-name: run-${{ inputs.trigger_type }}-${{ matrix.node-version }}-${{ matrix.architecture }} - parallel: true - - finish: - needs: build - if: always() - runs-on: ubuntu-latest - steps: - - name: Coveralls Finished - uses: coverallsapp/github-action@v2 - with: - github-token: ${{ secrets.GITHUB_TOKEN }} - parallel-finished: true diff --git a/lib/native_loader.js b/lib/native_loader.js index 2f401229..ae0b329b 100644 --- a/lib/native_loader.js +++ b/lib/native_loader.js @@ -175,4 +175,14 @@ function loadNativeAddon() { } } -module.exports = loadNativeAddon(); +const addon = loadNativeAddon(); + +// Export internal functions for testing purposes +if (process.env.NODE_ENV === 'test') { + addon.TestHelpers = { + customFallbackLoader, + loadNativeAddon, + }; +} + +module.exports = addon; diff --git a/lib/time_source.js b/lib/time_source.js index 54ead159..861f75f9 100644 --- a/lib/time_source.js +++ b/lib/time_source.js @@ -102,7 +102,7 @@ class TimeSource { * @return {undefined} */ attachNode(node) { - if ((!node) instanceof rclnodejs.ShadowNode) { + if (!(node instanceof rclnodejs.ShadowNode)) { throw new TypeValidationError('node', node, 'Node', { entityType: 'time source', }); diff --git a/test/test-client.js b/test/test-client.js new file mode 100644 index 00000000..0fa6b722 --- /dev/null +++ b/test/test-client.js @@ -0,0 +1,347 @@ +'use strict'; + +const assert = require('assert'); +const sinon = require('sinon'); +const rclnodejsBinding = require('../lib/native_loader.js'); +const Client = require('../lib/client.js'); +const DistroUtils = require('../lib/distro.js'); + +describe('Client coverage testing', function () { + let sandbox; + let mockHandle = { _handle: 'mock-handle' }; + let mockNodeHandle = { _handle: 'mock-node-handle' }; + + const MockTypeClass = { + type: () => ({ + interfaceName: 'AddTwoInts', + pkgName: 'example_interfaces', + }), + Request: class MockRequest { + constructor(data) { + Object.assign(this, data); + } + serialize() { + return new Uint8Array([0]); + } + }, + Response: class MockResponse { + constructor(data) { + Object.assign(this, data); + } + toPlainObject() { + return { sum: 3 }; + } + }, + }; + + beforeEach(function () { + sandbox = sinon.createSandbox(); + + // Stub native bindings + sandbox.stub(rclnodejsBinding, 'sendRequest').returns(12345); // Sequence number + sandbox.stub(rclnodejsBinding, 'serviceServerIsAvailable').returns(true); + sandbox + .stub(rclnodejsBinding, 'getClientServiceName') + .returns('test_service'); + sandbox.stub(rclnodejsBinding, 'createClient').returns(mockHandle); + sandbox.stub(rclnodejsBinding, 'getNodeLoggerName').returns('node_logger'); + + if (rclnodejsBinding.configureClientIntrospection) { + sandbox + .stub(rclnodejsBinding, 'configureClientIntrospection') + .returns(undefined); + } + }); + + afterEach(function () { + sandbox.restore(); + }); + + it('constructor sets properties correctly', function () { + const client = new Client( + mockHandle, + mockNodeHandle, + 'test_service', + MockTypeClass, + { + validateRequests: true, + validationOptions: { strict: false }, + } + ); + + assert.strictEqual(client.willValidateRequest, true); + assert.strictEqual(client._serviceName, 'test_service'); + }); + + it('createClient static method', function () { + const client = Client.createClient( + mockNodeHandle, + 'service', + MockTypeClass, + { qos: {} } + ); + assert.ok(client instanceof Client); + assert.ok(rclnodejsBinding.createClient.called); + }); + + it('willValidateRequest setter', function () { + const client = new Client( + mockHandle, + mockNodeHandle, + 's', + MockTypeClass, + {} + ); + client.willValidateRequest = true; + assert.strictEqual(client.willValidateRequest, true); + }); + + it('setValidation updates options', function () { + const client = new Client( + mockHandle, + mockNodeHandle, + 's', + MockTypeClass, + {} + ); + client.setValidation({ strict: false }); + assert.strictEqual(client._validationOptions.strict, false); + }); + + it('sendRequest throws on invalid callback', function () { + const client = new Client( + mockHandle, + mockNodeHandle, + 's', + MockTypeClass, + {} + ); + assert.throws(() => { + client.sendRequest({}, null); + }, /callback/); + }); + + it('sendRequest sends and stores callback', function () { + const client = new Client( + mockHandle, + mockNodeHandle, + 's', + MockTypeClass, + {} + ); + const spyCallback = sinon.spy(); + + client.sendRequest({ a: 1 }, spyCallback); + + assert.ok(rclnodejsBinding.sendRequest.called); + assert.ok(client._sequenceNumberToCallbackMap.has(12345)); + }); + + it('processResponse calls callback', function () { + const client = new Client( + mockHandle, + mockNodeHandle, + 's', + MockTypeClass, + {} + ); + const spyCallback = sinon.spy(); + + // Simulate sending + client.sendRequest({ a: 1 }, spyCallback); + + // Simulate receiving + const mockResponseWrapper = new MockTypeClass.Response(); + client.processResponse(12345, mockResponseWrapper); + + assert.ok(spyCallback.calledOnce); + assert.deepStrictEqual(spyCallback.firstCall.args[0], { sum: 3 }); + assert.strictEqual(client._sequenceNumberToCallbackMap.has(12345), false); + }); + + it('processResponse fails gracefully for unknown sequence', function () { + const client = new Client( + mockHandle, + mockNodeHandle, + 's', + MockTypeClass, + {} + ); + // Should verify it logs debug info, but hard to capture 'debug' module. + // We assume it runs without error. + client.processResponse(99999, {}); + }); + + it('sendRequestAsync resolves on success', async function () { + const client = new Client( + mockHandle, + mockNodeHandle, + 's', + MockTypeClass, + {} + ); + const promise = client.sendRequestAsync({ a: 1 }); + + // Simulate response arrival manually since we don't have the event loop of rclnodejs running + // We need to trigger the callback registered in the map + const callback = client._sequenceNumberToCallbackMap.get(12345); + assert.ok(callback); + + callback(new MockTypeClass.Response()); + + const result = await promise; + assert.deepStrictEqual(result, new MockTypeClass.Response()); + }); + + it('sendRequestAsync handles timeout', async function () { + const client = new Client( + mockHandle, + mockNodeHandle, + 's', + MockTypeClass, + {} + ); + + // We need to avoid waiting actual time. + // mocking AbortSignal.timeout is hard, usually implemented by node. + // We'll use a short timeout. + + try { + await client.sendRequestAsync({ a: 1 }, { timeout: 10 }); + assert.fail('Should have timed out'); + } catch (err) { + assert.ok( + err.name === 'TimeoutError' || + err.code === 'ETIMEDOUT' || + err.message.includes('Timeout'), + 'Error was: ' + err + ); + } + }); + + it('sendRequestAsync handles manual abort', async function () { + if (typeof AbortController === 'undefined') this.skip(); + + const client = new Client( + mockHandle, + mockNodeHandle, + 's', + MockTypeClass, + {} + ); + const ac = new AbortController(); + + const promise = client.sendRequestAsync({ a: 1 }, { signal: ac.signal }); + ac.abort(); + + try { + await promise; + assert.fail('Should have aborted'); + } catch (err) { + // Name might vary depending on polyfill or native + assert.ok(err.name === 'AbortError', 'Error name ' + err.name); + } + }); + + it('waitForService waits and returns true', async function () { + const client = new Client( + mockHandle, + mockNodeHandle, + 's', + MockTypeClass, + {} + ); + rclnodejsBinding.serviceServerIsAvailable.returns(true); + + const available = await client.waitForService(100); + assert.strictEqual(available, true); + }); + + it('waitForService handles timeout', async function () { + const client = new Client( + mockHandle, + mockNodeHandle, + 's', + MockTypeClass, + {} + ); + rclnodejsBinding.serviceServerIsAvailable.returns(false); + + const start = Date.now(); + const available = await client.waitForService(50); + const duration = Date.now() - start; + + assert.strictEqual(available, false); + assert.ok(duration >= 40); // Allows some slack + }); + + it('loggerName getter', function () { + const client = new Client( + mockHandle, + mockNodeHandle, + 's', + MockTypeClass, + {} + ); + assert.strictEqual(client.loggerName, 'node_logger'); + }); + + it('configureIntrospection warns on old distro', function () { + const client = new Client( + mockHandle, + mockNodeHandle, + 's', + MockTypeClass, + {} + ); + const clockStub = { handle: 'clock' }; + const qos = {}; + + // Mock DistroUtils.getDistroId to return humble (enum numeric) vs old + // We stub getDistroId on the class + sandbox.stub(DistroUtils, 'getDistroId').callsFake((name) => { + if (name === 'humble') return 2; + return 1; // Current distro is 1 (older than 2) + }); + + const consoleSpy = sandbox.spy(console, 'warn'); + + client.configureIntrospection(clockStub, qos, 'on'); + + assert.ok(consoleSpy.calledWithMatch(/not supported/)); + if (rclnodejsBinding.configureClientIntrospection) { + assert.strictEqual( + rclnodejsBinding.configureClientIntrospection.called, + false + ); + } + }); + + it('configureIntrospection calls binding on new distro', function () { + if (!rclnodejsBinding.configureClientIntrospection) { + this.skip(); + } + + const client = new Client( + mockHandle, + mockNodeHandle, + 's', + MockTypeClass, + {} + ); + const clockStub = { handle: 'clock' }; + const qos = {}; + + sandbox.stub(DistroUtils, 'getDistroId').callsFake((name) => { + if (name === 'humble') return 1; + return 2; // Current distro is 2 (newer than 1) + }); + + client.configureIntrospection(clockStub, qos, 'on'); + + assert.strictEqual( + rclnodejsBinding.configureClientIntrospection.called, + true + ); + }); +}); diff --git a/test/test-event-handle.js b/test/test-event-handle.js index 2cb2d815..e7dcfedc 100644 --- a/test/test-event-handle.js +++ b/test/test-event-handle.js @@ -15,10 +15,16 @@ 'use strict'; const assert = require('assert'); +const sinon = require('sinon'); const DistroUtils = require('../lib/distro.js'); const rclnodejs = require('../index.js'); -const { SubscriptionEventCallbacks } = require('../lib/event_handler.js'); -const { PublisherEventCallbacks } = require('../lib/event_handler.js'); +const rclnodejsBinding = require('../lib/native_loader.js'); +const { + SubscriptionEventCallbacks, + PublisherEventCallbacks, + PublisherEventType, + SubscriptionEventType, +} = require('../lib/event_handler.js'); describe('Event handle test suite prior to jazzy', function () { before(function () { @@ -239,3 +245,189 @@ describe('Event handle test suite', function () { ); }); }); + +describe('EventHandler unit testing (Mocks)', function () { + let sandbox; + let addedProps = []; + + function stubOptional(obj, method) { + if (!obj[method]) { + obj[method] = () => {}; + addedProps.push({ obj, method }); + } + return sandbox.stub(obj, method); + } + + beforeEach(function () { + sandbox = sinon.createSandbox(); + addedProps = []; + }); + + afterEach(function () { + sandbox.restore(); + addedProps.forEach(({ obj, method }) => { + delete obj[method]; + }); + addedProps = []; + }); + + describe('PublisherEventCallbacks', function () { + it('throws on unsupported distro', function () { + // Mock DistroUtils to return old version + sandbox.stub(DistroUtils, 'getDistroId').callsFake((val) => { + if (val === 'jazzy') return 10; + return 5; + }); + + assert.throws(() => { + new PublisherEventCallbacks(); + }, /only available in ROS 2 Jazzy/); + }); + + it('constructs on supported distro', function () { + sandbox.stub(DistroUtils, 'getDistroId').callsFake((val) => { + if (val === 'jazzy') return 10; + return 11; + }); + + const cb = new PublisherEventCallbacks(); + assert.ok(cb); + assert.deepStrictEqual(cb.eventHandlers, []); + }); + + it('getters and setters work', function () { + sandbox.stub(DistroUtils, 'getDistroId').callsFake((val) => { + if (val === 'jazzy') return 10; + return 11; + }); + + const cb = new PublisherEventCallbacks(); + const fn = () => {}; + + cb.deadline = fn; + assert.strictEqual(cb.deadline, fn); + + cb.incompatibleQos = fn; + assert.strictEqual(cb.incompatibleQos, fn); + + cb.liveliness = fn; + assert.strictEqual(cb.liveliness, fn); + + cb.incompatibleType = fn; + assert.strictEqual(cb.incompatibleType, fn); + + cb.matched = fn; + assert.strictEqual(cb.matched, fn); + }); + + it('createEventHandlers creates handles', function () { + sandbox.stub(DistroUtils, 'getDistroId').callsFake((val) => { + if (val === 'jazzy') return 10; + return 11; + }); + + const createStub = stubOptional( + rclnodejsBinding, + 'createPublisherEventHandle' + ).returns('mock-event-handle'); + + const cb = new PublisherEventCallbacks(); + cb.deadline = () => {}; + + const handlers = cb.createEventHandlers('pub-handle'); + + assert.strictEqual(handlers.length, 1); + assert.strictEqual(createStub.calledOnce, true); + assert.strictEqual( + createStub.firstCall.args[1], + PublisherEventType.PUBLISHER_OFFERED_DEADLINE_MISSED + ); + }); + }); + + describe('SubscriptionEventCallbacks', function () { + it('throws on unsupported distro', function () { + sandbox.stub(DistroUtils, 'getDistroId').callsFake((val) => { + if (val === 'jazzy') return 10; + return 5; + }); + + assert.throws(() => { + new SubscriptionEventCallbacks(); + }, /only available in ROS 2 Jazzy/); + }); + + it('constructs on supported distro', function () { + sandbox.stub(DistroUtils, 'getDistroId').callsFake((val) => { + if (val === 'jazzy') return 10; + return 11; + }); + + const cb = new SubscriptionEventCallbacks(); + assert.ok(cb); + }); + + it('getters and setters work', function () { + sandbox.stub(DistroUtils, 'getDistroId').callsFake((val) => { + if (val === 'jazzy') return 10; + return 11; + }); + + const cb = new SubscriptionEventCallbacks(); + const fn = () => {}; + + cb.messageLost = fn; + assert.strictEqual(cb.messageLost, fn); + }); + + it('createEventHandlers creates handles', function () { + sandbox.stub(DistroUtils, 'getDistroId').callsFake((val) => { + if (val === 'jazzy') return 10; + return 11; + }); + + const createStub = stubOptional( + rclnodejsBinding, + 'createSubscriptionEventHandle' + ).returns('mock-sub-event-handle'); + + const cb = new SubscriptionEventCallbacks(); + cb.messageLost = () => {}; + + const handlers = cb.createEventHandlers('sub-handle'); + + assert.strictEqual(handlers.length, 1); + assert.strictEqual( + createStub.calledWith( + 'sub-handle', + SubscriptionEventType.SUBSCRIPTION_MESSAGE_LOST + ), + true + ); + }); + }); + + describe('EventHandler interaction', function () { + it('takeData calls callback', function () { + sandbox.stub(DistroUtils, 'getDistroId').callsFake(() => 999); + const takeEventStub = stubOptional(rclnodejsBinding, 'takeEvent').returns( + { count: 1 } + ); + + const cb = new PublisherEventCallbacks(); + const spy = sinon.spy(); + cb.deadline = spy; + + stubOptional(rclnodejsBinding, 'createPublisherEventHandle').returns( + 'handle' + ); + cb.createEventHandlers('pub'); + + const handler = cb.eventHandlers[0]; + handler.takeData(); + + assert.ok(takeEventStub.called); + assert.ok(spy.calledWith({ count: 1 })); + }); + }); +}); diff --git a/test/test-logging.js b/test/test-logging.js index dcb0dee9..7c4946e3 100644 --- a/test/test-logging.js +++ b/test/test-logging.js @@ -16,6 +16,10 @@ const assert = require('assert'); const rclnodejs = require('../index.js'); +const sinon = require('sinon'); +const rclnodejsBinding = require('../lib/native_loader.js'); +const Logging = require('../lib/logging.js'); +const Context = require('../lib/context.js'); describe('Test logging util', function () { it('Test setting severity level', function () { @@ -228,3 +232,177 @@ describe('Test logging util', function () { rclnodejs.shutdown(); }); }); + +describe('Logging unit testing (Mocks)', function () { + let sandbox; + // Mock 'instanceof Context' is hard if we don't import real Context. + // So we might need to import Context to use it in test or mock the instanceof check. + + beforeEach(function () { + sandbox = sinon.createSandbox(); + }); + + afterEach(function () { + sandbox.restore(); + }); + + describe('Validation', function () { + const logger = new Logging('val-logger'); + + it('setLoggerLevel throws on invalid input', function () { + assert.throws(() => { + logger.setLoggerLevel('high'); + }, /level.*number/); + }); + + it('getLogger throws on invalid name', function () { + assert.throws(() => { + Logging.getLogger(123); + }, /name.*string/); + }); + + it('getChild throws on invalid name', function () { + assert.throws(() => { + logger.getChild(''); + }, /non-empty string/); + }); + }); + + describe('Logic and Binding Interaction', function () { + it('setLoggerLevel calls binding', function () { + const spy = sandbox.stub(rclnodejsBinding, 'setLoggerLevel'); + const logger = new Logging('test'); + logger.setLoggerLevel(10); + assert.ok(spy.calledWith('test', 10)); + }); + + it('loggerEffectiveLevel calls binding', function () { + sandbox.stub(rclnodejsBinding, 'getLoggerEffectiveLevel').returns(20); + const logger = new Logging('test'); + assert.strictEqual(logger.loggerEffectiveLevel, 20); + }); + + it('logging methods call _log -> binding', function () { + const logStub = sandbox.stub(rclnodejsBinding, 'log'); + const logger = new Logging('test'); + + logger.debug('debug msg'); + assert.ok(logStub.calledWithMatch('test', 10, 'debug msg')); + + logger.info('info msg'); + assert.ok(logStub.calledWithMatch('test', 20, 'info msg')); + + logger.warn('warn msg'); + assert.ok(logStub.calledWithMatch('test', 30, 'warn msg')); + + logger.error('error msg'); + assert.ok(logStub.calledWithMatch('test', 40, 'error msg')); + + logger.fatal('fatal msg'); + assert.ok(logStub.calledWithMatch('test', 50, 'fatal msg')); + }); + + it('_log validates message type', function () { + const logger = new Logging('test'); + assert.throws(() => { + logger.debug(123); + }, /message.*string/); + }); + + it('captures stack info (Caller class test)', function () { + const logStub = sandbox.stub(rclnodejsBinding, 'log'); + const logger = new Logging('test'); + + function internalFunction() { + logger.info('msg'); + } + internalFunction(); + + assert.ok(logStub.calledOnce); + const args = logStub.firstCall.args; + // signature: (name, severity, message, functionName, lineNumber, fileName) + const funcName = args[3]; + const lineNum = args[4]; + const fileName = args[5]; + + // This might look different depending on how mocha runs it, but usually 'internalFunction' + assert.strictEqual(funcName, 'internalFunction'); + assert.strictEqual(fileName, 'test-logging.js'); + assert.ok(typeof lineNum === 'number'); + }); + + it('getChild logic (rosout sublogger)', function () { + const logger = new Logging('parent'); + // rclnodejsBinding.addRosoutSublogger might be undefined in some envs unless we mock it + if (!rclnodejsBinding.addRosoutSublogger) { + rclnodejsBinding.addRosoutSublogger = () => {}; + } + const stub = sandbox + .stub(rclnodejsBinding, 'addRosoutSublogger') + .returns(true); + + const child = logger.getChild('child'); + + assert.strictEqual(child.name, 'parent.child'); + assert.ok(stub.calledWith('parent', 'child')); + }); + + it('destroy calls removeRosoutSublogger', function () { + const logger = new Logging('parent'); + rclnodejsBinding.addRosoutSublogger = () => true; + rclnodejsBinding.removeRosoutSublogger = () => {}; + + const removeStub = sandbox.stub( + rclnodejsBinding, + 'removeRosoutSublogger' + ); + + const child = logger.getChild('child'); + child.destroy(); + + assert.ok(removeStub.calledWith('parent', 'child')); + }); + + it('destroy does nothing if no parent', function () { + const logger = new Logging('solo'); + // ensure removeRosoutSublogger is spy + if (!rclnodejsBinding.removeRosoutSublogger) + rclnodejsBinding.removeRosoutSublogger = () => {}; + const removeStub = sandbox.stub( + rclnodejsBinding, + 'removeRosoutSublogger' + ); + + logger.destroy(); // Should do nothing + assert.strictEqual(removeStub.called, false); + }); + + it('configure calls binding', function () { + const stub = sandbox.stub(rclnodejsBinding, 'loggingConfigure'); + // Create a fake Context instance + // We can use Object.create(Context.prototype) to look like instance + const fakeCtx = Object.create(Context.prototype); + Object.defineProperty(fakeCtx, 'handle', { value: 'ctx-handle' }); + + Logging.configure(fakeCtx); + assert.ok(stub.calledWith('ctx-handle')); + }); + + it('configure throws on bad context', function () { + assert.throws(() => { + Logging.configure({}); + }, /context.*Context/); + }); + + it('shutdown calls binding', function () { + const stub = sandbox.stub(rclnodejsBinding, 'loggingFini'); + Logging.shutdown(); + assert.ok(stub.calledOnce); + }); + + it('getLoggingDirectory calls binding', function () { + sandbox.stub(rclnodejsBinding, 'getLoggingDirectory').returns('/logs'); + assert.strictEqual(Logging.getLoggingDirectory(), '/logs'); + }); + }); +}); diff --git a/test/test-message-validation.js b/test/test-message-validation.js index d3e0cafc..3b60e985 100644 --- a/test/test-message-validation.js +++ b/test/test-message-validation.js @@ -16,6 +16,19 @@ const assert = require('assert'); const rclnodejs = require('../index.js'); +const { + MessageValidationError, + TypeValidationError, +} = require('../lib/errors.js'); +const { + assertValidMessage, + validateMessage, + createMessageValidator, + getFieldNames, + getMessageSchema, + ValidationProblem, + getMessageTypeString, +} = require('../lib/message_validation.js'); describe('Message Validation Tests', function () { this.timeout(60 * 1000); @@ -438,3 +451,353 @@ describe('Message Validation Tests', function () { }); }); }); + +describe('MessageValidation unit testing (Mocks)', function () { + const MockStringMsg = class { + static get ROSMessageDef() { + return { + fields: [ + { name: 'data', type: { type: 'string', isPrimitiveType: true } }, + ], + constants: [], + baseType: { pkgName: 'std_msgs', type: 'String' }, + }; + } + static type() { + return { pkgName: 'std_msgs', subFolder: 'msg', interfaceName: 'String' }; + } + }; + + const MockArrayMsg = class { + static get ROSMessageDef() { + return { + fields: [ + { + name: 'covariance', + type: { + type: 'float64', + isPrimitiveType: true, + isArray: true, + isFixedSizeArray: true, + arraySize: 3, + }, + }, + { + name: 'unbounded', + type: { + type: 'int32', + isPrimitiveType: true, + isArray: true, + }, + }, + ], + constants: [], + }; + } + static type() { + return { + pkgName: 'test_pkg', + subFolder: 'msg', + interfaceName: 'ArrayTest', + }; + } + }; + + const MockBoundArrayMsg = class { + static get ROSMessageDef() { + return { + fields: [ + { + name: 'values', + type: { + type: 'int32', + isPrimitiveType: true, + isArray: true, + isUpperBound: true, + arraySize: 5, + }, + }, + ], + constants: [], + }; + } + static type() { + return { + pkgName: 'test_pkg', + subFolder: 'msg', + interfaceName: 'BoundArrayTest', + }; + } + }; + + const MockInt64Msg = class { + static get ROSMessageDef() { + return { + fields: [ + { name: 'id', type: { type: 'int64', isPrimitiveType: true } }, + ], + constants: [], + }; + } + static type() { + return { + pkgName: 'test_pkg', + subFolder: 'msg', + interfaceName: 'Int64Test', + }; + } + }; + + const MockNestedArrayMsg = class { + constructor() { + this.elements = []; + // Mock the property used by getNestedTypeClass for arrays + this.elements.classType = { elementType: MockStringMsg }; + } + static get ROSMessageDef() { + return { + fields: [ + { + name: 'elements', + type: { + type: 'String', + pkgName: 'std_msgs', + isPrimitiveType: false, + isArray: true, + }, + }, + ], + constants: [], + }; + } + static type() { + return { + pkgName: 'test_pkg', + subFolder: 'msg', + interfaceName: 'NestedArrayTest', + }; + } + }; + + describe('Utility functions', function () { + it('getMessageTypeString returns correct string', function () { + const str = getMessageTypeString(MockStringMsg); + assert.strictEqual(str, 'std_msgs/msg/String'); + }); + + it('getMessageTypeString returns unknown for invalid input', function () { + const str = getMessageTypeString({}); + assert.strictEqual(str, 'unknown'); + }); + + it('getMessageSchema returns schema', function () { + const schema = getMessageSchema(MockStringMsg); + assert.strictEqual(schema.messageType, 'std_msgs/msg/String'); + assert.ok(schema.fields.length > 0); + }); + + it('getFieldNames returns field names', function () { + const names = getFieldNames(MockStringMsg); + assert.deepStrictEqual(names, ['data']); + }); + }); + + describe('createMessageValidator', function () { + it('creates a function', function () { + const validator = createMessageValidator(MockStringMsg); + assert.strictEqual(typeof validator, 'function'); + }); + + it('throws on invalid type class', function () { + assert.throws(() => { + createMessageValidator(null); + }, TypeValidationError); + }); + + it('validator validates correctly', function () { + const validator = createMessageValidator(MockStringMsg); + const result = validator({ data: 'ok' }); + assert.strictEqual(result.valid, true); + + const resultFail = validator({ data: 123 }); + assert.strictEqual(resultFail.valid, false); + }); + }); + + describe('assertValidMessage', function () { + it('throws Validation Error when validation fails', function () { + const msg = { data: 123 }; + + assert.throws(() => { + assertValidMessage(msg, MockStringMsg); + }, MessageValidationError); + }); + + it('passes for valid message', function () { + const msg = { data: 'hello' }; + + assert.doesNotThrow(() => { + assertValidMessage(msg, MockStringMsg); + }); + }); + }); + + describe('validateMessage', function () { + it('detects unknown fields (strict mode)', function () { + const plainMsg = { data: 'hello', unknown: 1 }; + + const result = validateMessage(plainMsg, MockStringMsg, { strict: true }); + assert.strictEqual(result.valid, false); + assert.strictEqual( + result.issues[0].problem, + ValidationProblem.UNKNOWN_FIELD + ); + }); + + it('detects type mismatch', function () { + const plainMsg = { data: 123 }; + const result = validateMessage(plainMsg, MockStringMsg, { + checkTypes: true, + }); + assert.strictEqual(result.valid, false); + assert.strictEqual( + result.issues[0].problem, + ValidationProblem.TYPE_MISMATCH + ); + }); + + it('validates primitive wrapper check (single field optimization)', function () { + // Test single primitive value directly + const result = validateMessage('mystring', MockStringMsg); + assert.strictEqual(result.valid, true); + + const resultFail = validateMessage(123, MockStringMsg); + assert.strictEqual(resultFail.valid, false); + }); + + it('validates array constraints (fixed size)', function () { + const plainMsg = { covariance: [1, 2] }; // Too short (needs 3) + // Note: We need to omit 'unbounded' or provide it validly + + const result = validateMessage(plainMsg, MockArrayMsg, { + checkTypes: true, + }); + const issue = result.issues.find( + (i) => i.problem === ValidationProblem.ARRAY_LENGTH + ); + assert.ok(issue); + }); + + it('validates array constraints (type mismatch in array)', function () { + const plainMsg = { covariance: [1, 2, 'bad'] }; + const result = validateMessage(plainMsg, MockArrayMsg, { + checkTypes: true, + }); + const issue = result.issues.find( + (i) => i.problem === ValidationProblem.TYPE_MISMATCH + ); + assert.ok(issue); + }); + + it('detects missing required fields', function () { + const plainMsg = {}; + const result = validateMessage(plainMsg, MockStringMsg, { + checkRequired: true, + }); + assert.strictEqual(result.valid, false); + assert.strictEqual( + result.issues[0].problem, + ValidationProblem.MISSING_FIELD + ); + }); + + it('handles null/undefined object', function () { + const result = validateMessage(null, MockStringMsg); + assert.strictEqual(result.valid, false); + }); + + it('validates array constraints (upper bound)', function () { + const validMsg = { values: [1, 2, 3, 4, 5] }; + const validResult = validateMessage(validMsg, MockBoundArrayMsg); + assert.strictEqual(validResult.valid, true); + + const invalidMsg = { values: [1, 2, 3, 4, 5, 6] }; + const invalidResult = validateMessage(invalidMsg, MockBoundArrayMsg); + assert.strictEqual(invalidResult.valid, false); + assert.strictEqual( + invalidResult.issues[0].problem, + ValidationProblem.ARRAY_LENGTH + ); + }); + + it('allows TypedArray for array fields', function () { + const msg = { values: new Int32Array([1, 2, 3]) }; + const result = validateMessage(msg, MockBoundArrayMsg); + assert.strictEqual(result.valid, true); + }); + + it('allows number for int64/uint64 (BigInt) fields', function () { + const msg = { id: 12345 }; // JS Number + const result = validateMessage(msg, MockInt64Msg, { checkTypes: true }); + assert.strictEqual(result.valid, true); + }); + + it('detects type mismatch for int64', function () { + const msg = { id: 'not-a-number' }; + const result = validateMessage(msg, MockInt64Msg, { checkTypes: true }); + assert.strictEqual(result.valid, false); + assert.strictEqual( + result.issues[0].problem, + ValidationProblem.TYPE_MISMATCH + ); + }); + + it('validates nested message arrays', function () { + const validMsg = { + elements: [{ data: 'str1' }, { data: 'str2' }], + }; + const validResult = validateMessage(validMsg, MockNestedArrayMsg); + assert.strictEqual(validResult.valid, true); + }); + + it('detects errors in nested message arrays', function () { + const invalidMsg = { + elements: [{ data: 'str1' }, { data: 123 }], // 2nd element invalid + }; + const result = validateMessage(invalidMsg, MockNestedArrayMsg, { + checkTypes: true, + }); + + assert.strictEqual(result.valid, false); + const issue = result.issues[0]; + assert.ok(issue.field.includes('elements[1]')); + assert.strictEqual(issue.problem, ValidationProblem.TYPE_MISMATCH); + }); + + it('validates array of primitives and detects element type error', function () { + // Re-use MockBoundArrayMsg (int32 array) + const invalidMsg = { values: [1, 'bad', 3] }; + const result = validateMessage(invalidMsg, MockBoundArrayMsg, { + checkTypes: true, + }); + assert.strictEqual(result.valid, false); + const issue = result.issues[0]; + assert.ok(issue.field.includes('values[1]')); + assert.strictEqual(issue.problem, ValidationProblem.TYPE_MISMATCH); + }); + + it('reports error when schema is missing', function () { + const NoSchemaClass = class {}; + const result = validateMessage({}, NoSchemaClass); + assert.strictEqual(result.valid, false); + assert.strictEqual(result.issues[0].problem, 'NO_SCHEMA'); + }); + + it('reports error when type class is invalid', function () { + const result = validateMessage({}, 'ValidLookingStringButNotLoaded'); + // resolveTypeClass tends to return null if loadInterface fails for string + assert.strictEqual(result.valid, false); + assert.strictEqual(result.issues[0].problem, 'INVALID_TYPE_CLASS'); + }); + }); +}); diff --git a/test/test-native-loader.js b/test/test-native-loader.js new file mode 100644 index 00000000..71134fd2 --- /dev/null +++ b/test/test-native-loader.js @@ -0,0 +1,100 @@ +'use strict'; + +const assert = require('assert'); +const sinon = require('sinon'); + +const fs = require('fs'); +const child_process = require('child_process'); + +describe('NativeLoader testing', function () { + const sandbox = sinon.createSandbox(); + let originalPlatform; + let originalArch; + let originalEnv; + + beforeEach(function () { + originalPlatform = process.platform; + originalArch = process.arch; + originalEnv = { ...process.env }; + process.env.NODE_ENV = 'test'; + + // Clear cache to reload module + delete require.cache[require.resolve('../lib/native_loader.js')]; + }); + + afterEach(function () { + sandbox.restore(); + Object.defineProperty(process, 'platform', { value: originalPlatform }); + Object.defineProperty(process, 'arch', { value: originalArch }); + process.env = originalEnv; + }); + + function getLoader() { + return require('../lib/native_loader.js').TestHelpers; + } + + it('customFallbackLoader returns null on non-linux', function () { + Object.defineProperty(process, 'platform', { value: 'win32' }); + const loader = getLoader(); + assert.strictEqual(loader.customFallbackLoader(), null); + }); + + it('customFallbackLoader returns null if env info missing', function () { + Object.defineProperty(process, 'platform', { value: 'linux' }); + process.env.ROS_DISTRO = ''; + const loader = getLoader(); + assert.strictEqual(loader.customFallbackLoader(), null); + }); + + it('customFallbackLoader returns null if prebuild dir not found', function () { + Object.defineProperty(process, 'platform', { value: 'linux' }); + Object.defineProperty(process, 'arch', { value: 'x64' }); + process.env.ROS_DISTRO = 'humble'; + + sandbox.stub(fs, 'existsSync').returns(false); + + const loader = getLoader(); + assert.strictEqual(loader.customFallbackLoader(), null); + }); + + it('customFallbackLoader attempts to require exact match if exists', function () { + Object.defineProperty(process, 'platform', { value: 'linux' }); + Object.defineProperty(process, 'arch', { value: 'x64' }); + process.env.ROS_DISTRO = 'humble'; + + // Stub fs.existsSync to return true + const existsSync = sandbox.stub(fs, 'existsSync').returns(true); + + const loader = getLoader(); + assert.strictEqual(loader.customFallbackLoader(), null); + + // Verify it checked for the file + assert.ok(existsSync.called); + const args = existsSync.lastCall.args[0]; + assert.ok(args.includes('humble')); + assert.ok(args.includes('rclnodejs.node')); + }); + + it('loadNativeAddon force build triggers rebuild', function () { + process.env.RCLNODEJS_FORCE_BUILD = '1'; + const execSync = sandbox.stub(child_process, 'execSync'); + + // We expect it to try loading bindings after build + // Since we don't mock bindings, it will load the real one (if present) or fail. + // If it loads real one, test passes. + // If it fails, loadNativeAddon throws. We might need to handle that. + // But usually in dev env, the binding exists. + + try { + getLoader(); + // Wait, getLoader requires the file. + // The file calls loadNativeAddon() immediately. + // So valid test is just requiring the file. + } catch (e) { + // Ignore if loading binding fails, as long as execSync was called + } + + assert.ok(execSync.calledOnce); + assert.match(execSync.firstCall.args[0], /npm run rebuild/); + }); +}); diff --git a/test/test-serialization-modes.js b/test/test-serialization-modes.js index f823c3df..3787cc9d 100644 --- a/test/test-serialization-modes.js +++ b/test/test-serialization-modes.js @@ -16,6 +16,15 @@ const assert = require('assert'); const rclnodejs = require('../index.js'); +const { + isTypedArray, + needsJSONConversion, + toPlainArrays, + toJSONSafe, + toJSONString, + applySerializationMode, + isValidSerializationMode, +} = require('../lib/message_serialization.js'); describe('Serialization Modes Tests', function () { let node; @@ -144,3 +153,118 @@ describe('Serialization Modes Tests', function () { assert.strictEqual(subscription.serializationMode, 'default'); }); }); + +describe('Message Serialization Unit Tests', function () { + it('isTypedArray identifies TypedArrays', function () { + assert.strictEqual(isTypedArray(new Uint8Array(1)), true); + assert.strictEqual(isTypedArray(new Int32Array(1)), true); + assert.strictEqual(isTypedArray(new Float64Array(1)), true); + assert.strictEqual(isTypedArray([]), false); + assert.strictEqual(isTypedArray(new DataView(new ArrayBuffer(8))), false); + assert.strictEqual(isTypedArray(null), false); + }); + + it('needsJSONConversion identifies special types', function () { + assert.strictEqual(needsJSONConversion(10n), true); + assert.strictEqual( + needsJSONConversion(() => {}), + true + ); + assert.strictEqual(needsJSONConversion(undefined), true); + assert.strictEqual(needsJSONConversion(Infinity), true); + assert.strictEqual(needsJSONConversion(-Infinity), true); + assert.strictEqual(needsJSONConversion(NaN), true); + assert.strictEqual(needsJSONConversion(123), false); + assert.strictEqual(needsJSONConversion('string'), false); + assert.strictEqual(needsJSONConversion(null), false); + }); + + it('toPlainArrays converts recursively', function () { + const input = { + a: new Uint8Array([1, 2]), + b: { + c: new Float32Array([1.1, 2.2]), + d: [new Int8Array([3])], + }, + e: null, + f: undefined, // Should handle + }; + + // Note: Float32Array precision might cause exact equality issues if we compare strict deep equal with floats, + // but here we just check structure and array type. + const output = toPlainArrays(input); + + assert.ok(Array.isArray(output.a)); + assert.strictEqual(output.a[0], 1); + + assert.ok(Array.isArray(output.b.c)); + assert.ok(output.b.c.length === 2); + + assert.ok(Array.isArray(output.b.d[0])); + assert.strictEqual(output.b.d[0][0], 3); + + assert.strictEqual(output.e, null); + }); + + it('toJSONSafe converts special types', function () { + const input = { + big: 123n, + inf: Infinity, + ninf: -Infinity, + nan: NaN, + undef: undefined, + func: () => {}, + nested: { + arr: [10n], + }, + typed: new Uint8Array([1, 2]), + }; + + const output = toJSONSafe(input); + + assert.strictEqual(output.big, '123n'); + assert.strictEqual(output.inf, 'Infinity'); + assert.strictEqual(output.ninf, '-Infinity'); + assert.strictEqual(output.nan, 'NaN'); + assert.strictEqual(output.undef, undefined); + assert.strictEqual(output.func, '[Function]'); + assert.strictEqual(output.nested.arr[0], '10n'); + + // TypedArray in toJSONSafe is mapped to array and then items processed + assert.ok(Array.isArray(output.typed)); + assert.strictEqual(output.typed[0], 1); + }); + + it('toJSONString produces string', function () { + const input = { data: 123n }; + const str = toJSONString(input); + assert.strictEqual(JSON.parse(str).data, '123n'); + }); + + it('applySerializationMode works for all modes', function () { + const input = { data: new Uint8Array([1]) }; + + // default + assert.strictEqual(applySerializationMode(input, 'default'), input); + + // plain + const plain = applySerializationMode(input, 'plain'); + assert.ok(Array.isArray(plain.data)); + + // json + const json = applySerializationMode({ data: 10n }, 'json'); + assert.strictEqual(json.data, '10n'); + + // invalid + assert.throws(() => { + applySerializationMode(input, 'invalid'); + }, /Invalid serializationMode/); + }); + + it('isValidSerializationMode', function () { + assert.strictEqual(isValidSerializationMode('default'), true); + assert.strictEqual(isValidSerializationMode('plain'), true); + assert.strictEqual(isValidSerializationMode('json'), true); + assert.strictEqual(isValidSerializationMode('other'), false); + }); +}); diff --git a/test/test-time-source.js b/test/test-time-source.js index 7a3c96cf..c77a698f 100644 --- a/test/test-time-source.js +++ b/test/test-time-source.js @@ -15,6 +15,7 @@ 'use strict'; const assert = require('assert'); +const sinon = require('sinon'); const rclnodejs = require('../index.js'); const { Clock, Parameter, ParameterType, ROSClock, TimeSource, Time } = rclnodejs; @@ -148,4 +149,168 @@ describe('rclnodejs TimeSource testing', function () { done(); }, 3000); }); + + it('Test isRosTimeActive setter optimization', function () { + let timeSource = new TimeSource(node); + timeSource.isRosTimeActive = true; + assert.strictEqual(timeSource.isRosTimeActive, true); + + // Set to same value coverage check + timeSource.isRosTimeActive = true; + assert.strictEqual(timeSource.isRosTimeActive, true); + }); + + it('Test onParameterEvent', function () { + let timeSource = new TimeSource(node); + + // Correct update + const result = timeSource.onParameterEvent([ + { + name: 'use_sim_time', + type: rclnodejs.ParameterType.PARAMETER_BOOL, + value: true, + }, + ]); + assert.strictEqual(timeSource.isRosTimeActive, true); + assert.ok(result.successful); + + // Update with wrong type (should log error but not crash) + // Use stub to suppress output and verify call + const logger = node.getLogger(); + const errorStub = sinon.stub(logger, 'error'); + + const result2 = timeSource.onParameterEvent([ + { + name: 'use_sim_time', + type: rclnodejs.ParameterType.PARAMETER_INTEGER, + value: 123, + }, + ]); + assert.strictEqual(timeSource.isRosTimeActive, true); + assert.ok(result2.successful); + assert.ok(errorStub.calledOnce); + errorStub.restore(); + + // Update with unrelated parameter + timeSource.onParameterEvent([ + { + name: 'other_param', + type: rclnodejs.ParameterType.PARAMETER_BOOL, + value: false, + }, + ]); + assert.strictEqual(timeSource.isRosTimeActive, true); + }); + + it('Test detachNode', function () { + let timeSource = new TimeSource(node); + timeSource.isRosTimeActive = true; + // This creates subscription + assert.notStrictEqual(timeSource._clockSubscription, undefined); + + timeSource.detachNode(); + assert.strictEqual(timeSource._node, undefined); + assert.strictEqual(timeSource._clockSubscription, undefined); + }); + + it('Test attachNode validations', function () { + let timeSource = new TimeSource(null); + assert.throws(() => { + timeSource.attachNode({}); + }, rclnodejs.TypeValidationError); + + timeSource.attachNode(node); + assert.strictEqual(timeSource._node, node); + }); + + it('Test attachNode with invalid parameter type', function () { + // Create a node with the parameter already set to an integer. + // The use_sim_time parameter is expected to be a boolean (PARAMETER_BOOL). + // This test intentionally uses an integer to verify the error handling logic. + // We must use a new node because beforeEach node might already have default params or we want to ensure fresh state + const options = new rclnodejs.NodeOptions(); + options.parameterOverrides.push( + new rclnodejs.Parameter( + 'use_sim_time', + rclnodejs.ParameterType.PARAMETER_INTEGER, + 123 + ) + ); + + // Note: declaring parameter overrides automatically makes them available/declared on the node + const invalidParamNode = rclnodejs.createNode( + 'TestTimeSourceInvalidParam', + '', + rclnodejs.Context.defaultContext(), + options + ); + + const logger = invalidParamNode.getLogger(); + const errorStub = sinon.stub(logger, 'error'); + + let timeSource = new TimeSource(null); + timeSource.attachNode(invalidParamNode); + + assert.ok(errorStub.calledOnce); + assert.ok( + errorStub.firstCall.args[0].includes('Invalid type for parameter') + ); + + errorStub.restore(); + invalidParamNode.destroy(); + }); + + it('Test detachNode internal error state', function () { + let timeSource = new TimeSource(node); + + // Simulate broken state: subscription exists but node is gone + // We can't easily get a real subscription object without being active, + // but detachNode just checks if it's truthy before calling destroySubscription + timeSource._clockSubscription = { _handle: {} }; + timeSource._node = undefined; + + assert.throws( + () => { + timeSource.detachNode(); + }, + (err) => { + return ( + err instanceof rclnodejs.OperationError && + err.code === 'NO_NODE_ATTACHED' + ); + } + ); + }); + + it('Test re-attaching node', function () { + let timeSource = new TimeSource(node); + assert.strictEqual(timeSource._node, node); + + // Create a second node + let node2 = rclnodejs.createNode('TestTimeSource2'); + + // Attach should detach first node + timeSource.attachNode(node2); + + assert.strictEqual(timeSource._node, node2); + + node2.destroy(); + }); + + it('Test toggling isRosTimeActive', function () { + let timeSource = new TimeSource(node); + assert.strictEqual(timeSource.isRosTimeActive, false); + assert.strictEqual(timeSource._clockSubscription, undefined); + + // Enable + timeSource.isRosTimeActive = true; + assert.strictEqual(timeSource.isRosTimeActive, true); + assert.notStrictEqual(timeSource._clockSubscription, undefined); + + // Disable - currently the implementation does NOT destroy subscription on disable + // This test verifies current behavior (even if it might be considered a bug it ensures stability) + timeSource.isRosTimeActive = false; + assert.strictEqual(timeSource.isRosTimeActive, false); + assert.notStrictEqual(timeSource._clockSubscription, undefined); + }); }); diff --git a/test/test-timer.js b/test/test-timer.js index 468feffa..f984937d 100644 --- a/test/test-timer.js +++ b/test/test-timer.js @@ -17,6 +17,7 @@ const assert = require('assert'); const rclnodejs = require('../index.js'); const DistroUtils = require('../lib/distro.js'); +const sinon = require('sinon'); const TIMER_INTERVAL = BigInt('100000000'); describe('rclnodejs Timer class testing', function () { @@ -245,3 +246,98 @@ describe('rclnodejs Timer class testing', function () { }); }); }); + +describe('rclnodejs Timer class coverage testing', function () { + this.timeout(60 * 1000); + let node; + let timer; + + before(async function () { + await rclnodejs.init(); + }); + + after(function () { + rclnodejs.shutdown(); + }); + + beforeEach(function () { + node = rclnodejs.createNode('timer_coverage_node'); + timer = node.createTimer(TIMER_INTERVAL, () => {}); + }); + + afterEach(function () { + if (node) { + node.destroy(); + } + }); + + it('handle getter should return handle', function () { + assert.ok(timer.handle); + }); + + it('getNextCallTime returns undefined if rclnodejs function not present', function () { + const originalFunc = rclnodejs.getTimerNextCallTime; + try { + Object.defineProperty(rclnodejs, 'getTimerNextCallTime', { + value: undefined, + configurable: true, + writable: true, + }); + assert.strictEqual(timer.getNextCallTime(), undefined); + } catch (e) { + this.skip(); + } finally { + try { + if (originalFunc) { + Object.defineProperty(rclnodejs, 'getTimerNextCallTime', { + value: originalFunc, + configurable: true, + writable: true, + }); + } + } catch (e) {} + } + }); + + it('Distribution check warning for setOnResetCallback', function () { + const stub = sinon.stub(DistroUtils, 'getDistroId').returns(1); + stub.withArgs('humble').returns(2); + const consoleSpy = sinon.spy(console, 'warn'); + + try { + timer.setOnResetCallback(() => {}); + assert.ok(consoleSpy.calledWithMatch(/not supported/)); + } finally { + stub.restore(); + consoleSpy.restore(); + } + }); + + it('Distribution check warning for clearOnResetCallback', function () { + const stub = sinon.stub(DistroUtils, 'getDistroId').returns(1); + stub.withArgs('humble').returns(2); + const consoleSpy = sinon.spy(console, 'warn'); + + try { + timer.clearOnResetCallback(); + assert.ok(consoleSpy.calledWithMatch(/not supported/)); + } finally { + stub.restore(); + consoleSpy.restore(); + } + }); + + it('Distribution check warning for callTimerWithInfo', function () { + const stub = sinon.stub(DistroUtils, 'getDistroId').returns(1); + stub.withArgs('humble').returns(2); + const consoleSpy = sinon.spy(console, 'warn'); + + try { + timer.callTimerWithInfo(); + assert.ok(consoleSpy.calledWithMatch(/not supported/)); + } finally { + stub.restore(); + consoleSpy.restore(); + } + }); +}); diff --git a/test/test-utils.js b/test/test-utils.js new file mode 100644 index 00000000..beb357ce --- /dev/null +++ b/test/test-utils.js @@ -0,0 +1,183 @@ +'use strict'; + +const assert = require('assert'); +const fs = require('fs'); +const path = require('path'); +const os = require('os'); +const sinon = require('sinon'); +const utils = require('../lib/utils.js'); + +describe('Utils testing', function () { + let tmpDir; + const sandbox = sinon.createSandbox(); + + beforeEach(function () { + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'rclnodejs-test-utils-')); + }); + + afterEach(function () { + if (tmpDir && fs.existsSync(tmpDir)) { + fs.rmSync(tmpDir, { recursive: true, force: true }); + } + sandbox.restore(); + }); + + it('should verify pathExists works correctly', async function () { + const file = path.join(tmpDir, 'test-file.txt'); + fs.writeFileSync(file, 'content'); + + assert.strictEqual(await utils.pathExists(file), true); + assert.strictEqual( + await utils.pathExists(path.join(tmpDir, 'non-existent')), + false + ); + }); + + it('should verify ensureDir works correctly', async function () { + const dir = path.join(tmpDir, 'nested/dir'); + await utils.ensureDir(dir); + + assert.ok(fs.existsSync(dir)); + const stat = fs.statSync(dir); + assert.ok(stat.isDirectory()); + + // Should not throw if it exists + await utils.ensureDir(dir); + }); + + it('should valid ensureDirSync works correctly', function () {}); + + it('should verify ensureDirSync works correctly', function () { + const dir = path.join(tmpDir, 'nested/sync/dir'); + utils.ensureDirSync(dir); + + assert.ok(fs.existsSync(dir)); + const stat = fs.statSync(dir); + assert.ok(stat.isDirectory()); + + // Should not throw if it exists + utils.ensureDirSync(dir); + }); + + it('should valid emptyDir works correctly', async function () { + const dir = path.join(tmpDir, 'cleanup'); + fs.mkdirSync(dir); + fs.writeFileSync(path.join(dir, 'file1'), '1'); + fs.mkdirSync(path.join(dir, 'subdir')); + fs.writeFileSync(path.join(dir, 'subdir/file2'), '2'); + + await utils.emptyDir(dir); + + assert.ok(fs.existsSync(dir)); + const files = fs.readdirSync(dir); + assert.strictEqual(files.length, 0); + }); + + it('should handle emptyDir on non-existent directory', async function () { + const dir = path.join(tmpDir, 'non-existent-dir'); + await utils.emptyDir(dir); + assert.ok(!fs.existsSync(dir)); + }); + + it('should verify copy works correctly', async function () { + const src = path.join(tmpDir, 'src'); + const dest = path.join(tmpDir, 'dest'); + + await utils.ensureDir(src); + fs.writeFileSync(path.join(src, 'file.txt'), 'hello'); + + await utils.copy(src, dest); + + assert.ok(fs.existsSync(path.join(dest, 'file.txt'))); + assert.strictEqual( + fs.readFileSync(path.join(dest, 'file.txt'), 'utf8'), + 'hello' + ); + }); + + it('should verify file operation wrappers', async function () { + const file = path.join(tmpDir, 'wrap.txt'); + await utils.writeFile(file, 'data'); + assert.ok(fs.existsSync(file)); + assert.strictEqual(fs.readFileSync(file, 'utf8'), 'data'); + + utils.removeSync(file); + assert.ok(!fs.existsSync(file)); + + await utils.mkdir(path.join(tmpDir, 'wrap-dir')); + assert.ok(fs.existsSync(path.join(tmpDir, 'wrap-dir'))); + + await utils.remove(path.join(tmpDir, 'wrap-dir')); + assert.ok(!fs.existsSync(path.join(tmpDir, 'wrap-dir'))); + }); + + it('should verify readJsonSync', function () { + const file = path.join(tmpDir, 'data.json'); + fs.writeFileSync(file, '{"a":1}'); + const data = utils.readJsonSync(file); + assert.deepStrictEqual(data, { a: 1 }); + }); + + describe('Other utils testing', function () { + it('should detect ubuntu codename correctly', function () { + const originalPlatform = process.platform; + Object.defineProperty(process, 'platform', { value: 'linux' }); + + sandbox + .stub(fs, 'readFileSync') + .withArgs('/etc/os-release', 'utf8') + .returns('VERSION_CODENAME=noble\nNAME="Ubuntu"'); + + const codename = utils.detectUbuntuCodename(); + assert.strictEqual(codename, 'noble'); + + Object.defineProperty(process, 'platform', { value: originalPlatform }); + }); + + it('should return null for codename if not linux', function () { + const originalPlatform = process.platform; + Object.defineProperty(process, 'platform', { value: 'win32' }); + + const codename = utils.detectUbuntuCodename(); + assert.strictEqual(codename, null); + + Object.defineProperty(process, 'platform', { value: originalPlatform }); + }); + + it('should return null for codename if file read fails', function () { + const originalPlatform = process.platform; + Object.defineProperty(process, 'platform', { value: 'linux' }); + + sandbox.stub(fs, 'readFileSync').throws(new Error('no file')); + + const codename = utils.detectUbuntuCodename(); + assert.strictEqual(codename, null); + + Object.defineProperty(process, 'platform', { value: originalPlatform }); + }); + + it('should test normalizeNodeName', function () { + assert.strictEqual(utils.normalizeNodeName('/node'), 'node'); + assert.strictEqual(utils.normalizeNodeName('node'), 'node'); + assert.strictEqual(utils.normalizeNodeName('/ns/node'), 'ns/node'); + }); + + it('should test isClose', function () { + assert.strictEqual(utils.isClose(1.0, 1.0), true); + assert.strictEqual(utils.isClose(1.0, 1.0000000001), true); // 1e-10 diff + assert.strictEqual(utils.isClose(1.0, 1.1), false); + assert.strictEqual(utils.isClose(0, 0), true); + // Implementation check: utils.js returns false if not finite, UNLESS they are strictly equal + assert.strictEqual(utils.isClose(Infinity, Infinity), true); + assert.strictEqual(utils.isClose(1, Infinity), false); + }); + + it('should test compareVersions', function () { + assert.strictEqual(utils.compareVersions('1.2.3', '1.2.3', '=='), true); + assert.strictEqual(utils.compareVersions('1.2.3', '1.2.4', '<'), true); + assert.strictEqual(utils.compareVersions('1.3', '1.2.4', '>'), true); + assert.strictEqual(utils.compareVersions('1.2.3', '1.2', '>'), true); + assert.throws(() => utils.compareVersions('1.0', '1.0', 'badop')); + }); + }); +});