From e7924037886b0afa3e3b5ca41ccfb32974481966 Mon Sep 17 00:00:00 2001 From: Shefali Joshi Date: Wed, 25 Sep 2024 09:37:38 -0700 Subject: [PATCH 1/5] Bar graphs should only get latest historical datum (#7811) * Only as for latest historical telemetry * Add test for size 1 request when a bar graph is loaded * Use strategy latest instead of size 1 for historical request * Fix linting issues * Add size and strategy * Remove bar graph tests --------- Co-authored-by: Jesse Mazzella --- src/plugins/charts/bar/BarGraphView.vue | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/plugins/charts/bar/BarGraphView.vue b/src/plugins/charts/bar/BarGraphView.vue index aa26dc05d4..0bc90e3ebb 100644 --- a/src/plugins/charts/bar/BarGraphView.vue +++ b/src/plugins/charts/bar/BarGraphView.vue @@ -257,7 +257,9 @@ export default { return { end, - start + start, + size: 1, + strategy: 'latest' }; }, loadComposition() { From a8fbabe695a82f2f1826f8d2aeeb3f42ddfe5ebe Mon Sep 17 00:00:00 2001 From: David Tsay <3614296+davetsay@users.noreply.github.com> Date: Fri, 27 Sep 2024 14:32:14 -0700 Subject: [PATCH 2/5] fix(#7825): imagery pause (#7842) * more readable * unpause explicitly * fix jsdoc * e2e testing multiple image removal * prettier * fix to remove multiple images from history * move tests that use playwright clock api into own file * fix playwright clock tests * add aria-label to element * prevent straggler debounced function call on unmount * clean up and fix tests * update paths * lint fix * lint fix --------- Co-authored-by: John Hill --- e2e/helper/imageryUtils.js | 33 ++ .../imagery/exampleImagery.e2e.spec.js | 211 +++----- .../exampleImageryControlledClock.e2e.spec.js | 489 ++++++++++++++++++ src/api/telemetry/TelemetryCollection.js | 3 +- .../imagery/components/ImageryTimeView.vue | 1 + .../imagery/components/ImageryView.vue | 4 +- src/plugins/imagery/mixins/imageryData.js | 17 +- src/plugins/timeline/TimelineViewLayout.vue | 1 + 8 files changed, 613 insertions(+), 146 deletions(-) create mode 100644 e2e/helper/imageryUtils.js create mode 100644 e2e/tests/functional/plugins/imagery/exampleImageryControlledClock.e2e.spec.js diff --git a/e2e/helper/imageryUtils.js b/e2e/helper/imageryUtils.js new file mode 100644 index 0000000000..880964076c --- /dev/null +++ b/e2e/helper/imageryUtils.js @@ -0,0 +1,33 @@ +import { createDomainObjectWithDefaults } from '../appActions.js'; +import { expect } from '../pluginFixtures.js'; + +const IMAGE_LOAD_DELAY = 5 * 1000; +const FIVE_MINUTES = 1000 * 60 * 5; +const THIRTY_SECONDS = 1000 * 30; +const MOUSE_WHEEL_DELTA_Y = 120; + +/** + * @param {import('@playwright/test').Page} page + */ +async function createImageryViewWithShortDelay(page, { name, parent }) { + await createDomainObjectWithDefaults(page, { + name, + type: 'Example Imagery', + parent + }); + + await expect(page.locator('.l-browse-bar__object-name')).toContainText('Unnamed Example Imagery'); + await page.getByLabel('More actions').click(); + await page.getByLabel('Edit Properties').click(); + // Clear and set Image load delay to minimum value + await page.locator('input[type="number"]').fill(`${IMAGE_LOAD_DELAY}`); + await page.getByLabel('Save').click(); +} + +export { + createImageryViewWithShortDelay, + FIVE_MINUTES, + IMAGE_LOAD_DELAY, + MOUSE_WHEEL_DELTA_Y, + THIRTY_SECONDS +}; diff --git a/e2e/tests/functional/plugins/imagery/exampleImagery.e2e.spec.js b/e2e/tests/functional/plugins/imagery/exampleImagery.e2e.spec.js index d75e104e0d..d5d218bc05 100644 --- a/e2e/tests/functional/plugins/imagery/exampleImagery.e2e.spec.js +++ b/e2e/tests/functional/plugins/imagery/exampleImagery.e2e.spec.js @@ -30,16 +30,19 @@ import { navigateToObjectWithRealTime, setRealTimeMode } from '../../../../appActions.js'; -import { MISSION_TIME } from '../../../../constants.js'; +import { + createImageryViewWithShortDelay, + FIVE_MINUTES, + IMAGE_LOAD_DELAY, + MOUSE_WHEEL_DELTA_Y, + THIRTY_SECONDS +} from '../../../../helper/imageryUtils.js'; import { expect, test } from '../../../../pluginFixtures.js'; + const panHotkey = process.platform === 'linux' ? ['Shift', 'Alt'] : ['Alt']; const tagHotkey = ['Shift', 'Alt']; const expectedAltText = process.platform === 'linux' ? 'Shift+Alt drag to pan' : 'Alt drag to pan'; const thumbnailUrlParamsRegexp = /\?w=100&h=100/; -const IMAGE_LOAD_DELAY = 5 * 1000; -const MOUSE_WHEEL_DELTA_Y = 120; -const FIVE_MINUTES = 1000 * 60 * 5; -const THIRTY_SECONDS = 1000 * 30; //The following block of tests verifies the basic functionality of example imagery and serves as a template for Imagery objects embedded in other objects. test.describe('Example Imagery Object', () => { @@ -357,15 +360,10 @@ test.describe('Example Imagery Object', () => { }); }); -test.describe('Example Imagery in Display Layout @clock', () => { +test.describe('Example Imagery in Display Layout', () => { let displayLayout; test.beforeEach(async ({ page }) => { - // We mock the clock so that we don't need to wait for time driven events - // to verify functionality. - await page.clock.install({ time: MISSION_TIME }); - await page.clock.resume(); - // Go to baseURL await page.goto('./', { waitUntil: 'domcontentloaded' }); @@ -428,12 +426,7 @@ test.describe('Example Imagery in Display Layout @clock', () => { await expect.soft(pausePlayButton).toHaveClass(/is-paused/); }); - test('Imagery View operations @clock', async ({ page }) => { - test.info().annotations.push({ - type: 'issue', - description: 'https://github.com/nasa/openmct/issues/5265' - }); - + test('Imagery View operations', async ({ page }) => { // Edit mode await page.getByLabel('Edit Object').click(); @@ -526,14 +519,9 @@ test.describe('Example Imagery in Display Layout @clock', () => { }); }); -test.describe('Example Imagery in Flexible layout @clock', () => { +test.describe('Example Imagery in Flexible layout', () => { let flexibleLayout; test.beforeEach(async ({ page }) => { - // We mock the clock so that we don't need to wait for time driven events - // to verify functionality. - await page.clock.install({ time: MISSION_TIME }); - await page.clock.resume(); - await page.goto('./', { waitUntil: 'domcontentloaded' }); flexibleLayout = await createDomainObjectWithDefaults(page, { type: 'Flexible Layout' }); @@ -562,7 +550,7 @@ test.describe('Example Imagery in Flexible layout @clock', () => { await page.getByRole('button', { name: 'Close' }).click(); }); - test('Imagery View operations @clock', async ({ page, browserName }) => { + test('Imagery View operations', async ({ page, browserName }) => { test.fixme(browserName === 'firefox', 'This test needs to be updated to work with firefox'); test.info().annotations.push({ type: 'issue', @@ -573,14 +561,10 @@ test.describe('Example Imagery in Flexible layout @clock', () => { }); }); -test.describe('Example Imagery in Tabs View @clock', () => { +test.describe('Example Imagery in Tabs View', () => { let tabsView; - test.beforeEach(async ({ page }) => { - // We mock the clock so that we don't need to wait for time driven events - // to verify functionality. - await page.clock.install({ time: MISSION_TIME }); - await page.clock.resume(); + test.beforeEach(async ({ page }) => { await page.goto('./', { waitUntil: 'domcontentloaded' }); tabsView = await createDomainObjectWithDefaults(page, { type: 'Tabs View' }); @@ -607,7 +591,8 @@ test.describe('Example Imagery in Tabs View @clock', () => { // Wait for image thumbnail auto-scroll to complete await expect(page.getByLabel('Image Thumbnail from').last()).toBeInViewport(); }); - test('Imagery View operations @clock', async ({ page }) => { + + test('Imagery View operations', async ({ page }) => { await performImageryViewOperationsAndAssert(page, tabsView); }); }); @@ -668,16 +653,19 @@ test.describe('Example Imagery in Time Strip', () => { * 3. Can pan the image using the pan hotkey + mouse drag * 4. Clicking on the left arrow button pauses imagery and moves to the previous image * 5. Imagery is updated as new images stream in, regardless of pause status - * 6. Old images are discarded when new images stream in - * 7. Image brightness/contrast can be adjusted by dragging the sliders + * 6. Old images are discarded when their timestamps fall out of bounds + * 7. Multiple images can be discarded when their timestamps fall out of bounds + * 8. Image brightness/contrast can be adjusted by dragging the sliders * @param {import('@playwright/test').Page} page */ async function performImageryViewOperationsAndAssert(page, layoutObject) { - // Verify that imagery thumbnails use a thumbnail url - const thumbnailImages = page.getByLabel('Image thumbnail from').locator('.c-thumb__image'); - const mainImage = page.locator('.c-imagery__main-image__image'); - await expect(thumbnailImages.first()).toHaveAttribute('src', thumbnailUrlParamsRegexp); - await expect(mainImage).not.toHaveAttribute('src', thumbnailUrlParamsRegexp); + await test.step('Verify that imagery thumbnails use a thumbnail url', async () => { + const thumbnailImages = page.getByLabel('Image thumbnail from').locator('.c-thumb__image'); + const mainImage = page.locator('.c-imagery__main-image__image'); + await expect(thumbnailImages.first()).toHaveAttribute('src', thumbnailUrlParamsRegexp); + await expect(mainImage).not.toHaveAttribute('src', thumbnailUrlParamsRegexp); + }); + // Click previous image button const previousImageButton = page.getByLabel('Previous image'); await expect(previousImageButton).toBeVisible(); @@ -736,19 +724,6 @@ async function performImageryViewOperationsAndAssert(page, layoutObject) { // Unpause imagery await page.locator('.pause-play').click(); - // verify that old images are discarded - const lastImageInBounds = page.getByLabel('Image thumbnail from').first(); - const lastImageTimestamp = await lastImageInBounds.getAttribute('title'); - expect(lastImageTimestamp).not.toBeNull(); - - // go forward in time to ensure old images are discarded - await page.clock.fastForward(IMAGE_LOAD_DELAY); - await page.clock.resume(); - await expect(page.getByLabel(lastImageTimestamp)).toBeHidden(); - - //Get background-image url from background-image css prop - await assertBackgroundImageUrlFromBackgroundCss(page); - // Open the image filter menu await page.locator('[role=toolbar] button[title="Brightness and contrast"]').click(); @@ -815,24 +790,6 @@ async function assertBackgroundImageBrightness(page, expected) { expect(actual).toBe(expected); } -/** - * @param {import('@playwright/test').Page} page - */ -async function assertBackgroundImageUrlFromBackgroundCss(page) { - const backgroundImage = page.getByLabel('Focused Image Element'); - const backgroundImageUrl = await backgroundImage.evaluate((el) => { - return window - .getComputedStyle(el) - .getPropertyValue('background-image') - .match(/url\(([^)]+)\)/)[1]; - }); - - // go forward in time to ensure old images are discarded - await page.clock.fastForward(IMAGE_LOAD_DELAY); - await page.clock.resume(); - await expect(backgroundImage).not.toHaveJSProperty('background-image', backgroundImageUrl); -} - /** * @param {import('@playwright/test').Page} page */ @@ -918,62 +875,66 @@ async function mouseZoomOnImageAndAssert(page, factor = 2) { * @param {import('@playwright/test').Page} page */ async function buttonZoomOnImageAndAssert(page) { - // Lock the zoom and pan so it doesn't reset if a new image comes in - await page.getByLabel('Focused Image Element').hover({ trial: true }); - const lockButton = page.getByRole('button', { - name: 'Lock current zoom and pan across all images' - }); - if (!(await lockButton.isVisible())) { + await test.step('Can zoom using buttons', async () => { + // Lock the zoom and pan so it doesn't reset if a new image comes in await page.getByLabel('Focused Image Element').hover({ trial: true }); - } - await lockButton.click(); + const lockButton = page.getByRole('button', { + name: 'Lock current zoom and pan across all images' + }); - await expect(page.getByLabel('Focused Image Element')).toHaveJSProperty( - 'style.transform', - 'scale(1) translate(0px, 0px)' - ); + await lockButton.isVisible(); + // if (!(await lockButton.isVisible())) { + // await page.getByLabel('Focused Image Element').hover({ trial: true }); + // } + await lockButton.click(); - // Get initial image dimensions - const initialBoundingBox = await page.getByLabel('Focused Image Element').boundingBox(); + await expect(page.getByLabel('Focused Image Element')).toHaveJSProperty( + 'style.transform', + 'scale(1) translate(0px, 0px)' + ); - // Zoom in twice via button - await zoomIntoImageryByButton(page); - await expect(page.getByLabel('Focused Image Element')).toHaveJSProperty( - 'style.transform', - 'scale(2) translate(0px, 0px)' - ); - await zoomIntoImageryByButton(page); - await expect(page.getByLabel('Focused Image Element')).toHaveJSProperty( - 'style.transform', - 'scale(3) translate(0px, 0px)' - ); + // Get initial image dimensions + const initialBoundingBox = await page.getByLabel('Focused Image Element').boundingBox(); - // Get and assert zoomed in image dimensions - const zoomedInBoundingBox = await page.getByLabel('Focused Image Element').boundingBox(); - expect(zoomedInBoundingBox.height).toBeGreaterThan(initialBoundingBox.height); - expect(zoomedInBoundingBox.width).toBeGreaterThan(initialBoundingBox.width); + // Zoom in twice via button + await zoomIntoImageryByButton(page); + await expect(page.getByLabel('Focused Image Element')).toHaveJSProperty( + 'style.transform', + 'scale(2) translate(0px, 0px)' + ); + await zoomIntoImageryByButton(page); + await expect(page.getByLabel('Focused Image Element')).toHaveJSProperty( + 'style.transform', + 'scale(3) translate(0px, 0px)' + ); - // Zoom out once via button - await zoomOutOfImageryByButton(page); - await expect(page.getByLabel('Focused Image Element')).toHaveJSProperty( - 'style.transform', - 'scale(2) translate(0px, 0px)' - ); + // Get and assert zoomed in image dimensions + const zoomedInBoundingBox = await page.getByLabel('Focused Image Element').boundingBox(); + expect(zoomedInBoundingBox.height).toBeGreaterThan(initialBoundingBox.height); + expect(zoomedInBoundingBox.width).toBeGreaterThan(initialBoundingBox.width); - // Get and assert zoomed out image dimensions - const zoomedOutBoundingBox = await page.getByLabel('Focused Image Element').boundingBox(); - expect(zoomedOutBoundingBox.height).toBeLessThan(zoomedInBoundingBox.height); - expect(zoomedOutBoundingBox.width).toBeLessThan(zoomedInBoundingBox.width); + // Zoom out once via button + await zoomOutOfImageryByButton(page); + await expect(page.getByLabel('Focused Image Element')).toHaveJSProperty( + 'style.transform', + 'scale(2) translate(0px, 0px)' + ); - // Zoom out again via button, assert against the initial image dimensions - await zoomOutOfImageryByButton(page); - await expect(page.getByLabel('Focused Image Element')).toHaveJSProperty( - 'style.transform', - 'scale(1) translate(0px, 0px)' - ); + // Get and assert zoomed out image dimensions + const zoomedOutBoundingBox = await page.getByLabel('Focused Image Element').boundingBox(); + expect(zoomedOutBoundingBox.height).toBeLessThan(zoomedInBoundingBox.height); + expect(zoomedOutBoundingBox.width).toBeLessThan(zoomedInBoundingBox.width); - const finalBoundingBox = await page.getByLabel('Focused Image Element').boundingBox(); - expect(finalBoundingBox).toEqual(initialBoundingBox); + // Zoom out again via button, assert against the initial image dimensions + await zoomOutOfImageryByButton(page); + await expect(page.getByLabel('Focused Image Element')).toHaveJSProperty( + 'style.transform', + 'scale(1) translate(0px, 0px)' + ); + + const finalBoundingBox = await page.getByLabel('Focused Image Element').boundingBox(); + expect(finalBoundingBox).toEqual(initialBoundingBox); + }); } /** @@ -1035,24 +996,6 @@ async function resetImageryPanAndZoom(page) { await expect(page.locator('.c-thumb__viewable-area')).toBeHidden(); } -/** - * @param {import('@playwright/test').Page} page - */ -async function createImageryViewWithShortDelay(page, { name, parent }) { - await createDomainObjectWithDefaults(page, { - name, - type: 'Example Imagery', - parent - }); - - await expect(page.locator('.l-browse-bar__object-name')).toContainText('Unnamed Example Imagery'); - await page.getByLabel('More actions').click(); - await page.getByLabel('Edit Properties').click(); - // Clear and set Image load delay to minimum value - await page.locator('input[type="number"]').fill(`${IMAGE_LOAD_DELAY}`); - await page.getByLabel('Save').click(); -} - /** * @param {import('@playwright/test').Page} page */ diff --git a/e2e/tests/functional/plugins/imagery/exampleImageryControlledClock.e2e.spec.js b/e2e/tests/functional/plugins/imagery/exampleImageryControlledClock.e2e.spec.js new file mode 100644 index 0000000000..97c45acbd7 --- /dev/null +++ b/e2e/tests/functional/plugins/imagery/exampleImageryControlledClock.e2e.spec.js @@ -0,0 +1,489 @@ +/***************************************************************************** + * Open MCT, Copyright (c) 2014-2024, United States Government + * as represented by the Administrator of the National Aeronautics and Space + * Administration. All rights reserved. + * + * Open MCT is licensed under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * http://www.apache.org/licenses/LICENSE-2.0. + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the + * License for the specific language governing permissions and limitations + * under the License. + * + * Open MCT includes source code licensed under additional open source + * licenses. See the Open Source Licenses file (LICENSES.md) included with + * this source code distribution or the Licensing information page available + * at runtime from the About dialog for additional information. + *****************************************************************************/ + +/* +This test suite is dedicated to testing how imagery functions over time. +It only assumes that example imagery is present. +It uses https://playwright.dev/docs/clock to have control over time +*/ + +import { + createDomainObjectWithDefaults, + navigateToObjectWithRealTime, + setRealTimeMode, + setStartOffset +} from '../../../../appActions.js'; +import { MISSION_TIME } from '../../../../constants.js'; +import { + createImageryViewWithShortDelay, + FIVE_MINUTES, + IMAGE_LOAD_DELAY, + THIRTY_SECONDS +} from '../../../../helper/imageryUtils.js'; +import { expect, test } from '../../../../pluginFixtures.js'; + +test.describe('Example Imagery Object with Controlled Clock @clock', () => { + test.beforeEach(async ({ page }) => { + // We mock the clock so that we don't need to wait for time driven events + // to verify functionality. + await page.clock.install({ time: MISSION_TIME }); + await page.clock.resume(); + + //Go to baseURL + await page.goto('./', { waitUntil: 'domcontentloaded' }); + + // Create a default 'Example Imagery' object + // Click the Create button + await page.getByRole('button', { name: 'Create' }).click(); + + // Click text=Example Imagery + await page.getByRole('menuitem', { name: 'Example Imagery' }).click(); + + // Clear and set Image load delay to minimum value + await page.locator('input[type="number"]').clear(); + await page.locator('input[type="number"]').fill(`${IMAGE_LOAD_DELAY}`); + + await page.getByLabel('Save').click(); + + // Verify that the created object is focused + await expect(page.locator('.l-browse-bar__object-name')).toContainText( + 'Unnamed Example Imagery' + ); + await page.getByLabel('Focused Image Element').hover({ trial: true }); + + // set realtime mode + await setRealTimeMode(page); + await setStartOffset(page, { startMins: '05' }); + }); + + test('Imagery Time Bounding', async ({ page }) => { + test.info().annotations.push({ + type: 'issue', + description: 'https://github.com/nasa/openmct/issues/5265' + }); + test.info().annotations.push({ + type: 'issue', + description: 'https://github.com/nasa/openmct/issues/7825' + }); + + // verify that old images are discarded + const lastImageInBounds = page.getByLabel('Image thumbnail from').first(); + const lastImageTimestamp = await lastImageInBounds.getAttribute('title'); + expect(lastImageTimestamp).not.toBeNull(); + + // go forward in time to ensure old images are discarded + await page.clock.fastForward(IMAGE_LOAD_DELAY); + await page.clock.resume(); + await expect(page.getByLabel(lastImageTimestamp)).toBeHidden(); + + // go way forward in time to ensure multiple images are discarded + const IMAGES_TO_DISCARD_COUNT = 5; + + const lastImageToDiscard = page + .getByLabel('Image thumbnail from') + .nth(IMAGES_TO_DISCARD_COUNT - 1); + const lastImageToDiscardTimestamp = await lastImageToDiscard.getAttribute('title'); + expect(lastImageToDiscardTimestamp).not.toBeNull(); + + const imageAfterLastImageToDiscard = page + .getByLabel('Image thumbnail from') + .nth(IMAGES_TO_DISCARD_COUNT); + const imageAfterLastImageToDiscardTimestamp = + await imageAfterLastImageToDiscard.getAttribute('title'); + expect(imageAfterLastImageToDiscardTimestamp).not.toBeNull(); + + await page.clock.fastForward(IMAGE_LOAD_DELAY * IMAGES_TO_DISCARD_COUNT); + await page.clock.resume(); + + await expect(page.getByLabel(lastImageToDiscardTimestamp)).toBeHidden(); + await expect(page.getByLabel(imageAfterLastImageToDiscardTimestamp)).toBeVisible(); + }); + + test('Get background-image url from background-image css prop', async ({ page }) => { + await assertBackgroundImageUrlFromBackgroundCss(page); + }); +}); + +test.describe('Example Imagery in Display Layout with Controlled Clock @clock', () => { + let displayLayout; + + test.beforeEach(async ({ page }) => { + // We mock the clock so that we don't need to wait for time driven events + // to verify functionality. + await page.clock.install({ time: MISSION_TIME }); + await page.clock.resume(); + + // Go to baseURL + await page.goto('./', { waitUntil: 'domcontentloaded' }); + + displayLayout = await createDomainObjectWithDefaults(page, { type: 'Display Layout' }); + + // Create Example Imagery inside Display Layout + await createImageryViewWithShortDelay(page, { + name: 'Unnamed Example Imagery', + parent: displayLayout.uuid + }); + + // set realtime mode + await navigateToObjectWithRealTime( + page, + displayLayout.url, + `${FIVE_MINUTES}`, + `${THIRTY_SECONDS}` + ); + }); + + test('Imagery Time Bounding', async ({ page }) => { + test.info().annotations.push({ + type: 'issue', + description: 'https://github.com/nasa/openmct/issues/5265' + }); + test.info().annotations.push({ + type: 'issue', + description: 'https://github.com/nasa/openmct/issues/7825' + }); + + // Edit mode + await page.getByLabel('Edit Object').click(); + + // Click on example imagery to expose toolbar + await page.locator('.c-so-view__header').click(); + + // Adjust object height + await page.locator('div[title="Resize object height"] > input').click(); + await page.locator('div[title="Resize object height"] > input').fill('50'); + + // Adjust object width + await page.locator('div[title="Resize object width"] > input').click(); + await page.locator('div[title="Resize object width"] > input').fill('50'); + + await expect(page.getByLabel('Image Thumbnail from').last()).toBeInViewport(); + + // verify that old images are discarded + const lastImageInBounds = page.getByLabel('Image thumbnail from').first(); + const lastImageTimestamp = await lastImageInBounds.getAttribute('title'); + expect(lastImageTimestamp).not.toBeNull(); + + // go forward in time to ensure old images are discarded + await page.clock.fastForward(IMAGE_LOAD_DELAY); + await page.clock.resume(); + await expect(page.getByLabel(lastImageTimestamp)).toBeHidden(); + + // go way forward in time to ensure multiple images are discarded + const IMAGES_TO_DISCARD_COUNT = 5; + + const lastImageToDiscard = page + .getByLabel('Image thumbnail from') + .nth(IMAGES_TO_DISCARD_COUNT - 1); + const lastImageToDiscardTimestamp = await lastImageToDiscard.getAttribute('title'); + expect(lastImageToDiscardTimestamp).not.toBeNull(); + + const imageAfterLastImageToDiscard = page + .getByLabel('Image thumbnail from') + .nth(IMAGES_TO_DISCARD_COUNT); + const imageAfterLastImageToDiscardTimestamp = + await imageAfterLastImageToDiscard.getAttribute('title'); + expect(imageAfterLastImageToDiscardTimestamp).not.toBeNull(); + + await page.clock.fastForward(IMAGE_LOAD_DELAY * IMAGES_TO_DISCARD_COUNT); + await page.clock.resume(); + + await expect(page.getByLabel(lastImageToDiscardTimestamp)).toBeHidden(); + await expect(page.getByLabel(imageAfterLastImageToDiscardTimestamp)).toBeVisible(); + }); + + test('Get background-image url from background-image css prop @clock', async ({ page }) => { + await assertBackgroundImageUrlFromBackgroundCss(page); + }); +}); + +test.describe('Example Imagery in Flexible layout with Controlled Clock @clock', () => { + let flexibleLayout; + + test.beforeEach(async ({ page }) => { + // We mock the clock so that we don't need to wait for time driven events + // to verify functionality. + await page.clock.install({ time: MISSION_TIME }); + await page.clock.resume(); + + await page.goto('./', { waitUntil: 'domcontentloaded' }); + + flexibleLayout = await createDomainObjectWithDefaults(page, { type: 'Flexible Layout' }); + + // Create Example Imagery inside the Flexible Layout + await createImageryViewWithShortDelay(page, { + name: 'Unnamed Example Imagery', + parent: flexibleLayout.uuid + }); + + // set realtime mode + await navigateToObjectWithRealTime( + page, + flexibleLayout.url, + `${FIVE_MINUTES}`, + `${THIRTY_SECONDS}` + ); + + // Wait for image thumbnail auto-scroll to complete + await expect(page.getByLabel('Image Thumbnail from').last()).toBeInViewport(); + }); + + test('Imagery Time Bounding @clock', async ({ page, browserName }) => { + test.fixme(browserName === 'firefox', 'This test needs to be updated to work with firefox'); + test.info().annotations.push({ + type: 'issue', + description: 'https://github.com/nasa/openmct/issues/5326' + }); + test.info().annotations.push({ + type: 'issue', + description: 'https://github.com/nasa/openmct/issues/7825' + }); + + // verify that old images are discarded + const lastImageInBounds = page.getByLabel('Image thumbnail from').first(); + const lastImageTimestamp = await lastImageInBounds.getAttribute('title'); + expect(lastImageTimestamp).not.toBeNull(); + + // go forward in time to ensure old images are discarded + await page.clock.fastForward(IMAGE_LOAD_DELAY); + await page.clock.resume(); + await expect(page.getByLabel(lastImageTimestamp)).toBeHidden(); + + // go way forward in time to ensure multiple images are discarded + const IMAGES_TO_DISCARD_COUNT = 5; + + const lastImageToDiscard = page + .getByLabel('Image thumbnail from') + .nth(IMAGES_TO_DISCARD_COUNT - 1); + const lastImageToDiscardTimestamp = await lastImageToDiscard.getAttribute('title'); + expect(lastImageToDiscardTimestamp).not.toBeNull(); + + const imageAfterLastImageToDiscard = page + .getByLabel('Image thumbnail from') + .nth(IMAGES_TO_DISCARD_COUNT); + const imageAfterLastImageToDiscardTimestamp = + await imageAfterLastImageToDiscard.getAttribute('title'); + expect(imageAfterLastImageToDiscardTimestamp).not.toBeNull(); + + await page.clock.fastForward(IMAGE_LOAD_DELAY * IMAGES_TO_DISCARD_COUNT); + await page.clock.resume(); + + await expect(page.getByLabel(lastImageToDiscardTimestamp)).toBeHidden(); + await expect(page.getByLabel(imageAfterLastImageToDiscardTimestamp)).toBeVisible(); + }); + + test('Get background-image url from background-image css prop @clock', async ({ page }) => { + await assertBackgroundImageUrlFromBackgroundCss(page); + }); +}); + +test.describe('Example Imagery in Tabs View with Controlled Clock @clock', () => { + let timeStripObject; + + test.beforeEach(async ({ page }) => { + // We mock the clock so that we don't need to wait for time driven events + // to verify functionality. + await page.clock.install({ time: MISSION_TIME }); + await page.clock.resume(); + + await page.goto('./', { waitUntil: 'domcontentloaded' }); + + timeStripObject = await createDomainObjectWithDefaults(page, { type: 'Tabs View' }); + await page.goto(timeStripObject.url); + + /* Create Example Imagery with minimum Image Load Delay */ + // Click the Create button + await page.getByRole('button', { name: 'Create' }).click(); + + // Click text=Example Imagery + await page.getByRole('menuitem', { name: 'Example Imagery' }).click(); + + // Clear and set Image load delay to minimum value + await page.locator('input[type="number"]').clear(); + await page.locator('input[type="number"]').fill(`${IMAGE_LOAD_DELAY}`); + + await page.getByLabel('Save').click(); + + await expect(page.locator('.l-browse-bar__object-name')).toContainText( + 'Unnamed Example Imagery' + ); + + // set realtime mode + await navigateToObjectWithRealTime( + page, + timeStripObject.url, + `${FIVE_MINUTES}`, + `${THIRTY_SECONDS}` + ); + + // Wait for image thumbnail auto-scroll to complete + await expect(page.getByLabel('Image Thumbnail from').last()).toBeInViewport(); + }); + + test('Imagery Time Bounding @clock', async ({ page }) => { + test.info().annotations.push({ + type: 'issue', + description: 'https://github.com/nasa/openmct/issues/5265' + }); + test.info().annotations.push({ + type: 'issue', + description: 'https://github.com/nasa/openmct/issues/7825' + }); + + // verify that old images are discarded + const lastImageInBounds = page.getByLabel('Image thumbnail from').first(); + const lastImageTimestamp = await lastImageInBounds.getAttribute('title'); + expect(lastImageTimestamp).not.toBeNull(); + + // go forward in time to ensure old images are discarded + await page.clock.fastForward(IMAGE_LOAD_DELAY); + await page.clock.resume(); + await expect(page.getByLabel(lastImageTimestamp)).toBeHidden(); + + // go way forward in time to ensure multiple images are discarded + const IMAGES_TO_DISCARD_COUNT = 5; + + const lastImageToDiscard = page + .getByLabel('Image thumbnail from') + .nth(IMAGES_TO_DISCARD_COUNT - 1); + const lastImageToDiscardTimestamp = await lastImageToDiscard.getAttribute('title'); + expect(lastImageToDiscardTimestamp).not.toBeNull(); + + const imageAfterLastImageToDiscard = page + .getByLabel('Image thumbnail from') + .nth(IMAGES_TO_DISCARD_COUNT); + const imageAfterLastImageToDiscardTimestamp = + await imageAfterLastImageToDiscard.getAttribute('title'); + expect(imageAfterLastImageToDiscardTimestamp).not.toBeNull(); + + await page.clock.fastForward(IMAGE_LOAD_DELAY * IMAGES_TO_DISCARD_COUNT); + await page.clock.resume(); + + await expect(page.getByLabel(lastImageToDiscardTimestamp)).toBeHidden(); + await expect(page.getByLabel(imageAfterLastImageToDiscardTimestamp)).toBeVisible(); + }); + + test('Get background-image url from background-image css prop @clock', async ({ page }) => { + await assertBackgroundImageUrlFromBackgroundCss(page); + }); +}); + +test.describe('Example Imagery in Time Strip with Controlled Clock @clock', () => { + let timeStripObject; + + test.beforeEach(async ({ page }) => { + // We mock the clock so that we don't need to wait for time driven events + // to verify functionality. + await page.clock.install({ time: MISSION_TIME }); + await page.clock.resume(); + + await page.goto('./', { waitUntil: 'domcontentloaded' }); + + timeStripObject = await createDomainObjectWithDefaults(page, { type: 'Time Strip' }); + await page.goto(timeStripObject.url); + + /* Create Example Imagery with minimum Image Load Delay */ + // Click the Create button + await page.getByRole('button', { name: 'Create' }).click(); + + // Click text=Example Imagery + await page.getByRole('menuitem', { name: 'Example Imagery' }).click(); + + // Clear and set Image load delay to minimum value + await page.locator('input[type="number"]').clear(); + await page.locator('input[type="number"]').fill(`${IMAGE_LOAD_DELAY}`); + + await page.getByLabel('Save').click(); + + await expect(page.locator('.l-browse-bar__object-name')).toContainText( + 'Unnamed Example Imagery' + ); + + // set realtime mode + await navigateToObjectWithRealTime( + page, + timeStripObject.url, + `${FIVE_MINUTES}`, + `${THIRTY_SECONDS}` + ); + + // Wait for image thumbnail auto-scroll to complete + await expect(page.getByLabel('wrapper-').last()).toBeInViewport(); + }); + + test('Imagery Time Bounding @clock', async ({ page }) => { + test.info().annotations.push({ + type: 'issue', + description: 'https://github.com/nasa/openmct/issues/5265' + }); + test.info().annotations.push({ + type: 'issue', + description: 'https://github.com/nasa/openmct/issues/7825' + }); + + // verify that old images are discarded + const lastImageInBounds = page.getByLabel('wrapper-').first(); + const lastImageTimestamp = await lastImageInBounds.getAttribute('aria-label'); + expect(lastImageTimestamp).not.toBeNull(); + + // go forward in time to ensure old images are discarded + await page.clock.fastForward(IMAGE_LOAD_DELAY); + await page.clock.resume(); + await expect(page.getByLabel(lastImageTimestamp)).toBeHidden(); + + // go way forward in time to ensure multiple images are discarded + const IMAGES_TO_DISCARD_COUNT = 5; + + const lastImageToDiscard = page.getByLabel('wrapper-').nth(IMAGES_TO_DISCARD_COUNT - 1); + const lastImageToDiscardTimestamp = await lastImageToDiscard.getAttribute('aria-label'); + expect(lastImageToDiscardTimestamp).not.toBeNull(); + + const imageAfterLastImageToDiscard = page.getByLabel('wrapper-').nth(IMAGES_TO_DISCARD_COUNT); + const imageAfterLastImageToDiscardTimestamp = + await imageAfterLastImageToDiscard.getAttribute('aria-label'); + expect(imageAfterLastImageToDiscardTimestamp).not.toBeNull(); + + await page.clock.fastForward(IMAGE_LOAD_DELAY * IMAGES_TO_DISCARD_COUNT); + await page.clock.resume(); + + await expect(page.getByLabel(lastImageToDiscardTimestamp)).toBeHidden(); + await expect(page.getByLabel(imageAfterLastImageToDiscardTimestamp)).toBeVisible(); + }); +}); + +/** + * @param {import('@playwright/test').Page} page + */ +async function assertBackgroundImageUrlFromBackgroundCss(page) { + const backgroundImage = page.getByLabel('Focused Image Element'); + const backgroundImageUrl = await backgroundImage.evaluate((el) => { + return window + .getComputedStyle(el) + .getPropertyValue('background-image') + .match(/url\(([^)]+)\)/)[1]; + }); + + // go forward in time to ensure old images are discarded + await page.clock.fastForward(IMAGE_LOAD_DELAY); + await page.clock.resume(); + await expect(backgroundImage).not.toHaveJSProperty('background-image', backgroundImageUrl); +} diff --git a/src/api/telemetry/TelemetryCollection.js b/src/api/telemetry/TelemetryCollection.js index 78e48dc89b..d6f53816b9 100644 --- a/src/api/telemetry/TelemetryCollection.js +++ b/src/api/telemetry/TelemetryCollection.js @@ -116,8 +116,7 @@ export default class TelemetryCollection extends EventEmitter { } /** - * This will start the requests for historical and realtime data, - * as well as setting up initial values and watchers + * @returns {Array} All bounded telemetry */ getAll() { return this.boundedTelemetry; diff --git a/src/plugins/imagery/components/ImageryTimeView.vue b/src/plugins/imagery/components/ImageryTimeView.vue index dfb796b5b6..9c65939e65 100644 --- a/src/plugins/imagery/components/ImageryTimeView.vue +++ b/src/plugins/imagery/components/ImageryTimeView.vue @@ -370,6 +370,7 @@ export default { createImageWrapper(index, image, showImagePlaceholders) { const id = `${ID_PREFIX}${image.time}`; let imageWrapper = document.createElement('div'); + imageWrapper.ariaLabel = id; imageWrapper.classList.add(IMAGE_WRAPPER_CLASS); imageWrapper.style.left = `${this.xScale(image.time)}px`; this.setNSAttributesForElement(imageWrapper, { diff --git a/src/plugins/imagery/components/ImageryView.vue b/src/plugins/imagery/components/ImageryView.vue index 5f868b9eb8..db0541f029 100644 --- a/src/plugins/imagery/components/ImageryView.vue +++ b/src/plugins/imagery/components/ImageryView.vue @@ -620,7 +620,7 @@ export default { if (matchIndex > -1) { this.setFocusedImage(matchIndex); } else { - this.paused(); + this.paused(false); } } @@ -1082,7 +1082,7 @@ export default { paused(state) { this.isPaused = Boolean(state); - if (!state) { + if (!this.isPaused) { this.previousFocusedImage = null; this.setFocusedImage(this.nextImageIndex); this.autoScroll = true; diff --git a/src/plugins/imagery/mixins/imageryData.js b/src/plugins/imagery/mixins/imageryData.js index 804d06f4e3..04914b30a0 100644 --- a/src/plugins/imagery/mixins/imageryData.js +++ b/src/plugins/imagery/mixins/imageryData.js @@ -90,16 +90,17 @@ export default { dataCleared() { this.imageHistory = []; }, - dataRemoved(dataToRemove) { - this.imageHistory = this.imageHistory.filter((existingDatum) => { - const shouldKeep = dataToRemove.some((datumToRemove) => { - const existingDatumTimestamp = this.parseTime(existingDatum); - const datumToRemoveTimestamp = this.parseTime(datumToRemove); + dataRemoved(removed) { + const removedTimestamps = {}; + removed.forEach((_removed) => { + const removedTimestamp = this.parseTime(_removed); + removedTimestamps[removedTimestamp] = true; + }); - return existingDatumTimestamp !== datumToRemoveTimestamp; - }); + this.imageHistory = this.imageHistory.filter((image) => { + const imageTimestamp = this.parseTime(image); - return shouldKeep; + return !removedTimestamps[imageTimestamp]; }); }, setDataTimeContext() { diff --git a/src/plugins/timeline/TimelineViewLayout.vue b/src/plugins/timeline/TimelineViewLayout.vue index 4a85a9a04d..b1652599d4 100644 --- a/src/plugins/timeline/TimelineViewLayout.vue +++ b/src/plugins/timeline/TimelineViewLayout.vue @@ -99,6 +99,7 @@ export default { this.composition.off('remove', this.removeItem); this.composition.off('reorder', this.reorder); this.stopFollowingTimeContext(); + this.handleContentResize.cancel(); this.contentResizeObserver.disconnect(); }, mounted() { From c498f7d20c449a899e83e3a3446cb75b44ad6157 Mon Sep 17 00:00:00 2001 From: Charles Hacskaylo Date: Sat, 28 Sep 2024 09:17:32 -0700 Subject: [PATCH 3/5] Fix bad color value for Gauge 'Needle' type (#7821) * Closes #7820 - Fix 0 opacity fill in theme constants files for Gauge type needle. * move gauge plugin to its own suite * add two more snapshots * driveby: fix some flake * bug: update linting rule --------- Co-authored-by: John Hill Co-authored-by: Andrew Henry --- e2e/.eslintrc.cjs | 6 ++ e2e/README.md | 1 + .../visual-a11y/defaultPlugins.visual.spec.js | 10 --- e2e/tests/visual-a11y/gauge.visual.spec.js | 81 +++++++++++++++++++ e2e/tests/visual-a11y/notebook.visual.spec.js | 2 +- e2e/tests/visual-a11y/search.visual.spec.js | 2 +- src/styles/_constants-espresso.scss | 2 +- src/styles/_constants-maelstrom.scss | 2 +- src/styles/_constants-snow.scss | 2 +- 9 files changed, 93 insertions(+), 15 deletions(-) create mode 100644 e2e/tests/visual-a11y/gauge.visual.spec.js diff --git a/e2e/.eslintrc.cjs b/e2e/.eslintrc.cjs index e044757b41..a8fefcb27e 100644 --- a/e2e/.eslintrc.cjs +++ b/e2e/.eslintrc.cjs @@ -27,6 +27,12 @@ module.exports = { rules: { 'playwright/no-raw-locators': 'off' } + }, + { + files: ['**/*.visual.spec.js'], + rules: { + 'playwright/no-networkidle': 'off' //https://github.com/nasa/openmct/issues/7549 + } } ] }; diff --git a/e2e/README.md b/e2e/README.md index b10956ba44..881d8d2327 100644 --- a/e2e/README.md +++ b/e2e/README.md @@ -469,6 +469,7 @@ By adhering to this principle, we can create tests that are both robust and refl //Select from object await percySnapshot(page, `object selected (theme: ${theme})`) ``` +8. **Use `networkidle` to wait for network requests to complete**: This is necessary to ensure that all network requests have completed before taking a snapshot. This ensures that icons are loaded and other assets are available. https://github.com/nasa/openmct/issues/7549 #### How to write a great network test diff --git a/e2e/tests/visual-a11y/defaultPlugins.visual.spec.js b/e2e/tests/visual-a11y/defaultPlugins.visual.spec.js index d7d022d39d..4246141203 100644 --- a/e2e/tests/visual-a11y/defaultPlugins.visual.spec.js +++ b/e2e/tests/visual-a11y/defaultPlugins.visual.spec.js @@ -85,16 +85,6 @@ test.describe('Visual - Default @a11y', () => { await percySnapshot(page, `Display Layout Create Menu (theme: '${theme}')`); }); - test('Visual - Default Gauge', async ({ page, theme }) => { - await createDomainObjectWithDefaults(page, { - type: 'Gauge', - name: 'Default Gauge' - }); - - // Take a snapshot of the newly created Gauge object - await percySnapshot(page, `Default Gauge (theme: '${theme}')`); - }); - test.afterEach(async ({ page }, testInfo) => { await scanForA11yViolations(page, testInfo.title); }); diff --git a/e2e/tests/visual-a11y/gauge.visual.spec.js b/e2e/tests/visual-a11y/gauge.visual.spec.js new file mode 100644 index 0000000000..15d1d5f08d --- /dev/null +++ b/e2e/tests/visual-a11y/gauge.visual.spec.js @@ -0,0 +1,81 @@ +/***************************************************************************** + * Open MCT, Copyright (c) 2014-2024, United States Government + * as represented by the Administrator of the National Aeronautics and Space + * Administration. All rights reserved. + * + * Open MCT is licensed under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * http://www.apache.org/licenses/LICENSE-2.0. + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the + * License for the specific language governing permissions and limitations + * under the License. + * + * Open MCT includes source code licensed under additional open source + * licenses. See the Open Source Licenses file (LICENSES.md) included with + * this source code distribution or the Licensing information page available + * at runtime from the About dialog for additional information. + *****************************************************************************/ +import percySnapshot from '@percy/playwright'; + +import { createDomainObjectWithDefaults } from '../../appActions.js'; +import { scanForA11yViolations, test } from '../../avpFixtures.js'; +import { VISUAL_FIXED_URL } from '../../constants.js'; + +test.describe('Visual - Gauges', () => { + test.beforeEach(async ({ page }) => { + await page.goto(VISUAL_FIXED_URL, { waitUntil: 'domcontentloaded' }); + }); + + test('Visual - Default Gauge', async ({ page, theme }) => { + await createDomainObjectWithDefaults(page, { + type: 'Gauge', + name: 'Default Gauge' + }); + + // Take a snapshot of the newly created Gauge object + await percySnapshot(page, `Default Gauge (theme: '${theme}')`); + }); + + test('Visual - Needle Gauge with State Generator', async ({ page, theme }) => { + const needleGauge = await createDomainObjectWithDefaults(page, { + type: 'Gauge', + name: 'Needle Gauge' + }); + + //Modify the Gauge to be a Needle Gauge + await page.getByLabel('More actions').click(); + await page.getByLabel('Edit Properties...').click(); + await page.getByLabel('Gauge type', { exact: true }).selectOption('dial-needle'); + await page.getByText('Ok').click(); + + //Add a State Generator to the Gauge + await page.goto(needleGauge.url + '?hideTree=true&hideInspector=true', { + waitUntil: 'domcontentloaded' + }); + + // Take a snapshot of the newly created Gauge object + await percySnapshot(page, `Needle Gauge with no telemetry source (theme: '${theme}')`); + + //Add a State Generator to the Gauge. Note this requires that snapshots are taken within 5 seconds + await page.getByLabel('Create', { exact: true }).click(); + await page.getByLabel('State Generator').click(); + await page.getByLabel('Modal Overlay').getByLabel('Navigate to Needle Gauge').click(); + await page.getByLabel('Save').click(); + + //Add a State Generator to the Gauge + await page.goto(needleGauge.url + '?hideTree=true&hideInspector=true', { + waitUntil: 'domcontentloaded' + }); + + // Take a snapshot of the newly created Gauge object + await percySnapshot(page, `Needle Gauge with State Generator (theme: '${theme}')`); + }); + + test.afterEach(async ({ page }, testInfo) => { + await scanForA11yViolations(page, testInfo.title); + }); +}); diff --git a/e2e/tests/visual-a11y/notebook.visual.spec.js b/e2e/tests/visual-a11y/notebook.visual.spec.js index 3532953fbf..ca3f801fc2 100644 --- a/e2e/tests/visual-a11y/notebook.visual.spec.js +++ b/e2e/tests/visual-a11y/notebook.visual.spec.js @@ -98,7 +98,7 @@ test.describe('Visual - Notebook @a11y', () => { await page.getByLabel('Expand My Items folder').click(); - await page.goto(notebook.url); + await page.goto(notebook.url, { waitUntil: 'networkidle' }); await page .getByLabel('Navigate to Dropped Overlay Plot') diff --git a/e2e/tests/visual-a11y/search.visual.spec.js b/e2e/tests/visual-a11y/search.visual.spec.js index b6a4966e45..b399556913 100644 --- a/e2e/tests/visual-a11y/search.visual.spec.js +++ b/e2e/tests/visual-a11y/search.visual.spec.js @@ -53,7 +53,7 @@ test.describe('Grand Search @a11y', () => { theme }) => { // Navigate to display layout - await page.goto(displayLayout.url); + await page.goto(displayLayout.url, { waitUntil: 'networkidle' }); // Search for the object await page.getByRole('searchbox', { name: 'Search Input' }).click(); diff --git a/src/styles/_constants-espresso.scss b/src/styles/_constants-espresso.scss index 3abae3f1dc..36702317a5 100644 --- a/src/styles/_constants-espresso.scss +++ b/src/styles/_constants-espresso.scss @@ -461,7 +461,7 @@ $colorGaugeMeterTextValue: $colorGaugeTextValue; // Meter text value, overlaid o $colorGaugeRange: $colorBodyFg; // Range text color $colorGaugeLimitHigh: rgba($colorLimitRedBg, 0.4); $colorGaugeLimitLow: $colorGaugeLimitHigh; -$colorGaugeNeedle: rgba(#fff, 0); +$colorGaugeNeedle: $colorGaugeValue; // Color of needle in a needle gauge. $transitionTimeGauge: 150ms; // CSS transition time used to smooth needle gauge and meter value transitions $marginGaugeMeterValue: 10%; // Margin between meter value bar and bounds edges $gaugeMeterValueShadow: rgba(255, 255, 255, 0); diff --git a/src/styles/_constants-maelstrom.scss b/src/styles/_constants-maelstrom.scss index 8935dfb65b..b0f2999174 100644 --- a/src/styles/_constants-maelstrom.scss +++ b/src/styles/_constants-maelstrom.scss @@ -477,7 +477,7 @@ $colorGaugeMeterTextValue: $colorGaugeTextValue; // Meter text value, overlaid o $colorGaugeRange: $colorBodyFg; // Range text color $colorGaugeLimitHigh: rgba($colorLimitRedBg, 0.4); $colorGaugeLimitLow: $colorGaugeLimitHigh; -$colorGaugeNeedle: rgba(#fff, 0); +$colorGaugeNeedle: $colorGaugeValue; // Color of needle in a needle gauge. $transitionTimeGauge: 150ms; // CSS transition time used to smooth needle gauge and meter value transitions $marginGaugeMeterValue: 10%; // Margin between meter value bar and bounds edges $gaugeMeterValueShadow: rgba(255, 255, 255, 0); diff --git a/src/styles/_constants-snow.scss b/src/styles/_constants-snow.scss index 3bc73d0184..4b4efdb52b 100644 --- a/src/styles/_constants-snow.scss +++ b/src/styles/_constants-snow.scss @@ -460,7 +460,7 @@ $colorGaugeMeterTextValue: $colorGaugeTextValue; // Meter text value, overlaid o $colorGaugeRange: $colorBodyFg; // Range text color $colorGaugeLimitHigh: rgba($colorLimitRedBg, 0.2); $colorGaugeLimitLow: $colorGaugeLimitHigh; -$colorGaugeNeedle: rgba(#fff, 0); +$colorGaugeNeedle: $colorGaugeValue; // Color of needle in a needle gauge. $transitionTimeGauge: 150ms; // CSS transition time used to smooth needle gauge and meter value transitions $marginGaugeMeterValue: 10%; // Margin between meter value bar and bounds edges $gaugeMeterValueShadow: rgba(255, 255, 255, 0); From 29f1956d1ab2749b3a753e8267a0ae9f051d2d0f Mon Sep 17 00:00:00 2001 From: Andrew Henry Date: Mon, 30 Sep 2024 14:36:40 -0700 Subject: [PATCH 4/5] Improve telemetry buffering implementation (#7837) * Simplifies the implementation of telemetry buffering in Open MCT. * Switches from per-parameter buffering to a shared queue. Per-parameter buffering is too easily overwhelmed by bursts of telemetry from high-frequency parameters. A single shared buffer has more overhead to grow when a burst arrives without overflowing, because the buffer is shared across all parameters. * Removes the need for plugins to pass serialized code to a worker. * Switched to a "one-size-fits-all" batching strategy removing the need for plugins to define a batching strategy at all. * Captures buffering statistics for display in the PerformanceIndicator. --- .../performanceIndicator.e2e.spec.js | 72 +++++++ src/api/telemetry/BatchingWebSocket.js | 195 ++++++++++-------- src/api/telemetry/WebSocketWorker.js | 178 ++++++---------- src/plugins/performanceIndicator/plugin.js | 64 +++++- 4 files changed, 309 insertions(+), 200 deletions(-) create mode 100644 e2e/tests/functional/plugins/performanceIndicator/performanceIndicator.e2e.spec.js diff --git a/e2e/tests/functional/plugins/performanceIndicator/performanceIndicator.e2e.spec.js b/e2e/tests/functional/plugins/performanceIndicator/performanceIndicator.e2e.spec.js new file mode 100644 index 0000000000..ef1709fb99 --- /dev/null +++ b/e2e/tests/functional/plugins/performanceIndicator/performanceIndicator.e2e.spec.js @@ -0,0 +1,72 @@ +/***************************************************************************** + * Open MCT, Copyright (c) 2014-2023, United States Government + * as represented by the Administrator of the National Aeronautics and Space + * Administration. All rights reserved. + * + * Open MCT is licensed under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * http://www.apache.org/licenses/LICENSE-2.0. + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the + * License for the specific language governing permissions and limitations + * under the License. + * + * Open MCT includes source code licensed under additional open source + * licenses. See the Open Source Licenses file (LICENSES.md) included with + * this source code distribution or the Licensing information page available + * at runtime from the About dialog for additional information. + *****************************************************************************/ +import { expect, test } from '../../../../pluginFixtures.js'; + +test.describe('The performance indicator', () => { + test.beforeEach(async ({ page }) => { + await page.goto('./', { waitUntil: 'domcontentloaded' }); + await page.evaluate(() => { + const openmct = window.openmct; + openmct.install(openmct.plugins.PerformanceIndicator()); + }); + }); + + test('can be installed', ({ page }) => { + const performanceIndicator = page.getByTitle('Performance Indicator'); + expect(performanceIndicator).toBeDefined(); + }); + + test('Shows a numerical FPS value', async ({ page }) => { + // Frames Per Second. We need to wait at least 1 second to get a value. + // eslint-disable-next-line playwright/no-wait-for-timeout + await page.waitForTimeout(1000); + await expect(page.getByTitle('Performance Indicator')).toHaveText(/\d\d? fps/); + }); + + test('Supports showing optional extended performance information in an overlay for debugging', async ({ + page + }) => { + const performanceMeasurementLabel = 'Some measurement'; + const performanceMeasurementValue = 'Some value'; + + await page.evaluate( + ({ performanceMeasurementLabel: label, performanceMeasurementValue: value }) => { + const openmct = window.openmct; + openmct.performance.measurements.set(label, value); + }, + { performanceMeasurementLabel, performanceMeasurementValue } + ); + const performanceIndicator = page.getByTitle('Performance Indicator'); + await performanceIndicator.click(); + //Performance overlay is a crude debugging tool, it's evaluated once per second. + // eslint-disable-next-line playwright/no-wait-for-timeout + await page.waitForTimeout(1000); + const performanceOverlay = page.getByTitle('Performance Overlay'); + await expect(performanceOverlay).toBeVisible(); + await expect(performanceOverlay).toHaveText(new RegExp(`${performanceMeasurementLabel}.*`)); + await expect(performanceOverlay).toHaveText(new RegExp(`.*${performanceMeasurementValue}`)); + + //Confirm that it disappears if we click on it again. + await performanceIndicator.click(); + await expect(performanceOverlay).toBeHidden(); + }); +}); diff --git a/src/api/telemetry/BatchingWebSocket.js b/src/api/telemetry/BatchingWebSocket.js index a0687751b4..9c5596d0d6 100644 --- a/src/api/telemetry/BatchingWebSocket.js +++ b/src/api/telemetry/BatchingWebSocket.js @@ -20,25 +20,46 @@ * at runtime from the About dialog for additional information. *****************************************************************************/ import installWorker from './WebSocketWorker.js'; + /** - * Describes the strategy to be used when batching WebSocket messages - * - * @typedef BatchingStrategy - * @property {Function} shouldBatchMessage a function that accepts a single - * argument - the raw message received from the websocket. Every message - * received will be evaluated against this function so it should be performant. - * Note also that this function is executed in a worker, so it must be - * completely self-contained with no external dependencies. The function - * should return `true` if the message should be batched, and `false` if not. - * @property {Function} getBatchIdFromMessage a function that accepts a - * single argument - the raw message received from the websocket. Only messages - * where `shouldBatchMessage` has evaluated to true will be passed into this - * function. The function should return a unique value on which to batch the - * messages. For example a telemetry, channel, or parameter identifier. + * @typedef RequestIdleCallbackOptions + * @prop {Number} timeout If the number of milliseconds represented by this + * parameter has elapsed and the callback has not already been called, invoke + * the callback. + * @see https://developer.mozilla.org/en-US/docs/Web/API/Window/requestIdleCallback */ + /** - * Provides a reliable and convenient WebSocket abstraction layer that handles - * a lot of boilerplate common to managing WebSocket connections such as: + * Mocks requestIdleCallback for Safari using setTimeout. Functionality will be + * identical to setTimeout in Safari, which is to fire the callback function + * after the provided timeout period. + * + * In browsers that support requestIdleCallback, this const is just a + * pointer to the native function. + * + * @param {Function} callback a callback to be invoked during the next idle period, or + * after the specified timeout + * @param {RequestIdleCallbackOptions} options + * @see https://developer.mozilla.org/en-US/docs/Web/API/Window/requestIdleCallback + * + */ +function requestIdleCallbackPolyfill(callback, options) { + return ( + // eslint-disable-next-line compat/compat + window.requestIdleCallback ?? + ((fn, { timeout }) => + setTimeout(() => { + fn({ didTimeout: false }); + }, timeout)) + ); +} +const requestIdleCallback = requestIdleCallbackPolyfill(); + +const ONE_SECOND = 1000; + +/** + * Provides a WebSocket abstraction layer that handles a lot of boilerplate common + * to managing WebSocket connections such as: * - Establishing a WebSocket connection to a server * - Reconnecting on error, with a fallback strategy * - Queuing messages so that clients can send messages without concern for the current @@ -49,22 +70,19 @@ import installWorker from './WebSocketWorker.js'; * and batching of messages without blocking either the UI or server. * */ -// Shim for Internet Explorer, I mean Safari. It doesn't support requestIdleCallback, but it's in a tech preview, so it will be dropping soon. -const requestIdleCallback = - // eslint-disable-next-line compat/compat - window.requestIdleCallback ?? ((fn, { timeout }) => setTimeout(fn, timeout)); -const ONE_SECOND = 1000; -const FIVE_SECONDS = 5 * ONE_SECOND; - class BatchingWebSocket extends EventTarget { #worker; #openmct; #showingRateLimitNotification; - #maxBatchSize; - #applicationIsInitializing; - #maxBatchWait; + #maxBufferSize; + #throttleRate; #firstBatchReceived; + #lastBatchReceived; + #peakBufferSize = Number.NEGATIVE_INFINITY; + /** + * @param {import('openmct.js').OpenMCT} openmct + */ constructor(openmct) { super(); // Install worker, register listeners etc. @@ -74,9 +92,8 @@ class BatchingWebSocket extends EventTarget { this.#worker = new Worker(workerUrl); this.#openmct = openmct; this.#showingRateLimitNotification = false; - this.#maxBatchSize = Number.POSITIVE_INFINITY; - this.#maxBatchWait = ONE_SECOND; - this.#applicationIsInitializing = true; + this.#maxBufferSize = Number.POSITIVE_INFINITY; + this.#throttleRate = ONE_SECOND; this.#firstBatchReceived = false; const routeMessageToHandler = this.#routeMessageToHandler.bind(this); @@ -89,20 +106,6 @@ class BatchingWebSocket extends EventTarget { }, { once: true } ); - - openmct.once('start', () => { - // An idle callback is a pretty good indication that a complex display is done loading. At that point set the batch size more conservatively. - // Force it after 5 seconds if it hasn't happened yet. - requestIdleCallback( - () => { - this.#applicationIsInitializing = false; - this.setMaxBatchSize(this.#maxBatchSize); - }, - { - timeout: FIVE_SECONDS - } - ); - }); } /** @@ -137,57 +140,48 @@ class BatchingWebSocket extends EventTarget { } /** - * Set the strategy used to both decide which raw messages to batch, and how to group - * them. - * @param {BatchingStrategy} strategy The batching strategy to use when evaluating - * raw messages from the WebSocket. - */ - setBatchingStrategy(strategy) { - const serializedStrategy = { - shouldBatchMessage: strategy.shouldBatchMessage.toString(), - getBatchIdFromMessage: strategy.getBatchIdFromMessage.toString() - }; - - this.#worker.postMessage({ - type: 'setBatchingStrategy', - serializedStrategy - }); - } - - /** - * @param {number} maxBatchSize the maximum length of a batch of messages. For example, - * the maximum number of telemetry values to batch before dropping them + * @param {number} maxBufferSize the maximum length of the receive buffer in characters. * Note that this is a fail-safe that is only invoked if performance drops to the * point where Open MCT cannot keep up with the amount of telemetry it is receiving. * In this event it will sacrifice the oldest telemetry in the batch in favor of the * most recent telemetry. The user will be informed that telemetry has been dropped. * - * This should be set appropriately for the expected data rate. eg. If telemetry - * is received at 10Hz for each telemetry point, then a minimal combination of batch - * size and rate is 10 and 1000 respectively. Ideally you would add some margin, so - * 15 would probably be a better batch size. + * This should be set appropriately for the expected data rate. eg. If typical usage + * sees 2000 messages arriving at a client per second, with an average message size + * of 500 bytes, then 2000 * 500 = 1000000 characters will be right on the limit. + * In this scenario, a buffer size of 1500000 character might be more appropriate + * to allow some overhead for bursty telemetry, and temporary UI load during page + * load. + * + * The PerformanceIndicator plugin (openmct.plugins.PerformanceIndicator) gives + * statistics on buffer utilization. It can be used to scale the buffer appropriately. */ - setMaxBatchSize(maxBatchSize) { - this.#maxBatchSize = maxBatchSize; - if (!this.#applicationIsInitializing) { - this.#sendMaxBatchSizeToWorker(this.#maxBatchSize); - } + setMaxBufferSize(maxBatchSize) { + this.#maxBufferSize = maxBatchSize; + this.#sendMaxBufferSizeToWorker(this.#maxBufferSize); } - setMaxBatchWait(wait) { - this.#maxBatchWait = wait; - this.#sendBatchWaitToWorker(this.#maxBatchWait); + setThrottleRate(throttleRate) { + this.#throttleRate = throttleRate; + this.#sendThrottleRateToWorker(this.#throttleRate); } - #sendMaxBatchSizeToWorker(maxBatchSize) { + setThrottleMessagePattern(throttleMessagePattern) { this.#worker.postMessage({ - type: 'setMaxBatchSize', - maxBatchSize + type: 'setThrottleMessagePattern', + throttleMessagePattern }); } - #sendBatchWaitToWorker(maxBatchWait) { + #sendMaxBufferSizeToWorker(maxBufferSize) { this.#worker.postMessage({ - type: 'setMaxBatchWait', - maxBatchWait + type: 'setMaxBufferSize', + maxBufferSize + }); + } + + #sendThrottleRateToWorker(throttleRate) { + this.#worker.postMessage({ + type: 'setThrottleRate', + throttleRate }); } @@ -203,9 +197,38 @@ class BatchingWebSocket extends EventTarget { #routeMessageToHandler(message) { if (message.data.type === 'batch') { - this.start = Date.now(); const batch = message.data.batch; - if (batch.dropped === true && !this.#showingRateLimitNotification) { + const now = performance.now(); + + let currentBufferLength = message.data.currentBufferLength; + let maxBufferSize = message.data.maxBufferSize; + let parameterCount = batch.length; + if (this.#peakBufferSize < currentBufferLength) { + this.#peakBufferSize = currentBufferLength; + } + + if (this.#openmct.performance !== undefined) { + if (!isNaN(this.#lastBatchReceived)) { + const elapsed = (now - this.#lastBatchReceived) / 1000; + this.#lastBatchReceived = now; + this.#openmct.performance.measurements.set( + 'Parameters/s', + Math.floor(parameterCount / elapsed) + ); + } + this.#openmct.performance.measurements.set( + 'Buff. Util. (bytes)', + `${currentBufferLength} / ${maxBufferSize}` + ); + this.#openmct.performance.measurements.set( + 'Peak Buff. Util. (bytes)', + `${this.#peakBufferSize} / ${maxBufferSize}` + ); + } + + this.start = Date.now(); + const dropped = message.data.dropped; + if (dropped === true && !this.#showingRateLimitNotification) { const notification = this.#openmct.notifications.alert( 'Telemetry dropped due to client rate limiting.', { hint: 'Refresh individual telemetry views to retrieve dropped telemetry if needed.' } @@ -240,18 +263,16 @@ class BatchingWebSocket extends EventTarget { console.warn(`Event loop is too busy to process batch.`); this.#waitUntilIdleAndRequestNextBatch(batch); } else { - // After ingesting a telemetry batch, wait until the event loop is idle again before - // informing the worker we are ready for another batch. this.#readyForNextBatch(); } } else { - if (waitedFor > ONE_SECOND) { + if (waitedFor > this.#throttleRate) { console.warn(`Warning, batch processing took ${waitedFor}ms`); } this.#readyForNextBatch(); } }, - { timeout: ONE_SECOND } + { timeout: this.#throttleRate } ); } } diff --git a/src/api/telemetry/WebSocketWorker.js b/src/api/telemetry/WebSocketWorker.js index a745ff0ceb..f2791577e9 100644 --- a/src/api/telemetry/WebSocketWorker.js +++ b/src/api/telemetry/WebSocketWorker.js @@ -24,10 +24,6 @@ export default function installWorker() { const ONE_SECOND = 1000; const FALLBACK_AND_WAIT_MS = [1000, 5000, 5000, 10000, 10000, 30000]; - /** - * @typedef {import('./BatchingWebSocket').BatchingStrategy} BatchingStrategy - */ - /** * Provides a WebSocket connection that is resilient to errors and dropouts. * On an error or dropout, will automatically reconnect. @@ -215,17 +211,17 @@ export default function installWorker() { case 'message': this.#websocket.enqueueMessage(message.data.message); break; - case 'setBatchingStrategy': - this.setBatchingStrategy(message); - break; case 'readyForNextBatch': this.#messageBatcher.readyForNextBatch(); break; - case 'setMaxBatchSize': - this.#messageBatcher.setMaxBatchSize(message.data.maxBatchSize); + case 'setMaxBufferSize': + this.#messageBatcher.setMaxBufferSize(message.data.maxBufferSize); break; - case 'setMaxBatchWait': - this.#messageBatcher.setMaxBatchWait(message.data.maxBatchWait); + case 'setThrottleRate': + this.#messageBatcher.setThrottleRate(message.data.throttleRate); + break; + case 'setThrottleMessagePattern': + this.#messageBatcher.setThrottleMessagePattern(message.data.throttleMessagePattern); break; default: throw new Error(`Unknown message type: ${type}`); @@ -238,122 +234,69 @@ export default function installWorker() { disconnect() { this.#websocket.disconnect(); } - setBatchingStrategy(message) { - const { serializedStrategy } = message.data; - const batchingStrategy = { - // eslint-disable-next-line no-new-func - shouldBatchMessage: new Function(`return ${serializedStrategy.shouldBatchMessage}`)(), - // eslint-disable-next-line no-new-func - getBatchIdFromMessage: new Function(`return ${serializedStrategy.getBatchIdFromMessage}`)() - // Will also include maximum batch length here - }; - this.#messageBatcher.setBatchingStrategy(batchingStrategy); - } } /** - * Received messages from the WebSocket, and passes them along to the - * Worker interface and back to the main thread. + * Responsible for buffering messages */ - class WebSocketToWorkerMessageBroker { - #worker; - #messageBatcher; - - constructor(messageBatcher, worker) { - this.#messageBatcher = messageBatcher; - this.#worker = worker; - } - - routeMessageToHandler(data) { - if (this.#messageBatcher.shouldBatchMessage(data)) { - this.#messageBatcher.addMessageToBatch(data); - } else { - this.#worker.postMessage({ - type: 'message', - message: data - }); - } - } - } - - /** - * Responsible for batching messages according to the defined batching strategy. - */ - class MessageBatcher { - #batch; - #batchingStrategy; - #hasBatch = false; - #maxBatchSize; + class MessageBuffer { + #buffer; + #currentBufferLength; + #dropped; + #maxBufferSize; #readyForNextBatch; #worker; #throttledSendNextBatch; + #throttleMessagePattern; constructor(worker) { // No dropping telemetry unless we're explicitly told to. - this.#maxBatchSize = Number.POSITIVE_INFINITY; + this.#maxBufferSize = Number.POSITIVE_INFINITY; this.#readyForNextBatch = false; this.#worker = worker; this.#resetBatch(); - this.setMaxBatchWait(ONE_SECOND); + this.setThrottleRate(ONE_SECOND); } #resetBatch() { - this.#batch = {}; - this.#hasBatch = false; + //this.#batch = {}; + this.#buffer = []; + this.#currentBufferLength = 0; + this.#dropped = false; } - /** - * @param {BatchingStrategy} strategy - */ - setBatchingStrategy(strategy) { - this.#batchingStrategy = strategy; - } - /** - * Applies the `shouldBatchMessage` function from the supplied batching strategy - * to each message to determine if it should be added to a batch. If not batched, - * the message is immediately sent over the worker to the main thread. - * @param {any} message the message received from the WebSocket. See the WebSocket - * documentation for more details - - * https://developer.mozilla.org/en-US/docs/Web/API/MessageEvent/data - * @returns - */ - shouldBatchMessage(message) { - return ( - this.#batchingStrategy.shouldBatchMessage && - this.#batchingStrategy.shouldBatchMessage(message) - ); - } - /** - * Adds the given message to a batch. The batch group that the message is added - * to will be determined by the value returned by `getBatchIdFromMessage`. - * @param {any} message the message received from the WebSocket. See the WebSocket - * documentation for more details - - * https://developer.mozilla.org/en-US/docs/Web/API/MessageEvent/data - */ - addMessageToBatch(message) { - const batchId = this.#batchingStrategy.getBatchIdFromMessage(message); - let batch = this.#batch[batchId]; - if (batch === undefined) { - this.#hasBatch = true; - batch = this.#batch[batchId] = [message]; - } else { - batch.push(message); - } - if (batch.length > this.#maxBatchSize) { - console.warn( - `Exceeded max batch size of ${this.#maxBatchSize} for ${batchId}. Dropping value.` - ); - batch.shift(); - this.#batch.dropped = true; + + addMessageToBuffer(message) { + this.#buffer.push(message); + this.#currentBufferLength += message.length; + + for ( + let i = 0; + this.#currentBufferLength > this.#maxBufferSize && i < this.#buffer.length; + i++ + ) { + const messageToConsider = this.#buffer[i]; + if (this.#shouldThrottle(messageToConsider)) { + this.#buffer.splice(i, 1); + this.#currentBufferLength -= messageToConsider.length; + this.#dropped = true; + } } if (this.#readyForNextBatch) { this.#throttledSendNextBatch(); } } - setMaxBatchSize(maxBatchSize) { - this.#maxBatchSize = maxBatchSize; + + #shouldThrottle(message) { + return ( + this.#throttleMessagePattern !== undefined && this.#throttleMessagePattern.test(message) + ); } - setMaxBatchWait(maxBatchWait) { - this.#throttledSendNextBatch = throttle(this.#sendNextBatch.bind(this), maxBatchWait); + + setMaxBufferSize(maxBufferSize) { + this.#maxBufferSize = maxBufferSize; + } + setThrottleRate(throttleRate) { + this.#throttledSendNextBatch = throttle(this.#sendNextBatch.bind(this), throttleRate); } /** * Indicates that client code is ready to receive the next batch of @@ -362,21 +305,33 @@ export default function installWorker() { * any new data is available. */ readyForNextBatch() { - if (this.#hasBatch) { + if (this.#hasData()) { this.#throttledSendNextBatch(); } else { this.#readyForNextBatch = true; } } #sendNextBatch() { - const batch = this.#batch; + const buffer = this.#buffer; + const dropped = this.#dropped; + const currentBufferLength = this.#currentBufferLength; + this.#resetBatch(); this.#worker.postMessage({ type: 'batch', - batch + dropped, + currentBufferLength: currentBufferLength, + maxBufferSize: this.#maxBufferSize, + batch: buffer }); + this.#readyForNextBatch = false; - this.#hasBatch = false; + } + #hasData() { + return this.#currentBufferLength > 0; + } + setThrottleMessagePattern(priorityMessagePattern) { + this.#throttleMessagePattern = new RegExp(priorityMessagePattern, 'm'); } } @@ -408,15 +363,14 @@ export default function installWorker() { } const websocket = new ResilientWebSocket(self); - const messageBatcher = new MessageBatcher(self); - const workerBroker = new WorkerToWebSocketMessageBroker(websocket, messageBatcher); - const websocketBroker = new WebSocketToWorkerMessageBroker(messageBatcher, self); + const messageBuffer = new MessageBuffer(self); + const workerBroker = new WorkerToWebSocketMessageBroker(websocket, messageBuffer); self.addEventListener('message', (message) => { workerBroker.routeMessageToHandler(message); }); websocket.registerMessageCallback((data) => { - websocketBroker.routeMessageToHandler(data); + messageBuffer.addMessageToBuffer(data); }); self.websocketInstance = websocket; diff --git a/src/plugins/performanceIndicator/plugin.js b/src/plugins/performanceIndicator/plugin.js index 389cbf6ca1..6fab67a80b 100644 --- a/src/plugins/performanceIndicator/plugin.js +++ b/src/plugins/performanceIndicator/plugin.js @@ -19,14 +19,23 @@ * this source code distribution or the Licensing information page available * at runtime from the About dialog for additional information. *****************************************************************************/ +const PERFORMANCE_OVERLAY_RENDER_INTERVAL = 1000; + export default function PerformanceIndicator() { return function install(openmct) { let frames = 0; let lastCalculated = performance.now(); - const indicator = openmct.indicators.simpleIndicator(); + openmct.performance = { + measurements: new Map() + }; + const indicator = openmct.indicators.simpleIndicator(); + indicator.key = 'performance-indicator'; indicator.text('~ fps'); + indicator.description('Performance Indicator'); indicator.statusClass('s-status-info'); + indicator.on('click', showOverlay); + openmct.indicators.add(indicator); let rafHandle = requestAnimationFrame(incrementFrames); @@ -58,5 +67,58 @@ export default function PerformanceIndicator() { indicator.statusClass('s-status-error'); } } + + function showOverlay() { + const overlayStylesText = ` + #c-performance-indicator--overlay { + background-color:rgba(0,0,0,0.5); + position: absolute; + width: 300px; + left: calc(50% - 300px); + } + `; + const overlayMarkup = ` +
+ + +
+
+ `; + const overlayTemplate = document.createElement('div'); + overlayTemplate.innerHTML = overlayMarkup; + const overlay = overlayTemplate.cloneNode(true); + overlay.querySelector('.c-performance-indicator--row').remove(); + const overlayStyles = document.createElement('style'); + overlayStyles.appendChild(document.createTextNode(overlayStylesText)); + + document.head.appendChild(overlayStyles); + document.body.appendChild(overlay); + + indicator.off('click', showOverlay); + + const interval = setInterval(() => { + overlay.querySelector('#c-performance-indicator--table').innerHTML = ''; + + for (const [name, value] of openmct.performance.measurements.entries()) { + const newRow = overlayTemplate + .querySelector('.c-performance-indicator--row') + .cloneNode(true); + newRow.querySelector('.c-performance-indicator--measurement-name').innerText = name; + newRow.querySelector('.c-performance-indicator--measurement-value').innerText = value; + overlay.querySelector('#c-performance-indicator--table').appendChild(newRow); + } + }, PERFORMANCE_OVERLAY_RENDER_INTERVAL); + + indicator.on( + 'click', + () => { + overlayStyles.remove(); + overlay.remove(); + indicator.on('click', showOverlay); + clearInterval(interval); + }, + { once: true, capture: true } + ); + } }; } From ad30a0e2d06a287c05caec46ff22b1303d9e669e Mon Sep 17 00:00:00 2001 From: Jesse Mazzella Date: Tue, 1 Oct 2024 10:41:18 -0700 Subject: [PATCH 5/5] feat(Fault Management): allow fault providers to define shelve durations (#7849) * refactor: clean up FaultManagementView code * feat: providers can now provide "Shelve Duration" options * fix(exampleFaultSource): support `getShelveDurations` * a11y: aria label for fault management list item * a11y(FaultManagement): more labels * refactor: eliminate some faultUtils and refactor locator() out of tests * docs: add some more docs to fault management api * refactor: make for loop more readable * test: use static faults when testing * fix: set a timestamp for static faults and subtract so we get faults in order * refactor: autoformat * chore: add missing copyright header * fix: use as default parameter to get value as method is called * refactor: make magic number a const * fix(codecov): use codecov github action to upload * fix: generate the report * build: update circleci yml to use codecov orb * build: remove codecov scripts and package * build: don't use the orb because things can't be easy - nasa org disallows "third party" orbs * build: only use `sudo` if we ain't da root user --------- Co-authored-by: Andrew Henry --- .circleci/config.yml | 43 ++++- .github/workflows/e2e-couchdb.yml | 13 +- e2e/helper/faultUtils.js | 127 ++++++--------- .../faultManagement.e2e.spec.js | 124 +++++++------- example/faultManagement/exampleFaultSource.js | 4 + example/faultManagement/utils.js | 39 +++-- package-lock.json | 152 ------------------ package.json | 6 +- src/api/faultmanagement/FaultManagementAPI.js | 90 +++++++++-- .../FaultManagementListItem.vue | 44 +++-- .../faultManagement/FaultManagementView.vue | 101 ++++++------ 11 files changed, 362 insertions(+), 381 deletions(-) diff --git a/.circleci/config.yml b/.circleci/config.yml index 994555277d..5cd72f893a 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -37,6 +37,29 @@ commands: ls -latR >> /tmp/artifacts/dir.txt - store_artifacts: path: /tmp/artifacts/ + download_verify_codecov_cli: + description: 'Download and verify Codecov CLI' + steps: + - run: + name: Download and verify Codecov CLI + command: | + # Download Codecov CLI + curl -Os https://cli.codecov.io/latest/linux/codecov + + # Import Codecov's GPG key + curl https://keybase.io/codecovsecurity/pgp_keys.asc | gpg --no-default-keyring --keyring trustedkeys.gpg --import + + # Download and verify the SHA256SUM and its signature + curl -Os https://cli.codecov.io/latest/linux/codecov.SHA256SUM + curl -Os https://cli.codecov.io/latest/linux/codecov.SHA256SUM.sig + gpgv codecov.SHA256SUM.sig codecov.SHA256SUM + + # Verify the checksum + shasum -a 256 -c codecov.SHA256SUM + + # Make the codecov executable + [[ $EUID -ne 0 ]] && sudo chmod +x codecov || chmod +x codecov + ./codecov --help generate_e2e_code_cov_report: description: "Generate e2e code coverage artifacts and publish to codecov.io. Needed to that we can ignore the exit code status of the npm run test" parameters: @@ -44,7 +67,15 @@ commands: type: string steps: - run: npm run cov:e2e:report || true - - run: npm run cov:e2e:<>:publish + - download_verify_codecov_cli + - run: + name: Upload coverage report to Codecov + command: | + ./codecov --verbose upload-process --disable-search \ + -t $CODECOV_TOKEN \ + -n 'e2e-<>'-${CIRCLE_WORKFLOW_ID} \ + -F e2e-<> \ + -f ./coverage/e2e/lcov.info jobs: npm-audit: parameters: @@ -81,7 +112,15 @@ jobs: mkdir -p dist/reports/tests/ TESTFILES=$(circleci tests glob "src/**/*Spec.js") echo "$TESTFILES" | circleci tests run --command="xargs npm run test" --verbose - - run: npm run cov:unit:publish + - download_verify_codecov_cli + - run: + name: Upload coverage report to Codecov + command: | + ./codecov --verbose upload-process --disable-search \ + -t $CODECOV_TOKEN \ + -n 'unit-test'-${CIRCLE_WORKFLOW_ID} \ + -F unit \ + -f ./coverage/unit/lcov.info - store_test_results: path: dist/reports/tests/ - store_artifacts: diff --git a/.github/workflows/e2e-couchdb.yml b/.github/workflows/e2e-couchdb.yml index 48ba6f352a..1a8800324c 100644 --- a/.github/workflows/e2e-couchdb.yml +++ b/.github/workflows/e2e-couchdb.yml @@ -51,11 +51,18 @@ jobs: env: COMMIT_INFO_SHA: ${{github.event.pull_request.head.sha }} run: npm run test:e2e:couchdb + + - name: Generate Code Coverage Report + run: npm run cov:e2e:report - name: Publish Results to Codecov.io - env: - SUPER_SECRET: ${{ secrets.CODECOV_TOKEN }} - run: npm run cov:e2e:full:publish + uses: codecov/codecov-action@v4 + with: + token: ${{ secrets.CODECOV_TOKEN }} + files: ./coverage/e2e/lcov.info + flags: e2e-full + fail_ci_if_error: true + verbose: true - name: Archive test results if: success() || failure() diff --git a/e2e/helper/faultUtils.js b/e2e/helper/faultUtils.js index 05c7df98db..5e9e0ffa31 100644 --- a/e2e/helper/faultUtils.js +++ b/e2e/helper/faultUtils.js @@ -25,6 +25,7 @@ import { expect } from '../pluginFixtures.js'; /** * @param {import('@playwright/test').Page} page + * @returns {Promise} */ export async function navigateToFaultManagementWithExample(page) { await page.addInitScript({ @@ -36,6 +37,7 @@ export async function navigateToFaultManagementWithExample(page) { /** * @param {import('@playwright/test').Page} page + * @returns {Promise} */ export async function navigateToFaultManagementWithStaticExample(page) { await page.addInitScript({ @@ -47,6 +49,7 @@ export async function navigateToFaultManagementWithStaticExample(page) { /** * @param {import('@playwright/test').Page} page + * @returns {Promise} */ export async function navigateToFaultManagementWithoutExample(page) { await page.addInitScript({ @@ -58,6 +61,7 @@ export async function navigateToFaultManagementWithoutExample(page) { /** * @param {import('@playwright/test').Page} page + * @returns {Promise} */ async function navigateToFaultItemInTree(page) { await page.goto('./', { waitUntil: 'domcontentloaded' }); @@ -77,6 +81,8 @@ async function navigateToFaultItemInTree(page) { /** * @param {import('@playwright/test').Page} page + * @param {number} rowNumber + * @returns {Promise} */ export async function acknowledgeFault(page, rowNumber) { await openFaultRowMenu(page, rowNumber); @@ -86,6 +92,8 @@ export async function acknowledgeFault(page, rowNumber) { /** * @param {import('@playwright/test').Page} page + * @param {...number} nums + * @returns {Promise} */ export async function shelveMultipleFaults(page, ...nums) { const selectRows = nums.map((num) => { @@ -99,6 +107,8 @@ export async function shelveMultipleFaults(page, ...nums) { /** * @param {import('@playwright/test').Page} page + * @param {...number} nums + * @returns {Promise} */ export async function acknowledgeMultipleFaults(page, ...nums) { const selectRows = nums.map((num) => { @@ -106,50 +116,43 @@ export async function acknowledgeMultipleFaults(page, ...nums) { }); await Promise.all(selectRows); - await page.locator('button:has-text("Acknowledge")').click(); + await page.getByLabel('Acknowledge selected faults').click(); await page.getByLabel('Save').click(); } /** * @param {import('@playwright/test').Page} page + * @param {number} rowNumber + * @returns {Promise} */ export async function shelveFault(page, rowNumber) { await openFaultRowMenu(page, rowNumber); - await page.locator('.c-menu >> text="Shelve"').click(); - // Click [aria-label="Save"] + await page.getByLabel('Shelve', { exact: true }).click(); await page.getByLabel('Save').click(); } /** * @param {import('@playwright/test').Page} page - */ -export async function changeViewTo(page, view) { - await page.locator('.c-fault-mgmt__search-row select').first().selectOption(view); -} - -/** - * @param {import('@playwright/test').Page} page + * @param {'severity' | 'newest-first' | 'oldest-first'} sort + * @returns {Promise} */ export async function sortFaultsBy(page, sort) { - await page.locator('.c-fault-mgmt__list-header-sortButton select').selectOption(sort); + await page.getByTitle('Sort By').getByRole('combobox').selectOption(sort); } /** * @param {import('@playwright/test').Page} page + * @param {'acknowledged' | 'shelved' | 'standard view'} view + * @returns {Promise} */ -export async function enterSearchTerm(page, term) { - await page.locator('.c-fault-mgmt-search [aria-label="Search Input"]').fill(term); -} - -/** - * @param {import('@playwright/test').Page} page - */ -export async function clearSearch(page) { - await enterSearchTerm(page, ''); +export async function changeViewTo(page, view) { + await page.getByTitle('View Filter').getByRole('combobox').selectOption(view); } /** * @param {import('@playwright/test').Page} page + * @param {number} rowNumber + * @returns {Promise} */ export async function selectFaultItem(page, rowNumber) { await page @@ -165,71 +168,37 @@ export async function selectFaultItem(page, rowNumber) { /** * @param {import('@playwright/test').Page} page - */ -export async function getHighestSeverity(page) { - const criticalCount = await page.locator('[title=CRITICAL]').count(); - const warningCount = await page.locator('[title=WARNING]').count(); - - if (criticalCount > 0) { - return 'CRITICAL'; - } else if (warningCount > 0) { - return 'WARNING'; - } - - return 'WATCH'; -} - -/** - * @param {import('@playwright/test').Page} page - */ -export async function getLowestSeverity(page) { - const warningCount = await page.locator('[title=WARNING]').count(); - const watchCount = await page.locator('[title=WATCH]').count(); - - if (watchCount > 0) { - return 'WATCH'; - } else if (warningCount > 0) { - return 'WARNING'; - } - - return 'CRITICAL'; -} - -/** - * @param {import('@playwright/test').Page} page - */ -export async function getFaultResultCount(page) { - const count = await page.locator('.c-faults-list-view-item-body > .c-fault-mgmt__list').count(); - - return count; -} - -/** - * @param {import('@playwright/test').Page} page + * @param {number} rowNumber + * @returns {import('@playwright/test').Locator} */ export function getFault(page, rowNumber) { - const fault = page.locator( - `.c-faults-list-view-item-body > .c-fault-mgmt__list >> nth=${rowNumber - 1}` - ); + const fault = page.getByLabel('Fault triggered at').nth(rowNumber - 1); return fault; } /** * @param {import('@playwright/test').Page} page + * @param {string} name + * @returns {import('@playwright/test').Locator} */ export function getFaultByName(page, name) { - const fault = page.locator(`.c-fault-mgmt__list-faultname:has-text("${name}")`); + const fault = page.getByLabel('Fault triggered at').filter({ + hasText: name + }); return fault; } /** * @param {import('@playwright/test').Page} page + * @param {number} rowNumber + * @returns {Promise} */ export async function getFaultName(page, rowNumber) { const faultName = await page - .locator(`.c-fault-mgmt__list-faultname >> nth=${rowNumber - 1}`) + .getByLabel('Fault name', { exact: true }) + .nth(rowNumber - 1) .textContent(); return faultName; @@ -237,21 +206,13 @@ export async function getFaultName(page, rowNumber) { /** * @param {import('@playwright/test').Page} page - */ -export async function getFaultSeverity(page, rowNumber) { - const faultSeverity = await page - .locator(`.c-faults-list-view-item-body .c-fault-mgmt__list-severity >> nth=${rowNumber - 1}`) - .getAttribute('title'); - - return faultSeverity; -} - -/** - * @param {import('@playwright/test').Page} page + * @param {number} rowNumber + * @returns {Promise} */ export async function getFaultNamespace(page, rowNumber) { const faultNamespace = await page - .locator(`.c-fault-mgmt__list-path >> nth=${rowNumber - 1}`) + .getByLabel('Fault namespace') + .nth(rowNumber - 1) .textContent(); return faultNamespace; @@ -259,10 +220,13 @@ export async function getFaultNamespace(page, rowNumber) { /** * @param {import('@playwright/test').Page} page + * @param {number} rowNumber + * @returns {Promise} */ export async function getFaultTriggerTime(page, rowNumber) { const faultTriggerTime = await page - .locator(`.c-fault-mgmt__list-trigTime >> nth=${rowNumber - 1} >> .c-fault-mgmt-item__value`) + .getByLabel('Last Trigger Time') + .nth(rowNumber - 1) .textContent(); return faultTriggerTime.toString().trim(); @@ -270,11 +234,14 @@ export async function getFaultTriggerTime(page, rowNumber) { /** * @param {import('@playwright/test').Page} page + * @param {number} rowNumber + * @returns {Promise} */ export async function openFaultRowMenu(page, rowNumber) { // select await page - .getByLabel('Disposition actions') + .getByLabel('Fault triggered at') .nth(rowNumber - 1) + .getByLabel('Disposition Actions') .click(); } diff --git a/e2e/tests/functional/plugins/faultManagement/faultManagement.e2e.spec.js b/e2e/tests/functional/plugins/faultManagement/faultManagement.e2e.spec.js index 792c2abacc..df94b16651 100644 --- a/e2e/tests/functional/plugins/faultManagement/faultManagement.e2e.spec.js +++ b/e2e/tests/functional/plugins/faultManagement/faultManagement.e2e.spec.js @@ -24,19 +24,13 @@ import { acknowledgeFault, acknowledgeMultipleFaults, changeViewTo, - clearSearch, - enterSearchTerm, getFault, getFaultByName, getFaultName, getFaultNamespace, - getFaultResultCount, - getFaultSeverity, getFaultTriggerTime, - getHighestSeverity, - getLowestSeverity, - navigateToFaultManagementWithExample, navigateToFaultManagementWithoutExample, + navigateToFaultManagementWithStaticExample, selectFaultItem, shelveFault, shelveMultipleFaults, @@ -46,7 +40,7 @@ import { expect, test } from '../../../../pluginFixtures.js'; test.describe('The Fault Management Plugin using example faults', () => { test.beforeEach(async ({ page }) => { - await navigateToFaultManagementWithExample(page); + await navigateToFaultManagementWithStaticExample(page); }); test('Shows a criticality icon for every fault', async ({ page }) => { @@ -56,7 +50,7 @@ test.describe('The Fault Management Plugin using example faults', () => { expect(faultCount).toEqual(criticalityIconCount); }); - test('When selecting a fault, it has an "is-selected" class and it\'s information shows in the inspector', async ({ + test('When selecting a fault, it has an "is-selected" class and its information shows in the inspector', async ({ page }) => { await selectFaultItem(page, 1); @@ -67,9 +61,7 @@ test.describe('The Fault Management Plugin using example faults', () => { .getByLabel('Source inspector properties') .getByLabel('inspector property value'); - await expect( - page.locator('.c-faults-list-view-item-body > .c-fault-mgmt__list').first() - ).toHaveClass(/is-selected/); + await expect(page.getByLabel('Fault triggered at').first()).toHaveClass(/is-selected/); await expect(inspectorFaultName).toHaveCount(1); }); @@ -79,23 +71,18 @@ test.describe('The Fault Management Plugin using example faults', () => { await selectFaultItem(page, 1); await selectFaultItem(page, 2); - const selectedRows = page.locator( - '.c-fault-mgmt__list.is-selected .c-fault-mgmt__list-faultname' - ); - expect(await selectedRows.count()).toEqual(2); + const selectedRows = page.getByRole('checkbox', { checked: true }); + await expect(selectedRows).toHaveCount(2); await page.getByRole('tab', { name: 'Config' }).click(); const firstSelectedFaultName = await selectedRows.nth(0).textContent(); const secondSelectedFaultName = await selectedRows.nth(1).textContent(); - const firstNameInInspectorCount = await page - .locator(`.c-inspector__properties >> :text("${firstSelectedFaultName}")`) - .count(); - const secondNameInInspectorCount = await page - .locator(`.c-inspector__properties >> :text("${secondSelectedFaultName}")`) - .count(); - - expect(firstNameInInspectorCount).toEqual(0); - expect(secondNameInInspectorCount).toEqual(0); + await expect( + page.locator(`.c-inspector__properties >> :text("${firstSelectedFaultName}")`) + ).toHaveCount(0); + await expect( + page.locator(`.c-inspector__properties >> :text("${secondSelectedFaultName}")`) + ).toHaveCount(0); }); test('Allows you to shelve a fault', async ({ page }) => { @@ -186,44 +173,60 @@ test.describe('The Fault Management Plugin using example faults', () => { const faultFiveTriggerTime = await getFaultTriggerTime(page, 5); // should be all faults (5) - let faultResultCount = await getFaultResultCount(page); - expect(faultResultCount).toEqual(5); + await expect(page.getByLabel('Fault triggered at')).toHaveCount(5); // search namespace - await enterSearchTerm(page, faultThreeNamespace); + await page + .getByLabel('Fault Management Object View') + .getByLabel('Search Input') + .fill(faultThreeNamespace); - faultResultCount = await getFaultResultCount(page); - expect(faultResultCount).toEqual(1); + await expect(page.getByLabel('Fault triggered at')).toHaveCount(1); expect(await getFaultNamespace(page, 1)).toEqual(faultThreeNamespace); // all faults - await clearSearch(page); - faultResultCount = await getFaultResultCount(page); - expect(faultResultCount).toEqual(5); + await page.getByLabel('Fault Management Object View').getByLabel('Search Input').fill(''); + await expect(page.getByLabel('Fault triggered at')).toHaveCount(5); // search name - await enterSearchTerm(page, faultTwoName); + await page + .getByLabel('Fault Management Object View') + .getByLabel('Search Input') + .fill(faultTwoName); - faultResultCount = await getFaultResultCount(page); - expect(faultResultCount).toEqual(1); + await expect(page.getByLabel('Fault triggered at')).toHaveCount(1); expect(await getFaultName(page, 1)).toEqual(faultTwoName); // all faults - await clearSearch(page); - faultResultCount = await getFaultResultCount(page); - expect(faultResultCount).toEqual(5); + await page.getByLabel('Fault Management Object View').getByLabel('Search Input').fill(''); + await expect(page.getByLabel('Fault triggered at')).toHaveCount(5); // search triggerTime - await enterSearchTerm(page, faultFiveTriggerTime); + await page + .getByLabel('Fault Management Object View') + .getByLabel('Search Input') + .fill(faultFiveTriggerTime); - faultResultCount = await getFaultResultCount(page); - expect(faultResultCount).toEqual(1); + await expect(page.getByLabel('Fault triggered at')).toHaveCount(1); expect(await getFaultTriggerTime(page, 1)).toEqual(faultFiveTriggerTime); }); test('Allows you to sort faults', async ({ page }) => { - const highestSeverity = await getHighestSeverity(page); - const lowestSeverity = await getLowestSeverity(page); + /** + * Compares two severity levels and returns a number indicating their relative order. + * + * @param {'CRITICAL' | 'WARNING' | 'WATCH'} severity1 - The first severity level to compare. + * @param {'CRITICAL' | 'WARNING' | 'WATCH'} severity2 - The second severity level to compare. + * @returns {number} - A negative number if severity1 is less severe than severity2, + * a positive number if severity1 is more severe than severity2, + * or 0 if they are equally severe. + */ + // eslint-disable-next-line func-style + const compareSeverity = (severity1, severity2) => { + const severityOrder = ['WATCH', 'WARNING', 'CRITICAL']; + return severityOrder.indexOf(severity1) - severityOrder.indexOf(severity2); + }; + const faultOneName = 'Example Fault 1'; const faultFiveName = 'Example Fault 5'; let firstFaultName = await getFaultName(page, 1); @@ -237,10 +240,19 @@ test.describe('The Fault Management Plugin using example faults', () => { await sortFaultsBy(page, 'severity'); - const sortedHighestSeverity = await getFaultSeverity(page, 1); - const sortedLowestSeverity = await getFaultSeverity(page, 5); - expect(sortedHighestSeverity).toEqual(highestSeverity); - expect(sortedLowestSeverity).toEqual(lowestSeverity); + const firstFaultSeverityLabel = await page + .getByLabel('Severity:') + .first() + .getAttribute('aria-label'); + const firstFaultSeverity = firstFaultSeverityLabel.split(' ').slice(1).join(' '); + + const lastFaultSeverityLabel = await page + .getByLabel('Severity:') + .last() + .getAttribute('aria-label'); + const lastFaultSeverity = lastFaultSeverityLabel.split(' ').slice(1).join(' '); + + expect(compareSeverity(firstFaultSeverity, lastFaultSeverity)).toBeGreaterThan(0); }); }); @@ -250,24 +262,18 @@ test.describe('The Fault Management Plugin without using example faults', () => }); test('Shows no faults when no faults are provided', async ({ page }) => { - const faultCount = await page.locator('c-fault-mgmt__list').count(); - - expect(faultCount).toEqual(0); + await expect(page.getByLabel('Fault triggered at')).toHaveCount(0); await changeViewTo(page, 'acknowledged'); - const acknowledgedCount = await page.locator('c-fault-mgmt__list').count(); - expect(acknowledgedCount).toEqual(0); + await expect(page.getByLabel('Fault triggered at')).toHaveCount(0); await changeViewTo(page, 'shelved'); - const shelvedCount = await page.locator('c-fault-mgmt__list').count(); - expect(shelvedCount).toEqual(0); + await expect(page.getByLabel('Fault triggered at')).toHaveCount(0); }); test('Will return no faults when searching', async ({ page }) => { - await enterSearchTerm(page, 'fault'); + await page.getByLabel('Fault Management Object View').getByLabel('Search Input').fill('fault'); - const faultCount = await page.locator('c-fault-mgmt__list').count(); - - expect(faultCount).toEqual(0); + await expect(page.getByLabel('Fault triggered at')).toHaveCount(0); }); }); diff --git a/example/faultManagement/exampleFaultSource.js b/example/faultManagement/exampleFaultSource.js index 17592ba4b9..bf36ae6c47 100644 --- a/example/faultManagement/exampleFaultSource.js +++ b/example/faultManagement/exampleFaultSource.js @@ -20,6 +20,7 @@ * at runtime from the About dialog for additional information. *****************************************************************************/ +import { DEFAULT_SHELVE_DURATIONS } from '../../src/api/faultmanagement/FaultManagementAPI.js'; import { acknowledgeFault, randomFaults, shelveFault } from './utils.js'; export default function (staticFaults = false) { @@ -56,6 +57,9 @@ export default function (staticFaults = false) { return Promise.resolve({ success: true }); + }, + getShelveDurations() { + return DEFAULT_SHELVE_DURATIONS; } }); }; diff --git a/example/faultManagement/utils.js b/example/faultManagement/utils.js index ed5de3f0e0..16614d8ad5 100644 --- a/example/faultManagement/utils.js +++ b/example/faultManagement/utils.js @@ -1,4 +1,27 @@ +/***************************************************************************** + * Open MCT, Copyright (c) 2014-2024, United States Government + * as represented by the Administrator of the National Aeronautics and Space + * Administration. All rights reserved. + * + * Open MCT is licensed under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * http://www.apache.org/licenses/LICENSE-2.0. + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the + * License for the specific language governing permissions and limitations + * under the License. + * + * Open MCT includes source code licensed under additional open source + * licenses. See the Open Source Licenses file (LICENSES.md) included with + * this source code distribution or the Licensing information page available + * at runtime from the About dialog for additional information. + *****************************************************************************/ + const SEVERITIES = ['WATCH', 'WARNING', 'CRITICAL']; +const MOONWALK_TIMESTAMP = 14159040000; const NAMESPACE = '/Example/fault-'; const getRandom = { severity: () => SEVERITIES[Math.floor(Math.random() * 3)], @@ -13,7 +36,8 @@ const getRandom = { val = num; severity = SEVERITIES[severityIndex - 1]; - time = num; + // Subtract `num` from the timestamp so that the faults are in order + time = MOONWALK_TIMESTAMP - num; // Mon, 21 Jul 1969 02:56:00 GMT 🌔👨‍🚀👨‍🚀👨‍🚀 } return { @@ -43,14 +67,7 @@ const getRandom = { } }; -export function shelveFault( - fault, - opts = { - shelved: true, - comment: '', - shelveDuration: 90000 - } -) { +export function shelveFault(fault, opts = { shelved: true, comment: '', shelveDuration: 90000 }) { fault.shelved = true; setTimeout(() => { @@ -65,8 +82,8 @@ export function acknowledgeFault(fault) { export function randomFaults(staticFaults, count = 5) { let faults = []; - for (let x = 1, y = count + 1; x < y; x++) { - faults.push(getRandom.fault(x, staticFaults)); + for (let i = 1; i <= count; i++) { + faults.push(getRandom.fault(i, staticFaults)); } return faults; diff --git a/package-lock.json b/package-lock.json index 9a29ebe433..4eff9deb1a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -24,7 +24,6 @@ "@vue/compiler-sfc": "3.4.3", "babel-loader": "9.1.0", "babel-plugin-istanbul": "6.1.1", - "codecov": "3.8.3", "comma-separated-values": "3.6.4", "copy-webpack-plugin": "12.0.2", "cspell": "7.3.8", @@ -1586,15 +1585,6 @@ "integrity": "sha512-+9jVqKhRSpsc591z5vX+X5Yyw+he/HCB4iQ/RYxw35CEPaY1gnsNE43nf9n9AaYjAQrTiI/mOwKUKdUs9vf7Xg==", "dev": true }, - "node_modules/@tootallnate/once": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/@tootallnate/once/-/once-1.1.2.tgz", - "integrity": "sha512-RbzJvlNzmRq5c3O09UipeuXno4tA1FE6ikOjxZK0tuxVv3412l64l5t1W5pj4+rJq9vpkm/kwiR07aZXnsKPxw==", - "dev": true, - "engines": { - "node": ">= 6" - } - }, "node_modules/@types/body-parser": { "version": "1.19.5", "resolved": "https://registry.npmjs.org/@types/body-parser/-/body-parser-1.19.5.tgz", @@ -2308,18 +2298,6 @@ "node": ">=8.9" } }, - "node_modules/agent-base": { - "version": "6.0.2", - "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-6.0.2.tgz", - "integrity": "sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==", - "dev": true, - "dependencies": { - "debug": "4" - }, - "engines": { - "node": ">= 6.0.0" - } - }, "node_modules/aggregate-error": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/aggregate-error/-/aggregate-error-3.1.0.tgz", @@ -2454,16 +2432,6 @@ "sprintf-js": "~1.0.2" } }, - "node_modules/argv": { - "version": "0.0.2", - "resolved": "https://registry.npmjs.org/argv/-/argv-0.0.2.tgz", - "integrity": "sha512-dEamhpPEwRUBpLNHeuCm/v+g0anFByHahxodVO/BbAarHVBBg2MccCwf9K+o1Pof+2btdnkJelYVUWjW/VrATw==", - "deprecated": "Package no longer supported. Contact Support at https://www.npmjs.com/support for more info.", - "dev": true, - "engines": { - "node": ">=0.6.10" - } - }, "node_modules/array-flatten": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-1.1.1.tgz", @@ -3013,26 +2981,6 @@ "node": ">=0.10.0" } }, - "node_modules/codecov": { - "version": "3.8.3", - "resolved": "https://registry.npmjs.org/codecov/-/codecov-3.8.3.tgz", - "integrity": "sha512-Y8Hw+V3HgR7V71xWH2vQ9lyS358CbGCldWlJFR0JirqoGtOoas3R3/OclRTvgUYFK29mmJICDPauVKmpqbwhOA==", - "deprecated": "https://about.codecov.io/blog/codecov-uploader-deprecation-plan/", - "dev": true, - "dependencies": { - "argv": "0.0.2", - "ignore-walk": "3.0.4", - "js-yaml": "3.14.1", - "teeny-request": "7.1.1", - "urlgrey": "1.0.0" - }, - "bin": { - "codecov": "bin/codecov" - }, - "engines": { - "node": ">=4.0" - } - }, "node_modules/color-convert": { "version": "1.9.3", "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-1.9.3.tgz", @@ -5582,21 +5530,6 @@ "integrity": "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==", "dev": true }, - "node_modules/fast-url-parser": { - "version": "1.1.3", - "resolved": "https://registry.npmjs.org/fast-url-parser/-/fast-url-parser-1.1.3.tgz", - "integrity": "sha512-5jOCVXADYNuRkKFzNJ0dCCewsZiYo0dz8QNYljkOpFC6r2U4OBmKtvm/Tsuh4w1YYdDqDb31a8TVhBJ2OJKdqQ==", - "dev": true, - "dependencies": { - "punycode": "^1.3.2" - } - }, - "node_modules/fast-url-parser/node_modules/punycode": { - "version": "1.4.1", - "resolved": "https://registry.npmjs.org/punycode/-/punycode-1.4.1.tgz", - "integrity": "sha512-jmYNElW7yvO7TV33CjSmvSiE2yco3bV2czu/OzDKdMNVZQWfxCblURLhf+47syQRBntjfLdd/H0egrzIG+oaFQ==", - "dev": true - }, "node_modules/fastest-levenshtein": { "version": "1.0.16", "resolved": "https://registry.npmjs.org/fastest-levenshtein/-/fastest-levenshtein-1.0.16.tgz", @@ -6387,20 +6320,6 @@ "node": ">=8.0.0" } }, - "node_modules/http-proxy-agent": { - "version": "4.0.1", - "resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-4.0.1.tgz", - "integrity": "sha512-k0zdNgqWTGA6aeIRVpvfVob4fL52dTfaehylg0Y4UvSySvOq/Y+BOyPrgpUrA7HylqvU8vIZGsRuXmspskV0Tg==", - "dev": true, - "dependencies": { - "@tootallnate/once": "1", - "agent-base": "6", - "debug": "4" - }, - "engines": { - "node": ">= 6" - } - }, "node_modules/http-proxy-middleware": { "version": "2.0.6", "resolved": "https://registry.npmjs.org/http-proxy-middleware/-/http-proxy-middleware-2.0.6.tgz", @@ -6431,19 +6350,6 @@ "integrity": "sha512-8guHBZCwKnFhYdHr2ysuRWErTwhoN2X8XELRlrRwpmfeY2jjuUN4taQMsULKUVo1K4DvZl+0pgfyoysHxvmvEw==", "dev": true }, - "node_modules/https-proxy-agent": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-5.0.1.tgz", - "integrity": "sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA==", - "dev": true, - "dependencies": { - "agent-base": "6", - "debug": "4" - }, - "engines": { - "node": ">= 6" - } - }, "node_modules/human-signals": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/human-signals/-/human-signals-2.1.0.tgz", @@ -6486,15 +6392,6 @@ "node": ">= 4" } }, - "node_modules/ignore-walk": { - "version": "3.0.4", - "resolved": "https://registry.npmjs.org/ignore-walk/-/ignore-walk-3.0.4.tgz", - "integrity": "sha512-PY6Ii8o1jMRA1z4F2hRkH/xN59ox43DavKvD3oDpfurRlOJyAHpifIwpbdv1n4jt4ov0jSpw3kQ4GhJnpBL6WQ==", - "dev": true, - "dependencies": { - "minimatch": "^3.0.4" - } - }, "node_modules/image-size": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/image-size/-/image-size-1.1.1.tgz", @@ -10346,15 +10243,6 @@ "node": ">= 0.6" } }, - "node_modules/stream-events": { - "version": "1.0.5", - "resolved": "https://registry.npmjs.org/stream-events/-/stream-events-1.0.5.tgz", - "integrity": "sha512-E1GUzBSgvct8Jsb3v2X15pjzN1tYebtbLaMg+eBOUOAxgbLoSbT2NS91ckc5lJD1KfLjId+jXJRgo0qnV5Nerg==", - "dev": true, - "dependencies": { - "stubs": "^3.0.0" - } - }, "node_modules/streamroller": { "version": "3.1.5", "resolved": "https://registry.npmjs.org/streamroller/-/streamroller-3.1.5.tgz", @@ -10537,12 +10425,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/stubs": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/stubs/-/stubs-3.0.0.tgz", - "integrity": "sha512-PdHt7hHUJKxvTCgbKX9C1V/ftOcjJQgz8BZwNfV5c4B6dcGqlpelTbJ999jBGZ2jYiPAwcX5dP6oBwVlBlUbxw==", - "dev": true - }, "node_modules/style-loader": { "version": "3.3.3", "resolved": "https://registry.npmjs.org/style-loader/-/style-loader-3.3.3.tgz", @@ -10608,31 +10490,6 @@ "node": ">=6" } }, - "node_modules/teeny-request": { - "version": "7.1.1", - "resolved": "https://registry.npmjs.org/teeny-request/-/teeny-request-7.1.1.tgz", - "integrity": "sha512-iwY6rkW5DDGq8hE2YgNQlKbptYpY5Nn2xecjQiNjOXWbKzPGUfmeUBCSQbbr306d7Z7U2N0TPl+/SwYRfua1Dg==", - "dev": true, - "dependencies": { - "http-proxy-agent": "^4.0.0", - "https-proxy-agent": "^5.0.0", - "node-fetch": "^2.6.1", - "stream-events": "^1.0.5", - "uuid": "^8.0.0" - }, - "engines": { - "node": ">=10" - } - }, - "node_modules/teeny-request/node_modules/uuid": { - "version": "8.3.2", - "resolved": "https://registry.npmjs.org/uuid/-/uuid-8.3.2.tgz", - "integrity": "sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==", - "dev": true, - "bin": { - "uuid": "dist/bin/uuid" - } - }, "node_modules/terser": { "version": "5.29.1", "resolved": "https://registry.npmjs.org/terser/-/terser-5.29.1.tgz", @@ -11013,15 +10870,6 @@ "punycode": "^2.1.0" } }, - "node_modules/urlgrey": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/urlgrey/-/urlgrey-1.0.0.tgz", - "integrity": "sha512-hJfIzMPJmI9IlLkby8QrsCykQ+SXDeO2W5Q9QTW3QpqZVTx4a/K7p8/5q+/isD8vsbVaFgql/gvAoQCRQ2Cb5w==", - "dev": true, - "dependencies": { - "fast-url-parser": "^1.1.3" - } - }, "node_modules/util-deprecate": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", diff --git a/package.json b/package.json index 5677abe42b..7bd2b12e89 100644 --- a/package.json +++ b/package.json @@ -27,7 +27,6 @@ "@vue/compiler-sfc": "3.4.3", "babel-loader": "9.1.0", "babel-plugin-istanbul": "6.1.1", - "codecov": "3.8.3", "comma-separated-values": "3.6.4", "copy-webpack-plugin": "12.0.2", "cspell": "7.3.8", @@ -131,9 +130,6 @@ "update-about-dialog-copyright": "perl -pi -e 's/20\\d\\d\\-202\\d/2014\\-2023/gm' ./src/ui/layout/AboutDialog.vue", "update-copyright-date": "npm run update-about-dialog-copyright && grep -lr --null --include=*.{js,scss,vue,ts,sh,html,md,frag} 'Copyright (c) 20' . | xargs -r0 perl -pi -e 's/Copyright\\s\\(c\\)\\s20\\d\\d\\-20\\d\\d/Copyright \\(c\\)\\ 2014\\-2024/gm'", "cov:e2e:report": "nyc report --reporter=lcovonly --report-dir=./coverage/e2e", - "cov:e2e:full:publish": "codecov --disable=gcov -f ./coverage/e2e/lcov.info -F e2e-full", - "cov:e2e:ci:publish": "codecov --disable=gcov -f ./coverage/e2e/lcov.info -F e2e-ci", - "cov:unit:publish": "codecov --disable=gcov -f ./coverage/unit/lcov.info -F unit", "prepare": "npm run build:prod && npx tsc" }, "homepage": "https://nasa.github.io/openmct", @@ -160,4 +156,4 @@ "keywords": [ "nasa" ] -} +} \ No newline at end of file diff --git a/src/api/faultmanagement/FaultManagementAPI.js b/src/api/faultmanagement/FaultManagementAPI.js index 07eef4f7cb..2eb3673a61 100644 --- a/src/api/faultmanagement/FaultManagementAPI.js +++ b/src/api/faultmanagement/FaultManagementAPI.js @@ -20,6 +20,32 @@ * at runtime from the About dialog for additional information. *****************************************************************************/ +/** @type {ShelveDuration[]} */ +export const DEFAULT_SHELVE_DURATIONS = [ + { + name: '5 Minutes', + value: 300000 + }, + { + name: '10 Minutes', + value: 600000 + }, + { + name: '15 Minutes', + value: 900000 + }, + { + name: 'Indefinite', + value: null + } +]; + +/** + * Provides an API for managing faults within Open MCT. + * It allows for the addition of a fault provider, checking for provider support, and + * performing various operations such as requesting, subscribing to, acknowledging, + * and shelving faults. + */ export default class FaultManagementAPI { /** * @param {import("openmct").OpenMCT} openmct @@ -29,14 +55,20 @@ export default class FaultManagementAPI { } /** - * @param {*} provider + * Sets the provider for the Fault Management API. + * The provider should implement methods for acknowledging and shelving faults. + * + * @param {*} provider - The provider to be set. */ addProvider(provider) { this.provider = provider; } /** - * @returns {boolean} + * Checks if the current provider supports fault management actions. + * Specifically, it checks if the provider has methods for acknowledging and shelving faults. + * + * @returns {boolean} - Returns true if the provider supports fault management actions, otherwise false. */ supportsActions() { return ( @@ -45,48 +77,78 @@ export default class FaultManagementAPI { } /** - * @param {import('openmct').DomainObject} domainObject - * @returns {Promise.} + * Requests fault data for a given domain object. + * This method checks if the current provider supports the request operation for the given domain object. + * If supported, it delegates the request to the provider's request method. + * If not supported, it returns a rejected promise. + * + * @param {import('openmct').DomainObject} domainObject - The domain object for which fault data is requested. + * @returns {Promise.} - A promise that resolves to an array of fault API responses. */ request(domainObject) { if (!this.provider?.supportsRequest(domainObject)) { - return Promise.reject(); + return Promise.reject('Provider does not support request operation'); } return this.provider.request(domainObject); } /** - * @param {import('openmct').DomainObject} domainObject - * @param {Function} callback - * @returns {Function} unsubscribe + * Subscribes to fault data updates for a given domain object. + * This method checks if the current provider supports the subscribe operation for the given domain object. + * If supported, it delegates the subscription to the provider's subscribe method. + * If not supported, it returns a rejected promise. + * + * @param {import('openmct').DomainObject} domainObject - The domain object for which to subscribe to fault data updates. + * @param {Function} callback - The callback function to be called with fault data updates. + * @returns {Function} unsubscribe - A function to unsubscribe from the fault data updates. */ subscribe(domainObject, callback) { if (!this.provider?.supportsSubscribe(domainObject)) { - return Promise.reject(); + return Promise.reject('Provider does not support subscribe operation'); } return this.provider.subscribe(domainObject, callback); } /** - * @param {Fault} fault - * @param {*} ackData + * Acknowledges a fault using the provider's acknowledgeFault method. + * + * @param {Fault} fault - The fault object to be acknowledged. + * @param {*} ackData - Additional data required for acknowledging the fault. + * @returns {Promise.} - A promise that resolves when the fault is acknowledged. */ acknowledgeFault(fault, ackData) { return this.provider.acknowledgeFault(fault, ackData); } /** - * @param {Fault} fault - * @param {*} shelveData - * @returns {Promise.} + * Shelves a fault using the provider's shelveFault method. + * + * @param {Fault} fault - The fault object to be shelved. + * @param {*} shelveData - Additional data required for shelving the fault. + * @returns {Promise.} - A promise that resolves when the fault is shelved. */ shelveFault(fault, shelveData) { return this.provider.shelveFault(fault, shelveData); } + + /** + * Retrieves the available shelve durations from the provider, or the default durations if the + * provider does not provide any. + * @returns {ShelveDuration[]} + */ + getShelveDurations() { + return this.provider?.getShelveDurations() ?? DEFAULT_SHELVE_DURATIONS; + } } +/** + * @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 + */ + /** * @typedef {Object} TriggerValueInfo * @property {number} value diff --git a/src/plugins/faultManagement/FaultManagementListItem.vue b/src/plugins/faultManagement/FaultManagementListItem.vue index b472e8523f..d5a15c5297 100644 --- a/src/plugins/faultManagement/FaultManagementListItem.vue +++ b/src/plugins/faultManagement/FaultManagementListItem.vue @@ -21,7 +21,12 @@ -->