mirror of
https://github.com/nasa/openmct.git
synced 2024-12-23 15:02:23 +00:00
Merge branch 'master' into vipergc-415
This commit is contained in:
commit
9960c5db67
@ -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
|
||||
}
|
||||
}
|
||||
]
|
||||
};
|
||||
|
@ -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
|
||||
|
||||
|
33
e2e/helper/imageryUtils.js
Normal file
33
e2e/helper/imageryUtils.js
Normal file
@ -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
|
||||
};
|
@ -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
|
||||
*/
|
||||
|
@ -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);
|
||||
}
|
@ -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();
|
||||
});
|
||||
});
|
@ -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);
|
||||
});
|
||||
|
81
e2e/tests/visual-a11y/gauge.visual.spec.js
Normal file
81
e2e/tests/visual-a11y/gauge.visual.spec.js
Normal file
@ -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);
|
||||
});
|
||||
});
|
@ -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')
|
||||
|
@ -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();
|
||||
|
@ -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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
@ -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;
|
||||
|
@ -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;
|
||||
|
@ -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, {
|
||||
|
@ -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;
|
||||
|
@ -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() {
|
||||
|
@ -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 = `
|
||||
<div id="c-performance-indicator--overlay" title="Performance Overlay">
|
||||
<table id="c-performance-indicator--table">
|
||||
<tr class="c-performance-indicator--row"><td class="c-performance-indicator--measurement-name"></td><td class="c-performance-indicator--measurement-value"></td></tr>
|
||||
</table>
|
||||
</div>
|
||||
`;
|
||||
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 }
|
||||
);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
@ -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() {
|
||||
|
@ -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);
|
||||
|
@ -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);
|
||||
|
@ -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);
|
||||
|
Loading…
Reference in New Issue
Block a user