Tests 1, 4, 5 constructed an Adapter with the default statusInterval=1000 and no mock for setInterval, leaking a real status timer that held the event loop open past the assertions. Single-file runs masked it; node --test test/basic/ blocked the whole runner. Fix: set static statusInterval = 0 + invoke node.handlers.close() (or mock setInterval where the test asserts on registration timing). 113/113 basic tests pass in batch in ~400 ms. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
338 lines
13 KiB
JavaScript
338 lines
13 KiB
JavaScript
'use strict';
|
|
|
|
const test = require('node:test');
|
|
const assert = require('node:assert/strict');
|
|
const EventEmitter = require('events');
|
|
|
|
const BaseNodeAdapter = require('../../src/nodered/BaseNodeAdapter');
|
|
|
|
// ---- test doubles ---------------------------------------------------------
|
|
|
|
function makeLogger() {
|
|
const calls = { warn: [], error: [], info: [], debug: [] };
|
|
return {
|
|
warn: (...a) => calls.warn.push(a.join(' ')),
|
|
error: (...a) => calls.error.push(a.join(' ')),
|
|
info: (...a) => calls.info.push(a.join(' ')),
|
|
debug: (...a) => calls.debug.push(a.join(' ')),
|
|
_calls: calls,
|
|
};
|
|
}
|
|
|
|
function makeNode(id = 'node-1') {
|
|
const sends = [];
|
|
const statuses = [];
|
|
const handlers = {};
|
|
return {
|
|
id,
|
|
sends,
|
|
statuses,
|
|
handlers,
|
|
send(arr) { sends.push(arr); },
|
|
status(b) { statuses.push(b); },
|
|
on(ev, fn) { handlers[ev] = fn; },
|
|
warn() {},
|
|
error() {},
|
|
};
|
|
}
|
|
|
|
function makeRED() {
|
|
return { nodes: { getNode: () => null } };
|
|
}
|
|
|
|
// Fake domain — surfaces just enough of the BaseDomain contract that
|
|
// BaseNodeAdapter touches (config, logger, emitter, getOutput, getStatusBadge,
|
|
// optionally tick + close). Avoids the JSON-config dependency BaseDomain has.
|
|
function makeDomain(opts = {}) {
|
|
const logger = opts.logger || makeLogger();
|
|
return class FakeDomain {
|
|
constructor(config) {
|
|
this.config = config;
|
|
this.logger = logger;
|
|
this.emitter = new EventEmitter();
|
|
this.tickCount = 0;
|
|
this.closed = false;
|
|
this._output = opts.output || { temperature: 21 };
|
|
this._badge = opts.badge || { fill: 'green', shape: 'dot', text: 'OK' };
|
|
}
|
|
tick() { this.tickCount += 1; }
|
|
getOutput() { return this._output; }
|
|
getStatusBadge() { return this._badge; }
|
|
close() { this.closed = true; }
|
|
};
|
|
}
|
|
|
|
// uiConfig field set used by configManager.buildConfig — measurement is
|
|
// chosen as the config-file name because measurement.json ships in
|
|
// generalFunctions/src/configs and getConfig() is called during construction.
|
|
function uiConfigFixture() {
|
|
return {
|
|
name: 'm1', unit: 'C', logLevel: 'warn',
|
|
positionVsParent: 'upstream', hasDistance: true, distance: 5,
|
|
};
|
|
}
|
|
|
|
// ---- 1. Construction with full subclass succeeds --------------------------
|
|
|
|
test('full subclass constructs and stores wiring on this', () => {
|
|
const Domain = makeDomain();
|
|
class Adapter extends BaseNodeAdapter {
|
|
static DomainClass = Domain;
|
|
static commands = [];
|
|
// Disable the real status interval — would hold the event loop open
|
|
// past the test and stall `node --test test/basic/` runs.
|
|
static statusInterval = 0;
|
|
buildDomainConfig() { return { extra: { foo: 1 } }; }
|
|
}
|
|
const node = makeNode();
|
|
const a = new Adapter(uiConfigFixture(), makeRED(), node, 'measurement');
|
|
assert.equal(a.name, 'measurement');
|
|
assert.equal(a.node, node);
|
|
assert.equal(node.source, a.source);
|
|
assert.equal(a.config.extra.foo, 1);
|
|
assert.equal(a.config.general.name, 'm1');
|
|
node.handlers.close(() => {});
|
|
});
|
|
|
|
// ---- 2-4. Static-field validation -----------------------------------------
|
|
|
|
test('direct new BaseNodeAdapter() throws abstract error', () => {
|
|
assert.throws(
|
|
() => new BaseNodeAdapter({}, makeRED(), makeNode(), 'measurement'),
|
|
/abstract/,
|
|
);
|
|
});
|
|
|
|
test('subclass without static DomainClass throws clearly', () => {
|
|
class Bad extends BaseNodeAdapter { static commands = []; buildDomainConfig() { return {}; } }
|
|
assert.throws(
|
|
() => new Bad({}, makeRED(), makeNode(), 'measurement'),
|
|
/DomainClass is required/,
|
|
);
|
|
});
|
|
|
|
test('subclass without static commands throws clearly', () => {
|
|
class Bad extends BaseNodeAdapter {
|
|
static DomainClass = makeDomain();
|
|
buildDomainConfig() { return {}; }
|
|
}
|
|
assert.throws(
|
|
() => new Bad({}, makeRED(), makeNode(), 'measurement'),
|
|
/commands is required/,
|
|
);
|
|
});
|
|
|
|
test('static commands = [] is allowed (explicit no-op registry)', () => {
|
|
class Adapter extends BaseNodeAdapter {
|
|
static DomainClass = makeDomain();
|
|
static commands = [];
|
|
static statusInterval = 0; // see fix in test #1
|
|
buildDomainConfig() { return {}; }
|
|
}
|
|
const node = makeNode();
|
|
assert.doesNotThrow(
|
|
() => new Adapter(uiConfigFixture(), makeRED(), node, 'measurement'),
|
|
);
|
|
node.handlers.close(() => {});
|
|
});
|
|
|
|
// ---- 5. Registration message after 100 ms ---------------------------------
|
|
|
|
test('registration message fires on Port 2 after 100 ms with child.register', (t) => {
|
|
t.mock.timers.enable({ apis: ['setTimeout', 'setInterval'] });
|
|
class Adapter extends BaseNodeAdapter {
|
|
static DomainClass = makeDomain();
|
|
static commands = [];
|
|
buildDomainConfig() { return {}; }
|
|
}
|
|
const node = makeNode('xyz');
|
|
new Adapter(uiConfigFixture(), makeRED(), node, 'measurement');
|
|
assert.equal(node.sends.length, 0);
|
|
t.mock.timers.tick(100);
|
|
assert.equal(node.sends.length, 1);
|
|
const [p0, p1, reg] = node.sends[0];
|
|
assert.equal(p0, null);
|
|
assert.equal(p1, null);
|
|
assert.equal(reg.topic, 'child.register');
|
|
assert.equal(reg.payload, 'xyz');
|
|
assert.equal(reg.positionVsParent, 'upstream');
|
|
assert.equal(reg.distance, 5);
|
|
});
|
|
|
|
// ---- 6. Tick mode ---------------------------------------------------------
|
|
|
|
test('static tickInterval > 0 calls source.tick() on schedule and emits outputs', (t) => {
|
|
t.mock.timers.enable({ apis: ['setInterval', 'setTimeout'] });
|
|
class Adapter extends BaseNodeAdapter {
|
|
static DomainClass = makeDomain();
|
|
static commands = [];
|
|
static tickInterval = 50;
|
|
static statusInterval = 0;
|
|
buildDomainConfig() { return {}; }
|
|
}
|
|
const node = makeNode();
|
|
const a = new Adapter(uiConfigFixture(), makeRED(), node, 'measurement');
|
|
assert.equal(a.source.tickCount, 0);
|
|
t.mock.timers.tick(50);
|
|
assert.equal(a.source.tickCount, 1);
|
|
t.mock.timers.tick(100);
|
|
assert.equal(a.source.tickCount, 3);
|
|
// Every tick triggers an output emission (the first carries the changed
|
|
// fields; subsequent ones may emit nulls because of delta compression —
|
|
// but node.send is called either way).
|
|
assert.ok(node.sends.length >= 3);
|
|
});
|
|
|
|
// ---- 7. Event-driven default ----------------------------------------------
|
|
|
|
test('default (no tick) subscribes to "output-changed" on source.emitter', (t) => {
|
|
t.mock.timers.enable({ apis: ['setTimeout'] });
|
|
class Adapter extends BaseNodeAdapter {
|
|
static DomainClass = makeDomain();
|
|
static commands = [];
|
|
static statusInterval = 0;
|
|
buildDomainConfig() { return {}; }
|
|
}
|
|
const node = makeNode();
|
|
const a = new Adapter(uiConfigFixture(), makeRED(), node, 'measurement');
|
|
// Drain the registration tick so we can isolate output emissions.
|
|
t.mock.timers.tick(100);
|
|
const before = node.sends.length;
|
|
a.source.emitter.emit('output-changed');
|
|
assert.equal(node.sends.length, before + 1);
|
|
const last = node.sends[node.sends.length - 1];
|
|
assert.equal(last.length, 3);
|
|
assert.equal(last[2], null);
|
|
});
|
|
|
|
// ---- 8. _emitOutputs shape ------------------------------------------------
|
|
|
|
test('_emitOutputs sends [processMsg, influxMsg, null] with both formatters', () => {
|
|
class Adapter extends BaseNodeAdapter {
|
|
static DomainClass = makeDomain({ output: { v: 1 } });
|
|
static commands = [];
|
|
static statusInterval = 0;
|
|
buildDomainConfig() { return {}; }
|
|
}
|
|
const node = makeNode();
|
|
const a = new Adapter(uiConfigFixture(), makeRED(), node, 'measurement');
|
|
node.sends.length = 0;
|
|
a._emitOutputs();
|
|
assert.equal(node.sends.length, 1);
|
|
const [proc, influx, port2] = node.sends[0];
|
|
assert.ok(proc && typeof proc === 'object', 'process msg present');
|
|
assert.ok(influx && typeof influx === 'object', 'influxdb msg present');
|
|
assert.equal(port2, null);
|
|
});
|
|
|
|
// ---- 9-10. Input dispatch -------------------------------------------------
|
|
|
|
test('input handler dispatches a known topic to the registered handler', async () => {
|
|
const seen = [];
|
|
class Adapter extends BaseNodeAdapter {
|
|
static DomainClass = makeDomain();
|
|
static commands = [{
|
|
topic: 'set.mode',
|
|
handler: (source, msg, ctx) => { seen.push({ source, msg, ctx }); },
|
|
}];
|
|
static statusInterval = 0;
|
|
buildDomainConfig() { return {}; }
|
|
}
|
|
const node = makeNode();
|
|
const a = new Adapter(uiConfigFixture(), makeRED(), node, 'measurement');
|
|
let donec = 0;
|
|
await node.handlers.input({ topic: 'set.mode', payload: 'auto' }, () => {}, () => { donec += 1; });
|
|
assert.equal(seen.length, 1);
|
|
assert.equal(seen[0].source, a.source);
|
|
assert.equal(seen[0].msg.payload, 'auto');
|
|
assert.equal(donec, 1);
|
|
});
|
|
|
|
test('input handler with unknown topic warns and does not crash', async () => {
|
|
const logger = makeLogger();
|
|
class Adapter extends BaseNodeAdapter {
|
|
static DomainClass = makeDomain({ logger });
|
|
static commands = [];
|
|
static statusInterval = 0;
|
|
buildDomainConfig() { return {}; }
|
|
}
|
|
const node = makeNode();
|
|
new Adapter(uiConfigFixture(), makeRED(), node, 'measurement');
|
|
let donec = 0;
|
|
await node.handlers.input({ topic: 'totally.unknown', payload: 1 }, () => {}, () => { donec += 1; });
|
|
assert.equal(donec, 1);
|
|
assert.ok(logger._calls.warn.some((m) => m.includes('totally.unknown')));
|
|
});
|
|
|
|
// ---- 11. Status updater wiring --------------------------------------------
|
|
|
|
test('status updater receives static statusInterval', (t) => {
|
|
t.mock.timers.enable({ apis: ['setInterval', 'setTimeout'] });
|
|
class Adapter extends BaseNodeAdapter {
|
|
static DomainClass = makeDomain({ badge: { fill: 'red', shape: 'ring', text: 'X' } });
|
|
static commands = [];
|
|
static statusInterval = 250;
|
|
buildDomainConfig() { return {}; }
|
|
}
|
|
const node = makeNode();
|
|
new Adapter(uiConfigFixture(), makeRED(), node, 'measurement');
|
|
assert.equal(node.statuses.length, 0);
|
|
t.mock.timers.tick(250);
|
|
assert.equal(node.statuses.length, 1);
|
|
assert.deepEqual(node.statuses[0], { fill: 'red', shape: 'ring', text: 'X' });
|
|
});
|
|
|
|
// ---- 12. Close handler ----------------------------------------------------
|
|
|
|
test('close handler clears tick interval, stops status, clears badge, calls source.close', (t) => {
|
|
t.mock.timers.enable({ apis: ['setInterval', 'setTimeout'] });
|
|
class Adapter extends BaseNodeAdapter {
|
|
static DomainClass = makeDomain();
|
|
static commands = [];
|
|
static tickInterval = 100;
|
|
static statusInterval = 100;
|
|
buildDomainConfig() { return {}; }
|
|
}
|
|
const node = makeNode();
|
|
const a = new Adapter(uiConfigFixture(), makeRED(), node, 'measurement');
|
|
t.mock.timers.tick(200); // two ticks fire
|
|
const ticksAtClose = a.source.tickCount;
|
|
let donec = 0;
|
|
node.handlers.close(() => { donec += 1; });
|
|
assert.equal(donec, 1);
|
|
assert.equal(a.source.closed, true);
|
|
// Final node.status({}) appears in statuses.
|
|
assert.deepEqual(node.statuses[node.statuses.length - 1], {});
|
|
// No further ticks after close.
|
|
t.mock.timers.tick(1000);
|
|
assert.equal(a.source.tickCount, ticksAtClose);
|
|
});
|
|
|
|
// ---- 13. Hook points fire when defined ------------------------------------
|
|
|
|
test('extraSetup, extraInputDispatch, extraClose hooks fire when present', async (t) => {
|
|
t.mock.timers.enable({ apis: ['setTimeout'] });
|
|
const trace = [];
|
|
class Adapter extends BaseNodeAdapter {
|
|
static DomainClass = makeDomain();
|
|
static commands = [{ topic: 'set.x', handler: () => { trace.push('handler'); } }];
|
|
static statusInterval = 0;
|
|
buildDomainConfig() { return {}; }
|
|
extraSetup() { trace.push('extraSetup'); }
|
|
extraInputDispatch(msg) { trace.push(`extraInput:${msg.topic}`); }
|
|
extraClose() { trace.push('extraClose'); }
|
|
}
|
|
const node = makeNode();
|
|
new Adapter(uiConfigFixture(), makeRED(), node, 'measurement');
|
|
assert.ok(trace.includes('extraSetup'));
|
|
await node.handlers.input({ topic: 'set.x', payload: 1 }, () => {}, () => {});
|
|
assert.ok(trace.includes('handler'));
|
|
assert.ok(trace.includes('extraInput:set.x'));
|
|
// Unknown-topic path also runs extraInputDispatch — by design, it's the
|
|
// fallback the contract documents.
|
|
await node.handlers.input({ topic: 'unknown', payload: 1 }, () => {}, () => {});
|
|
assert.ok(trace.includes('extraInput:unknown'));
|
|
node.handlers.close(() => {});
|
|
assert.ok(trace.includes('extraClose'));
|
|
});
|