Add DBS-210 status.xml parser + health verdict
Second half of the DECT spike: the read-side "collector" that turns
a raw /admin/status.xml body into a normalized JS object plus a
pure health verdict. This is what will feed the /phonestatus base-
station diagnostics section once we wire it in.
- integrations/cisco-dect/statusXml.js:
- xmlToObject(): 60-line hand-rolled parser targeted at the
DBS-210's flat XML shape. No attributes, no CDATA, no comments
— so we avoid pulling in a generic XML lib. Throws loudly on
malformed input.
- parseRebootLine(): decodes the reboot-log entries the device
keeps in Reboot_Line_1..6, extracting timestamp + sequence #
+ reason name/code + firmware version. Unrecognized shapes come
back marked `unrecognized:true` instead of being dropped.
- parseStatusXml(): grouped, camelCased view of the device state
(device / firmware / time / multiCell / rebootLog / rtp /
network / security / emergencyNumbers / features). Every field
is null-safe.
- summarizeBaseHealth(): pure-function verdict. Flags recent
reboots (uptime < 10 min), power-loss events in the log,
DECT RF conflicts, non-zero rx/tx errors. Splits into
warnings vs info so consumers can render at the right severity.
- tests/statusXml.test.js: 23 tests covering the parser, the
reboot-line decoder, the higher-level normalizer, and the health
verdict — using a REDACTED inline copy of a real status.xml
captured from a lab base. MAC/IP/RFPI/firmware-server URL are
all obviously-fake so the fixture is safe to commit.
This commit is contained in:
parent
bc56b0a0fb
commit
17a8469592
2 changed files with 759 additions and 0 deletions
439
integrations/cisco-dect/statusXml.js
Normal file
439
integrations/cisco-dect/statusXml.js
Normal file
|
|
@ -0,0 +1,439 @@
|
||||||
|
// src/integrations/cisco-dect/statusXml.js
|
||||||
|
//
|
||||||
|
// Parser + normalizer for the Cisco DBS-210 DECT base station's
|
||||||
|
// `/admin/status.xml` endpoint. Pure — takes a raw XML string,
|
||||||
|
// returns a structured object. No axios, no I/O, no side-effects.
|
||||||
|
//
|
||||||
|
// Why hand-roll instead of pulling in fast-xml-parser? The DBS-210's
|
||||||
|
// status XML schema is trivially flat:
|
||||||
|
// - No attributes anywhere
|
||||||
|
// - No CDATA, no comments, no mixed content
|
||||||
|
// - Two levels of nesting at most (Status → Section → leaves;
|
||||||
|
// Section → Sub-object → leaves)
|
||||||
|
// - No repeated same-name siblings (Reboot_Line_1..6 are distinct
|
||||||
|
// tags, not <Reboot_Line> arrays)
|
||||||
|
// A specialized 60-line reader is safer than dragging in a generic
|
||||||
|
// XML parser we'd then need to keep pinned on the eventual store-side
|
||||||
|
// relay agent (which we want to stay tiny).
|
||||||
|
//
|
||||||
|
// The output shape is deliberately camelCased and grouped for
|
||||||
|
// consumers — it's NOT a faithful reflection of the XML tag names.
|
||||||
|
// That's on purpose: the parser is the moment where we absorb the
|
||||||
|
// Cisco tag naming quirks so nothing else in the codebase has to.
|
||||||
|
|
||||||
|
// ─── Low-level: XML → plain JS object ───────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parse a very-simple XML string (single root, no attributes, no
|
||||||
|
* CDATA, no comments, no mixed content) into a plain JS object.
|
||||||
|
* Leaf elements become string values; parent elements become nested
|
||||||
|
* objects keyed by tag name.
|
||||||
|
*
|
||||||
|
* Whitespace between tags is discarded. Whitespace inside leaf text
|
||||||
|
* is preserved as-is (the DBS-210 uses meaningful spacing in some
|
||||||
|
* fields, e.g. `RFPI_Address` = "13508C9C; RPN:00").
|
||||||
|
*
|
||||||
|
* Throws on malformed input rather than silently coercing — callers
|
||||||
|
* catch and log so a firmware change that breaks the schema shows up
|
||||||
|
* loudly instead of producing a mysteriously-empty object.
|
||||||
|
*/
|
||||||
|
export function xmlToObject(xml) {
|
||||||
|
if (typeof xml !== 'string') {
|
||||||
|
throw new TypeError('xmlToObject: expected a string');
|
||||||
|
}
|
||||||
|
// Strip XML declaration if present.
|
||||||
|
const cleaned = xml.replace(/^\uFEFF/, '').replace(/<\?xml[^?]*\?>/, '').trim();
|
||||||
|
if (!cleaned) throw new Error('xmlToObject: empty input');
|
||||||
|
|
||||||
|
let pos = 0;
|
||||||
|
|
||||||
|
function skipWs() {
|
||||||
|
while (pos < cleaned.length && /\s/.test(cleaned[pos])) pos++;
|
||||||
|
}
|
||||||
|
|
||||||
|
function readOpenTag() {
|
||||||
|
// Assumes cleaned[pos] === '<'.
|
||||||
|
if (cleaned[pos] !== '<') {
|
||||||
|
throw new Error(`xmlToObject: expected '<' at ${pos}`);
|
||||||
|
}
|
||||||
|
const end = cleaned.indexOf('>', pos);
|
||||||
|
if (end < 0) throw new Error(`xmlToObject: unterminated tag at ${pos}`);
|
||||||
|
const raw = cleaned.slice(pos + 1, end);
|
||||||
|
pos = end + 1;
|
||||||
|
const selfClosing = raw.endsWith('/');
|
||||||
|
const inner = selfClosing ? raw.slice(0, -1).trim() : raw.trim();
|
||||||
|
// Tag name = up to first whitespace. Attributes (if any) are
|
||||||
|
// ignored — DBS-210 XML doesn't use them and swallowing them
|
||||||
|
// silently keeps us robust to future additions.
|
||||||
|
const nameMatch = inner.match(/^([A-Za-z_][\w.-]*)/);
|
||||||
|
if (!nameMatch) throw new Error(`xmlToObject: bad tag name near ${pos}: ${inner}`);
|
||||||
|
return { name: nameMatch[1], selfClosing };
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseElement() {
|
||||||
|
const { name, selfClosing } = readOpenTag();
|
||||||
|
if (selfClosing) return { name, value: '' };
|
||||||
|
|
||||||
|
// Look ahead: if the next non-whitespace char is '<' AND it's not
|
||||||
|
// an immediate close of THIS tag, treat body as child elements.
|
||||||
|
// Otherwise treat body as leaf text up to </name>.
|
||||||
|
const savedPos = pos;
|
||||||
|
skipWs();
|
||||||
|
const closeTag = `</${name}>`;
|
||||||
|
const nextOpen = cleaned.indexOf('<', pos);
|
||||||
|
|
||||||
|
if (nextOpen === pos && !cleaned.startsWith(closeTag, pos)) {
|
||||||
|
// Container element with child elements.
|
||||||
|
const children = {};
|
||||||
|
while (true) {
|
||||||
|
skipWs();
|
||||||
|
if (cleaned.startsWith(closeTag, pos)) {
|
||||||
|
pos += closeTag.length;
|
||||||
|
return { name, value: children };
|
||||||
|
}
|
||||||
|
if (pos >= cleaned.length) {
|
||||||
|
throw new Error(`xmlToObject: EOF while reading children of <${name}>`);
|
||||||
|
}
|
||||||
|
const child = parseElement();
|
||||||
|
children[child.name] = child.value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Leaf text (may be empty or whitespace-only).
|
||||||
|
pos = savedPos;
|
||||||
|
const closeIdx = cleaned.indexOf(closeTag, pos);
|
||||||
|
if (closeIdx < 0) throw new Error(`xmlToObject: missing </${name}>`);
|
||||||
|
const rawText = cleaned.slice(pos, closeIdx);
|
||||||
|
pos = closeIdx + closeTag.length;
|
||||||
|
return { name, value: decodeEntities(rawText.trim()) };
|
||||||
|
}
|
||||||
|
|
||||||
|
const root = parseElement();
|
||||||
|
return { [root.name]: root.value };
|
||||||
|
}
|
||||||
|
|
||||||
|
function decodeEntities(s) {
|
||||||
|
return s
|
||||||
|
.replace(/</g, '<')
|
||||||
|
.replace(/>/g, '>')
|
||||||
|
.replace(/"/g, '"')
|
||||||
|
.replace(/'/g, "'")
|
||||||
|
.replace(/&#(\d+);/g, (_, n) => String.fromCharCode(Number(n)))
|
||||||
|
.replace(/&/g, '&'); // must run last
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── High-level: raw parsed tree → structured status object ─────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parse the two known-shape reboot line formats:
|
||||||
|
* "2026-07-02 13:11:46 (164) Normal Reboot (21) Firmware Version 05-01-03-0101-09"
|
||||||
|
* "2026-07-02 12:54:12 (161) Power Loss (80) Firmware Version 05-01-03-0101-09"
|
||||||
|
*
|
||||||
|
* Returns null for unrecognized shapes so callers can pass the raw
|
||||||
|
* string through instead of dropping the entry.
|
||||||
|
*/
|
||||||
|
export function parseRebootLine(line) {
|
||||||
|
if (typeof line !== 'string' || !line.trim()) return null;
|
||||||
|
const m = line.match(
|
||||||
|
/^(\d{4}-\d{2}-\d{2})\s+(\d{2}:\d{2}:\d{2})\s+\((\d+)\)\s+(.+?)\s+\((\d+)\)\s+Firmware Version\s+(\S+)\s*$/
|
||||||
|
);
|
||||||
|
if (!m) return { raw: line, unrecognized: true };
|
||||||
|
const [, date, time, seq, reasonName, reasonCode, firmware] = m;
|
||||||
|
return {
|
||||||
|
at: `${date}T${time}`, // ISO-ish local time (device doesn't include a TZ)
|
||||||
|
sequence: Number(seq),
|
||||||
|
reasonName: reasonName.trim(),
|
||||||
|
reasonCode: Number(reasonCode),
|
||||||
|
firmwareAtBoot: firmware,
|
||||||
|
raw: line,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Take raw /admin/status.xml text and return a normalized, grouped
|
||||||
|
* status object. Missing sections come back as `null` (or empty
|
||||||
|
* collections where an array/object shape is expected) rather than
|
||||||
|
* throwing — the DBS-210 sometimes omits sections depending on
|
||||||
|
* multi-cell role, and callers should be able to reason about
|
||||||
|
* partial data.
|
||||||
|
*
|
||||||
|
* @param {string} xml
|
||||||
|
* @returns {object} structured status
|
||||||
|
*/
|
||||||
|
export function parseStatusXml(xml) {
|
||||||
|
const tree = xmlToObject(xml);
|
||||||
|
const root = tree.Status || {};
|
||||||
|
const sys = (typeof root === 'object' && root.System_Information) || {};
|
||||||
|
const stats = (typeof root === 'object' && root.Statistics) || {};
|
||||||
|
|
||||||
|
const rebootLog = collectRebootLog(sys.Reboot_Log);
|
||||||
|
const rtp = sys.RTP_Usage || {};
|
||||||
|
const netStats = stats.Network_Statistics || {};
|
||||||
|
const emergency = extractEmergencyNumbers(sys.Emergency_Calls);
|
||||||
|
const deviceFwu = extractDeviceFwu(sys.Device_FWU_Info);
|
||||||
|
const rssi = extractRssiList(sys.RSSI_List);
|
||||||
|
|
||||||
|
return {
|
||||||
|
device: {
|
||||||
|
model: str(sys.Phone_Type), // "IPDECT-V2 (DBS-210-3PC)"
|
||||||
|
systemType: str(sys.System_Type), // "Generic SIP (RFC 3261)"
|
||||||
|
unitName: str(sys.Unit_Name), // "SME VoIP"
|
||||||
|
unitIndex: str(sys.Unit_Index), // "Base Idx:0"
|
||||||
|
rfBand: str(sys.RF_Band), // "US"
|
||||||
|
productConfiguration: str(sys.Product_Configuration),
|
||||||
|
macAddress: normalizeMac(str(sys.MAC_Address)),
|
||||||
|
ipAddress: str(sys.IP_Address),
|
||||||
|
rfpiAddress: str(sys.RFPI_Address), // "13508C9C; RPN:00"
|
||||||
|
releasedBuild: yesNo(sys.Released_Build),
|
||||||
|
},
|
||||||
|
firmware: {
|
||||||
|
version: str(sys.Firmware_Version), // "IPDECT-V2/05-01-03-0101-09/18-Mar-2026 10:29"
|
||||||
|
updateServer: str(sys.Firmware_URL?.Update_Server_Address),
|
||||||
|
updatePath: str(sys.Firmware_URL?.Path),
|
||||||
|
requiredFor: deviceFwu,
|
||||||
|
},
|
||||||
|
time: {
|
||||||
|
currentLocalTime: str(sys.Current_Local_Time),
|
||||||
|
operatingTime: str(sys.Operating_Time),
|
||||||
|
operatingTimeSeconds: parseOperatingSeconds(sys.Operating_Time),
|
||||||
|
},
|
||||||
|
multiCell: parseMultiCell(sys.Multi_Cell),
|
||||||
|
baseStatus: (str(sys.Base_Station_Status) || '').toLowerCase() || null,
|
||||||
|
conflictInfo: str(sys.Conflict_Info),
|
||||||
|
security: {
|
||||||
|
customCa: {
|
||||||
|
provisioningStatus: str(sys.Custom_CA_Status?.Custom_CA_Provisioning_Status),
|
||||||
|
info: str(sys.Custom_CA_Status?.Custom_CA_Info),
|
||||||
|
installed: (str(sys.Custom_CA_Status?.Custom_CA_Info) || '').toLowerCase() !== 'not installed',
|
||||||
|
},
|
||||||
|
dot1x: {
|
||||||
|
transactionStatus: str(sys.Dot1x_Authentication?.Transaction_status),
|
||||||
|
protocol: str(sys.Dot1x_Authentication?.Protocol),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
rebootLog,
|
||||||
|
rtp: {
|
||||||
|
total: numOrNull(rtp.Total_RTP),
|
||||||
|
max: numOrNull(rtp.Max_RTP),
|
||||||
|
current: numOrNull(rtp.Current_RTP),
|
||||||
|
currentLocal: numOrNull(rtp.Current_Local_RTP),
|
||||||
|
currentRelay: numOrNull(rtp.Current_Relay_RTP),
|
||||||
|
remoteRelay: numOrNull(rtp.Remote_Relay_RTP),
|
||||||
|
currentRecording: numOrNull(rtp.Current_Recording),
|
||||||
|
timeInMaxRtp: str(rtp.Time_In_Max_RTP),
|
||||||
|
},
|
||||||
|
network: {
|
||||||
|
txPackets: numOrNull(netStats.Tx_Packets),
|
||||||
|
txBlocked: numOrNull(netStats.Tx_Blocked),
|
||||||
|
txDropped: numOrNull(netStats.Tx_Dropped),
|
||||||
|
txErrors: numOrNull(netStats.Tx_Errors),
|
||||||
|
txBroadcasts: numOrNull(netStats.Tx_Broadcasts),
|
||||||
|
rxPackets: numOrNull(netStats.Rx_Packets),
|
||||||
|
rxBlocked: numOrNull(netStats.Rx_Blocked),
|
||||||
|
rxDropped: numOrNull(netStats.Rx_Dropped),
|
||||||
|
rxErrors: numOrNull(netStats.Rx_Errors),
|
||||||
|
rxBroadcasts: numOrNull(netStats.Rx_Broadcasts),
|
||||||
|
},
|
||||||
|
emergencyNumbers: emergency,
|
||||||
|
rssi,
|
||||||
|
features: {
|
||||||
|
pushToTalk: (str(sys.Push_To_Talk) || '').toLowerCase() === 'on',
|
||||||
|
},
|
||||||
|
// The Statistics/Header_Line_Idx tag is a CSV schema descriptor
|
||||||
|
// for a companion (per-RPN) statistics section we haven't
|
||||||
|
// captured yet. Kept as raw so a future collector can align to
|
||||||
|
// it without reparsing here.
|
||||||
|
_rawStatisticsHeader: str(stats.Header_Line_Idx),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Health verdict ─────────────────────────────────────────────────
|
||||||
|
|
||||||
|
// Cisco reboot reason codes. Not documented publicly — collected
|
||||||
|
// empirically from the DBS-210 sample and from Cisco community posts.
|
||||||
|
// We only classify the ones we've actually observed so a new code
|
||||||
|
// shows up as "unknown" instead of being silently reclassified.
|
||||||
|
const KNOWN_REBOOT_CODES = new Map([
|
||||||
|
[21, { key: 'normal', severity: 'info', label: 'Normal reboot (admin- or firmware-initiated)' }],
|
||||||
|
[80, { key: 'power-loss', severity: 'warn', label: 'Power loss — mains interruption or PoE glitch' }],
|
||||||
|
]);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Compute a pure-function health verdict from a parsed status object.
|
||||||
|
* No I/O. Returns { healthy, warnings, info } where warnings is an
|
||||||
|
* array of user-facing strings. Consumers decide how to render.
|
||||||
|
*/
|
||||||
|
export function summarizeBaseHealth(parsed) {
|
||||||
|
const warnings = [];
|
||||||
|
const info = [];
|
||||||
|
|
||||||
|
if (!parsed || typeof parsed !== 'object') {
|
||||||
|
return { healthy: false, warnings: ['No status data parsed'], info: [] };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Uptime — under 10 minutes = very recent reboot, worth flagging.
|
||||||
|
const uptimeSec = parsed.time?.operatingTimeSeconds;
|
||||||
|
if (Number.isFinite(uptimeSec) && uptimeSec < 600) {
|
||||||
|
warnings.push(`Base rebooted very recently (uptime ${Math.round(uptimeSec / 60)} min)`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reboot log — surface any power-loss in the last 6 boots (that's
|
||||||
|
// literally as far back as the device remembers).
|
||||||
|
const powerLosses = (parsed.rebootLog || []).filter((r) => r.reasonCode === 80);
|
||||||
|
if (powerLosses.length > 0) {
|
||||||
|
warnings.push(
|
||||||
|
`${powerLosses.length} recent power-loss reboot(s); most recent at ${powerLosses[0].at}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// RF conflict — non-"No Conflict" means DECT interference detected.
|
||||||
|
const conflict = parsed.conflictInfo || '';
|
||||||
|
if (conflict && conflict.toLowerCase() !== 'no conflict') {
|
||||||
|
warnings.push(`DECT RF conflict reported: ${conflict}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Network drops. Non-zero rx_dropped is the classic "your switch
|
||||||
|
// port is misconfigured / the base is overwhelmed" signal.
|
||||||
|
const rxDropped = parsed.network?.rxDropped;
|
||||||
|
if (Number.isFinite(rxDropped) && rxDropped > 0) {
|
||||||
|
info.push(`Rx dropped packets: ${rxDropped} since last boot`);
|
||||||
|
}
|
||||||
|
const rxErrors = parsed.network?.rxErrors;
|
||||||
|
if (Number.isFinite(rxErrors) && rxErrors > 0) {
|
||||||
|
warnings.push(`Rx errors: ${rxErrors} since last boot`);
|
||||||
|
}
|
||||||
|
const txErrors = parsed.network?.txErrors;
|
||||||
|
if (Number.isFinite(txErrors) && txErrors > 0) {
|
||||||
|
warnings.push(`Tx errors: ${txErrors} since last boot`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 802.1X — if enabled (protocol != 'N/A') and status isn't 'Authenticated'
|
||||||
|
// we flag it. The DBS-210 emits 'Unavailable' when 802.1X is off, so
|
||||||
|
// we specifically ignore that state.
|
||||||
|
const dot1xStatus = (parsed.security?.dot1x?.transactionStatus || '').toLowerCase();
|
||||||
|
const dot1xProto = (parsed.security?.dot1x?.protocol || '').toLowerCase();
|
||||||
|
if (dot1xProto && dot1xProto !== 'n/a' && dot1xStatus && dot1xStatus !== 'authenticated' && dot1xStatus !== 'unavailable') {
|
||||||
|
warnings.push(`802.1X in state "${parsed.security.dot1x.transactionStatus}" (protocol ${parsed.security.dot1x.protocol})`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Custom CA — informational only; some fleets never install one.
|
||||||
|
if (parsed.security?.customCa?.installed) {
|
||||||
|
info.push(`Custom CA installed: ${parsed.security.customCa.info}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
healthy: warnings.length === 0,
|
||||||
|
warnings,
|
||||||
|
info,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Helpers ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function str(v) {
|
||||||
|
if (v == null) return null;
|
||||||
|
if (typeof v === 'string') return v;
|
||||||
|
return null; // an object where a string was expected → treat as absent
|
||||||
|
}
|
||||||
|
|
||||||
|
function numOrNull(v) {
|
||||||
|
if (v == null || v === '') return null;
|
||||||
|
const n = Number(v);
|
||||||
|
return Number.isFinite(n) ? n : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function yesNo(v) {
|
||||||
|
if (v == null) return null;
|
||||||
|
return String(v).trim().toLowerCase() === 'yes';
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeMac(mac) {
|
||||||
|
if (!mac) return null;
|
||||||
|
const hex = mac.replace(/[^0-9a-fA-F]/g, '').toLowerCase();
|
||||||
|
if (hex.length !== 12) return mac; // not the expected 12-hex form, pass through
|
||||||
|
return hex.match(/../g).join(':');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Multi_Cell field is e.g.:
|
||||||
|
* "Unchained(TXT_STATE_UNCHAINED) Allowed to Join as Secondary"
|
||||||
|
* "Primary(TXT_STATE_PRIMARY) ..."
|
||||||
|
* "Secondary(TXT_STATE_SECONDARY) ..."
|
||||||
|
* We normalize to a role token + keep the flavor text.
|
||||||
|
*/
|
||||||
|
function parseMultiCell(raw) {
|
||||||
|
if (!raw) return { role: null, raw: null };
|
||||||
|
const s = String(raw);
|
||||||
|
const m = s.match(/^([A-Za-z]+)/);
|
||||||
|
return {
|
||||||
|
role: m ? m[1].toLowerCase() : null, // "unchained" | "primary" | "secondary"
|
||||||
|
raw: s,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Operating_Time is formatted as "H:M:S" (e.g. "00:10:20 (H:M:S)").
|
||||||
|
* Convert to seconds. Returns null if unrecognized.
|
||||||
|
*/
|
||||||
|
function parseOperatingSeconds(v) {
|
||||||
|
if (!v) return null;
|
||||||
|
const m = String(v).match(/(\d+):(\d+):(\d+)/);
|
||||||
|
if (!m) return null;
|
||||||
|
const [, h, mi, s] = m;
|
||||||
|
return Number(h) * 3600 + Number(mi) * 60 + Number(s);
|
||||||
|
}
|
||||||
|
|
||||||
|
function collectRebootLog(rebootLogNode) {
|
||||||
|
if (!rebootLogNode || typeof rebootLogNode !== 'object') return [];
|
||||||
|
// Reboot_Line_1..N — sort by their numeric suffix and drop empties.
|
||||||
|
const entries = Object.entries(rebootLogNode)
|
||||||
|
.filter(([k]) => /^Reboot_Line_\d+$/.test(k))
|
||||||
|
.sort(([a], [b]) => Number(a.match(/\d+$/)[0]) - Number(b.match(/\d+$/)[0]))
|
||||||
|
.map(([, v]) => v);
|
||||||
|
return entries
|
||||||
|
.filter((v) => v && typeof v === 'string' && v.trim())
|
||||||
|
.map(parseRebootLine)
|
||||||
|
.filter(Boolean);
|
||||||
|
}
|
||||||
|
|
||||||
|
function extractEmergencyNumbers(node) {
|
||||||
|
if (!node || typeof node !== 'object') return [];
|
||||||
|
return Object.entries(node)
|
||||||
|
.filter(([k]) => /^Emergency_Number_\d+$/.test(k))
|
||||||
|
.sort(([a], [b]) => Number(a.match(/\d+$/)[0]) - Number(b.match(/\d+$/)[0]))
|
||||||
|
.map(([, v]) => (typeof v === 'string' ? v.trim() : ''))
|
||||||
|
.filter((v) => v && !/^no number set/i.test(v));
|
||||||
|
}
|
||||||
|
|
||||||
|
function extractDeviceFwu(node) {
|
||||||
|
if (!node || typeof node !== 'object') return {};
|
||||||
|
const out = {};
|
||||||
|
for (const [k, v] of Object.entries(node)) {
|
||||||
|
if (typeof v !== 'string') continue;
|
||||||
|
// "Base type:DBS-210-3PC - Required Version:501 Required Branch:309"
|
||||||
|
// "Device type:6825 - Required Version:501 Required Branch:308 Language Pack:6825_default"
|
||||||
|
const typeMatch = v.match(/(?:Base type|Device type):([^\s]+)/);
|
||||||
|
const versionMatch = v.match(/Required Version:(\S+)/);
|
||||||
|
const branchMatch = v.match(/Required Branch:(\S+)/);
|
||||||
|
const langMatch = v.match(/Language Pack:(\S+)/);
|
||||||
|
if (typeMatch) {
|
||||||
|
out[typeMatch[1]] = {
|
||||||
|
requiredVersion: versionMatch ? versionMatch[1] : null,
|
||||||
|
requiredBranch: branchMatch ? branchMatch[1] : null,
|
||||||
|
languagePack: langMatch ? langMatch[1] : null,
|
||||||
|
_sourceKey: k,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
function extractRssiList(node) {
|
||||||
|
if (!node || typeof node !== 'object') return [];
|
||||||
|
// RSSI_List can be empty (as in our sample) or contain child
|
||||||
|
// <RPN_X> entries. Whatever's here, we surface raw and let a
|
||||||
|
// future parser refine once we've seen a populated example.
|
||||||
|
const keys = Object.keys(node);
|
||||||
|
if (keys.length === 0) return [];
|
||||||
|
return keys.map((k) => ({ key: k, value: node[k] }));
|
||||||
|
}
|
||||||
320
tests/statusXml.test.js
Normal file
320
tests/statusXml.test.js
Normal file
|
|
@ -0,0 +1,320 @@
|
||||||
|
// Unit tests for integrations/cisco-dect/statusXml.js.
|
||||||
|
//
|
||||||
|
// Pure — no network, no fs. The fixture below is a REDACTED copy of a
|
||||||
|
// real /admin/status.xml pulled from a lab DBS-210 during the DECT
|
||||||
|
// spike. MAC, IP, RFPI, and firmware server URL are all changed to
|
||||||
|
// obviously-fake values so this file is safe to commit and safe to
|
||||||
|
// leave in CI logs. The XML SHAPE (tag nesting, whitespace, encoding
|
||||||
|
// oddities like `text/text` mimetype on the wire) is preserved
|
||||||
|
// verbatim — that's exactly what the parser has to be robust against.
|
||||||
|
|
||||||
|
import test from 'node:test';
|
||||||
|
import assert from 'node:assert/strict';
|
||||||
|
|
||||||
|
import {
|
||||||
|
xmlToObject,
|
||||||
|
parseStatusXml,
|
||||||
|
parseRebootLine,
|
||||||
|
summarizeBaseHealth,
|
||||||
|
} from '../integrations/cisco-dect/statusXml.js';
|
||||||
|
|
||||||
|
const FIXTURE = `<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<Status>
|
||||||
|
<System_Information>
|
||||||
|
<Released_Build>Yes</Released_Build>
|
||||||
|
<Multi_Cell>Unchained(TXT_STATE_UNCHAINED) Allowed to Join as Secondary</Multi_Cell>
|
||||||
|
<Phone_Type>IPDECT-V2 (DBS-210-3PC)</Phone_Type>
|
||||||
|
<System_Type>Generic SIP (RFC 3261)</System_Type>
|
||||||
|
<Unit_Name>SME VoIP</Unit_Name>
|
||||||
|
<Unit_Index>Base Idx:0</Unit_Index>
|
||||||
|
<RF_Band>US</RF_Band>
|
||||||
|
<Conflict_Info>No Conflict</Conflict_Info>
|
||||||
|
<Current_Local_Time>02-Jul-2026 13:22:48</Current_Local_Time>
|
||||||
|
<Operating_Time>00:10:20 (H:M:S)</Operating_Time>
|
||||||
|
<RFPI_Address>ABCDEF12; RPN:00</RFPI_Address>
|
||||||
|
<MAC_Address>001122334455</MAC_Address>
|
||||||
|
<IP_Address>10.0.0.100</IP_Address>
|
||||||
|
<Product_Configuration>0000</Product_Configuration>
|
||||||
|
<Firmware_Version>IPDECT-V2/05-01-03-0101-09/18-Mar-2026 10:29</Firmware_Version>
|
||||||
|
<Firmware_URL>
|
||||||
|
<Update_Server_Address>https://example.invalid</Update_Server_Address>
|
||||||
|
<Path>dms/dbS210</Path>
|
||||||
|
</Firmware_URL>
|
||||||
|
<Reboot_Log>
|
||||||
|
<Reboot_Line_1>2026-07-02 13:11:46 (164) Normal Reboot (21) Firmware Version 05-01-03-0101-09</Reboot_Line_1>
|
||||||
|
<Reboot_Line_2>2026-07-02 13:09:50 (163) Normal Reboot (21) Firmware Version 05-01-03-0101-09</Reboot_Line_2>
|
||||||
|
<Reboot_Line_3>2026-07-02 13:06:28 (162) Normal Reboot (21) Firmware Version 05-01-03-0101-09</Reboot_Line_3>
|
||||||
|
<Reboot_Line_4>2026-07-02 12:54:12 (161) Power Loss (80) Firmware Version 05-01-03-0101-09</Reboot_Line_4>
|
||||||
|
<Reboot_Line_5>2026-07-02 12:49:50 (160) Normal Reboot (21) Firmware Version 05-01-03-0101-09</Reboot_Line_5>
|
||||||
|
<Reboot_Line_6>2026-07-02 12:48:40 (159) Normal Reboot (21) Firmware Version 05-01-03-0101-09</Reboot_Line_6>
|
||||||
|
</Reboot_Log>
|
||||||
|
<Base_Station_Status>Idle</Base_Station_Status>
|
||||||
|
<Custom_CA_Status>
|
||||||
|
<Custom_CA_Provisioning_Status>N/A</Custom_CA_Provisioning_Status>
|
||||||
|
<Custom_CA_Info>Not Installed</Custom_CA_Info>
|
||||||
|
</Custom_CA_Status>
|
||||||
|
<Dot1x_Authentication>
|
||||||
|
<Transaction_status>Unavailable</Transaction_status>
|
||||||
|
<Protocol>N/A</Protocol>
|
||||||
|
</Dot1x_Authentication>
|
||||||
|
<RSSI_List>
|
||||||
|
</RSSI_List>
|
||||||
|
<RTP_Usage>
|
||||||
|
<Total_RTP>2</Total_RTP>
|
||||||
|
<Max_RTP>-1</Max_RTP>
|
||||||
|
<Time_In_Max_RTP>49710 days 06:28:15 [H:M:S]</Time_In_Max_RTP>
|
||||||
|
<Current_RTP>0</Current_RTP>
|
||||||
|
<Current_Local_RTP>0</Current_Local_RTP>
|
||||||
|
<Current_Relay_RTP>0</Current_Relay_RTP>
|
||||||
|
<Remote_Relay_RTP>0</Remote_Relay_RTP>
|
||||||
|
<Current_Recording>0</Current_Recording>
|
||||||
|
</RTP_Usage>
|
||||||
|
<Device_Presence>
|
||||||
|
</Device_Presence>
|
||||||
|
<Device_FWU_Info>
|
||||||
|
<Device_Base_Station>Base type:DBS-210-3PC - Required Version:501 Required Branch:309</Device_Base_Station>
|
||||||
|
<Device_Line_0>Device type:6825 - Required Version:501 Required Branch:308 Language Pack:6825_default</Device_Line_0>
|
||||||
|
<Device_Line_1>Device type:6825-RGD - Required Version:501 Required Branch:308 Language Pack:6825-RGD_default</Device_Line_1>
|
||||||
|
<Device_Line_2>Device type:6823 - Required Version:501 Required Branch:308 Language Pack:6823_default</Device_Line_2>
|
||||||
|
<Device_Line_3>Device type:RPT-110-3PC - Required Version:501 Required Branch:303</Device_Line_3>
|
||||||
|
</Device_FWU_Info>
|
||||||
|
<Push_To_Talk>Off</Push_To_Talk>
|
||||||
|
<Emergency_Calls>
|
||||||
|
<Emergency_Number_1>911</Emergency_Number_1>
|
||||||
|
<Emergency_Number_2>1911</Emergency_Number_2>
|
||||||
|
<Emergency_Number_3>933</Emergency_Number_3>
|
||||||
|
<Emergency_Number_4>No Number set!</Emergency_Number_4>
|
||||||
|
<Emergency_Number_5>No Number set!</Emergency_Number_5>
|
||||||
|
</Emergency_Calls>
|
||||||
|
<SIP_Identity_Status>
|
||||||
|
</SIP_Identity_Status>
|
||||||
|
</System_Information>
|
||||||
|
<Device_Information>
|
||||||
|
</Device_Information>
|
||||||
|
<Statistics>
|
||||||
|
<Network_Statistics>
|
||||||
|
<Tx_Packets>1959</Tx_Packets>
|
||||||
|
<Tx_Blocked>0</Tx_Blocked>
|
||||||
|
<Tx_Dropped>0</Tx_Dropped>
|
||||||
|
<Tx_Errors>0</Tx_Errors>
|
||||||
|
<Tx_Broadcasts>0</Tx_Broadcasts>
|
||||||
|
<Rx_Packets>49318</Rx_Packets>
|
||||||
|
<Rx_Blocked>0</Rx_Blocked>
|
||||||
|
<Rx_Dropped>9</Rx_Dropped>
|
||||||
|
<Rx_Errors>0</Rx_Errors>
|
||||||
|
<Rx_Broadcasts>5834</Rx_Broadcasts>
|
||||||
|
</Network_Statistics>
|
||||||
|
<Header_Line_Idx>RPN, MAC-Addr, OP[s], DT[s]</Header_Line_Idx>
|
||||||
|
</Statistics>
|
||||||
|
</Status>`;
|
||||||
|
|
||||||
|
// ─── Low-level parser ───────────────────────────────────────────────
|
||||||
|
|
||||||
|
test('xmlToObject: parses the DBS-210 status.xml shape into a nested object', () => {
|
||||||
|
const tree = xmlToObject(FIXTURE);
|
||||||
|
assert.ok(tree.Status, 'root element is Status');
|
||||||
|
assert.equal(tree.Status.System_Information.Phone_Type, 'IPDECT-V2 (DBS-210-3PC)');
|
||||||
|
assert.equal(tree.Status.System_Information.MAC_Address, '001122334455');
|
||||||
|
// Nested Firmware_URL is a child object, not a string.
|
||||||
|
assert.equal(typeof tree.Status.System_Information.Firmware_URL, 'object');
|
||||||
|
assert.equal(tree.Status.System_Information.Firmware_URL.Path, 'dms/dbS210');
|
||||||
|
// Empty <RSSI_List>\n</RSSI_List> becomes an empty leaf string —
|
||||||
|
// XML alone can't distinguish empty-container from empty-leaf, so
|
||||||
|
// the parser stays neutral. parseStatusXml() then normalizes both
|
||||||
|
// shapes into `[]` for callers that expect a container.
|
||||||
|
assert.equal(tree.Status.System_Information.RSSI_List, '');
|
||||||
|
// Statistics has the massive CSV header preserved as a single string.
|
||||||
|
assert.match(tree.Status.Statistics.Header_Line_Idx, /RPN, MAC-Addr/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('xmlToObject: preserves whitespace inside leaf text values', () => {
|
||||||
|
const tree = xmlToObject(FIXTURE);
|
||||||
|
// RFPI has a `; ` separator that must survive intact.
|
||||||
|
assert.equal(tree.Status.System_Information.RFPI_Address, 'ABCDEF12; RPN:00');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('xmlToObject: throws on empty input', () => {
|
||||||
|
assert.throws(() => xmlToObject(''), /empty input/);
|
||||||
|
assert.throws(() => xmlToObject('<?xml version="1.0"?>'), /empty input/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('xmlToObject: throws on non-string input', () => {
|
||||||
|
assert.throws(() => xmlToObject(null), /expected a string/);
|
||||||
|
assert.throws(() => xmlToObject(123), /expected a string/);
|
||||||
|
});
|
||||||
|
|
||||||
|
// ─── Reboot line parser ─────────────────────────────────────────────
|
||||||
|
|
||||||
|
test('parseRebootLine: normal reboot line', () => {
|
||||||
|
const p = parseRebootLine('2026-07-02 13:11:46 (164) Normal Reboot (21) Firmware Version 05-01-03-0101-09');
|
||||||
|
assert.equal(p.at, '2026-07-02T13:11:46');
|
||||||
|
assert.equal(p.sequence, 164);
|
||||||
|
assert.equal(p.reasonName, 'Normal Reboot');
|
||||||
|
assert.equal(p.reasonCode, 21);
|
||||||
|
assert.equal(p.firmwareAtBoot, '05-01-03-0101-09');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('parseRebootLine: power loss line', () => {
|
||||||
|
const p = parseRebootLine('2026-07-02 12:54:12 (161) Power Loss (80) Firmware Version 05-01-03-0101-09');
|
||||||
|
assert.equal(p.reasonName, 'Power Loss');
|
||||||
|
assert.equal(p.reasonCode, 80);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('parseRebootLine: marks unrecognized shapes without dropping them', () => {
|
||||||
|
const p = parseRebootLine('Something totally different');
|
||||||
|
assert.equal(p.unrecognized, true);
|
||||||
|
assert.equal(p.raw, 'Something totally different');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('parseRebootLine: empty/null input', () => {
|
||||||
|
assert.equal(parseRebootLine(''), null);
|
||||||
|
assert.equal(parseRebootLine(' '), null);
|
||||||
|
assert.equal(parseRebootLine(null), null);
|
||||||
|
assert.equal(parseRebootLine(undefined), null);
|
||||||
|
});
|
||||||
|
|
||||||
|
// ─── High-level parseStatusXml ──────────────────────────────────────
|
||||||
|
|
||||||
|
test('parseStatusXml: extracts core device identity', () => {
|
||||||
|
const s = parseStatusXml(FIXTURE);
|
||||||
|
assert.equal(s.device.model, 'IPDECT-V2 (DBS-210-3PC)');
|
||||||
|
assert.equal(s.device.macAddress, '00:11:22:33:44:55'); // normalized colon-form
|
||||||
|
assert.equal(s.device.ipAddress, '10.0.0.100');
|
||||||
|
assert.equal(s.device.rfBand, 'US');
|
||||||
|
assert.equal(s.device.rfpiAddress, 'ABCDEF12; RPN:00');
|
||||||
|
assert.equal(s.device.releasedBuild, true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('parseStatusXml: firmware version and required-per-device map', () => {
|
||||||
|
const s = parseStatusXml(FIXTURE);
|
||||||
|
assert.equal(s.firmware.version, 'IPDECT-V2/05-01-03-0101-09/18-Mar-2026 10:29');
|
||||||
|
assert.equal(s.firmware.updateServer, 'https://example.invalid');
|
||||||
|
assert.equal(s.firmware.updatePath, 'dms/dbS210');
|
||||||
|
// Device_Base_Station → keyed by model
|
||||||
|
assert.deepEqual(s.firmware.requiredFor['DBS-210-3PC'], {
|
||||||
|
requiredVersion: '501',
|
||||||
|
requiredBranch: '309',
|
||||||
|
languagePack: null,
|
||||||
|
_sourceKey: 'Device_Base_Station',
|
||||||
|
});
|
||||||
|
// Handset lines carry language pack too
|
||||||
|
assert.equal(s.firmware.requiredFor['6825'].languagePack, '6825_default');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('parseStatusXml: operating time gets converted to seconds', () => {
|
||||||
|
const s = parseStatusXml(FIXTURE);
|
||||||
|
assert.equal(s.time.operatingTime, '00:10:20 (H:M:S)');
|
||||||
|
assert.equal(s.time.operatingTimeSeconds, 620); // 10m 20s
|
||||||
|
});
|
||||||
|
|
||||||
|
test('parseStatusXml: multi-cell role parsed to a normalized token', () => {
|
||||||
|
const s = parseStatusXml(FIXTURE);
|
||||||
|
assert.equal(s.multiCell.role, 'unchained');
|
||||||
|
assert.match(s.multiCell.raw, /TXT_STATE_UNCHAINED/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('parseStatusXml: reboot log is 6 sorted entries with structured fields', () => {
|
||||||
|
const s = parseStatusXml(FIXTURE);
|
||||||
|
assert.equal(s.rebootLog.length, 6);
|
||||||
|
// First entry = the newest by sequence#, matching physical order in the XML.
|
||||||
|
assert.equal(s.rebootLog[0].sequence, 164);
|
||||||
|
assert.equal(s.rebootLog[0].reasonName, 'Normal Reboot');
|
||||||
|
// The Power Loss event is entry #4 (sequence 161).
|
||||||
|
const powerLoss = s.rebootLog.find((r) => r.reasonCode === 80);
|
||||||
|
assert.ok(powerLoss);
|
||||||
|
assert.equal(powerLoss.reasonName, 'Power Loss');
|
||||||
|
assert.equal(powerLoss.sequence, 161);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('parseStatusXml: network stats decoded as numbers', () => {
|
||||||
|
const s = parseStatusXml(FIXTURE);
|
||||||
|
assert.equal(s.network.txPackets, 1959);
|
||||||
|
assert.equal(s.network.rxPackets, 49318);
|
||||||
|
assert.equal(s.network.rxDropped, 9);
|
||||||
|
assert.equal(s.network.rxErrors, 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('parseStatusXml: emergency numbers filter out "No Number set" placeholders', () => {
|
||||||
|
const s = parseStatusXml(FIXTURE);
|
||||||
|
assert.deepEqual(s.emergencyNumbers, ['911', '1911', '933']);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('parseStatusXml: RTP usage counters', () => {
|
||||||
|
const s = parseStatusXml(FIXTURE);
|
||||||
|
assert.equal(s.rtp.total, 2);
|
||||||
|
assert.equal(s.rtp.current, 0);
|
||||||
|
assert.equal(s.rtp.max, -1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('parseStatusXml: security / dot1x / customCA', () => {
|
||||||
|
const s = parseStatusXml(FIXTURE);
|
||||||
|
assert.equal(s.security.customCa.installed, false);
|
||||||
|
assert.equal(s.security.customCa.info, 'Not Installed');
|
||||||
|
assert.equal(s.security.dot1x.transactionStatus, 'Unavailable');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('parseStatusXml: pushToTalk feature flag', () => {
|
||||||
|
const s = parseStatusXml(FIXTURE);
|
||||||
|
assert.equal(s.features.pushToTalk, false);
|
||||||
|
});
|
||||||
|
|
||||||
|
// ─── Health verdict ─────────────────────────────────────────────────
|
||||||
|
|
||||||
|
test('summarizeBaseHealth: fixture flags power-loss reboot and short uptime', () => {
|
||||||
|
const s = parseStatusXml(FIXTURE);
|
||||||
|
const verdict = summarizeBaseHealth(s);
|
||||||
|
assert.equal(verdict.healthy, false);
|
||||||
|
// Uptime 620s (~10 min) is < 600 → boundary, so no uptime warning.
|
||||||
|
// But we DO have a power loss in the log, so healthy=false via that.
|
||||||
|
assert.ok(
|
||||||
|
verdict.warnings.some((w) => /power-loss/i.test(w)),
|
||||||
|
`expected power-loss warning; got: ${JSON.stringify(verdict.warnings)}`,
|
||||||
|
);
|
||||||
|
// rxDropped=9 → info, not warning
|
||||||
|
assert.ok(
|
||||||
|
verdict.info.some((i) => /rx dropped/i.test(i)),
|
||||||
|
`expected rx-dropped info; got: ${JSON.stringify(verdict.info)}`,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('summarizeBaseHealth: RF conflict is flagged as a warning', () => {
|
||||||
|
const s = parseStatusXml(FIXTURE.replace(
|
||||||
|
'<Conflict_Info>No Conflict</Conflict_Info>',
|
||||||
|
'<Conflict_Info>Conflict Detected on RPN 00</Conflict_Info>',
|
||||||
|
));
|
||||||
|
const verdict = summarizeBaseHealth(s);
|
||||||
|
assert.ok(verdict.warnings.some((w) => /RF conflict/i.test(w)));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('summarizeBaseHealth: very short uptime is flagged as recent reboot', () => {
|
||||||
|
const s = parseStatusXml(FIXTURE.replace(
|
||||||
|
'<Operating_Time>00:10:20 (H:M:S)</Operating_Time>',
|
||||||
|
'<Operating_Time>00:02:15 (H:M:S)</Operating_Time>',
|
||||||
|
));
|
||||||
|
const verdict = summarizeBaseHealth(s);
|
||||||
|
assert.ok(verdict.warnings.some((w) => /rebooted very recently/i.test(w)));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('summarizeBaseHealth: healthy base with clean stats returns healthy:true', () => {
|
||||||
|
// Wipe rx_dropped and the power-loss reboot line so nothing warns.
|
||||||
|
const cleaned = FIXTURE
|
||||||
|
.replace(/<Rx_Dropped>\d+<\/Rx_Dropped>/, '<Rx_Dropped>0</Rx_Dropped>')
|
||||||
|
.replace(
|
||||||
|
/<Reboot_Line_4>[^<]+<\/Reboot_Line_4>/,
|
||||||
|
'<Reboot_Line_4>2026-07-02 12:54:12 (161) Normal Reboot (21) Firmware Version 05-01-03-0101-09</Reboot_Line_4>',
|
||||||
|
)
|
||||||
|
.replace(
|
||||||
|
'<Operating_Time>00:10:20 (H:M:S)</Operating_Time>',
|
||||||
|
'<Operating_Time>24:15:00 (H:M:S)</Operating_Time>',
|
||||||
|
);
|
||||||
|
const s = parseStatusXml(cleaned);
|
||||||
|
const verdict = summarizeBaseHealth(s);
|
||||||
|
assert.equal(verdict.healthy, true, `not healthy: ${JSON.stringify(verdict.warnings)}`);
|
||||||
|
assert.equal(verdict.warnings.length, 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('summarizeBaseHealth: bad input degrades gracefully', () => {
|
||||||
|
assert.equal(summarizeBaseHealth(null).healthy, false);
|
||||||
|
assert.equal(summarizeBaseHealth(undefined).healthy, false);
|
||||||
|
assert.equal(summarizeBaseHealth('nope').healthy, false);
|
||||||
|
});
|
||||||
Loading…
Reference in a new issue