diff --git a/e2e/appActions.js b/e2e/appActions.js index c119d0c034..e4fbf75899 100644 --- a/e2e/appActions.js +++ b/e2e/appActions.js @@ -30,18 +30,37 @@ */ /** - * This common function creates a `domainObject` with default options. It is the preferred way of creating objects - * in the e2e suite when uninterested in properties of the objects themselves. - * @param {import('@playwright/test').Page} page - * @param {string} type - * @param {string | undefined} name + * Defines parameters to be used in the creation of a domain object. + * @typedef {Object} CreateObjectOptions + * @property {string} type the type of domain object to create (e.g.: "Sine Wave Generator"). + * @property {string} [name] the desired name of the created domain object. + * @property {string | import('../src/api/objects/ObjectAPI').Identifier} [parent] the Identifier or uuid of the parent object. */ -async function createDomainObjectWithDefaults(page, type, name) { - // Navigate to focus the 'My Items' folder, and hide the object tree - // This is necessary so that subsequent objects can be created without a parent - // TODO: Ideally this would navigate to a common `e2e` folder - await page.goto('./#/browse/mine?hideTree=true'); + +/** + * Contains information about the newly created domain object. + * @typedef {Object} CreatedObjectInfo + * @property {string} name the name of the created object + * @property {string} uuid the uuid of the created object + * @property {string} url the relative url to the object (for use with `page.goto()`) + */ + +/** + * This common function creates a domain object with the default options. It is the preferred way of creating objects + * in the e2e suite when uninterested in properties of the objects themselves. + * + * @param {import('@playwright/test').Page} page + * @param {CreateObjectOptions} options + * @returns {Promise} An object containing information about the newly created domain object. + */ +async function createDomainObjectWithDefaults(page, { type, name, parent = 'mine' }) { + const parentUrl = await getHashUrlToDomainObject(page, parent); + + // Navigate to the parent object. This is necessary to create the object + // in the correct location, such as a folder, layout, or plot. + await page.goto(`${parentUrl}?hideTree=true`); await page.waitForLoadState('networkidle'); + //Click the Create button await page.click('button:has-text("Create")'); @@ -50,7 +69,7 @@ async function createDomainObjectWithDefaults(page, type, name) { // Modify the name input field of the domain object to accept 'name' if (name) { - const nameInput = page.locator('input[type="text"]').nth(2); + const nameInput = page.locator('form[name="mctForm"] .first input[type="text"]'); await nameInput.fill(""); await nameInput.fill(name); } @@ -63,12 +82,28 @@ async function createDomainObjectWithDefaults(page, type, name) { page.waitForSelector('.c-message-banner__message') ]); - return name || `Unnamed ${type}`; + // Wait until the URL is updated + await page.waitForURL(`**/${parent}/*`); + const uuid = await getFocusedObjectUuid(page); + const objectUrl = await getHashUrlToDomainObject(page, uuid); + + if (await _isInEditMode(page, uuid)) { + // Save (exit edit mode) + await page.locator('button[title="Save"]').click(); + await page.locator('li[title="Save and Finish Editing"]').click(); + } + + return { + name: name || `Unnamed ${type}`, + uuid: uuid, + url: objectUrl + }; } /** * Open the given `domainObject`'s context menu from the object tree. * Expands the 'My Items' folder if it is not already expanded. +* * @param {import('@playwright/test').Page} page * @param {string} myItemsFolderName the name of the "My Items" folder * @param {string} domainObjectName the display name of the `domainObject` @@ -85,8 +120,154 @@ async function openObjectTreeContextMenu(page, myItemsFolderName, domainObjectNa }); } +/** + * Gets the UUID of the currently focused object by parsing the current URL + * and returning the last UUID in the path. + * @param {import('@playwright/test').Page} page + * @returns {Promise} the uuid of the focused object + */ +async function getFocusedObjectUuid(page) { + const UUIDv4Regexp = /[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}/gi; + const focusedObjectUuid = await page.evaluate((regexp) => { + return window.location.href.match(regexp).at(-1); + }, UUIDv4Regexp); + + return focusedObjectUuid; +} + +/** + * Returns the hashUrl to the domainObject given its uuid. + * Useful for directly navigating to the given domainObject. + * + * URLs returned will be of the form `'./browse/#/mine///...'` + * + * @param {import('@playwright/test').Page} page + * @param {string} uuid the uuid of the object to get the url for + * @returns {Promise} the url of the object + */ +async function getHashUrlToDomainObject(page, uuid) { + const hashUrl = await page.evaluate(async (objectUuid) => { + const path = await window.openmct.objects.getOriginalPath(objectUuid); + let url = './#/browse/' + [...path].reverse() + .map((object) => window.openmct.objects.makeKeyString(object.identifier)) + .join('/'); + + // Drop the vestigial '/ROOT' if it exists + if (url.includes('/ROOT')) { + url = url.split('/ROOT').join(''); + } + + return url; + }, uuid); + + return hashUrl; +} + +/** + * Utilizes the OpenMCT API to detect if the given object has an active transaction (is in Edit mode). + * @private + * @param {import('@playwright/test').Page} page + * @param {string | import('../src/api/objects/ObjectAPI').Identifier} identifier + * @return {Promise} true if the object has an active transaction, false otherwise + */ +async function _isInEditMode(page, identifier) { + // eslint-disable-next-line no-return-await + return await page.evaluate((objectIdentifier) => window.openmct.objects.isTransactionActive(objectIdentifier), identifier); +} + +/** + * Set the time conductor mode to either fixed timespan or realtime mode. + * @param {import('@playwright/test').Page} page + * @param {boolean} [isFixedTimespan=true] true for fixed timespan mode, false for realtime mode; default is true + */ +async function setTimeConductorMode(page, isFixedTimespan = true) { + // Click 'mode' button + await page.locator('.c-mode-button').click(); + + // Switch time conductor mode + if (isFixedTimespan) { + await page.locator('data-testid=conductor-modeOption-fixed').click(); + } else { + await page.locator('data-testid=conductor-modeOption-realtime').click(); + } +} + +/** + * Set the time conductor to fixed timespan mode + * @param {import('@playwright/test').Page} page + */ +async function setFixedTimeMode(page) { + await setTimeConductorMode(page, true); +} + +/** + * Set the time conductor to realtime mode + * @param {import('@playwright/test').Page} page + */ +async function setRealTimeMode(page) { + await setTimeConductorMode(page, false); +} + +/** + * @typedef {Object} OffsetValues + * @property {string | undefined} hours + * @property {string | undefined} mins + * @property {string | undefined} secs + */ + +/** + * Set the values (hours, mins, secs) for the TimeConductor offsets when in realtime mode + * @param {import('@playwright/test').Page} page + * @param {OffsetValues} offset + * @param {import('@playwright/test').Locator} offsetButton + */ +async function setTimeConductorOffset(page, {hours, mins, secs}, offsetButton) { + await offsetButton.click(); + + if (hours) { + await page.fill('.pr-time-controls__hrs', hours); + } + + if (mins) { + await page.fill('.pr-time-controls__mins', mins); + } + + if (secs) { + await page.fill('.pr-time-controls__secs', secs); + } + + // Click the check button + await page.locator('.pr-time__buttons .icon-check').click(); +} + +/** + * Set the values (hours, mins, secs) for the start time offset when in realtime mode + * @param {import('@playwright/test').Page} page + * @param {OffsetValues} offset + */ +async function setStartOffset(page, offset) { + const startOffsetButton = page.locator('data-testid=conductor-start-offset-button'); + await setTimeConductorOffset(page, offset, startOffsetButton); +} + +/** + * Set the values (hours, mins, secs) for the end time offset when in realtime mode + * @param {import('@playwright/test').Page} page + * @param {OffsetValues} offset + */ +async function setEndOffset(page, offset) { + const endOffsetButton = page.locator('data-testid=conductor-end-offset-button'); + await setTimeConductorOffset(page, offset, endOffsetButton); +} + // eslint-disable-next-line no-undef module.exports = { createDomainObjectWithDefaults, - openObjectTreeContextMenu + openObjectTreeContextMenu, + getHashUrlToDomainObject, + getFocusedObjectUuid, + setFixedTimeMode, + setRealTimeMode, + setStartOffset, + setEndOffset }; diff --git a/e2e/tests/framework/appActions.e2e.spec.js b/e2e/tests/framework/appActions.e2e.spec.js index fdb228708e..3a95000ba7 100644 --- a/e2e/tests/framework/appActions.e2e.spec.js +++ b/e2e/tests/framework/appActions.e2e.spec.js @@ -23,19 +23,66 @@ const { test, expect } = require('../../baseFixtures.js'); const { createDomainObjectWithDefaults } = require('../../appActions.js'); -test.describe('appActions tests', () => { - test('createDomainObjectsWithDefaults can create multiple objects in a row', async ({ page }) => { +test.describe('AppActions', () => { + test('createDomainObjectsWithDefaults', async ({ page }) => { await page.goto('./', { waitUntil: 'networkidle' }); - await createDomainObjectWithDefaults(page, 'Timer', 'Timer Foo'); - await createDomainObjectWithDefaults(page, 'Timer', 'Timer Bar'); - await createDomainObjectWithDefaults(page, 'Timer', 'Timer Baz'); - // Expand the tree - await page.click('.c-disclosure-triangle'); + const e2eFolder = await createDomainObjectWithDefaults(page, { + type: 'Folder', + name: 'e2e folder' + }); - // Verify the objects were created - await expect(page.locator('a :text("Timer Foo")')).toBeVisible(); - await expect(page.locator('a :text("Timer Bar")')).toBeVisible(); - await expect(page.locator('a :text("Timer Baz")')).toBeVisible(); + await test.step('Create multiple flat objects in a row', async () => { + const timer1 = await createDomainObjectWithDefaults(page, { + type: 'Timer', + name: 'Timer Foo', + parent: e2eFolder.uuid + }); + const timer2 = await createDomainObjectWithDefaults(page, { + type: 'Timer', + name: 'Timer Bar', + parent: e2eFolder.uuid + }); + const timer3 = await createDomainObjectWithDefaults(page, { + type: 'Timer', + name: 'Timer Baz', + parent: e2eFolder.uuid + }); + + await page.goto(timer1.url, { waitUntil: 'networkidle' }); + await expect(page.locator('.l-browse-bar__object-name')).toHaveText('Timer Foo'); + await page.goto(timer2.url, { waitUntil: 'networkidle' }); + await expect(page.locator('.l-browse-bar__object-name')).toHaveText('Timer Bar'); + await page.goto(timer3.url, { waitUntil: 'networkidle' }); + await expect(page.locator('.l-browse-bar__object-name')).toHaveText('Timer Baz'); + }); + + await test.step('Create multiple nested objects in a row', async () => { + const folder1 = await createDomainObjectWithDefaults(page, { + type: 'Folder', + name: 'Folder Foo', + parent: e2eFolder.uuid + }); + const folder2 = await createDomainObjectWithDefaults(page, { + type: 'Folder', + name: 'Folder Bar', + parent: folder1.uuid + }); + const folder3 = await createDomainObjectWithDefaults(page, { + type: 'Folder', + name: 'Folder Baz', + parent: folder2.uuid + }); + await page.goto(folder1.url, { waitUntil: 'networkidle' }); + await expect(page.locator('.l-browse-bar__object-name')).toHaveText('Folder Foo'); + await page.goto(folder2.url, { waitUntil: 'networkidle' }); + await expect(page.locator('.l-browse-bar__object-name')).toHaveText('Folder Bar'); + await page.goto(folder3.url, { waitUntil: 'networkidle' }); + await expect(page.locator('.l-browse-bar__object-name')).toHaveText('Folder Baz'); + + expect(folder1.url).toBe(`${e2eFolder.url}/${folder1.uuid}`); + expect(folder2.url).toBe(`${e2eFolder.url}/${folder1.uuid}/${folder2.uuid}`); + expect(folder3.url).toBe(`${e2eFolder.url}/${folder1.uuid}/${folder2.uuid}/${folder3.uuid}`); + }); }); }); diff --git a/e2e/tests/framework/exampleTemplate.e2e.spec.js b/e2e/tests/framework/exampleTemplate.e2e.spec.js index b5d20062e8..038be15ac2 100644 --- a/e2e/tests/framework/exampleTemplate.e2e.spec.js +++ b/e2e/tests/framework/exampleTemplate.e2e.spec.js @@ -58,7 +58,7 @@ test.describe('Renaming Timer Object', () => { //Open a browser, navigate to the main page, and wait until all networkevents to resolve await page.goto('./', { waitUntil: 'networkidle' }); //We provide some helper functions in appActions like createDomainObjectWithDefaults. This example will create a Timer object - await createDomainObjectWithDefaults(page, 'Timer'); + await createDomainObjectWithDefaults(page, { type: 'Timer' }); //Assert the object to be created and check it's name in the title await expect(page.locator('.l-browse-bar__object-name')).toContainText('Unnamed Timer'); @@ -73,7 +73,7 @@ test.describe('Renaming Timer Object', () => { //Open a browser, navigate to the main page, and wait until all networkevents to resolve await page.goto('./', { waitUntil: 'networkidle' }); //We provide some helper functions in appActions like createDomainObjectWithDefaults. This example will create a Timer object - await createDomainObjectWithDefaults(page, 'Timer'); + await createDomainObjectWithDefaults(page, { type: 'Timer' }); //Expect the object to be created and check it's name in the title await expect(page.locator('.l-browse-bar__object-name')).toContainText('Unnamed Timer'); diff --git a/e2e/tests/framework/generateVisualTestData.e2e.spec.js b/e2e/tests/framework/generateVisualTestData.e2e.spec.js index df6350d251..7cb37719f9 100644 --- a/e2e/tests/framework/generateVisualTestData.e2e.spec.js +++ b/e2e/tests/framework/generateVisualTestData.e2e.spec.js @@ -31,29 +31,13 @@ TODO: Provide additional validation of object properties as it grows. */ +const { createDomainObjectWithDefaults } = require('../../appActions.js'); const { test, expect } = require('../../pluginFixtures.js'); -test('Generate Visual Test Data @localStorage', async ({ page, context, openmctConfig }) => { - const { myItemsFolderName } = openmctConfig; - +test('Generate Visual Test Data @localStorage', async ({ page, context }) => { //Go to baseURL await page.goto('./', { waitUntil: 'networkidle' }); - - await page.locator('button:has-text("Create")').click(); - - // add overlay plot with defaults - await page.locator('li:has-text("Overlay Plot")').click(); - - await Promise.all([ - page.waitForNavigation(), - page.locator('text=OK').click(), - //Wait for Save Banner to appear1 - page.waitForSelector('.c-message-banner__message') - ]); - - // save (exit edit mode) - await page.locator('text=Snapshot Save and Finish Editing Save and Continue Editing >> button').nth(1).click(); - await page.locator('text=Save and Finish Editing').click(); + const overlayPlot = await createDomainObjectWithDefaults(page, { type: 'Overlay Plot' }); // click create button await page.locator('button:has-text("Create")').click(); @@ -67,16 +51,12 @@ test('Generate Visual Test Data @localStorage', async ({ page, context, openmctC await Promise.all([ page.waitForNavigation(), page.locator('text=OK').click(), - //Wait for Save Banner to appear1 + //Wait for Save Banner to appear page.waitForSelector('.c-message-banner__message') ]); // focus the overlay plot - await page.locator(`text=Open MCT ${myItemsFolderName} >> span`).nth(3).click(); - await Promise.all([ - page.waitForNavigation(), - page.locator('text=Unnamed Overlay Plot').first().click() - ]); + await page.goto(overlayPlot.url); await expect(page.locator('.l-browse-bar__object-name')).toContainText('Unnamed Overlay Plot'); //Save localStorage for future test execution diff --git a/e2e/tests/functional/example/eventGenerator.e2e.spec.js b/e2e/tests/functional/example/eventGenerator.e2e.spec.js index bba8940b74..0db74c480a 100644 --- a/e2e/tests/functional/example/eventGenerator.e2e.spec.js +++ b/e2e/tests/functional/example/eventGenerator.e2e.spec.js @@ -35,7 +35,10 @@ test.describe('Example Event Generator CRUD Operations', () => { //Create a name for the object const newObjectName = 'Test Event Generator'; - await createDomainObjectWithDefaults(page, 'Event Message Generator', newObjectName); + await createDomainObjectWithDefaults(page, { + type: 'Event Message Generator', + name: newObjectName + }); //Assertions against newly created object which define standard behavior await expect(page.waitForURL(/.*&view=table/)).toBeTruthy(); diff --git a/e2e/tests/functional/plugins/conditionSet/conditionSet.e2e.spec.js b/e2e/tests/functional/plugins/conditionSet/conditionSet.e2e.spec.js index 847f9b28a4..f3f9826aea 100644 --- a/e2e/tests/functional/plugins/conditionSet/conditionSet.e2e.spec.js +++ b/e2e/tests/functional/plugins/conditionSet/conditionSet.e2e.spec.js @@ -27,6 +27,7 @@ demonstrate some playwright for test developers. This pattern should not be re-u */ const { test, expect } = require('../../../../pluginFixtures.js'); +const { createDomainObjectWithDefaults } = require('../../../../appActions'); let conditionSetUrl; let getConditionSetIdentifierFromUrl; @@ -178,3 +179,24 @@ test.describe.serial('Condition Set CRUD Operations on @localStorage', () => { }); }); + +test.describe('Basic Condition Set Use', () => { + test('Can add a condition', async ({ page }) => { + //Navigate to baseURL + await page.goto('./', { waitUntil: 'networkidle' }); + + // Create a new condition set + await createDomainObjectWithDefaults(page, { + type: 'Condition Set', + name: "Test Condition Set" + }); + // Change the object to edit mode + await page.locator('[title="Edit"]').click(); + + // Click Add Condition button + await page.locator('#addCondition').click(); + // Check that the new Unnamed Condition section appears + const numOfUnnamedConditions = await page.locator('text=Unnamed Condition').count(); + expect(numOfUnnamedConditions).toEqual(1); + }); +}); diff --git a/e2e/tests/functional/plugins/displayLayout/displayLayout.e2e.spec.js b/e2e/tests/functional/plugins/displayLayout/displayLayout.e2e.spec.js new file mode 100644 index 0000000000..83090fc0e7 --- /dev/null +++ b/e2e/tests/functional/plugins/displayLayout/displayLayout.e2e.spec.js @@ -0,0 +1,122 @@ +/***************************************************************************** + * Open MCT, Copyright (c) 2014-2022, 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 { test, expect } = require('../../../../pluginFixtures'); +const { createDomainObjectWithDefaults, setStartOffset, setFixedTimeMode, setRealTimeMode } = require('../../../../appActions'); + +test.describe('Testing Display Layout @unstable', () => { + let sineWaveObject; + test.beforeEach(async ({ page }) => { + await page.goto('./', { waitUntil: 'networkidle' }); + await setRealTimeMode(page); + + // Create Sine Wave Generator + sineWaveObject = await createDomainObjectWithDefaults(page, { + type: 'Sine Wave Generator', + name: "Test Sine Wave Generator" + }); + }); + test('alpha-numeric widget telemetry value exactly matches latest telemetry value received in real time', async ({ page }) => { + // Create a Display Layout + await createDomainObjectWithDefaults(page, { + type: 'Display Layout', + name: "Test Display Layout" + }); + // Edit Display Layout + await page.locator('[title="Edit"]').click(); + + // Expand the 'My Items' folder in the left tree + await page.locator('.c-tree__item__view-control.c-disclosure-triangle').click(); + // Add the Sine Wave Generator to the Display Layout and save changes + await page.dragAndDrop('text=Test Sine Wave Generator', '.l-layout__grid-holder'); + await page.locator('button[title="Save"]').click(); + await page.locator('text=Save and Finish Editing').click(); + + // Subscribe to the Sine Wave Generator data + // On getting data, check if the value found in the Display Layout is the most recent value + // from the Sine Wave Generator + const getTelemValuePromise = await subscribeToTelemetry(page, sineWaveObject.uuid); + const formattedTelemetryValue = await getTelemValuePromise; + const displayLayoutValuePromise = await page.waitForSelector(`text="${formattedTelemetryValue}"`); + const displayLayoutValue = await displayLayoutValuePromise.textContent(); + const trimmedDisplayValue = displayLayoutValue.trim(); + + await expect(trimmedDisplayValue).toBe(formattedTelemetryValue); + }); + test('alpha-numeric widget telemetry value exactly matches latest telemetry value received in fixed time', async ({ page }) => { + // Create a Display Layout + await createDomainObjectWithDefaults(page, { + type: 'Display Layout', + name: "Test Display Layout" + }); + // Edit Display Layout + await page.locator('[title="Edit"]').click(); + + // Expand the 'My Items' folder in the left tree + await page.locator('.c-tree__item__view-control.c-disclosure-triangle').click(); + // Add the Sine Wave Generator to the Display Layout and save changes + await page.dragAndDrop('text=Test Sine Wave Generator', '.l-layout__grid-holder'); + await page.locator('button[title="Save"]').click(); + await page.locator('text=Save and Finish Editing').click(); + + // Subscribe to the Sine Wave Generator data + const getTelemValuePromise = await subscribeToTelemetry(page, sineWaveObject.uuid); + // Set an offset of 1 minute and then change the time mode to fixed to set a 1 minute historical window + await setStartOffset(page, { mins: '1' }); + await setFixedTimeMode(page); + + // On getting data, check if the value found in the Display Layout is the most recent value + // from the Sine Wave Generator + const formattedTelemetryValue = await getTelemValuePromise; + const displayLayoutValuePromise = await page.waitForSelector(`text="${formattedTelemetryValue}"`); + const displayLayoutValue = await displayLayoutValuePromise.textContent(); + const trimmedDisplayValue = displayLayoutValue.trim(); + + await expect(trimmedDisplayValue).toBe(formattedTelemetryValue); + }); +}); + +/** + * Util for subscribing to a telemetry object by object identifier + * Limitations: Currently only works to return telemetry once to the node scope + * To Do: See if there's a way to await this multiple times to allow for multiple + * values to be returned over time + * @param {import('@playwright/test').Page} page + * @param {string} objectIdentifier identifier for object + * @returns {Promise} the formatted sin telemetry value + */ +async function subscribeToTelemetry(page, objectIdentifier) { + const getTelemValuePromise = new Promise(resolve => page.exposeFunction('getTelemValue', resolve)); + + await page.evaluate(async (telemetryIdentifier) => { + const telemetryObject = await window.openmct.objects.get(telemetryIdentifier); + const metadata = window.openmct.telemetry.getMetadata(telemetryObject); + const formats = await window.openmct.telemetry.getFormatMap(metadata); + window.openmct.telemetry.subscribe(telemetryObject, (obj) => { + const sinVal = obj.sin; + const formattedSinVal = formats.sin.format(sinVal); + window.getTelemValue(formattedSinVal); + }); + }, objectIdentifier); + + return getTelemValuePromise; +} diff --git a/e2e/tests/functional/plugins/imagery/exampleImagery.e2e.spec.js b/e2e/tests/functional/plugins/imagery/exampleImagery.e2e.spec.js index 54256f9966..42e532b44c 100644 --- a/e2e/tests/functional/plugins/imagery/exampleImagery.e2e.spec.js +++ b/e2e/tests/functional/plugins/imagery/exampleImagery.e2e.spec.js @@ -41,7 +41,7 @@ test.describe('Example Imagery Object', () => { await page.goto('./', { waitUntil: 'networkidle' }); // Create a default 'Example Imagery' object - createDomainObjectWithDefaults(page, 'Example Imagery'); + createDomainObjectWithDefaults(page, { type: 'Example Imagery' }); await Promise.all([ page.waitForNavigation(), diff --git a/e2e/tests/functional/plugins/lad/lad.e2e.spec.js b/e2e/tests/functional/plugins/lad/lad.e2e.spec.js new file mode 100644 index 0000000000..4ec084c1b2 --- /dev/null +++ b/e2e/tests/functional/plugins/lad/lad.e2e.spec.js @@ -0,0 +1,120 @@ +/***************************************************************************** + * Open MCT, Copyright (c) 2014-2022, 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 { test, expect } = require('../../../../pluginFixtures'); +const { createDomainObjectWithDefaults, setStartOffset, setFixedTimeMode, setRealTimeMode } = require('../../../../appActions'); + +test.describe('Testing LAD table @unstable', () => { + let sineWaveObject; + test.beforeEach(async ({ page }) => { + await page.goto('./', { waitUntil: 'networkidle' }); + await setRealTimeMode(page); + + // Create Sine Wave Generator + sineWaveObject = await createDomainObjectWithDefaults(page, { + type: 'Sine Wave Generator', + name: "Test Sine Wave Generator" + }); + }); + test('telemetry value exactly matches latest telemetry value received in real time', async ({ page }) => { + // Create LAD table + await createDomainObjectWithDefaults(page, { + type: 'LAD Table', + name: "Test LAD Table" + }); + // Edit LAD table + await page.locator('[title="Edit"]').click(); + + // Expand the 'My Items' folder in the left tree + await page.locator('.c-tree__item__view-control.c-disclosure-triangle').click(); + // Add the Sine Wave Generator to the LAD table and save changes + await page.dragAndDrop('text=Test Sine Wave Generator', '.c-lad-table-wrapper'); + await page.locator('button[title="Save"]').click(); + await page.locator('text=Save and Finish Editing').click(); + + // Subscribe to the Sine Wave Generator data + // On getting data, check if the value found in the LAD table is the most recent value + // from the Sine Wave Generator + const getTelemValuePromise = await subscribeToTelemetry(page, sineWaveObject.uuid); + const subscribeTelemValue = await getTelemValuePromise; + const ladTableValuePromise = await page.waitForSelector(`text="${subscribeTelemValue}"`); + const ladTableValue = await ladTableValuePromise.textContent(); + + expect(ladTableValue).toBe(subscribeTelemValue); + }); + test('telemetry value exactly matches latest telemetry value received in fixed time', async ({ page }) => { + // Create LAD table + await createDomainObjectWithDefaults(page, { + type: 'LAD Table', + name: "Test LAD Table" + }); + // Edit LAD table + await page.locator('[title="Edit"]').click(); + + // Expand the 'My Items' folder in the left tree + await page.locator('.c-tree__item__view-control.c-disclosure-triangle').click(); + // Add the Sine Wave Generator to the LAD table and save changes + await page.dragAndDrop('text=Test Sine Wave Generator', '.c-lad-table-wrapper'); + await page.locator('button[title="Save"]').click(); + await page.locator('text=Save and Finish Editing').click(); + + // Subscribe to the Sine Wave Generator data + const getTelemValuePromise = await subscribeToTelemetry(page, sineWaveObject.uuid); + // Set an offset of 1 minute and then change the time mode to fixed to set a 1 minute historical window + await setStartOffset(page, { mins: '1' }); + await setFixedTimeMode(page); + + // On getting data, check if the value found in the LAD table is the most recent value + // from the Sine Wave Generator + const subscribeTelemValue = await getTelemValuePromise; + const ladTableValuePromise = await page.waitForSelector(`text="${subscribeTelemValue}"`); + const ladTableValue = await ladTableValuePromise.textContent(); + + expect(ladTableValue).toBe(subscribeTelemValue); + }); +}); + +/** + * Util for subscribing to a telemetry object by object identifier + * Limitations: Currently only works to return telemetry once to the node scope + * To Do: See if there's a way to await this multiple times to allow for multiple + * values to be returned over time + * @param {import('@playwright/test').Page} page + * @param {string} objectIdentifier identifier for object + * @returns {Promise} the formatted sin telemetry value + */ +async function subscribeToTelemetry(page, objectIdentifier) { + const getTelemValuePromise = new Promise(resolve => page.exposeFunction('getTelemValue', resolve)); + + await page.evaluate(async (telemetryIdentifier) => { + const telemetryObject = await window.openmct.objects.get(telemetryIdentifier); + const metadata = window.openmct.telemetry.getMetadata(telemetryObject); + const formats = await window.openmct.telemetry.getFormatMap(metadata); + window.openmct.telemetry.subscribe(telemetryObject, (obj) => { + const sinVal = obj.sin; + const formattedSinVal = formats.sin.format(sinVal); + window.getTelemValue(formattedSinVal); + }); + }, objectIdentifier); + + return getTelemValuePromise; +} diff --git a/e2e/tests/functional/plugins/notebook/tags.e2e.spec.js b/e2e/tests/functional/plugins/notebook/tags.e2e.spec.js index 2d48606729..4e7c00450e 100644 --- a/e2e/tests/functional/plugins/notebook/tags.e2e.spec.js +++ b/e2e/tests/functional/plugins/notebook/tags.e2e.spec.js @@ -36,7 +36,7 @@ async function createNotebookAndEntry(page, iterations = 1) { //Go to baseURL await page.goto('./', { waitUntil: 'networkidle' }); - createDomainObjectWithDefaults(page, 'Notebook'); + createDomainObjectWithDefaults(page, { type: 'Notebook' }); for (let iteration = 0; iteration < iterations; iteration++) { // Click text=To start a new entry, click here or drag and drop any object @@ -139,11 +139,28 @@ test.describe('Tagging in Notebooks @addInit', () => { await page.locator('[aria-label="OpenMCT Search"] input[type="search"]').fill('sc'); await expect(page.locator('[aria-label="Search Result"]')).not.toContainText("Driving"); }); + + test('Can delete objects with tags and neither return in search', async ({ page }) => { + await createNotebookEntryAndTags(page); + // Delete Notebook + await page.locator('button[title="More options"]').click(); + await page.locator('li[title="Remove this object from its containing object."]').click(); + await page.locator('button:has-text("OK")').click(); + await page.goto('./', { waitUntil: 'networkidle' }); + + // Fill [aria-label="OpenMCT Search"] input[type="search"] + await page.locator('[aria-label="OpenMCT Search"] input[type="search"]').fill('Unnamed'); + await expect(page.locator('text=No matching results.')).toBeVisible(); + await page.locator('[aria-label="OpenMCT Search"] input[type="search"]').fill('sci'); + await expect(page.locator('text=No matching results.')).toBeVisible(); + await page.locator('[aria-label="OpenMCT Search"] input[type="search"]').fill('dri'); + await expect(page.locator('text=No matching results.')).toBeVisible(); + }); test('Tags persist across reload', async ({ page }) => { //Go to baseURL await page.goto('./', { waitUntil: 'networkidle' }); - await createDomainObjectWithDefaults(page, 'Clock'); + await createDomainObjectWithDefaults(page, { type: 'Clock' }); const ITERATIONS = 4; await createNotebookEntryAndTags(page, ITERATIONS); diff --git a/e2e/tests/functional/plugins/telemetryTable/telemetryTable.e2e.spec.js b/e2e/tests/functional/plugins/telemetryTable/telemetryTable.e2e.spec.js index dbbf5c1a90..15656603ee 100644 --- a/e2e/tests/functional/plugins/telemetryTable/telemetryTable.e2e.spec.js +++ b/e2e/tests/functional/plugins/telemetryTable/telemetryTable.e2e.spec.js @@ -20,55 +20,26 @@ * at runtime from the About dialog for additional information. *****************************************************************************/ +const { createDomainObjectWithDefaults } = require('../../../../appActions'); const { test, expect } = require('../../../../pluginFixtures'); test.describe('Telemetry Table', () => { - test('unpauses and filters data when paused by button and user changes bounds', async ({ page, openmctConfig }) => { + test('unpauses and filters data when paused by button and user changes bounds', async ({ page }) => { test.info().annotations.push({ type: 'issue', description: 'https://github.com/nasa/openmct/issues/5113' }); - const { myItemsFolderName } = openmctConfig; - const bannerMessage = '.c-message-banner__message'; - const createButton = 'button:has-text("Create")'; - await page.goto('./', { waitUntil: 'networkidle' }); - // Click create button - await page.locator(createButton).click(); - await page.locator('li:has-text("Telemetry Table")').click(); - - await Promise.all([ - page.waitForNavigation(), - page.locator('text=OK').click(), - // Wait for Save Banner to appear - page.waitForSelector(bannerMessage) - ]); - - // Save (exit edit mode) - await page.locator('text=Snapshot Save and Finish Editing Save and Continue Editing >> button').nth(3).click(); - await page.locator('text=Save and Finish Editing').click(); - - // Click create button - await page.locator(createButton).click(); - - // add Sine Wave Generator with defaults - await page.locator('li:has-text("Sine Wave Generator")').click(); - - await Promise.all([ - page.waitForNavigation(), - page.locator('text=OK').click(), - // Wait for Save Banner to appear - page.waitForSelector(bannerMessage) - ]); + const table = await createDomainObjectWithDefaults(page, { type: 'Telemetry Table' }); + await createDomainObjectWithDefaults(page, { + type: 'Sine Wave Generator', + parent: table.uuid + }); // focus the Telemetry Table - await page.locator(`text=Open MCT ${myItemsFolderName} >> span`).nth(3).click(); - await Promise.all([ - page.waitForNavigation(), - page.locator('text=Unnamed Telemetry Table').first().click() - ]); + page.goto(table.url); // Click pause button const pauseButton = page.locator('button.c-button.icon-pause'); diff --git a/e2e/tests/functional/plugins/timeConductor/timeConductor.e2e.spec.js b/e2e/tests/functional/plugins/timeConductor/timeConductor.e2e.spec.js index 8d764526b3..59d3171706 100644 --- a/e2e/tests/functional/plugins/timeConductor/timeConductor.e2e.spec.js +++ b/e2e/tests/functional/plugins/timeConductor/timeConductor.e2e.spec.js @@ -21,6 +21,7 @@ *****************************************************************************/ const { test, expect } = require('../../../../baseFixtures'); +const { setFixedTimeMode, setRealTimeMode, setStartOffset, setEndOffset } = require('../../../../appActions'); test.describe('Time conductor operations', () => { test('validate start time does not exceeds end time', async ({ page }) => { @@ -147,88 +148,3 @@ test.describe('Time conductor input fields real-time mode', () => { expect(page.url()).toContain(`endDelta=${endDelta}`); }); }); - -/** - * @typedef {Object} OffsetValues - * @property {string | undefined} hours - * @property {string | undefined} mins - * @property {string | undefined} secs - */ - -/** - * Set the values (hours, mins, secs) for the start time offset when in realtime mode - * @param {import('@playwright/test').Page} page - * @param {OffsetValues} offset - */ -async function setStartOffset(page, offset) { - const startOffsetButton = page.locator('data-testid=conductor-start-offset-button'); - await setTimeConductorOffset(page, offset, startOffsetButton); -} - -/** - * Set the values (hours, mins, secs) for the end time offset when in realtime mode - * @param {import('@playwright/test').Page} page - * @param {OffsetValues} offset - */ -async function setEndOffset(page, offset) { - const endOffsetButton = page.locator('data-testid=conductor-end-offset-button'); - await setTimeConductorOffset(page, offset, endOffsetButton); -} - -/** - * Set the time conductor to fixed timespan mode - * @param {import('@playwright/test').Page} page - */ -async function setFixedTimeMode(page) { - await setTimeConductorMode(page, true); -} - -/** - * Set the time conductor to realtime mode - * @param {import('@playwright/test').Page} page - */ -async function setRealTimeMode(page) { - await setTimeConductorMode(page, false); -} - -/** - * Set the values (hours, mins, secs) for the TimeConductor offsets when in realtime mode - * @param {import('@playwright/test').Page} page - * @param {OffsetValues} offset - * @param {import('@playwright/test').Locator} offsetButton - */ -async function setTimeConductorOffset(page, {hours, mins, secs}, offsetButton) { - await offsetButton.click(); - - if (hours) { - await page.fill('.pr-time-controls__hrs', hours); - } - - if (mins) { - await page.fill('.pr-time-controls__mins', mins); - } - - if (secs) { - await page.fill('.pr-time-controls__secs', secs); - } - - // Click the check button - await page.locator('.icon-check').click(); -} - -/** - * Set the time conductor mode to either fixed timespan or realtime mode. - * @param {import('@playwright/test').Page} page - * @param {boolean} [isFixedTimespan=true] true for fixed timespan mode, false for realtime mode; default is true - */ -async function setTimeConductorMode(page, isFixedTimespan = true) { - // Click 'mode' button - await page.locator('.c-mode-button').click(); - - // Switch time conductor mode - if (isFixedTimespan) { - await page.locator('data-testid=conductor-modeOption-fixed').click(); - } else { - await page.locator('data-testid=conductor-modeOption-realtime').click(); - } -} diff --git a/e2e/tests/functional/plugins/timer/timer.e2e.spec.js b/e2e/tests/functional/plugins/timer/timer.e2e.spec.js index bf8dca37c7..0235a1d2c1 100644 --- a/e2e/tests/functional/plugins/timer/timer.e2e.spec.js +++ b/e2e/tests/functional/plugins/timer/timer.e2e.spec.js @@ -26,7 +26,7 @@ const { openObjectTreeContextMenu, createDomainObjectWithDefaults } = require('. test.describe('Timer', () => { test.beforeEach(async ({ page }) => { await page.goto('./', { waitUntil: 'networkidle' }); - await createDomainObjectWithDefaults(page, 'timer'); + await createDomainObjectWithDefaults(page, { type: 'timer' }); }); test('Can perform actions on the Timer', async ({ page, openmctConfig }) => { diff --git a/e2e/tests/functional/search.e2e.spec.js b/e2e/tests/functional/search.e2e.spec.js index 4aeb19c0d4..0daa1b3f9f 100644 --- a/e2e/tests/functional/search.e2e.spec.js +++ b/e2e/tests/functional/search.e2e.spec.js @@ -107,6 +107,9 @@ test.describe("Search Tests @unstable", () => { // Verify that no results are found expect(await searchResults.count()).toBe(0); + + // Verify proper message appears + await expect(page.locator('text=No matching results.')).toBeVisible(); }); test('Validate single object in search result', async ({ page }) => { diff --git a/e2e/tests/visual/addInit.visual.spec.js b/e2e/tests/visual/addInit.visual.spec.js index 5a04c253e2..5c73a82aba 100644 --- a/e2e/tests/visual/addInit.visual.spec.js +++ b/e2e/tests/visual/addInit.visual.spec.js @@ -53,7 +53,7 @@ test.describe('Visual - addInit', () => { //Go to baseURL await page.goto('./#/browse/mine?hideTree=true', { waitUntil: 'networkidle' }); - await createDomainObjectWithDefaults(page, CUSTOM_NAME); + await createDomainObjectWithDefaults(page, { type: CUSTOM_NAME }); // Take a snapshot of the newly created CUSTOM_NAME notebook await percySnapshot(page, `Restricted Notebook with CUSTOM_NAME (theme: '${theme}')`); diff --git a/e2e/tests/visual/default.visual.spec.js b/e2e/tests/visual/default.visual.spec.js index 8b67c9326e..0ce27e7ec2 100644 --- a/e2e/tests/visual/default.visual.spec.js +++ b/e2e/tests/visual/default.visual.spec.js @@ -67,9 +67,9 @@ test.describe('Visual - Default', () => { await percySnapshot(page, `About (theme: '${theme}')`); }); - test('Visual - Default Condition Set', async ({ page, theme }) => { + test.fixme('Visual - Default Condition Set', async ({ page, theme }) => { - await createDomainObjectWithDefaults(page, 'Condition Set'); + await createDomainObjectWithDefaults(page, { type: 'Condition Set' }); // Take a snapshot of the newly created Condition Set object await percySnapshot(page, `Default Condition Set (theme: '${theme}')`); @@ -81,7 +81,7 @@ test.describe('Visual - Default', () => { description: 'https://github.com/nasa/openmct/issues/5349' }); - await createDomainObjectWithDefaults(page, 'Condition Widget'); + await createDomainObjectWithDefaults(page, { type: 'Condition Widget' }); // Take a snapshot of the newly created Condition Widget object await percySnapshot(page, `Default Condition Widget (theme: '${theme}')`); @@ -137,8 +137,8 @@ test.describe('Visual - Default', () => { await percySnapshot(page, `removed amplitude property value (theme: '${theme}')`); }); - test('Visual - Save Successful Banner', async ({ page, theme }) => { - await createDomainObjectWithDefaults(page, 'Timer'); + test.fixme('Visual - Save Successful Banner', async ({ page, theme }) => { + await createDomainObjectWithDefaults(page, { type: 'Timer' }); await page.locator('.c-message-banner__message').hover({ trial: true }); await percySnapshot(page, `Banner message shown (theme: '${theme}')`); @@ -159,8 +159,8 @@ test.describe('Visual - Default', () => { }); - test('Visual - Default Gauge is correct', async ({ page, theme }) => { - await createDomainObjectWithDefaults(page, 'Gauge'); + test.fixme('Visual - Default Gauge is correct', async ({ page, theme }) => { + await createDomainObjectWithDefaults(page, { type: 'Gauge' }); // Take a snapshot of the newly created Gauge object await percySnapshot(page, `Default Gauge (theme: '${theme}')`); diff --git a/e2e/tests/visual/search.visual.spec.js b/e2e/tests/visual/search.visual.spec.js index 1af0e65b55..1cbf292501 100644 --- a/e2e/tests/visual/search.visual.spec.js +++ b/e2e/tests/visual/search.visual.spec.js @@ -46,7 +46,10 @@ test.describe('Grand Search', () => { // await page.locator('text=Snapshot Save and Finish Editing Save and Continue Editing >> button').nth(1).click(); // await page.locator('text=Save and Finish Editing').click(); const folder1 = 'Folder1'; - await createDomainObjectWithDefaults(page, 'Folder', folder1); + await createDomainObjectWithDefaults(page, { + type: 'Folder', + name: folder1 + }); // Click [aria-label="OpenMCT Search"] input[type="search"] await page.locator('[aria-label="OpenMCT Search"] input[type="search"]').click(); diff --git a/package.json b/package.json index c526c1e3dd..a3b11c41b7 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "openmct", - "version": "2.1.0-SNAPSHOT", + "version": "2.0.8-SNAPSHOT", "description": "The Open MCT core platform", "devDependencies": { "@babel/eslint-parser": "7.18.9", diff --git a/src/api/annotation/AnnotationAPI.js b/src/api/annotation/AnnotationAPI.js index a197620389..6b9e910be0 100644 --- a/src/api/annotation/AnnotationAPI.js +++ b/src/api/annotation/AnnotationAPI.js @@ -40,6 +40,8 @@ const ANNOTATION_TYPES = Object.freeze({ PLOT_SPATIAL: 'PLOT_SPATIAL' }); +const ANNOTATION_TYPE = 'annotation'; + /** * @typedef {Object} Tag * @property {String} key a unique identifier for the tag @@ -54,7 +56,7 @@ export default class AnnotationAPI extends EventEmitter { this.ANNOTATION_TYPES = ANNOTATION_TYPES; - this.openmct.types.addType('annotation', { + this.openmct.types.addType(ANNOTATION_TYPE, { name: 'Annotation', description: 'A user created note or comment about time ranges, pixel space, and geospatial features.', creatable: false, @@ -136,6 +138,10 @@ export default class AnnotationAPI extends EventEmitter { this.availableTags[tagKey] = tagsDefinition; } + isAnnotation(domainObject) { + return domainObject && (domainObject.type === ANNOTATION_TYPE); + } + getAvailableTags() { if (this.availableTags) { const rearrangedToArray = Object.keys(this.availableTags).map(tagKey => { @@ -271,7 +277,10 @@ export default class AnnotationAPI extends EventEmitter { const searchResults = (await Promise.all(this.openmct.objects.search(matchingTagKeys, abortController, this.openmct.objects.SEARCH_TYPES.TAGS))).flat(); const appliedTagSearchResults = this.#addTagMetaInformationToResults(searchResults, matchingTagKeys); const appliedTargetsModels = await this.#addTargetModelsToResults(appliedTagSearchResults); + const resultsWithValidPath = appliedTargetsModels.filter(result => { + return this.openmct.objects.isReachable(result.targetModels?.[0]?.originalPath); + }); - return appliedTargetsModels; + return resultsWithValidPath; } } diff --git a/src/api/annotation/AnnotationAPISpec.js b/src/api/annotation/AnnotationAPISpec.js index 731aead3cc..8aa45864d8 100644 --- a/src/api/annotation/AnnotationAPISpec.js +++ b/src/api/annotation/AnnotationAPISpec.js @@ -27,15 +27,26 @@ describe("The Annotation API", () => { let openmct; let mockObjectProvider; let mockDomainObject; + let mockFolderObject; let mockAnnotationObject; beforeEach((done) => { openmct = createOpenMct(); openmct.install(new ExampleTagsPlugin()); const availableTags = openmct.annotation.getAvailableTags(); + mockFolderObject = { + type: 'root', + name: 'folderFoo', + location: '', + identifier: { + key: 'someParent', + namespace: 'fooNameSpace' + } + }; mockDomainObject = { type: 'notebook', name: 'fooRabbitNotebook', + location: 'fooNameSpace:someParent', identifier: { key: 'some-object', namespace: 'fooNameSpace' @@ -68,6 +79,8 @@ describe("The Annotation API", () => { return mockDomainObject; } else if (identifier.key === mockAnnotationObject.identifier.key) { return mockAnnotationObject; + } else if (identifier.key === mockFolderObject.identifier.key) { + return mockFolderObject; } else { return null; } @@ -150,6 +163,7 @@ describe("The Annotation API", () => { // use local worker sharedWorkerToRestore = openmct.objects.inMemorySearchProvider.worker; openmct.objects.inMemorySearchProvider.worker = null; + await openmct.objects.inMemorySearchProvider.index(mockFolderObject); await openmct.objects.inMemorySearchProvider.index(mockDomainObject); await openmct.objects.inMemorySearchProvider.index(mockAnnotationObject); }); diff --git a/src/api/objects/ObjectAPI.js b/src/api/objects/ObjectAPI.js index ed5b53e6cd..b82bc61591 100644 --- a/src/api/objects/ObjectAPI.js +++ b/src/api/objects/ObjectAPI.js @@ -34,11 +34,11 @@ import InMemorySearchProvider from './InMemorySearchProvider'; * Uniquely identifies a domain object. * * @typedef Identifier - * @memberof module:openmct.ObjectAPI~ * @property {string} namespace the namespace to/from which this domain * object should be loaded/stored. * @property {string} key a unique identifier for the domain object * within that namespace + * @memberof module:openmct.ObjectAPI~ */ /** @@ -615,27 +615,60 @@ export default class ObjectAPI { * @param {module:openmct.ObjectAPI~Identifier[]} identifiers */ areIdsEqual(...identifiers) { + const firstIdentifier = utils.parseKeyString(identifiers[0]); + return identifiers.map(utils.parseKeyString) .every(identifier => { - return identifier === identifiers[0] - || (identifier.namespace === identifiers[0].namespace - && identifier.key === identifiers[0].key); + return identifier === firstIdentifier + || (identifier.namespace === firstIdentifier.namespace + && identifier.key === firstIdentifier.key); }); } - getOriginalPath(identifier, path = []) { - return this.get(identifier).then((domainObject) => { - path.push(domainObject); - let location = domainObject.location; + /** + * Given an original path check if the path is reachable via root + * @param {Array} originalPath an array of path objects to check + * @returns {boolean} whether the domain object is reachable + */ + isReachable(originalPath) { + if (originalPath && originalPath.length) { + return (originalPath[originalPath.length - 1].type === 'root'); + } - if (location) { - return this.getOriginalPath(utils.parseKeyString(location), path); - } else { - return path; - } + return false; + } + + #pathContainsDomainObject(keyStringToCheck, path) { + if (!keyStringToCheck) { + return false; + } + + return path.some(pathElement => { + const identifierToCheck = utils.parseKeyString(keyStringToCheck); + + return this.areIdsEqual(identifierToCheck, pathElement.identifier); }); } + /** + * Given an identifier, constructs the original path by walking up its parents + * @param {module:openmct.ObjectAPI~Identifier} identifier + * @param {Array} path an array of path objects + * @returns {Promise>} a promise containing an array of domain objects + */ + async getOriginalPath(identifier, path = []) { + const domainObject = await this.get(identifier); + path.push(domainObject); + const { location } = domainObject; + if (location && (!this.#pathContainsDomainObject(location, path))) { + // if we have a location, and we don't already have this in our constructed path, + // then keep walking up the path + return this.getOriginalPath(utils.parseKeyString(location), path); + } else { + return path; + } + } + isObjectPathToALink(domainObject, objectPath) { return objectPath !== undefined && objectPath.length > 1 diff --git a/src/api/objects/ObjectAPISpec.js b/src/api/objects/ObjectAPISpec.js index 4f6574598b..e473fc572e 100644 --- a/src/api/objects/ObjectAPISpec.js +++ b/src/api/objects/ObjectAPISpec.js @@ -377,6 +377,73 @@ describe("The Object API", () => { }); }); + describe("getOriginalPath", () => { + let mockGrandParentObject; + let mockParentObject; + let mockChildObject; + + beforeEach(() => { + const mockObjectProvider = jasmine.createSpyObj("mock object provider", [ + "create", + "update", + "get" + ]); + + mockGrandParentObject = { + type: 'folder', + name: 'Grand Parent Folder', + location: 'fooNameSpace:child', + identifier: { + key: 'grandParent', + namespace: 'fooNameSpace' + } + }; + mockParentObject = { + type: 'folder', + name: 'Parent Folder', + location: 'fooNameSpace:grandParent', + identifier: { + key: 'parent', + namespace: 'fooNameSpace' + } + }; + mockChildObject = { + type: 'folder', + name: 'Child Folder', + location: 'fooNameSpace:parent', + identifier: { + key: 'child', + namespace: 'fooNameSpace' + } + }; + + // eslint-disable-next-line require-await + mockObjectProvider.get = async (identifier) => { + if (identifier.key === mockGrandParentObject.identifier.key) { + return mockGrandParentObject; + } else if (identifier.key === mockParentObject.identifier.key) { + return mockParentObject; + } else if (identifier.key === mockChildObject.identifier.key) { + return mockChildObject; + } else { + return null; + } + }; + + openmct.objects.addProvider('fooNameSpace', mockObjectProvider); + + mockObjectProvider.create.and.returnValue(Promise.resolve(true)); + mockObjectProvider.update.and.returnValue(Promise.resolve(true)); + + openmct.objects.addProvider('fooNameSpace', mockObjectProvider); + }); + + it('can construct paths even with cycles', async () => { + const objectPath = await objectAPI.getOriginalPath(mockChildObject.identifier); + expect(objectPath.length).toEqual(3); + }); + }); + describe("transactions", () => { beforeEach(() => { spyOn(openmct.editor, 'isEditing').and.returnValue(true); diff --git a/src/api/objects/object-utils.js b/src/api/objects/object-utils.js index 6b4a78a775..abcb59c41e 100644 --- a/src/api/objects/object-utils.js +++ b/src/api/objects/object-utils.js @@ -91,6 +91,10 @@ define([ * @returns keyString */ function makeKeyString(identifier) { + if (!identifier) { + throw new Error("Cannot make key string from null identifier"); + } + if (isKeyString(identifier)) { return identifier; } diff --git a/src/plugins/condition/criterion/TelemetryCriterion.js b/src/plugins/condition/criterion/TelemetryCriterion.js index e343b9d598..75e91f7ddf 100644 --- a/src/plugins/condition/criterion/TelemetryCriterion.js +++ b/src/plugins/condition/criterion/TelemetryCriterion.js @@ -51,7 +51,11 @@ export default class TelemetryCriterion extends EventEmitter { } initialize() { - this.telemetryObjectIdAsString = this.openmct.objects.makeKeyString(this.telemetryDomainObjectDefinition.telemetry); + this.telemetryObjectIdAsString = ""; + if (![undefined, null, ""].includes(this.telemetryDomainObjectDefinition?.telemetry)) { + this.telemetryObjectIdAsString = this.openmct.objects.makeKeyString(this.telemetryDomainObjectDefinition.telemetry); + } + this.updateTelemetryObjects(this.telemetryDomainObjectDefinition.telemetryObjects); if (this.isValid() && this.isStalenessCheck() && this.isValidInput()) { this.subscribeForStaleData(); diff --git a/src/plugins/timelist/Timelist.vue b/src/plugins/timelist/Timelist.vue index 58af224c39..0e65599413 100644 --- a/src/plugins/timelist/Timelist.vue +++ b/src/plugins/timelist/Timelist.vue @@ -188,7 +188,8 @@ export default { if (domainObject.type === 'plan') { this.getPlanDataAndSetConfig({ ...this.domainObject, - selectFile: domainObject.selectFile + selectFile: domainObject.selectFile, + sourceMap: domainObject.sourceMap }); } }, diff --git a/src/styles/_controls.scss b/src/styles/_controls.scss index 3e356036d6..c986925783 100644 --- a/src/styles/_controls.scss +++ b/src/styles/_controls.scss @@ -25,13 +25,14 @@ /******************************************************** CONTROL-SPECIFIC MIXINS */ @mixin menuOuter() { border-radius: $basicCr; - box-shadow: $shdwMenuInner, $shdwMenu; + box-shadow: $shdwMenu; + @if $shdwMenuInner != none { + box-shadow: $shdwMenuInner, $shdwMenu; + } background: $colorMenuBg; color: $colorMenuFg; - //filter: $filterMenu; // 2022: causing all kinds of weird visual bugs in Chrome text-shadow: $shdwMenuText; padding: $interiorMarginSm; - //box-shadow: $shdwMenu; display: flex; flex-direction: column; position: absolute; @@ -60,14 +61,13 @@ cursor: pointer; display: flex; padding: nth($menuItemPad, 1) nth($menuItemPad, 2); - transition: $transIn; white-space: nowrap; @include hover { background: $colorMenuHovBg; color: $colorMenuHovFg; &:before { - color: $colorMenuHovIc; + color: $colorMenuHovIc !important; } } diff --git a/src/ui/layout/search/GrandSearch.vue b/src/ui/layout/search/GrandSearch.vue index 49a48baaf6..88dfe198e6 100644 --- a/src/ui/layout/search/GrandSearch.vue +++ b/src/ui/layout/search/GrandSearch.vue @@ -77,7 +77,6 @@ export default { } this.searchValue = value; - this.searchLoading = true; // clear any previous search results this.annotationSearchResults = []; this.objectSearchResults = []; @@ -85,8 +84,13 @@ export default { if (this.searchValue) { await this.getSearchResults(); } else { - this.searchLoading = false; - this.$refs.searchResultsDropDown.showResults(this.annotationSearchResults, this.objectSearchResults); + const dropdownOptions = { + searchLoading: this.searchLoading, + searchValue: this.searchValue, + annotationSearchResults: this.annotationSearchResults, + objectSearchResults: this.objectSearchResults + }; + this.$refs.searchResultsDropDown.showResults(dropdownOptions); } }, getPathsForObjects(objectsNeedingPaths) { @@ -103,6 +107,8 @@ export default { async getSearchResults() { // an abort controller will be passed in that will be used // to cancel an active searches if necessary + this.searchLoading = true; + this.$refs.searchResultsDropDown.showSearchStarted(); this.abortSearchController = new AbortController(); const abortSignal = this.abortSearchController.signal; try { @@ -110,10 +116,15 @@ export default { const fullObjectSearchResults = await Promise.all(this.openmct.objects.search(this.searchValue, abortSignal)); const aggregatedObjectSearchResults = fullObjectSearchResults.flat(); const aggregatedObjectSearchResultsWithPaths = await this.getPathsForObjects(aggregatedObjectSearchResults); - const filterAnnotations = aggregatedObjectSearchResultsWithPaths.filter(result => { - return result.type !== 'annotation'; + const filterAnnotationsAndValidPaths = aggregatedObjectSearchResultsWithPaths.filter(result => { + if (this.openmct.annotation.isAnnotation(result)) { + return false; + } + + return this.openmct.objects.isReachable(result?.originalPath); }); - this.objectSearchResults = filterAnnotations; + this.objectSearchResults = filterAnnotationsAndValidPaths; + this.searchLoading = false; this.showSearchResults(); } catch (error) { console.error(`😞 Error searching`, error); @@ -125,7 +136,13 @@ export default { } }, showSearchResults() { - this.$refs.searchResultsDropDown.showResults(this.annotationSearchResults, this.objectSearchResults); + const dropdownOptions = { + searchLoading: this.searchLoading, + searchValue: this.searchValue, + annotationSearchResults: this.annotationSearchResults, + objectSearchResults: this.objectSearchResults + }; + this.$refs.searchResultsDropDown.showResults(dropdownOptions); document.body.addEventListener('click', this.handleOutsideClick); }, handleOutsideClick(event) { diff --git a/src/ui/layout/search/GrandSearchSpec.js b/src/ui/layout/search/GrandSearchSpec.js index bb8d6dbc09..32d719b11f 100644 --- a/src/ui/layout/search/GrandSearchSpec.js +++ b/src/ui/layout/search/GrandSearchSpec.js @@ -39,6 +39,8 @@ describe("GrandSearch", () => { let mockAnnotationObject; let mockDisplayLayout; let mockFolderObject; + let mockAnotherFolderObject; + let mockTopObject; let originalRouterPath; beforeEach((done) => { @@ -70,11 +72,29 @@ describe("GrandSearch", () => { } } }; + mockTopObject = { + type: 'root', + name: 'Top Folder', + identifier: { + key: 'topObject', + namespace: 'fooNameSpace' + } + }; + mockAnotherFolderObject = { + type: 'folder', + name: 'Another Test Folder', + location: 'fooNameSpace:topObject', + identifier: { + key: 'someParent', + namespace: 'fooNameSpace' + } + }; mockFolderObject = { type: 'folder', name: 'Test Folder', + location: 'fooNameSpace:someParent', identifier: { - key: 'some-folder', + key: 'someFolder', namespace: 'fooNameSpace' } }; @@ -122,6 +142,10 @@ describe("GrandSearch", () => { return mockDisplayLayout; } else if (identifier.key === mockFolderObject.identifier.key) { return mockFolderObject; + } else if (identifier.key === mockAnotherFolderObject.identifier.key) { + return mockAnotherFolderObject; + } else if (identifier.key === mockTopObject.identifier.key) { + return mockTopObject; } else { return null; } diff --git a/src/ui/layout/search/SearchResultsDropDown.vue b/src/ui/layout/search/SearchResultsDropDown.vue index 2140389580..54e098a315 100644 --- a/src/ui/layout/search/SearchResultsDropDown.vue +++ b/src/ui/layout/search/SearchResultsDropDown.vue @@ -22,8 +22,6 @@ +