diff --git a/.github/workflows/yamcs-quickstart-e2e.yml b/.github/workflows/yamcs-quickstart-e2e.yml index ad5f2461..5efa438b 100644 --- a/.github/workflows/yamcs-quickstart-e2e.yml +++ b/.github/workflows/yamcs-quickstart-e2e.yml @@ -59,7 +59,7 @@ jobs: elif [ "${{ matrix.openmct-version }}" = "stable" ]; then npm run build:example fi - - run: npx playwright@1.45.2 install chromium + - run: npx playwright@1.47.2 install chromium - name: Check that yamcs is available run: | docker ps -a diff --git a/example/index.js b/example/index.js index a0c47f03..9350677e 100644 --- a/example/index.js +++ b/example/index.js @@ -9,7 +9,11 @@ const config = { yamcsProcessor: "realtime", yamcsFolder: "myproject", throttleRate: 1000, - maxBatchSize: 20 + // Batch size is specified in characers as there is no performant way of calculating true + // memory usage of a string buffer in real-time. + // String characters can be 8 or 16 bits in JavaScript, depending on the code page used. + // Thus 500,000 characters requires up to 16MB of memory (1,000,000 * 16). + maxBufferSize: 1000000 }; const STATUS_STYLES = { NO_STATUS: { @@ -41,7 +45,9 @@ const STATUS_STYLES = { const openmct = window.openmct; (() => { - const THIRTY_MINUTES = 30 * 60 * 1000; + const ONE_SECOND = 1000; + const ONE_MINUTE = ONE_SECOND * 60; + const THIRTY_MINUTES = ONE_MINUTE * 30; openmct.setAssetPath("/node_modules/openmct/dist"); @@ -54,7 +60,6 @@ const openmct = window.openmct; document.addEventListener("DOMContentLoaded", function () { openmct.start(); }); - openmct.install( openmct.plugins.Conductor({ menuOptions: [ diff --git a/src/openmct-yamcs.js b/src/openmct-yamcs.js index c7c44956..04bb2bc8 100644 --- a/src/openmct-yamcs.js +++ b/src/openmct-yamcs.js @@ -28,7 +28,6 @@ import LimitProvider from './providers/limit-provider.js'; import EventLimitProvider from './providers/event-limit-provider.js'; import UserProvider from './providers/user/user-provider.js'; -import { faultModelConvertor } from './providers/fault-mgmt-providers/utils.js'; import YamcsFaultProvider from './providers/fault-mgmt-providers/yamcs-fault-provider.js'; import { OBJECT_TYPES } from './const.js'; @@ -69,16 +68,16 @@ export default function install( configuration.yamcsInstance, configuration.yamcsProcessor, configuration.throttleRate, - configuration.maxBatchSize + configuration.maxBufferSize ); openmct.telemetry.addProvider(realtimeTelemetryProvider); realtimeTelemetryProvider.connect(); openmct.faults.addProvider(new YamcsFaultProvider(openmct, { - faultModelConvertor, historicalEndpoint: configuration.yamcsHistoricalEndpoint, - yamcsInstance: configuration.yamcsInstance + yamcsInstance: configuration.yamcsInstance, + yamcsProcessor: configuration.yamcsProcessor })); const stalenessProvider = new YamcsStalenessProvider( diff --git a/src/providers/fault-mgmt-providers/fault-action-provider.js b/src/providers/fault-mgmt-providers/fault-action-provider.js index 61e83b9e..ad986293 100644 --- a/src/providers/fault-mgmt-providers/fault-action-provider.js +++ b/src/providers/fault-mgmt-providers/fault-action-provider.js @@ -1,7 +1,7 @@ -import { FAULT_MANAGEMENT_ALARMS, FAULT_MANAGEMENT_DEFAULT_SHELVE_DURATION } from './fault-mgmt-constants.js'; +import { FAULT_MGMT_ALARMS, FAULT_MGMT_ACTIONS } from './fault-mgmt-constants.js'; export default class FaultActionProvider { - constructor(url, instance, processor = 'realtime') { + constructor(url, instance, processor) { this.url = url; this.instance = instance; this.processor = processor; @@ -9,52 +9,92 @@ export default class FaultActionProvider { acknowledgeFault(fault, { comment = '' } = {}) { const payload = { - comment, - state: 'acknowledged' + comment }; - const options = this._getOptions(payload); - const url = this._getUrl(fault); + const options = this.#getOptions(payload); + const url = this.#getUrl(fault, FAULT_MGMT_ACTIONS.ACKNOWLEDGE); - return this._sendRequest(url, options); + return this.#sendRequest(url, options); } - shelveFault(fault, { shelved = true, comment = '', shelveDuration = FAULT_MANAGEMENT_DEFAULT_SHELVE_DURATION } = {}) { - let payload = {}; + /** + * Shelves or unshelves a fault. + * @param {FaultModel} fault the fault to perform the action on + * @param {Object} options the options to perform the action with + * @param {boolean} options.shelved whether to shelve or unshelve the fault + * @param {string} options.comment the comment to add to the fault + * @param {number} options.shelveDuration the duration to shelve the fault for + * @returns {Promise} the response from the server + */ + shelveFault(fault, { shelved = true, comment = '', shelveDuration } = {}) { + const payload = {}; + const action = shelved ? FAULT_MGMT_ACTIONS.SHELVE : FAULT_MGMT_ACTIONS.UNSHELVE; + if (shelved) { payload.comment = comment; payload.shelveDuration = shelveDuration; - payload.state = 'shelved'; - } else { - payload.state = 'unshelved'; } - const options = this._getOptions(payload); - let url = this._getUrl(fault); + const options = this.#getOptions(payload); + const url = this.#getUrl(fault, action); - return this._sendRequest(url, options); + return this.#sendRequest(url, options); } - _getOptions(payload) { + /** + * @typedef {Object} ShelveDuration + * @property {string} name - The name of the shelve duration + * @property {number|null} value - The value of the shelve duration in milliseconds, or null for indefinite + */ + + /** + * @returns {ShelveDuration[]} the list of shelve durations + */ + getShelveDurations() { + return [ + { + name: '5 Minutes', + value: 300000 + }, + { + name: '10 Minutes', + value: 600000 + }, + { + name: '15 Minutes', + value: 900000 + }, + { + name: 'Indefinite', + value: null + } + ]; + } + + #getOptions(payload) { return { body: JSON.stringify(payload), - // credentials: 'same-origin', headers: { 'Content-Type': 'application/json' }, - method: 'PATCH', + method: 'POST', mode: 'cors' }; } - _getUrl(fault) { - let url = `${this.url}api/processors/${this.instance}/${this.processor}/${FAULT_MANAGEMENT_ALARMS}`; - url += `${fault.namespace}/${fault.name}`; - url += `/${fault.seqNum}`; - - return url; + /** + * @param {FaultModel} fault the fault to perform the action on + * @param {'acknowledge' | 'shelve' | 'unshelve' | 'clear'} action the action to perform on the fault + * @returns {string} the URL to perform the action on the fault + */ + #getUrl(fault, action) { + return `${this.url}api/processors/${this.instance}/${this.processor}/${FAULT_MGMT_ALARMS}` + + `${fault.namespace}/${fault.name}/${fault.seqNum}:${action}`; } - _sendRequest(url, options) { + #sendRequest(url, options) { return fetch(url, options); } } + +/** @typedef {import('./utils.js').FaultModel} FaultModel */ diff --git a/src/providers/fault-mgmt-providers/fault-mgmt-constants.js b/src/providers/fault-mgmt-providers/fault-mgmt-constants.js index dfb37a34..3205a400 100644 --- a/src/providers/fault-mgmt-providers/fault-mgmt-constants.js +++ b/src/providers/fault-mgmt-providers/fault-mgmt-constants.js @@ -1,3 +1,9 @@ -export const FAULT_MANAGEMENT_ALARMS = 'alarms'; -export const FAULT_MANAGEMENT_TYPE = 'faultManagement'; -export const FAULT_MANAGEMENT_DEFAULT_SHELVE_DURATION = 90000; +export const FAULT_MGMT_ALARMS = 'alarms'; +export const FAULT_MGMT_TYPE = 'faultManagement'; +export const DEFAULT_SHELVE_DURATION = 90000; +export const FAULT_MGMT_ACTIONS = Object.freeze({ + SHELVE: 'shelve', + UNSHELVE: 'unshelve', + ACKNOWLEDGE: 'acknowledge', + CLEAR: 'clear' +}); diff --git a/src/providers/fault-mgmt-providers/historical-fault-provider.js b/src/providers/fault-mgmt-providers/historical-fault-provider.js index c7a5ce10..4c1d7ee1 100644 --- a/src/providers/fault-mgmt-providers/historical-fault-provider.js +++ b/src/providers/fault-mgmt-providers/historical-fault-provider.js @@ -1,23 +1,34 @@ -import { FAULT_MANAGEMENT_ALARMS, FAULT_MANAGEMENT_TYPE } from './fault-mgmt-constants.js'; +import { FAULT_MGMT_ALARMS, FAULT_MGMT_TYPE } from './fault-mgmt-constants.js'; +import { convertDataToFaultModel } from './utils.js'; export default class HistoricalFaultProvider { - constructor(faultModelConverter, url, instance, processor = 'realtime') { - this.faultModelConverter = faultModelConverter; + constructor(url, instance, processor) { this.url = url; this.instance = instance; this.processor = processor; } + /** + * @param {import('openmct').DomainObject} domainObject + * @returns {boolean} + */ supportsRequest(domainObject) { - return domainObject.type === FAULT_MANAGEMENT_TYPE; + return domainObject.type === FAULT_MGMT_TYPE; } + /** + * @returns {Promise} + */ async request() { - let url = `${this.url}api/processors/${this.instance}/${this.processor}/${FAULT_MANAGEMENT_ALARMS}`; + const url = `${this.url}api/processors/${this.instance}/${this.processor}/${FAULT_MGMT_ALARMS}`; const res = await fetch(url); const faultsData = await res.json(); - return faultsData.alarms?.map(this.faultModelConverter); + return faultsData.alarms?.map(convertDataToFaultModel); } } + +/** + * @typedef {import('./utils.js').FaultModel} FaultModel + */ diff --git a/src/providers/fault-mgmt-providers/realtime-fault-provider.js b/src/providers/fault-mgmt-providers/realtime-fault-provider.js index c1952b3f..3c64622b 100644 --- a/src/providers/fault-mgmt-providers/realtime-fault-provider.js +++ b/src/providers/fault-mgmt-providers/realtime-fault-provider.js @@ -1,11 +1,11 @@ -import { FAULT_MANAGEMENT_TYPE } from './fault-mgmt-constants.js'; +import { FAULT_MGMT_TYPE } from './fault-mgmt-constants.js'; import { DATA_TYPES, NAMESPACE, OBJECT_TYPES } from '../../const.js'; +import { convertDataToFaultModel } from './utils.js'; export default class RealtimeFaultProvider { #openmct; - constructor(openmct, faultModelConverter, instance) { + constructor(openmct, instance) { this.#openmct = openmct; - this.faultModelConverter = faultModelConverter; this.instance = instance; this.lastSubscriptionId = 1; @@ -30,7 +30,7 @@ export default class RealtimeFaultProvider { } supportsSubscribe(domainObject) { - return domainObject.type === FAULT_MANAGEMENT_TYPE; + return domainObject.type === FAULT_MGMT_TYPE; } subscribe(domainObject, callback) { @@ -53,8 +53,6 @@ export default class RealtimeFaultProvider { } handleResponse(type, response, callback) { - const faultData = this.faultModelConverter(response, type); - - callback(faultData); + callback(convertDataToFaultModel(response, type)); } } diff --git a/src/providers/fault-mgmt-providers/utils.js b/src/providers/fault-mgmt-providers/utils.js index b5319483..51c25f16 100644 --- a/src/providers/fault-mgmt-providers/utils.js +++ b/src/providers/fault-mgmt-providers/utils.js @@ -1,3 +1,4 @@ +/* eslint-disable func-style */ /***************************************************************************** * Open MCT, Copyright (c) 2014-2021, United States Government * as represented by the Administrator of the National Aeronautics and Space @@ -19,15 +20,22 @@ * this source code distribution or the Licensing information page available * at runtime from the About dialog for additional information. *****************************************************************************/ - import { getValue } from '../../utils.js'; -function faultModelConvertor(faultData, type) { +/** + * Converts fault data to a FaultModel. + * + * @param {Object} faultData + * @param {string} [type] + * @returns {FaultModel} + */ +const convertDataToFaultModel = (faultData, type) => { + const parameterDetail = faultData?.parameterDetail; + const currentValueDetail = parameterDetail?.currentValue; + const triggerValueDetail = parameterDetail?.triggerValue; - const currentValue = faultData?.parameterDetail?.currentValue - && getValue(faultData.parameterDetail.currentValue); - const triggerValue = faultData?.parameterDetail?.triggerValue - && getValue(faultData.parameterDetail.triggerValue); + const currentValue = currentValueDetail ? getValue(currentValueDetail) : undefined; + const triggerValue = triggerValueDetail ? getValue(triggerValueDetail) : undefined; return { type: type || faultData?.type, @@ -35,8 +43,8 @@ function faultModelConvertor(faultData, type) { acknowledged: Boolean(faultData?.acknowledged), currentValueInfo: { value: currentValue, - rangeCondition: faultData?.parameterDetail?.currentValue?.rangeCondition, - monitoringResult: faultData?.parameterDetail?.currentValue?.monitoringResult + rangeCondition: currentValueDetail?.rangeCondition, + monitoringResult: currentValueDetail?.monitoringResult }, id: `id-${faultData?.id?.namespace}-${faultData?.id?.name}`, name: faultData?.id?.name, @@ -44,17 +52,38 @@ function faultModelConvertor(faultData, type) { seqNum: faultData?.seqNum, severity: faultData?.severity, shelved: Boolean(faultData?.shelveInfo), - shortDescription: faultData?.parameterDetail?.parameter?.shortDescription, + shortDescription: parameterDetail?.parameter?.shortDescription, triggerTime: faultData?.triggerTime, triggerValueInfo: { value: triggerValue, - rangeCondition: faultData?.parameterDetail?.triggerValue?.rangeCondition, - monitoringResult: faultData?.parameterDetail?.triggerValue?.monitoringResult + rangeCondition: triggerValueDetail?.rangeCondition, + monitoringResult: triggerValueDetail?.monitoringResult } } }; -} - -export { - faultModelConvertor }; + +export { convertDataToFaultModel }; + +/** + * @typedef {Object} FaultModel + * @property {string} type + * @property {Object} fault + * @property {boolean} fault.acknowledged + * @property {Object} fault.currentValueInfo + * @property {*} fault.currentValueInfo.value + * @property {string} fault.currentValueInfo.rangeCondition + * @property {string} fault.currentValueInfo.monitoringResult + * @property {string} fault.id + * @property {string} fault.name + * @property {string} fault.namespace + * @property {number} fault.seqNum + * @property {string} fault.severity + * @property {boolean} fault.shelved + * @property {string} fault.shortDescription + * @property {number} fault.triggerTime + * @property {Object} fault.triggerValueInfo + * @property {*} fault.triggerValueInfo.value + * @property {string} fault.triggerValueInfo.rangeCondition + * @property {string} fault.triggerValueInfo.monitoringResult + */ diff --git a/src/providers/fault-mgmt-providers/yamcs-fault-provider.js b/src/providers/fault-mgmt-providers/yamcs-fault-provider.js index 81cb7cdc..71a545ae 100644 --- a/src/providers/fault-mgmt-providers/yamcs-fault-provider.js +++ b/src/providers/fault-mgmt-providers/yamcs-fault-provider.js @@ -2,10 +2,11 @@ import HistoricalFaultProvider from './historical-fault-provider.js'; import RealtimeFaultProvider from './realtime-fault-provider.js'; import FaultActionProvider from './fault-action-provider.js'; +const DEFAULT_PROCESSOR = 'realtime'; + export default class YamcsFaultProvider { - constructor(openmct, { faultModelConvertor, historicalEndpoint, yamcsInstance, yamcsProcessor } = {}) { + constructor(openmct, { historicalEndpoint, yamcsInstance, yamcsProcessor = DEFAULT_PROCESSOR } = {}) { this.historicalFaultProvider = new HistoricalFaultProvider( - faultModelConvertor, historicalEndpoint, yamcsInstance, yamcsProcessor @@ -13,7 +14,6 @@ export default class YamcsFaultProvider { this.realtimeFaultProvider = new RealtimeFaultProvider( openmct, - faultModelConvertor, yamcsInstance ); @@ -29,5 +29,6 @@ export default class YamcsFaultProvider { this.supportsSubscribe = this.realtimeFaultProvider.supportsSubscribe.bind(this.realtimeFaultProvider); this.acknowledgeFault = this.faultActionProvider.acknowledgeFault.bind(this.faultActionProvider); this.shelveFault = this.faultActionProvider.shelveFault.bind(this.faultActionProvider); + this.getShelveDurations = this.faultActionProvider.getShelveDurations.bind(this.faultActionProvider); } } diff --git a/src/providers/realtime-provider.js b/src/providers/realtime-provider.js index 7889b577..6820740e 100644 --- a/src/providers/realtime-provider.js +++ b/src/providers/realtime-provider.js @@ -39,11 +39,17 @@ import { import { commandToTelemetryDatum } from './commands.js'; import { eventToTelemetryDatum, eventShouldBeFiltered } from './events.js'; +const ONE_SECOND = 1000; +const ONE_MILLION_CHARACTERS = 1000000; + +//Everything except parameter messages are housekeeping and if they're dropped bad things can happen. +const PARAMETER_MESSAGES = '^{[\\s]*"type":\\s"parameters'; + export default class RealtimeProvider { #socketWorker = null; #openmct; - constructor(openmct, url, instance, processor = 'realtime', maxBatchWait = 1000, maxBatchSize = 15) { + constructor(openmct, url, instance, processor = 'realtime', throttleRate = ONE_SECOND, maxBufferSize = ONE_MILLION_CHARACTERS) { this.url = url; this.instance = instance; this.processor = processor; @@ -59,8 +65,10 @@ export default class RealtimeProvider { this.subscriptionsByCall = new Map(); this.subscriptionsById = {}; this.#socketWorker = new openmct.telemetry.BatchingWebSocket(openmct); + this.#socketWorker.setThrottleMessagePattern(PARAMETER_MESSAGES); this.#openmct = openmct; - this.#setBatchingStrategy(maxBatchWait, maxBatchSize); + this.#socketWorker.setThrottleRate(throttleRate); + this.#socketWorker.setMaxBufferSize(maxBufferSize); this.addSupportedObjectTypes(Object.values(OBJECT_TYPES)); this.addSupportedDataTypes(Object.values(DATA_TYPES)); @@ -82,33 +90,6 @@ export default class RealtimeProvider { this.#setCallFromClock(clock); } } - #setBatchingStrategy(maxBatchWait, maxBatchSize) { - // This strategy batches parameter value messages - this.#socketWorker.setBatchingStrategy({ - /* istanbul ignore next */ - shouldBatchMessage: /* istanbul ignore next */ (message) => { - // If a parameter value message, the message type will be "parameters" - // The type field is always located at a character offset of 13 and - // if it is "parameters" will be 10 characters long. - const type = message.substring(13, 23); - - return type === 'parameters'; - }, - /* istanbul ignore next */ - getBatchIdFromMessage: /* istanbul ignore next */ (message) => { - // Only dealing with "parameters" messages at this point. The call number - // identifies the parameter, and is used for batching. Will be located - // at a character offset of 36. Because it is of indeterminate length - // (we don't know the number) we have to do a sequential search forward - // from the 37th character for a terminating ",". - const callNumber = message.substring(36, message.indexOf(",", 37)); - - return callNumber; - } - }); - this.#socketWorker.setMaxBatchWait(maxBatchWait); - this.#socketWorker.setMaxBatchSize(maxBatchSize); - } addSupportedObjectTypes(types) { types.forEach(type => this.supportedObjectTypes[type] = type); @@ -158,7 +139,7 @@ export default class RealtimeProvider { if (subscriptionDetails) { this.sendUnsubscribeMessage(subscriptionDetails); - this.subscriptionsByCall.delete(subscriptionDetails.call.toString()); + this.subscriptionsByCall.delete(subscriptionDetails.call); delete this.subscriptionsById[id]; } }; @@ -183,12 +164,16 @@ export default class RealtimeProvider { this.sendUnsubscribeMessage(subscriptionDetails); if (this.subscriptionsById[id]) { - this.subscriptionsByCall.delete(this.subscriptionsById[id].call.toString()); + this.subscriptionsByCall.delete(this.subscriptionsById[id].call); delete this.subscriptionsById[id]; } }; } + getSubscriptionByObjectIdentifier(identifier) { + return Object.values(this.subscriptionsById).find(subscription => this.#openmct.objects.areIdsEqual(subscription.domainObject.identifier, identifier)); + } + buildSubscriptionDetails(domainObject, callback, options) { let subscriptionId = this.lastSubscriptionId++; let subscriptionDetails = { @@ -224,52 +209,78 @@ export default class RealtimeProvider { }); if (correspondingSubscription !== undefined) { - this.remoteClockCallNumber = correspondingSubscription.call.toString(); + this.remoteClockCallNumber = correspondingSubscription.call; } else { delete this.remoteClockCallNumber; } } - #processBatchQueue(batchQueue, call) { - let subscriptionDetails = this.subscriptionsByCall.get(call); - let telemetryData = []; + #processParameterUpdates(parameterValuesByCall) { + //If remote clock active, process its value before any telemetry values to ensure the bounds are always up to date. + if (this.remoteClockCallNumber !== undefined) { + const remoteClockValues = parameterValuesByCall.get(this.remoteClockCallNumber); + const subscriptionDetails = this.subscriptionsByCall.get(this.remoteClockCallNumber); - // possibly cancelled - if (!subscriptionDetails) { - return; - } + if (remoteClockValues !== undefined && remoteClockValues.length > 0) { + const allClockValues = []; - batchQueue.forEach((rawMessage) => { - const message = JSON.parse(rawMessage); - const values = message.data.values || []; - const parentName = subscriptionDetails.domainObject.name; + remoteClockValues.forEach((parameterValue) => { + this.#convertMessageToDatumAndReportStaleness(parameterValue, subscriptionDetails, allClockValues); + }); - values.forEach(parameter => { - const datum = convertYamcsToOpenMctDatum(parameter, parentName); + if (allClockValues.length > 0) { + subscriptionDetails.callback(allClockValues); + } + + // Delete so we don't process it twice. + parameterValuesByCall.delete(this.remoteClockCallNumber); + } + } - if (this.observingStaleness[subscriptionDetails.name] !== undefined) { - const status = STALENESS_STATUS_MAP[parameter.acquisitionStatus]; + // Now process all non-clock parameter updates + for (const [call, parameterValues] of parameterValuesByCall.entries()) { + const allTelemetryData = []; + const subscriptionDetails = this.subscriptionsByCall.get(call); - if (this.observingStaleness[subscriptionDetails.name].response.isStale !== status) { - const stalenesResponseObject = buildStalenessResponseObject( - status, - parameter[METADATA_TIME_KEY] - ); - this.observingStaleness[subscriptionDetails.name].response = stalenesResponseObject; - this.observingStaleness[subscriptionDetails.name].callback(stalenesResponseObject); - } - } + // possibly cancelled + if (!subscriptionDetails) { + continue; + } - addLimitInformation(parameter, datum); - telemetryData.push(datum); + parameterValues.forEach((parameterValue) => { + this.#convertMessageToDatumAndReportStaleness(parameterValue, subscriptionDetails, allTelemetryData); }); - }); - if (telemetryData.length > 0) { - subscriptionDetails.callback(telemetryData); + if (allTelemetryData.length > 0) { + subscriptionDetails.callback(allTelemetryData); + } } } + #convertMessageToDatumAndReportStaleness(parameterValue, subscriptionDetails, allTelemetryData) { + const values = parameterValue.data.values || []; + const parentName = subscriptionDetails.domainObject.name; + values.forEach(parameter => { + const datum = convertYamcsToOpenMctDatum(parameter, parentName); + + if (this.observingStaleness[subscriptionDetails.name] !== undefined) { + const status = STALENESS_STATUS_MAP[parameter.acquisitionStatus]; + + if (this.observingStaleness[subscriptionDetails.name].response.isStale !== status) { + const stalenesResponseObject = buildStalenessResponseObject( + status, + parameter[METADATA_TIME_KEY] + ); + this.observingStaleness[subscriptionDetails.name].response = stalenesResponseObject; + this.observingStaleness[subscriptionDetails.name].callback(stalenesResponseObject); + } + } + + addLimitInformation(parameter, datum); + allTelemetryData.push(datum); + }); + } + connect() { if (this.connected) { return; @@ -285,84 +296,87 @@ export default class RealtimeProvider { }); this.#socketWorker.addEventListener('batch', (batchEvent) => { - const batch = batchEvent.detail; - - let remoteClockValue; - // If remote clock active, process its value before any telemetry values to ensure the bounds are always up to date. - if (this.remoteClockCallNumber !== undefined) { - remoteClockValue = batch[this.remoteClockCallNumber]; - if (remoteClockValue !== undefined) { - this.#processBatchQueue(batch[this.remoteClockCallNumber], this.remoteClockCallNumber); - - // Delete so we don't process it twice. - delete batch[this.remoteClockCallNumber]; - } - } + const newBatch = batchEvent.detail; + const parametersByCall = new Map(); + newBatch.forEach(messageString => { + const message = JSON.parse(messageString); + const call = message.call; + if (message.type === 'parameters') { + // First, group parameter updates by call + let arrayOfParametersForCall = parametersByCall.get(call); + + if (arrayOfParametersForCall === undefined) { + arrayOfParametersForCall = []; + parametersByCall.set(call, arrayOfParametersForCall); + } - Object.keys(batch).forEach((call) => { - this.#processBatchQueue(batch[call], call); - }); - }); + arrayOfParametersForCall.push(message); + } else { + if (!this.isSupportedDataType(message.type)) { + return; + } - this.#socketWorker.addEventListener('message', (messageEvent) => { - const message = JSON.parse(messageEvent.detail); - if (!this.isSupportedDataType(message.type)) { - return; - } + const isReply = message.type === DATA_TYPES.DATA_TYPE_REPLY; + let subscriptionDetails; - const isReply = message.type === DATA_TYPES.DATA_TYPE_REPLY; - const call = message.call; - let subscriptionDetails; + if (isReply) { + const id = message.data.replyTo; + subscriptionDetails = this.subscriptionsById[id]; - if (isReply) { - const id = message.data.replyTo; - subscriptionDetails = this.subscriptionsById[id]; - subscriptionDetails.call = call; - // Subsequent retrieval uses a string, so for performance reasons we use a string as a key. - this.subscriptionsByCall.set(call.toString(), subscriptionDetails); + // Susbcriptions can be cancelled before we even get to this stage during tests due to rapid navigation. + if (!subscriptionDetails) { + return; + } - const remoteClockIdentifier = this.#openmct.time.getClock()?.identifier; - const isRemoteClockActive = remoteClockIdentifier !== undefined; + subscriptionDetails.call = call; + // Subsequent retrieval uses a string, so for performance reasons we use a string as a key. + this.subscriptionsByCall.set(call, subscriptionDetails); - if (isRemoteClockActive && subscriptionDetails.domainObject.identifier.key === remoteClockIdentifier.key) { - this.remoteClockCallNumber = call.toString(); - } - } else { - subscriptionDetails = this.subscriptionsByCall.get(message.call.toString()); + const remoteClockIdentifier = this.#openmct.time.getClock()?.identifier; + const isRemoteClockActive = remoteClockIdentifier !== undefined; - // possibly cancelled - if (!subscriptionDetails) { - return; - } - - if (this.isCommandMessage(message)) { - const datum = commandToTelemetryDatum(message.data); - subscriptionDetails.callback(datum); - } else if (this.isEventMessage(message)) { - if (eventShouldBeFiltered(message.data, subscriptionDetails.options)) { - // ignore event + if (isRemoteClockActive && subscriptionDetails.domainObject.identifier.key === remoteClockIdentifier.key) { + this.remoteClockCallNumber = call; + } } else { - const datum = eventToTelemetryDatum(message.data); - subscriptionDetails.callback(datum); - } - } else if (this.isMdbChangesMessage(message)) { - if (!this.isParameterType(message)) { - return; - } - - const parameterName = message.data.parameterOverride.parameter; - if (this.observingLimitChanges[parameterName] !== undefined) { - const alarmRange = message.data.parameterOverride.defaultAlarm?.staticAlarmRange ?? []; - this.observingLimitChanges[parameterName].callback(getLimitFromAlarmRange(alarmRange)); + subscriptionDetails = this.subscriptionsByCall.get(message.call); + + // possibly cancelled + if (!subscriptionDetails) { + return; + } + + if (this.isCommandMessage(message)) { + const datum = commandToTelemetryDatum(message.data); + subscriptionDetails.callback(datum); + } else if (this.isEventMessage(message)) { + if (eventShouldBeFiltered(message.data, subscriptionDetails.options)) { + // ignore event + } else { + const datum = eventToTelemetryDatum(message.data); + subscriptionDetails.callback(datum); + } + } else if (this.isMdbChangesMessage(message)) { + if (!this.isParameterType(message)) { + return; + } + + const parameterName = message.data.parameterOverride.parameter; + if (this.observingLimitChanges[parameterName] !== undefined) { + const alarmRange = message.data.parameterOverride.defaultAlarm?.staticAlarmRange ?? []; + this.observingLimitChanges[parameterName].callback(getLimitFromAlarmRange(alarmRange)); + } + + if (subscriptionDetails.callback) { + subscriptionDetails.callback(message.data); + } + } else { + subscriptionDetails.callback(message.data); + } } - - if (subscriptionDetails.callback) { - subscriptionDetails.callback(message.data); - } - } else { - subscriptionDetails.callback(message.data); } - } + }); + this.#processParameterUpdates(parametersByCall); }); } diff --git a/tests/e2e/yamcs/barGraph.e2e.spec.mjs b/tests/e2e/yamcs/barGraph.e2e.spec.mjs index bf2fb12d..80ab0d3e 100644 --- a/tests/e2e/yamcs/barGraph.e2e.spec.mjs +++ b/tests/e2e/yamcs/barGraph.e2e.spec.mjs @@ -27,7 +27,7 @@ import { pluginFixtures, appActions } from 'openmct-e2e'; import { searchAndLinkTelemetryToObject } from '../yamcsAppActions.mjs'; const { test, expect } = pluginFixtures; -const { createDomainObjectWithDefaults } = appActions; +const { createDomainObjectWithDefaults, setFixedTimeMode } = appActions; test.describe('Bar Graph @yamcs', () => { let barGraph; @@ -36,7 +36,7 @@ test.describe('Bar Graph @yamcs', () => { test.beforeEach(async ({ page }) => { // Open a browser, navigate to the main page, and wait until all networkevents to resolve await page.goto('./', { waitUntil: 'networkidle' }); - + await setFixedTimeMode(page); // Create the Bar Graph barGraph = await createDomainObjectWithDefaults(page, { type: 'Graph', name: 'Bar Graph' }); // Enter edit mode for the overlay plot diff --git a/tests/e2e/yamcs/faultManagement.e2e.spec.mjs b/tests/e2e/yamcs/faultManagement.e2e.spec.mjs index d3713788..6082427a 100644 --- a/tests/e2e/yamcs/faultManagement.e2e.spec.mjs +++ b/tests/e2e/yamcs/faultManagement.e2e.spec.mjs @@ -25,19 +25,265 @@ Staleness Specific Tests */ import { pluginFixtures } from 'openmct-e2e'; -const { test } = pluginFixtures; +const { test, expect } = pluginFixtures; + +const YAMCS_API_URL = "http://localhost:8090/api/"; +const FAULT_PARAMETER = "Latitude"; + +/** + * Get the locator for a triggered fault list item by severity. + * @param {import('@playwright/test').Page} page - The page object. + * @param {string} severity - The severity of the fault. + * @returns {import('@playwright/test').Locator} - The locator for the fault's severity label. + */ +function getTriggeredFaultBySeverity(page, severity) { + return page.getByLabel(new RegExp(`Fault triggered at.*${severity}.*`, 'i')); +} + +test.describe("Fault Management @yamcs", () => { + test.beforeAll("activate alarms on the telemetry point", async () => { + // Set the default alarms for the parameter in such a way + // that it is guaranteed to produce a fault on load. + const response = await setDefaultAlarms(FAULT_PARAMETER, [ + { + level: 'WATCH', + minInclusive: 808, + maxInclusive: 810 + }, + { + level: 'WARNING', + minInclusive: 810.01, + maxInclusive: 812 + }, + { + level: 'DISTRESS', + minInclusive: 812.01, + maxInclusive: 814 + }, + { + level: 'CRITICAL', + minInclusive: 814.01, + maxInclusive: 820 + }, + { + level: 'SEVERE', + minInclusive: 820.01, + maxInclusive: 824 + } + ]); + expect(response.status).toBe(200); + }); + + test.beforeEach(async ({ page }) => { + const networkPromise = page.waitForResponse('**/api/mdb/myproject/parameters**'); + await page.goto('./', { waitUntil: 'domcontentloaded' }); + // Wait until the YAMCS parameter request resolves + await networkPromise; + }); + + test('Shows faults of differing severities ', async ({ page }) => { + // Intercept the request to set the alarm to WATCH severity + await page.route('**/api/**/alarms', route => modifyAlarmSeverity(route, FAULT_PARAMETER, 'WATCH')); + + await test.step('Shows fault with severity WATCH', async () => { + await page.goto('./', { waitUntil: 'domcontentloaded' }); + + const alarmsRequest = page.waitForRequest('**/api/**/alarms'); + await page.getByLabel('Navigate to Fault Management').click(); + await alarmsRequest; + await expect(getTriggeredFaultBySeverity(page, 'WATCH')).toBeVisible(); + }); + + // Intercept the request to set the alarm to WARNING severity + await page.route('**/api/**/alarms', route => modifyAlarmSeverity(route, FAULT_PARAMETER, 'WARNING')); + + await test.step('Shows fault with severity WARNING', async () => { + await page.goto('./', { waitUntil: 'domcontentloaded' }); + + const alarmsRequest = page.waitForRequest('**/api/**/alarms'); + await page.getByLabel('Navigate to Fault Management').click(); + await alarmsRequest; + await expect(getTriggeredFaultBySeverity(page, 'WARNING')).toBeVisible(); + }); + + // Intercept the request to set the alarm to CRITICAL severity + await page.route('**/api/**/alarms', route => modifyAlarmSeverity(route, FAULT_PARAMETER, 'CRITICAL')); + + await test.step('Shows fault with severity CRITICAL', async () => { + await page.goto('./', { waitUntil: 'domcontentloaded' }); + + const alarmsRequest = page.waitForRequest('**/api/**/alarms'); + await page.getByLabel('Navigate to Fault Management').click(); + await alarmsRequest; + await expect(getTriggeredFaultBySeverity(page, 'CRITICAL')).toBeVisible(); + }); + }); + + test('Faults may be shelved for a period of time', async ({ page }) => { + await test.step('Set the alarm to critical and mock the shelve request', async () => { + // Intercept the response to set the alarm to critical + await page.route('**/api/**/alarms', route => modifyAlarmSeverity(route, FAULT_PARAMETER, 'CRITICAL')); + + // Intercept the request to shelve the fault and set the duration to 1000ms so + // we don't have to wait long for the fault to un-shelve + await page.route('**/api/**/*:shelve', async route => { + if (route.request().method() === 'POST') { + let requestBody = await route.request().postDataJSON(); + requestBody.shelveDuration = 10000; + await route.continue({ postData: requestBody }); + } else { + await route.continue(); + } + }); + }); + + await test.step('Shelve the fault', async () => { + const alarmsRequest = page.waitForRequest('**/api/**/alarms'); + await page.getByLabel('Navigate to Fault Management').click(); + await alarmsRequest; + await expect(page.getByLabel(/Fault triggered at.*CRITICAL.*/)).toBeVisible(); + await page.getByLabel('Select fault: Latitude in /myproject').check(); + await page.getByLabel('Shelve selected faults').click(); + await page.locator('#comment-textarea').fill("Shelvin' a fault!"); + await page.getByLabel('Save').click(); + }); + + await test.step('Shelved faults are visible in the Shelved view', async () => { + await expect(page.getByLabel(/Fault triggered at.*CRITICAL.*/)).toBeHidden(); + await page.getByTitle('View Filter').getByRole('combobox').selectOption('Shelved'); + await expect(page.getByLabel(/Fault triggered at.*CRITICAL.*/)).toBeVisible(); + await page.getByTitle('View Filter').getByRole('combobox').selectOption('Standard View'); + }); + await test.step('Fault is visible in the Standard view after shelve duration expires', async () => { + // Have a longer timeout to account for the fault being shelved + await expect(getTriggeredFaultBySeverity(page, 'CRITICAL')).toBeVisible({ timeout: 10000 }); + await page.getByTitle('View Filter').getByRole('combobox').selectOption('Shelved'); + await expect(getTriggeredFaultBySeverity(page, 'CRITICAL')).toBeHidden(); + }); + }); + + test('Faults may be acknowledged', async ({ page }) => { + await test.step('Set the alarm to critical', async () => { + // Intercept the response to set the alarm to critical + await page.route('**/api/**/alarms', route => modifyAlarmSeverity(route, FAULT_PARAMETER, 'CRITICAL')); -test.describe.fixme("Fault management tests @yamcs", () => { - // eslint-disable-next-line require-await - test('Show faults ', async ({ page }) => { - test.step('for historic alarm violations', () => { - // Navigate to fault management in the tree - // Expect that there is indication of a fault }); - test.step('show historic and live faults when new alarms are triggered in real time', () => { - // Wait for new data - // Expect that live faults are displayed + await test.step('Acknowledge the fault', async () => { + const alarmsRequest = page.waitForRequest('**/api/**/alarms'); + await page.getByLabel('Navigate to Fault Management').click(); + await alarmsRequest; + await expect(getTriggeredFaultBySeverity(page, 'CRITICAL')).toBeVisible(); + await page.getByLabel('Select fault: Latitude in /myproject').check(); + await page.getByLabel('Acknowledge selected faults').click(); + await page.locator('#comment-textarea').fill("Acknowledging a fault!"); + await page.getByLabel('Save').click(); }); + + await test.step('Acknowledged faults are visible in the Acknowledged view', async () => { + await expect(getTriggeredFaultBySeverity(page, 'CRITICAL')).toBeHidden(); + await page.getByTitle('View Filter').getByRole('combobox').selectOption('Acknowledged'); + await expect(getTriggeredFaultBySeverity(page, 'CRITICAL')).toBeVisible(); + }); + }); + + test.afterAll("remove alarms from the telemetry point", async () => { + const responses = await clearAlarms(FAULT_PARAMETER); + for (const res of responses) { + expect.soft(res.status).toBe(200); + } }); }); + +/** + * @typedef {Object} AlarmRange + * @property {'WATCH' | 'WARNING' | 'DISTRESS' | 'CRITICAL' | 'SEVERE'} level - The alarm level. + * @property {number} minInclusive - The minimum inclusive value for the alarm. + * @property {number} maxInclusive - The maximum inclusive value for the alarm. + */ + +/** + * Set default alarms for a parameter. + * @param {string} parameter - The parameter to set alarms for. + * @param {AlarmRange[]} staticAlarmRanges - The static alarm ranges to set. + * @param {string} [instance='myproject'] - The instance name. + * @param {string} [processor='realtime'] - The processor name. + */ +// eslint-disable-next-line require-await +async function setDefaultAlarms(parameter, staticAlarmRanges = [], instance = 'myproject', processor = 'realtime') { + return fetch(`${YAMCS_API_URL}mdb-overrides/${instance}/${processor}/parameters/${instance}/${parameter}`, { + method: 'PATCH', + headers: { + 'Content-Type': 'application/json' + }, + body: JSON.stringify({ + action: 'SET_DEFAULT_ALARMS', + defaultAlarm: { + staticAlarmRange: staticAlarmRanges + } + }) + }); +} + +/** + * Clear alarms for a parameter. + * @param {string} parameter - The parameter to clear alarms for. + * @param {string} [instance='myproject'] - The instance name. + * @param {string} [processor='realtime'] - The processor name. + * @returns {Promise} - The response from the server. + */ +// eslint-disable-next-line require-await +async function clearAlarms(parameter, instance = 'myproject', processor = 'realtime') { + await setDefaultAlarms(parameter, [], instance, processor); + const response = await getAlarms(instance); + const alarms = await response.json(); + const alarmsToClear = Object.values(alarms).map(alarm => { + + return { + name: alarm[0].id.name, + seqNum: alarm[0].seqNum + }; + }); + + return Promise.all( + alarmsToClear.map(alarm => + fetch(`${YAMCS_API_URL}processors/${instance}/${processor}/alarms/${alarm.name}/${alarm.seqNum}:clear`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json' + } + }) + ) + ); +} + +// eslint-disable-next-line require-await +async function getAlarms(instance = 'myproject') { + return fetch(`${YAMCS_API_URL}archive/${instance}/alarms`); +} + +/** + * @param {import('@playwright/test').Route} route + * @param {string} alarmName + * @param {string} newSeverity + */ +async function modifyAlarmSeverity(route, alarmName, newSeverity) { + const response = await route.fetch(); + let body = await response.json(); + const newBody = { ...body }; + + // Modify the rawValue.floatValue to trigger a specific alarm + body.alarms.forEach((alarm, index) => { + if (alarm.id.name === alarmName) { + newBody.alarms[index].severity = newSeverity; + } + }); + + return route.fulfill({ + response, + json: newBody, + headers: { + ...response.headers() + } + }); +} diff --git a/tests/e2e/yamcs/realtimeData.e2e.spec.mjs b/tests/e2e/yamcs/realtimeData.e2e.spec.mjs index 4aad789a..85324bda 100644 --- a/tests/e2e/yamcs/realtimeData.e2e.spec.mjs +++ b/tests/e2e/yamcs/realtimeData.e2e.spec.mjs @@ -52,19 +52,22 @@ test.describe('Realtime telemetry displays', () => { }); // Go to baseURL - await page.goto('./', { waitUntil: 'domcontentloaded' }); + await page.goto('./', { waitUntil: 'networkidle' }); await page.evaluate((thirtyMinutes) => { - const openmct = window.openmct; - - openmct.install(openmct.plugins.RemoteClock({ - namespace: "taxonomy", - key: "~myproject~Battery1_Temp" - })); - - openmct.time.setClock('remote-clock'); - openmct.time.setClockOffsets({ - start: -thirtyMinutes, - end: 0 + return new Promise((resolve) => { + const openmct = window.openmct; + + openmct.install(openmct.plugins.RemoteClock({ + namespace: "taxonomy", + key: "~myproject~Battery1_Temp" + })); + + openmct.time.setClock('remote-clock'); + openmct.time.setClockOffsets({ + start: -thirtyMinutes, + end: 15000 + }); + setTimeout(resolve, 2000); }); }, THIRTY_MINUTES); yamcsURL = new URL('/yamcs-proxy/', page.url()).toString(); @@ -119,7 +122,9 @@ test.describe('Realtime telemetry displays', () => { test('Correctly shows the latest values', async ({ page }) => { // Wait a reasonable amount of time for new telemetry to come in. - // There is nothing significant about the number chosen. + // There is nothing significant about the number chosen. It's + // long enough to ensure we have new telemetry, short enough that + // it doesn't significantly increase test time. const WAIT_FOR_MORE_TELEMETRY = 3000; const ladTable = await getLadTableByName(page, 'Test LAD Table'); @@ -248,31 +253,58 @@ test.describe('Realtime telemetry displays', () => { expect(notification).toHaveCount(0); } }); - - test('Open MCT does drop telemetry when the UI is under load', async ({ page }) => { - // 1. Make sure the display is done loading, and populated with values (ie. we are in a steady state) - const ladTable = await getLadTableByName(page, 'Test LAD Table'); - await getParameterValuesFromLadTable(ladTable); - - // 2. Block the UI with a loop + /** + * This tests for an edge-case found during testing where throttling occurs during subscription handshaking with the server. + * In this scenario, subscribers never receive telemetry because the subscription was never properly initialized. + * This test confirms that after blocking the UI and inducing throttling, that all subscribed telemetry objects received telemetry. + */ + test('When the UI is blocked during initialization, does not drop subscription housekeeping messages', async ({ page }) => { + // 1. Block the UI await page.evaluate(() => { return new Promise((resolveBlockingLoop) => { - //5s x 10Hz data = 50 telemetry values which should easily overrun the buffer length of 20. let start = Date.now(); let now = Date.now(); - // Block the UI thread for 5s - while (now - start < 5000) { + // Block the UI thread for 6s + while (now - start < 10000) { now = Date.now(); } resolveBlockingLoop(); }); }); - // Check for telemetry dropped notification + + //Confirm that throttling occurred const notification = page.getByRole('alert'); - expect(notification).toHaveCount(1); const text = await notification.innerText(); expect(text).toBe('Telemetry dropped due to client rate limiting.'); + + //Confirm that all subscribed telemetry points receive telemetry. This tests that subscriptions were established successfully and + //tests for a failure mode where housekeeping telemetry was being dropped if the UI was blocked during initialization of telemetry subscriptions + const parametersToSubscribeTo = Object.values(namesToParametersMap).map(parameter => parameter.replaceAll('/', '~')); + const subscriptionsThatTelemetry = await page.evaluate(async (parameters) => { + const openmct = window.openmct; + const telemetryObjects = await Promise.all( + Object.values(parameters).map( + (parameterId) => openmct.objects.get( + { + namespace: 'taxonomy', + key: parameterId + } + ) + )); + const subscriptionsAllReturned = await Promise.all(telemetryObjects.map((telemetryObject) => { + return new Promise(resolve => { + const unsubscribe = openmct.telemetry.subscribe(telemetryObject, () => { + unsubscribe(); + resolve(true); + }); + }); + })); + + return subscriptionsAllReturned; + }, parametersToSubscribeTo); + + expect(subscriptionsThatTelemetry.length).toBe(parametersToSubscribeTo.length); }); test('Open MCT shows the latest telemetry after UI is temporarily blocked', async ({ page }) => { @@ -283,19 +315,24 @@ test.describe('Realtime telemetry displays', () => { return new Promise((resolveBlockingLoop) => { let start = Date.now(); let now = Date.now(); - // Block the UI thread for 5s - while (now - start < 5000) { + // Block the UI thread for 10s + while (now - start < 10000) { now = Date.now(); } - resolveBlockingLoop(); + requestIdleCallback(resolveBlockingLoop); }); }); + //Confirm that throttling occurred + const notification = page.getByRole('alert'); + const text = await notification.innerText(); + expect(text).toBe('Telemetry dropped due to client rate limiting.'); + // Disable playback await disableLink(yamcsURL); - // Wait 1 second for values to propagate to client and render on screen. + // Wait for values to propagate to client and render on screen. await page.waitForTimeout(TELEMETRY_PROPAGATION_TIME); const latestValueObjects = await latestParameterValues(Object.values(namesToParametersMap), yamcsURL); @@ -307,7 +344,7 @@ test.describe('Realtime telemetry displays', () => { test('Open MCT accurately batches telemetry when requested', async ({ page }) => { - // 1. Subscribe to batched telemetry, + // 1. Subscribe to batched telemetry,e const telemetryValues = await page.evaluate(async () => { const openmct = window.openmct; const telemetryObject = await openmct.objects.get({ @@ -342,13 +379,129 @@ test.describe('Realtime telemetry displays', () => { }); const formattedParameterArchiveTelemetry = toOpenMctTelemetryFormat(parameterArchiveTelemetry); sortOpenMctTelemetryAscending(formattedParameterArchiveTelemetry); - telemetryValues.forEach((telemetry, index) => { expect(telemetry.value).toBe(formattedParameterArchiveTelemetry[index].value); expect(telemetry.timestamp).toBe(formattedParameterArchiveTelemetry[index].timestamp); }); }); + test('Open MCT does not drop telemetry when a burst of telemetry arrives that exceeds 60 messages', async ({ page }) => { + const PARAMETER_VALUES_COUNT = 60; + /** + * A failure mode of the previous implementation of batching was when bursts of telemetry from a parameter arrived all at once. + * A burst of 60 messages will overwhelm a per-parameter telemetry buffer of 50, but will not overwhelm a larger shared buffer. + */ + + // Disable real playback. We are going to inject our own batch of messages + await disableLink(yamcsURL); + + /** + * Yamcs tracks subscriptions by "call number". The call number is assigned by Yamcs, + * so we don't know it ahead of time. We have to retrieve it at runtime after the subscription + * has been established. + * + * We need to know the call number, because it's how the receiver (Open MCT) ties a parameter + * value that is received over a WebSocket back to the correct subscription. + */ + const batteryTempParameterCallNumber = await page.evaluate(async () => { + const openmct = window.openmct; + const objectIdentifier = { + namespace: 'taxonomy', + key: '~myproject~Battery1_Temp' + }; + const telemetryObject = await openmct.objects.get(objectIdentifier); + const yamcsRealtimeProvider = await openmct.telemetry.findSubscriptionProvider(telemetryObject); + + return yamcsRealtimeProvider.getSubscriptionByObjectIdentifier(objectIdentifier).call; + + }); + + websocketWorker.evaluate(({call, COUNT}) => { + const messageEvents = []; + /** + * Inject a burst of 60 messages. + */ + for (let messageCount = 0; messageCount < COUNT; messageCount++) { + const message = { + "type": "parameters", + //This is where we use the call number retrieved previously + "call": call, + "seq": messageCount, + "data": { + "@type": "/yamcs.protobuf.processing.SubscribeParametersData", + "values": [ + { + "rawValue": { + "type": "FLOAT", + "floatValue": 10.204108 + }, + "engValue": { + "type": "FLOAT", + "floatValue": 10.204108 + }, + "acquisitionTime": new Date(Date.now() + messageCount).toISOString(), + "generationTime": new Date(Date.now() + messageCount).toISOString(), + "acquisitionStatus": "ACQUIRED", + "numericId": 1 + } + ] + } + }; + /** + * We are building an array of Event objects of type 'message'. Dispatching an event of this + * type on a WebSocket will cause all listeners subscribed to 'message' events to receive it. + * The receiving code will not know the difference between an Event that is dispatched from + * code vs. one that caused by the arrival of data over the wire. + * @see https://developer.mozilla.org/en-US/docs/Web/API/WebSocket/message_event + */ + const event = new Event('message'); + event.data = JSON.stringify(message); + messageEvents.push(event); + } + + /** + * Dispatch the 60 WebSocket message events we just created + */ + messageEvents.forEach(event => { + self.currentWebSocket.dispatchEvent(event); + }); + + }, { + call: batteryTempParameterCallNumber, + COUNT: PARAMETER_VALUES_COUNT + }); + + // Subscribe to Battery1_Temp so we can confirm that the injected parameter values were received, + const telemetryValues = await page.evaluate(async () => { + const openmct = window.openmct; + const objectIdentifier = { + namespace: 'taxonomy', + key: '~myproject~Battery1_Temp' + }; + const telemetryObject = await openmct.objects.get(objectIdentifier); + + return new Promise((resolveWithTelemetry) => { + openmct.telemetry.subscribe(telemetryObject, (telemetry) => { + resolveWithTelemetry(telemetry); + }, {strategy: 'batch'}); + }); + }); + // To avoid test flake use >= instead of =. Because yamcs is also flowing data immediately prior to this test there + // can be some real data still in the buffer or in-transit. It's inherently stochastic because the Yamcs instance is not + // isolated between tests, but it doesn't invalidate the test in this case. + expect(telemetryValues.length).toBeGreaterThanOrEqual(PARAMETER_VALUES_COUNT); + + const notification = page.getByRole('alert'); + const count = await notification.count(); + + if (count > 0) { + const text = await notification.innerText(); + expect(text).not.toBe('Telemetry dropped due to client rate limiting.'); + } else { + expect(notification).toHaveCount(0); + } + }); + function sortOpenMctTelemetryAscending(telemetry) { return telemetry.sort((a, b) => { if (a.timestamp < b.timestamp) { @@ -414,7 +567,7 @@ test.describe('Realtime telemetry displays', () => { } /** - * @param {import('playwright').Page} page + * @param {import('playwright').Page} page * @returns {Promise<{parameterNameText: string, parameterValueText: string}[]>} */ async function getParameterValuesFromAllGauges(page) { diff --git a/tests/e2e/yamcs/telemetryTables.e2e.spec.mjs b/tests/e2e/yamcs/telemetryTables.e2e.spec.mjs index e151dfac..e298108d 100644 --- a/tests/e2e/yamcs/telemetryTables.e2e.spec.mjs +++ b/tests/e2e/yamcs/telemetryTables.e2e.spec.mjs @@ -24,8 +24,10 @@ Telemetry Table Specific Tests */ -import { pluginFixtures } from 'openmct-e2e'; +import { pluginFixtures, appActions } from 'openmct-e2e'; const { test, expect } = pluginFixtures; +const { setRealTimeMode } = appActions; +const FIVE_SECONDS = 5*1000; test.describe("Telemetry Tables tests @yamcs", () => { @@ -77,4 +79,64 @@ test.describe("Telemetry Tables tests @yamcs", () => { await expect(page.getByRole('button', { name: 'SHOW LIMITED' })).toBeVisible(); }); + test('Telemetry tables are sorted in desc order correctly', async ({ page }) => { + await setRealTimeMode(page); + + //navigate to CCSDS_Packet_Length with a specified realtime window + await page.goto('./#/browse/taxonomy:spacecraft/taxonomy:~myproject/taxonomy:~myproject~CCSDS_Packet_Length?tc.mode=local&tc.startDelta=5000&tc.endDelta=5000&tc.timeSystem=utc&view=table', {waitUntil: 'domcontentloaded'}); + + // wait 5 seconds for the table to fill + await page.waitForTimeout(FIVE_SECONDS); + // pause the table + await page.getByLabel('Pause').click(); + const telemTableDesc = await page.getByLabel("CCSDS_Packet_Length table content"); + + // assert that they're in desc order + expect(await assertTableRowsInOrder(telemTableDesc, 'desc')).toBe(true); + + // Unpause + await page.getByLabel('Play').click(); + + // flip sort order + await page.locator('thead div').filter({ hasText: 'Timestamp' }).click(); + + // wait for x seconds + await page.waitForTimeout(FIVE_SECONDS); + + // pause the table + await page.getByLabel('Pause').click(); + const telemTableAsc = await page.getByLabel("CCSDS_Packet_Length table content"); + // assert that they're in asc order + expect(await assertTableRowsInOrder(telemTableAsc, 'asc')).toBe(true); + }); + + /** + * Returns whether a list of timestamp based rows are in asc or desc order + * @param { Node } telemTable Node for telemetry table + * @param { string } order 'asc' or 'desc' + * @returns {Boolean} All table rows are in order + */ + async function assertTableRowsInOrder(telemTable, order) { + let rowsAreInOrder = false; + const allRows = await (await telemTable.getByLabel('Table Row')).all(); + const arrayOfTimestamps = await Promise.all(allRows.map(async (row) => { + const timestamp = await row.getByLabel(/utc table cell.*/).innerText(); + return new Date(timestamp).getTime(); + })); + // check that they're in order + // arrayOfTimestamps + if (order === 'desc') { + rowsAreInOrder = arrayOfTimestamps.every((timestamp, index) => { + return index === 0 || timestamp <= arrayOfTimestamps[index - 1]; + }); + } else { + //order === 'asc' + rowsAreInOrder = arrayOfTimestamps.every((timestamp, index) => { + return index === 0 || timestamp >= arrayOfTimestamps[index - 1]; + }); + } + + return rowsAreInOrder; + } + });