diff --git a/.eslintrc.cjs b/.eslintrc.cjs index bb8e6131..2ebc9b85 100644 --- a/.eslintrc.cjs +++ b/.eslintrc.cjs @@ -25,7 +25,7 @@ module.exports = { "browser": true, "es6": true, "jasmine": true, - "amd": true + "amd": false }, "extends": "eslint:recommended", "parser": "@babel/eslint-parser", @@ -37,7 +37,16 @@ module.exports = { "impliedStrict": true } }, + "plugins": ['import'], "rules": { + 'import/no-amd': 'error', + 'import/no-commonjs': 'error', + 'import/named': 'error', + 'import/no-webpack-loader-syntax': 'error', + 'import/first': 'error', + 'import/no-import-module-exports': 'error', + 'import/no-mutable-exports': 'error', + 'import/no-unused-modules': 'error', "no-bitwise": "error", "curly": "error", "eqeqeq": "error", @@ -237,6 +246,12 @@ module.exports = { "varsIgnorePattern": "controller" } ] + } + }, + { + "files": ['*.eslintrc.cjs'], + "env": { + "node": true } } ] diff --git a/.github/workflows/node.js.yml b/.github/workflows/node.js.yml index 20e79ee3..cbd27434 100644 --- a/.github/workflows/node.js.yml +++ b/.github/workflows/node.js.yml @@ -1,4 +1,4 @@ -name: npm install and build:example +name: npm install, build, and lint on: push: @@ -31,3 +31,15 @@ jobs: elif [ "${{ matrix.openmct-version }}" = "stable" ]; then npm run build:example fi + + lint: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v3 + - name: Use Node.js 20.x + uses: actions/setup-node@v4 + with: + node-version: 20.x + - run: npm install + - name: Run lint + run: npm run lint diff --git a/.github/workflows/yamcs-quickstart-e2e.yml b/.github/workflows/yamcs-quickstart-e2e.yml index 14294c6a..97c22179 100644 --- a/.github/workflows/yamcs-quickstart-e2e.yml +++ b/.github/workflows/yamcs-quickstart-e2e.yml @@ -21,7 +21,7 @@ jobs: matrix: yamcs-version: - default - - 5.8.3 #viper + - 5.8.7 #viper ## disabling until we get confirmation- 5.3.2 #ab openmct-version: - latest @@ -74,9 +74,7 @@ jobs: echo "Error: Unable to fetch Yamcs version. HTTP status code: $response" exit 1 fi - - name: Run Quickstart tests and publish to deploysentinel - env: - DEPLOYSENTINEL_API_KEY: ${{ secrets.DEPLOYSENTINEL_API_KEY }} + - name: Run Quickstart tests run: npm run test:e2e:quickstart - name: Capture docker logs to file if: always() diff --git a/.gitignore b/.gitignore index 877200a5..5f9eecfc 100644 --- a/.gitignore +++ b/.gitignore @@ -12,3 +12,4 @@ test-results # Misc .DS_Store +.vscode/settings.json diff --git a/.webpack/webpack.common.js b/.webpack/webpack.common.js index 2d445253..6191ea59 100644 --- a/.webpack/webpack.common.js +++ b/.webpack/webpack.common.js @@ -25,15 +25,14 @@ import { fileURLToPath } from 'node:url'; const projectRootDir = fileURLToPath(new URL('../', import.meta.url)); -// eslint-disable no-undef -const WEBPACK_COMMON_CONFIG = { +/** @type {import('webpack').Configuration} */ +const commonConfig = { + context: projectRootDir, performance: { hints: false }, - resolve: { - alias: { - saveAs: "file-saver/src/FileSaver.js", - } + entry: { + 'openmct-yamcs': './src/openmct-yamcs.js' }, module: { rules: [ @@ -42,16 +41,19 @@ const WEBPACK_COMMON_CONFIG = { enforce: "pre", use: ["source-map-loader"] } - ] + ], }, output: { globalObject: "this", filename: '[name].js', - // eslint-disable-next-line no-undef path: path.resolve(projectRootDir, 'dist'), - libraryTarget: 'umd', - library: 'openmctYamcs' + library: { + type: 'umd', + export: 'default', + name: 'openmctYamcs' + } } }; -export default WEBPACK_COMMON_CONFIG; + +export default commonConfig; diff --git a/.webpack/webpack.dev.js b/.webpack/webpack.dev.js index c33ba8ee..84a9f5b8 100644 --- a/.webpack/webpack.dev.js +++ b/.webpack/webpack.dev.js @@ -19,30 +19,29 @@ * this source code distribution or the Licensing information page available * at runtime from the About dialog for additional information. *****************************************************************************/ - import path from 'path'; +import { fileURLToPath } from 'url'; import { merge } from 'webpack-merge'; -import common from './webpack.common.js'; -import { fileURLToPath } from 'node:url'; +import commonConfig from './webpack.common.js'; + +// Replicate __dirname functionality for ES modules +const __dirname = path.dirname(fileURLToPath(import.meta.url)); -const projectRootDir = fileURLToPath(new URL('../', import.meta.url)); -export default merge(common, { - context: projectRootDir, +/** @type {import('webpack').Configuration} */ +const devConfig = { mode: 'development', devtool: 'eval-source-map', entry: { - 'openmct-yamcs-example': path.resolve(projectRootDir, 'example/index.js') + 'openmct-yamcs-example': './example/index.js' }, devServer: { compress: true, port: 9000, static: [{ - // eslint-disable-next-line no-undef - directory: path.join(projectRootDir, 'example') + directory: path.join(__dirname, '../example'), }, { - // eslint-disable-next-line no-undef - directory: path.join(projectRootDir, '/node_modules/openmct/dist'), - publicPath: '/node_modules/openmct/dist' + directory: path.join(__dirname, '../node_modules/openmct/dist'), + publicPath: '/dist', }], proxy: { "/yamcs-proxy/*": { @@ -59,5 +58,12 @@ export default merge(common, { pathRewrite: { '^/yamcs-proxy-ws/': '' } } } + }, + resolve: { + alias: { + openmct: path.resolve(__dirname, '../node_modules/openmct/dist/openmct.js') + } } -}); +}; + +export default merge(commonConfig, devConfig); diff --git a/.webpack/webpack.prod.js b/.webpack/webpack.prod.js index c1bcba9d..46bc12dd 100644 --- a/.webpack/webpack.prod.js +++ b/.webpack/webpack.prod.js @@ -20,18 +20,12 @@ * at runtime from the About dialog for additional information. *****************************************************************************/ -import path from 'path'; import { merge } from 'webpack-merge'; import common from './webpack.common.js'; -import { fileURLToPath } from 'node:url'; -const projectRootDir = fileURLToPath(new URL('../', import.meta.url)); - -export default merge(common, { - context: projectRootDir, +/** @type {import('webpack').Configuration} */ +const prodConfig = { mode: 'production', - entry: { - 'openmct-yamcs': path.resolve(projectRootDir, 'src/plugin.js') - }, devtool: 'source-map' -}); +} +export default merge(common, prodConfig); diff --git a/README.md b/README.md index 7f7c315a..4c0e07f7 100644 --- a/README.md +++ b/README.md @@ -91,6 +91,24 @@ openmct.install(installYamcsPlugin({ | yamcsInstance | The name of the instance configured in YAMCS that you wish to connect to. | myproject | | yamcsFolder | The name of the instance configured in YAMCS that you wish to connect to. | myproject | +## dictionaryRequestCacheStrategyPromise +installYamcsPlugin also accepts an optional promise argument `dictionaryRequestCacheStrategyPromise`. This strategy is passed to the request for loading the YAMCS dictionary, or the `#loadTelemetryDictionary` function in `object-provider.js`. An example of how to make use of this is below. +``` +let cacheStrategy; +const cacheStrategyPromise = new Promise(resolve => cacheStrategy) + +openmct.install(installYamcsPlugin( + configuration, + cacheStrategryPromise +)) + +if (DICTIONARY_VERSION_IS_NEW) { // some check to determine dictionary version + cacheStrategy({cache: 'reload'}) +} else { + cacheStrategy({}) +} +``` + ## Special XTCE features If you are using an XTCE configuration in Yamcs, there are two special diff --git a/example/index.html b/example/index.html index 239e1011..29ce5701 100644 --- a/example/index.html +++ b/example/index.html @@ -21,7 +21,7 @@ Open MCT - YAMCS Example - + diff --git a/example/index.js b/example/index.js index 50168adc..a88b8997 100644 --- a/example/index.js +++ b/example/index.js @@ -1,5 +1,5 @@ -import installYamcsPlugin from '../src/plugin.js'; +import installYamcsPlugin from '../src/openmct-yamcs.js'; const config = { "yamcsDictionaryEndpoint": "http://localhost:9000/yamcs-proxy/", @@ -8,7 +8,9 @@ const config = { "yamcsUserEndpoint": "http://localhost:9000/yamcs-proxy/api/user/", "yamcsInstance": "myproject", "yamcsProcessor": "realtime", - "yamcsFolder": "myproject" + "yamcsFolder": "myproject", + "throttleRate": 1000, + "maxBatchSize": 15 }; const STATUS_STYLES = { "NO_STATUS": { @@ -39,57 +41,55 @@ const STATUS_STYLES = { }; const openmct = window.openmct; -(function () { - const THIRTY_MINUTES = 30 * 60 * 1000; +const THIRTY_MINUTES = 30 * 60 * 1000; - openmct.setAssetPath('/node_modules/openmct/dist'); +openmct.setAssetPath('/dist'); - installDefaultPlugins(); - openmct.install(installYamcsPlugin(config)); - openmct.install(openmct.plugins.OperatorStatus({statusStyles: STATUS_STYLES})); +installDefaultPlugins(); +openmct.install(installYamcsPlugin(config)); +openmct.install(openmct.plugins.OperatorStatus({statusStyles: STATUS_STYLES})); - document.addEventListener('DOMContentLoaded', function () { - openmct.start(); - }); +document.addEventListener('DOMContentLoaded', function () { + openmct.start(); +}); - function installDefaultPlugins() { - openmct.install(openmct.plugins.LocalStorage()); - openmct.install(openmct.plugins.Espresso()); - openmct.install(openmct.plugins.MyItems()); - openmct.install(openmct.plugins.example.Generator()); - openmct.install(openmct.plugins.example.ExampleImagery()); - openmct.install(openmct.plugins.UTCTimeSystem()); - openmct.install(openmct.plugins.TelemetryMean()); +function installDefaultPlugins() { + openmct.install(openmct.plugins.LocalStorage()); + openmct.install(openmct.plugins.Espresso()); + openmct.install(openmct.plugins.MyItems()); + openmct.install(openmct.plugins.example.Generator()); + openmct.install(openmct.plugins.example.ExampleImagery()); + openmct.install(openmct.plugins.UTCTimeSystem()); + openmct.install(openmct.plugins.TelemetryMean()); - openmct.install(openmct.plugins.DisplayLayout({ - showAsView: ['summary-widget', 'example.imagery', 'yamcs.image'] - })); - openmct.install(openmct.plugins.Conductor({ - menuOptions: [ - { - name: "Realtime", - timeSystem: 'utc', - clock: 'local', - clockOffsets: { - start: -THIRTY_MINUTES, - end: 0 - } - }, - { - name: "Fixed", - timeSystem: 'utc', - bounds: { - start: Date.now() - THIRTY_MINUTES, - end: 0 - } + openmct.install(openmct.plugins.DisplayLayout({ + showAsView: ['summary-widget', 'example.imagery', 'yamcs.image'] + })); + openmct.install(openmct.plugins.Conductor({ + menuOptions: [ + { + name: "Realtime", + timeSystem: 'utc', + clock: 'local', + clockOffsets: { + start: -THIRTY_MINUTES, + end: 0 } - ] - })); - openmct.install(openmct.plugins.SummaryWidget()); - openmct.install(openmct.plugins.Notebook()); - openmct.install(openmct.plugins.LADTable()); - openmct.install(openmct.plugins.ClearData(['table', 'telemetry.plot.overlay', 'telemetry.plot.stacked'])); + }, + { + name: "Fixed", + timeSystem: 'utc', + bounds: { + start: Date.now() - THIRTY_MINUTES, + end: 0 + } + } + ] + })); + openmct.install(openmct.plugins.SummaryWidget()); + openmct.install(openmct.plugins.Notebook()); + openmct.install(openmct.plugins.LADTable()); + openmct.install(openmct.plugins.ClearData(['table', 'telemetry.plot.overlay', 'telemetry.plot.stacked'])); - openmct.install(openmct.plugins.FaultManagement()); - } -}()); + openmct.install(openmct.plugins.FaultManagement()); +} diff --git a/package.json b/package.json index 9779bf52..cbcc1613 100644 --- a/package.json +++ b/package.json @@ -20,6 +20,7 @@ "test:e2e:smoke": "npx playwright test --config=./tests/e2e/playwright-quickstart.config.js --project=chromium quickstartSmoke", "test:e2e:quickstart": "npx playwright test --config=./tests/e2e/playwright-quickstart.config.js --project=chromium tests/e2e/yamcs/", "test:e2e:quickstart:local": "npx playwright test --config=./tests/e2e/playwright-quickstart.config.js --project=local-chrome tests/e2e/yamcs/", + "test:e2e:watch": "npx playwright test --ui --config=./tests/e2e/playwright-quickstart.config.js", "wait-for-yamcs": "wait-on http-get://localhost:8090/ -v" }, "keywords": [ @@ -37,11 +38,11 @@ "devDependencies": { "@babel/core": "7.20.12", "@babel/eslint-parser": "7.19.1", - "@deploysentinel/playwright": "0.3.4", "@playwright/test": "1.39.0", "babel-loader": "9.1.0", "babel-plugin-istanbul": "6.1.1", "eslint": "8.38.0", + "eslint-plugin-import":"2.29.1", "eventemitter3": "4.0.7", "file-saver": "2.0.5", "semver": "7.5.2", diff --git a/src/actions/exportToCSV/ExportToCSVAction.js b/src/actions/exportToCSV/ExportToCSVAction.js index 31a50b83..4089de39 100644 --- a/src/actions/exportToCSV/ExportToCSVAction.js +++ b/src/actions/exportToCSV/ExportToCSVAction.js @@ -21,7 +21,7 @@ *****************************************************************************/ import { OBJECT_TYPES } from "../../const.js"; import {idToQualifiedName} from "../../utils.js"; -import {saveAs} from 'saveAs'; +import { saveAs } from 'file-saver'; const SUPPORTED_TYPES = [OBJECT_TYPES.TELEMETRY_OBJECT_TYPE, OBJECT_TYPES.AGGREGATE_TELEMETRY_TYPE]; diff --git a/src/plugin.js b/src/openmct-yamcs.js similarity index 94% rename from src/plugin.js rename to src/openmct-yamcs.js index 5a7da0da..249d19e9 100644 --- a/src/plugin.js +++ b/src/openmct-yamcs.js @@ -1,5 +1,5 @@ /***************************************************************************** - * Open MCT, Copyright (c) 2014-2022, United States Government + * Open MCT, Copyright (c) 2014-2024, United States Government * as represented by the Administrator of the National Aeronautics and Space * Administration. All rights reserved. * @@ -36,11 +36,13 @@ import OperatorStatusTelemetry from './providers/user/operator-status-telemetry. import LatestTelemetryProvider from './providers/latest-telemetry-provider.js'; import PollQuestionParameter from './providers/user/poll-question-parameter.js'; import PollQuestionTelemetry from './providers/user/poll-question-telemetry.js'; -import ExportToCSVActionPlugin from "./actions/exportToCSV/plugin.js"; - -export default function installYamcsPlugin(configuration) { - return function install(openmct) { +import ExportToCSVActionPlugin from './actions/exportToCSV/plugin.js'; +export default function install( + configuration, + dictionaryRequestCacheStrategyPromise +) { + return (openmct) => { openmct.install(openmct.plugins.ISOTimeFormat()); const latestTelemetryProvider = new LatestTelemetryProvider({ @@ -58,9 +60,12 @@ export default function installYamcsPlugin(configuration) { openmct.telemetry.addProvider(historicalTelemetryProvider); const realtimeTelemetryProvider = new RealtimeProvider( + openmct, configuration.yamcsWebsocketEndpoint, configuration.yamcsInstance, - configuration.yamcsProcessor + configuration.yamcsProcessor, + configuration.throttleRate, + configuration.maxBatchSize ); openmct.telemetry.addProvider(realtimeTelemetryProvider); realtimeTelemetryProvider.connect(); @@ -127,7 +132,8 @@ export default function installYamcsPlugin(configuration) { pollQuestionParameter, pollQuestionTelemetry, realtimeTelemetryProvider, - configuration.yamcsProcessor + configuration.yamcsProcessor, + dictionaryRequestCacheStrategyPromise ); openmct.objects.addRoot({ diff --git a/src/providers/limit-provider.js b/src/providers/limit-provider.js index 54722ed6..e864c188 100644 --- a/src/providers/limit-provider.js +++ b/src/providers/limit-provider.js @@ -100,7 +100,7 @@ export default class LimitProvider { const limits = domainObject.configuration.limits; return { - limits: async () => limits + limits: () => Promise.resolve(limits) }; } diff --git a/src/providers/object-provider.js b/src/providers/object-provider.js index de499d49..81891a49 100644 --- a/src/providers/object-provider.js +++ b/src/providers/object-provider.js @@ -39,7 +39,7 @@ const YAMCS_API_MAP = { const operatorStatusParameter = new OperatorStatusParameter(); export default class YamcsObjectProvider { - constructor(openmct, url, instance, folderName, roleStatusTelemetry, pollQuestionParameter, pollQuestionTelemetry, realtimeTelemetryProvider, processor = 'realtime') { + constructor(openmct, url, instance, folderName, roleStatusTelemetry, pollQuestionParameter, pollQuestionTelemetry, realtimeTelemetryProvider, processor = 'realtime', dictionaryRequestCacheStrategyPromise = Promise.resolve({})) { this.openmct = openmct; this.url = url; this.instance = instance; @@ -52,6 +52,7 @@ export default class YamcsObjectProvider { this.dictionary = {}; this.limitOverrides = {}; this.dictionaryPromise = null; + this.dictionaryRequestCacheStrategyPromise = dictionaryRequestCacheStrategyPromise; this.roleStatusTelemetry = roleStatusTelemetry; this.pollQuestionParameter = pollQuestionParameter; this.pollQuestionTelemetry = pollQuestionTelemetry; @@ -180,7 +181,7 @@ export default class YamcsObjectProvider { #getTelemetryDictionary() { if (!this.dictionaryPromise) { - this.dictionaryPromise = this.#loadTelemetryDictionary(this.url, this.instance, this.folderName) + this.dictionaryPromise = this.#loadTelemetryDictionary() .finally(() => { this.roleStatusTelemetry.dictionaryLoadComplete(); }); @@ -193,8 +194,10 @@ export default class YamcsObjectProvider { const operation = 'parameters?details=yes&limit=1000'; const parameterUrl = this.url + 'api/mdb/' + this.instance + '/' + operation; const url = this.#getMdbUrl('space-systems'); - const spaceSystems = await accumulateResults(url, {}, 'spaceSystems', []); - const parameters = await accumulateResults(parameterUrl, {}, 'parameters', []); + const requestOptions = await this.dictionaryRequestCacheStrategyPromise; + + const spaceSystems = await accumulateResults(url, requestOptions, 'spaceSystems', []); + const parameters = await accumulateResults(parameterUrl, requestOptions, 'parameters', []); /* Sort the space systems by name, so that the children of the root object are in sorted order. */ diff --git a/src/providers/realtime-provider.js b/src/providers/realtime-provider.js index 42c8da98..61c78af8 100644 --- a/src/providers/realtime-provider.js +++ b/src/providers/realtime-provider.js @@ -20,7 +20,7 @@ * at runtime from the About dialog for additional information. *****************************************************************************/ -import * as MESSAGES from './messages.js'; +import { SUBSCRIBE, UNSUBSCRIBE } from './messages.js'; import { OBJECT_TYPES, DATA_TYPES, @@ -33,7 +33,6 @@ import { import { buildStalenessResponseObject, idToQualifiedName, - qualifiedNameToId, getValue, addLimitInformation, getLimitFromAlarmRange @@ -41,9 +40,11 @@ import { import { commandToTelemetryDatum } from './commands.js'; import { eventToTelemetryDatum, eventShouldBeFiltered } from './events.js'; -const FALLBACK_AND_WAIT_MS = [1000, 5000, 5000, 10000, 10000, 30000]; export default class RealtimeProvider { - constructor(url, instance, processor = 'realtime') { + #socketWorker = null; + #openmct; + + constructor(openmct, url, instance, processor = 'realtime', rate = 1000, maxBatchSize = 15) { this.url = url; this.instance = instance; this.processor = processor; @@ -58,9 +59,56 @@ export default class RealtimeProvider { this.lastSubscriptionId = 1; this.subscriptionsByCall = new Map(); this.subscriptionsById = {}; + this.#socketWorker = new openmct.telemetry.BatchingWebSocket(openmct); + this.#openmct = openmct; + this.#setBatchingStrategy(rate, maxBatchSize); this.addSupportedObjectTypes(Object.values(OBJECT_TYPES)); this.addSupportedDataTypes(Object.values(DATA_TYPES)); + const setCallFromClockIfNecessary = this.#setCallFromClockIfNecessary.bind(this); + + openmct.time.on('clock', setCallFromClockIfNecessary); + + openmct.once('destroy', () => { + openmct.time.off('clock', setCallFromClockIfNecessary); + }); + } + + #setCallFromClockIfNecessary(clock) { + if (clock === undefined) { + this.unsetCall(); + } + + if (clock.key === 'remote-clock') { + this.#setCallFromClock(clock); + } + } + #setBatchingStrategy(rate, 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.setRate(rate); + this.#socketWorker.setMaxBatchSize(maxBatchSize); } addSupportedObjectTypes(types) { @@ -70,7 +118,6 @@ export default class RealtimeProvider { addSupportedDataTypes(dataTypes) { dataTypes.forEach(dataType => this.supportedDataTypes[dataType] = dataType); } - supportsSubscribe(domainObject) { return this.isSupportedObjectType(domainObject.type); } @@ -112,7 +159,7 @@ export default class RealtimeProvider { if (subscriptionDetails) { this.sendUnsubscribeMessage(subscriptionDetails); - this.subscriptionsByCall.delete(subscriptionDetails.call); + this.subscriptionsByCall.delete(subscriptionDetails.call.toString()); delete this.subscriptionsById[id]; } }; @@ -137,7 +184,7 @@ export default class RealtimeProvider { this.sendUnsubscribeMessage(subscriptionDetails); if (this.subscriptionsById[id]) { - this.subscriptionsByCall.delete(this.subscriptionsById[id].call); + this.subscriptionsByCall.delete(this.subscriptionsById[id].call.toString()); delete this.subscriptionsById[id]; } }; @@ -161,46 +208,78 @@ export default class RealtimeProvider { sendSubscribeMessage(subscriptionDetails) { const domainObject = subscriptionDetails.domainObject; - const message = MESSAGES.SUBSCRIBE[domainObject.type](subscriptionDetails); + const message = SUBSCRIBE[domainObject.type](subscriptionDetails); - this.sendOrQueueMessage(message); + this.sendMessage(message); } sendUnsubscribeMessage(subscriptionDetails) { - let message = MESSAGES.UNSUBSCRIBE(subscriptionDetails); + let message = UNSUBSCRIBE(subscriptionDetails); - this.sendOrQueueMessage(message); + this.sendMessage(message); } - reconnect() { - this.subscriptionsByCall.clear(); + #setCallFromClock(clock) { + const correspondingSubscription = Object.values(this.subscriptionsById).find(subscription => { + return subscription.domainObject.identifier.key === clock.identifier.key; + }); - if (this.reconnectTimeout) { - return; + if (correspondingSubscription !== undefined) { + this.remoteClockCallNumber = correspondingSubscription.call.toString(); + } else { + delete this.remoteClockCallNumber; } + } - this.reconnectTimeout = setTimeout(() => { - this.connect(); - delete this.reconnectTimeout; - }, FALLBACK_AND_WAIT_MS[this.currentWaitIndex]); + #processBatchQueue(batchQueue, call) { + let subscriptionDetails = this.subscriptionsByCall.get(call); + let telemetryData = []; - if (this.currentWaitIndex < FALLBACK_AND_WAIT_MS.length - 1) { - this.currentWaitIndex++; + // possibly cancelled + if (!subscriptionDetails) { + return; } - } - sendOrQueueMessage(request) { - if (this.connected) { - try { - this.sendMessage(request); - } catch (error) { - this.connected = false; - this.requests.push(request); - console.error("🚨 Error while attempting to send to websocket, closing websocket", error); - this.socket.close(); - } - } else { - this.requests.push(request); + batchQueue.forEach((rawMessage) => { + const message = JSON.parse(rawMessage); + const values = message.data.values || []; + const parentName = subscriptionDetails.domainObject.name; + + values.forEach(parameter => { + let datum = { + timestamp: parameter[METADATA_TIME_KEY] + }; + const value = getValue(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); + } + } + + if (parameter.engValue.type !== AGGREGATE_TYPE) { + datum.value = value; + } else { + datum = { + ...datum, + ...value + }; + } + + addLimitInformation(parameter, datum); + telemetryData.push(datum); + }); + }); + + if (telemetryData.length > 0) { + subscriptionDetails.callback(telemetryData); } } @@ -212,80 +291,65 @@ export default class RealtimeProvider { let wsUrl = `${this.url}`; this.lastSubscriptionId = 1; this.connected = false; - this.socket = new WebSocket(wsUrl); - this.socket.onopen = () => { - clearTimeout(this.reconnectTimeout); + this.#socketWorker.connect(wsUrl); - this.connected = true; - console.debug(`🔌 Established websocket connection to ${wsUrl}`); - - this.currentWaitIndex = 0; + this.#socketWorker.addEventListener('error', () => { this.resubscribeToAll(); - this.flushQueue(); - }; + }); + + this.#socketWorker.addEventListener('batch', (batchEvent) => { + const batch = batchEvent.detail; - this.socket.onmessage = (event) => { - const message = JSON.parse(event.data); + 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]; + } + } + + Object.keys(batch).forEach((call) => { + this.#processBatchQueue(batch[call], call); + }); + }); + + 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; + const call = message.call; let subscriptionDetails; if (isReply) { const id = message.data.replyTo; - const call = message.call; subscriptionDetails = this.subscriptionsById[id]; subscriptionDetails.call = call; - this.subscriptionsByCall.set(call, subscriptionDetails); + // Subsequent retrieval uses a string, so for performance reasons we use a string as a key. + this.subscriptionsByCall.set(call.toString(), subscriptionDetails); + + const remoteClockIdentifier = this.#openmct.time.getClock()?.identifier; + const isRemoteClockActive = remoteClockIdentifier !== undefined; + + if (isRemoteClockActive && subscriptionDetails.domainObject.identifier.key === remoteClockIdentifier.key) { + this.remoteClockCallNumber = call.toString(); + } } else { - subscriptionDetails = this.subscriptionsByCall.get(message.call); + subscriptionDetails = this.subscriptionsByCall.get(message.call.toString()); // possibly cancelled if (!subscriptionDetails) { return; } - if (this.isTelemetryMessage(message)) { - let values = message.data.values || []; - let parentName = subscriptionDetails.domainObject.name; - - values.forEach(parameter => { - let datum = { - id: qualifiedNameToId(subscriptionDetails.name), - timestamp: parameter[METADATA_TIME_KEY] - }; - let value = getValue(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); - } - } - - if (parameter.engValue.type !== AGGREGATE_TYPE) { - datum.value = value; - } else { - datum = { - ...datum, - ...value - }; - } - - addLimitInformation(parameter, datum); - subscriptionDetails.callback(datum); - }); - } else if (this.isCommandMessage(message)) { + if (this.isCommandMessage(message)) { const datum = commandToTelemetryDatum(message.data); subscriptionDetails.callback(datum); } else if (this.isEventMessage(message)) { @@ -312,20 +376,7 @@ export default class RealtimeProvider { subscriptionDetails.callback(message.data); } } - }; - - this.socket.onerror = (error) => { - console.error(`🚨 Websocket error, closing websocket`, error); - this.socket.close(); - }; - - this.socket.onclose = () => { - console.warn('🚪 Websocket closed. Attempting to reconnect...'); - this.connected = false; - this.socket = null; - - this.reconnect(); - }; + }); } resubscribeToAll() { @@ -334,30 +385,8 @@ export default class RealtimeProvider { }); } - flushQueue() { - let shouldCloseWebsocket = false; - this.requests = this.requests.filter((request) => { - try { - this.sendMessage(request); - } catch (error) { - this.connected = false; - console.error('🚨 Error while attempting to send to websocket, closing websocket', error); - - shouldCloseWebsocket = true; - - return true; - } - - return false; - }); - - if (shouldCloseWebsocket) { - this.socket.close(); - } - } - sendMessage(message) { - this.socket.send(message); + this.#socketWorker.sendMessage(message); } isTelemetryMessage(message) { diff --git a/tests/e2e/playwright-quickstart.config.js b/tests/e2e/playwright-quickstart.config.js index fb91bd99..c7cbe537 100644 --- a/tests/e2e/playwright-quickstart.config.js +++ b/tests/e2e/playwright-quickstart.config.js @@ -64,9 +64,7 @@ const config = { open: 'never', outputFolder: '../html-test-results' //Must be in different location due to https://github.com/microsoft/playwright/issues/12840 }], - ['junit', { outputFile: 'test-results/results.xml' }], - ['@deploysentinel/playwright'] - ] + ['junit', { outputFile: 'test-results/results.xml' }]] }; export default config; diff --git a/tests/e2e/yamcs/historicalData.e2e.spec.js b/tests/e2e/yamcs/historicalData.e2e.spec.js index d94d60a7..a17642eb 100644 --- a/tests/e2e/yamcs/historicalData.e2e.spec.js +++ b/tests/e2e/yamcs/historicalData.e2e.spec.js @@ -24,8 +24,8 @@ Network Specific Tests */ -import { test, expect } from '../opensource/pluginFixtures'; -import { setFixedTimeMode } from '../opensource/appActions'; +import { test, expect } from '../opensource/pluginFixtures.js'; +import { setFixedTimeMode } from '../opensource/appActions.js'; test.describe("Samples endpoint with useRawValue search param @yamcs", () => { // Collect all request events, specifically for YAMCS diff --git a/tests/e2e/yamcs/limits.e2e.spec.js b/tests/e2e/yamcs/limits.e2e.spec.js index 9513f9c2..c7144c41 100644 --- a/tests/e2e/yamcs/limits.e2e.spec.js +++ b/tests/e2e/yamcs/limits.e2e.spec.js @@ -24,8 +24,8 @@ MDB Limits Specific Tests */ -import { test, expect } from '../opensource/pluginFixtures'; -import { createDomainObjectWithDefaults, waitForPlotsToRender } from '../opensource/appActions'; +import { test, expect } from '../opensource/pluginFixtures.js'; +import { createDomainObjectWithDefaults, waitForPlotsToRender } from '../opensource/appActions.js'; test.describe("Mdb runtime limits tests @yamcs", () => { test('Can show mdb limits when changed', async ({ page }) => { @@ -51,22 +51,22 @@ test.describe("Mdb runtime limits tests @yamcs", () => { const detectorTreeItem = page.getByRole('treeitem', { name: /Detector_Temp/ }); // Enter edit mode for the overlay plot - await page.click('button[title="Edit"]'); + await page.getByLabel('Edit Object').click(); //Drag and drop the Detector_Temp telemetry endpoint into this overlay plot const objectPane = page.locator('.c-object-view'); await detectorTreeItem.dragTo(objectPane); // Save (exit edit mode) - await page.locator('button[title="Save"]').click(); - await page.locator('li[title="Save and Finish Editing"]').click(); + await page.getByRole('button', { name: 'Save' }).click(); + await page.getByRole('listitem', { name: 'Save and Finish Editing' }).click(); // Assert that no limit lines are shown by default await page.waitForSelector('.js-limit-area', { state: 'attached' }); expect(await page.locator('.c-plot-limit-line').count()).toBe(0); // Enter edit mode - await page.click('button[title="Edit"]'); + await page.getByLabel('Edit Object').click(); // Expand the "Detector_Temp" plot series options and enable limit lines await page.getByRole('tab', { name: 'Config' }).click(); @@ -81,8 +81,8 @@ test.describe("Mdb runtime limits tests @yamcs", () => { .check(); // Save (exit edit mode) - await page.locator('button[title="Save"]').click(); - await page.locator('li[title="Save and Finish Editing"]').click(); + await page.getByRole('button', { name: 'Save' }).click(); + await page.getByRole('listitem', { name: 'Save and Finish Editing' }).click(); // Change the limits for the Detector_Temp parameter using the yamcs API) const runTimeLimitChangeResponse = await page.request.patch('http://localhost:8090/api/mdb-overrides/myproject/realtime/parameters/myproject/Detector_Temp', { @@ -136,22 +136,22 @@ test.describe("Mdb runtime limits tests @yamcs", () => { const detectorTreeItem = page.getByRole('treeitem', { name: /Detector_Temp/ }); // Enter edit mode for the overlay plot - await page.click('button[title="Edit"]'); + await page.getByLabel('Edit Object').click(); //Drag and drop the Detector_Temp telemetry endpoint into this overlay plot const objectPane = page.locator('.c-object-view'); await detectorTreeItem.dragTo(objectPane); // Save (exit edit mode) - await page.locator('button[title="Save"]').click(); - await page.locator('li[title="Save and Finish Editing"]').click(); + await page.getByRole('button', { name: 'Save' }).click(); + await page.getByRole('listitem', { name: 'Save and Finish Editing' }).click(); // Assert that no limit lines are shown by default await page.waitForSelector('.js-limit-area', { state: 'attached' }); expect(await page.locator('.c-plot-limit-line').count()).toBe(0); // Enter edit mode - await page.click('button[title="Edit"]'); + await page.getByLabel('Edit Object').click(); // Expand the "Detector_Temp" plot series options and enable limit lines await page.getByRole('tab', { name: 'Config' }).click(); @@ -166,8 +166,8 @@ test.describe("Mdb runtime limits tests @yamcs", () => { .check(); // Save (exit edit mode) - await page.locator('button[title="Save"]').click(); - await page.locator('li[title="Save and Finish Editing"]').click(); + await page.getByRole('button', { name: 'Save' }).click(); + await page.getByRole('listitem', { name: 'Save and Finish Editing' }).click(); //navigate away from the overlay plot await page.goto("./", { waitUntil: "networkidle" }); diff --git a/tests/e2e/yamcs/network.e2e.spec.js b/tests/e2e/yamcs/network.e2e.spec.js index 62a17bb5..67ce032c 100644 --- a/tests/e2e/yamcs/network.e2e.spec.js +++ b/tests/e2e/yamcs/network.e2e.spec.js @@ -19,117 +19,129 @@ * this source code distribution or the Licensing information page available * at runtime from the About dialog for additional information. *****************************************************************************/ - /* -Network Specific Tests -*/ + * Network Specific Tests for Open MCT and YAMCS connectivity. + * This suite verifies the network requests made by the application to ensure correct interaction with YAMCS. + */ -import { test, expect } from '../opensource/pluginFixtures'; -import { setFixedTimeMode } from '../opensource/appActions'; +import { test, expect } from '../opensource/pluginFixtures.js'; +import { setFixedTimeMode } from '../opensource/appActions.js'; +/** + * This test suite checks the network requests made by Open MCT to YAMCS. + */ test.describe("Quickstart network requests @yamcs", () => { - // Collect all request events, specifically for YAMCS + // Keeping track of network requests during the tests. let networkRequests = []; let filteredRequests = []; - test('Validate network traffic to YAMCS', async ({ page }) => { - page.on('request', (request) => networkRequests.push(request)); - // Go to baseURL - await page.goto("./", { waitUntil: "networkidle" }); + // These variables hold the promises for specific network requests we expect to occur. + let parameterArchiveGet, batchGetStaleness, allParams, userGet, mdbOverride, mdbGet; - const myProjectTreeItem = page.locator('.c-tree__item').filter({ hasText: 'myproject'}); - await expect(myProjectTreeItem).toBeVisible(); - const firstMyProjectTriangle = myProjectTreeItem.first().locator('span.c-disclosure-triangle'); - await firstMyProjectTriangle.click(); - const secondMyProjectTriangle = myProjectTreeItem.nth(1).locator('span.c-disclosure-triangle'); - await secondMyProjectTriangle.click(); + test('Validate network traffic to YAMCS', async ({ page }) => { + // Listening for all network requests and pushing them into networkRequests array. + page.on('request', request => networkRequests.push(request)); - await page.waitForLoadState('networkidle'); + // Setting up promises to wait for specific network responses. networkRequests = []; - await page.locator('text=CCSDS_Packet_Sequence').first().click(); - await page.waitForLoadState('networkidle'); - - // wait for debounced requests in YAMCS Latest Telemetry Provider to finish - await new Promise(resolve => setTimeout(resolve, 500)); + mdbGet = page.waitForResponse('**/api/mdb/myproject/space-systems'); + allParams = page.waitForResponse('**/api/mdb/myproject/parameters?details=yes&limit=1000'); + userGet = page.waitForResponse('**/api/user/'); + mdbOverride = page.waitForResponse('**/api/mdb-overrides/myproject/realtime'); + // Testing the initial page load and verifying the presence of specific elements. + await page.goto("./", { waitUntil: "networkidle" }); + await Promise.all([mdbGet, allParams, userGet, mdbOverride]); filteredRequests = filterNonFetchRequests(networkRequests); + expect(filteredRequests.length).toBe(4); - // Network requests for the composite telemetry with multiple items should be: - // 1. batched request for latest telemetry using the bulk API - expect(filteredRequests.length).toBe(1); + // I'm not sure what is going on here + const myProjectTreeItem = page.locator('.c-tree__item').filter({ hasText: 'myproject' }); + await expect(myProjectTreeItem).toBeVisible(); + await myProjectTreeItem.first().locator('span.c-disclosure-triangle').click(); + await myProjectTreeItem.nth(1).locator('span.c-disclosure-triangle').click(); + // More UI interactions and network request verifications. await page.waitForLoadState('networkidle'); networkRequests = []; - await page.locator('text=CCSDS_Packet_Length').click(); + batchGetStaleness = page.waitForResponse('**/api/processors/myproject/realtime/parameters:batchGet'); + await page.getByRole('treeitem', { name: 'Expand CCSDS_Packet_Sequence' }).click(); + await batchGetStaleness; + await page.waitForLoadState('networkidle'); + expect(networkRequests.length).toBe(1); - // wait for debounced requests in YAMCS Latest Telemetry Provider to finish - await new Promise(resolve => setTimeout(resolve, 500)); + // Further UI interactions and network requests verification. + networkRequests = []; + parameterArchiveGet = page.waitForResponse('**/api/archive/myproject/parameters/myproject/CCSDS_Packet_Length/samples**'); + batchGetStaleness = page.waitForResponse('**/api/processors/myproject/realtime/parameters:batchGet'); + await page.getByRole('treeitem', { name: 'CCSDS_Packet_Length' }).click(); - filteredRequests = filterNonFetchRequests(networkRequests); + await Promise.all([parameterArchiveGet, batchGetStaleness]); - // Should only be fetching: - // 1. telemetry from parameter archive - // 2. POST: batchGet for staleness - expect(filteredRequests.length).toBe(2); + await page.waitForLoadState('networkidle'); + expect(networkRequests.length).toBe(2); - // Change to fixed time + // Simulating the change to fixed time mode and validating network requests. + networkRequests = []; + parameterArchiveGet = page.waitForResponse('**/api/archive/myproject/parameters/myproject/CCSDS_Packet_Length/samples**'); await setFixedTimeMode(page); - await page.waitForLoadState('networkidle'); + await parameterArchiveGet; + expect(networkRequests.length).toBe(1); + + // Clicking on a different telemetry item to generate new requests. networkRequests = []; - await page.locator('text=CCSDS_Packet_Sequence').first().click(); - await page.waitForLoadState('networkidle'); + let groupFlagsGet = page.waitForResponse('**/api/archive/myproject/parameters/myproject/CCSDS_Packet_Sequence.GroupFlags**'); + let countGet = page.waitForResponse('**/api/archive/myproject/parameters/myproject/CCSDS_Packet_Sequence.Count**'); + batchGetStaleness = page.waitForResponse('**/api/processors/myproject/realtime/parameters:batchGet'); - // wait for debounced requests in YAMCS Latest Telemetry Provider to finish - await new Promise(resolve => setTimeout(resolve, 500)); + await page.getByRole('treeitem', { name: 'Expand CCSDS_Packet_Sequence' }).click(); + await page.waitForLoadState('networkidle'); - filteredRequests = filterNonFetchRequests(networkRequests); + await Promise.all([groupFlagsGet, countGet, batchGetStaleness]); - // Should fetch from parameter archive, so: - // 1. GET for first telemetry item from parameter archive - // 2. GET for second telemetry item from parameter archive - // 3. POST: batchGet for staleness - expect(filteredRequests.length).toBe(3); + expect(networkRequests.length).toBe(3); - await page.waitForLoadState('networkidle'); + // Clicking on the telemetry item in Fixed Time mode to generate two requests. networkRequests = []; - await page.locator('text=CCSDS_Packet_Length').first().click(); + parameterArchiveGet = page.waitForResponse('**/api/archive/myproject/parameters/myproject/CCSDS_Packet_Length/samples**'); + batchGetStaleness = page.waitForResponse('**/api/processors/myproject/realtime/parameters:batchGet'); + await page.getByRole('treeitem', { name: 'CCSDS_Packet_Length' }).click(); await page.waitForLoadState('networkidle'); - // wait for debounced requests in YAMCS Latest Telemetry Provider to finish - await new Promise(resolve => setTimeout(resolve, 500)); - filteredRequests = filterNonFetchRequests(networkRequests); - // Should only be fetching telemetry from parameter archive, - // with no further request for limits should be made. - // 1. GET for telemetry item from parameter archive - // 2. POST: batchGet for staleness - expect(filteredRequests.length).toBe(2); + await Promise.all([parameterArchiveGet, batchGetStaleness]); + + // Waiting for debounced requests in YAMCS Latest Telemetry Provider to finish. + expect(networkRequests.length).toBe(2); + // Simulating a page refresh to generate a sequence of network requests. networkRequests = []; - await page.reload(); - await page.waitForLoadState('networkidle'); + userGet = page.waitForResponse('**/api/user/'); + allParams = page.waitForResponse('**/api/mdb/myproject/parameters?details=yes&limit=1000'); + mdbOverride = page.waitForResponse('**/api/mdb-overrides/myproject/realtime'); + parameterArchiveGet = page.waitForResponse('**/api/archive/myproject/parameters/myproject/CCSDS_Packet_Length/samples**'); + batchGetStaleness = page.waitForResponse('**/api/processors/myproject/realtime/parameters:batchGet'); + mdbOverride = page.waitForResponse('**/api/mdb-overrides/myproject/realtime'); - // wait for debounced requests in YAMCS Latest Telemetry Provider to finish - await new Promise(resolve => setTimeout(resolve, 500)); + await page.reload({ waitUntil: 'networkidle' }); + await Promise.all([allParams, userGet, mdbOverride, parameterArchiveGet, batchGetStaleness, mdbOverride]); - // Should be fetching: - // 1. user api - // 2. space systems - // 3. parameter dictionary - // 4. specific parameter telemetry for CCSDS_Packet_Length - // 5. POST: batchGet for staleness - // 6. GET for telemetry item mdb overrides + // Waiting for debounced requests in YAMCS Latest Telemetry Provider to finish. filteredRequests = filterNonFetchRequests(networkRequests); expect(filteredRequests.length).toBe(6); + // Removing the 'request' event listener to prevent potential memory leaks. + page.removeListener('request', request => networkRequests.push(request)); }); - // Try to reduce indeterminism of browser requests by only returning fetch requests. - // Filter out preflight CORS, fetching stylesheets, page icons, etc. that can occur during tests + /** + * Filters out non-fetch requests from the given array of network requests. + * This includes preflight CORS, fetching stylesheets, page icons, etc. + * @param {Array} requests - Array of network requests to filter. + * @returns {Array} Filtered network requests. + */ function filterNonFetchRequests(requests) { - return requests.filter(request => { - return (request.resourceType() === 'fetch'); - }); + return requests.filter(request => request.resourceType() === 'fetch'); } }); diff --git a/tests/e2e/yamcs/quickstartSmoke.e2e.spec.js b/tests/e2e/yamcs/quickstartSmoke.e2e.spec.js index f7d9056d..611cef9c 100644 --- a/tests/e2e/yamcs/quickstartSmoke.e2e.spec.js +++ b/tests/e2e/yamcs/quickstartSmoke.e2e.spec.js @@ -33,7 +33,7 @@ comfortable running this test during a live mission?" Avoid creating or deleting Make no assumptions about the order that elements appear in the DOM. */ -import { test, expect } from '../opensource/baseFixtures'; +import { test, expect } from '../opensource/baseFixtures.js'; test.describe("Quickstart smoke tests @yamcs", () => { test('Verify that the create button appears and that the Folder Domain Object is available for selection', async ({ page }) => { @@ -41,7 +41,7 @@ test.describe("Quickstart smoke tests @yamcs", () => { await page.goto('./', { waitUntil: 'networkidle' }); //Click the Create button - await page.click('button:has-text("Create")'); + await page.getByRole('button', { name: 'Create' }).click(); // Verify that Create Folder appears in the dropdown await expect(page.locator(':nth-match(:text("Folder"), 2)')).toBeEnabled(); diff --git a/tests/e2e/yamcs/search.e2e.spec.js b/tests/e2e/yamcs/search.e2e.spec.js index e24fdf29..72984e40 100644 --- a/tests/e2e/yamcs/search.e2e.spec.js +++ b/tests/e2e/yamcs/search.e2e.spec.js @@ -24,7 +24,7 @@ Search Specific Tests */ -import { test, expect } from '../opensource/pluginFixtures'; +import { test, expect } from '../opensource/pluginFixtures.js'; test.describe("Quickstart search tests @yamcs", () => { test('Validate aggregate in search result', async ({ page }) => { diff --git a/tests/git-opensource-tests.sh b/tests/git-opensource-tests.sh index 1f99b818..419bbbf5 100644 --- a/tests/git-opensource-tests.sh +++ b/tests/git-opensource-tests.sh @@ -36,6 +36,6 @@ mv opensource/e2e/appActions.js ./opensource # Move subfolders mv opensource/e2e/*/ ./opensource # Move eslint config -mv opensource/e2e/.eslintrc.cjs ./opensource +mv opensource/e2e/.eslintrc.*js ./opensource # Cleanup rm -rf opensource/e2e