diff --git a/.github/workflows/e2e-couchdb.yml b/.github/workflows/e2e-couchdb.yml new file mode 100644 index 0000000000..c6ecf3eab1 --- /dev/null +++ b/.github/workflows/e2e-couchdb.yml @@ -0,0 +1,37 @@ +name: "e2e-couchdb" +on: + workflow_dispatch: + pull_request: + types: + - labeled + - opened +env: + OPENMCT_DATABASE_NAME: openmct + COUCH_ADMIN_USER: admin + COUCH_ADMIN_PASSWORD: password + COUCH_BASE_LOCAL: http://localhost:5984 + COUCH_NODE_NAME: nonode@nohost +jobs: + e2e-couchdb: + if: ${{ github.event.label.name == 'pr:e2e:couchdb' }} + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v3 + - run : docker-compose up -d -f src/plugins/persistence/couch/couchdb-compose.yaml + - run : sh src/plugins/persistence/couch/setup-couchdb.sh + - uses: actions/setup-node@v3 + with: + node-version: '16' + - run: npx playwright@1.23.0 install + - run: npm install + - run: sh src/plugins/persistence/couch/replace-localstorage-with-couchdb-indexhtml.sh + - run: npm run test:e2e:couchdb + - run: ls -latr + - name: Archive test results + uses: actions/upload-artifact@v3 + with: + path: test-results + - name: Archive html test results + uses: actions/upload-artifact@v3 + with: + path: html-test-results diff --git a/e2e/appActions.js b/e2e/appActions.js index e4fbf75899..c7a4428d5d 100644 --- a/e2e/appActions.js +++ b/e2e/appActions.js @@ -102,20 +102,15 @@ async function createDomainObjectWithDefaults(page, { type, name, parent = 'mine /** * Open the given `domainObject`'s context menu from the object tree. -* Expands the 'My Items' folder if it is not already expanded. +* Expands the path to the object and scrolls to it if necessary. * * @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` +* @param {string} url the url to the object */ -async function openObjectTreeContextMenu(page, myItemsFolderName, domainObjectName) { - const myItemsFolder = page.locator(`text=Open MCT ${myItemsFolderName} >> span`).nth(3); - const className = await myItemsFolder.getAttribute('class'); - if (!className.includes('c-disclosure-triangle--expanded')) { - await myItemsFolder.click(); - } - - await page.locator(`a:has-text("${domainObjectName}")`).click({ +async function openObjectTreeContextMenu(page, url) { + await page.goto(url); + await page.click('button[title="Show selected item in tree"]'); + await page.locator('.is-navigated-object').click({ button: 'right' }); } @@ -129,7 +124,7 @@ async function openObjectTreeContextMenu(page, myItemsFolderName, domainObjectNa 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); + return window.location.href.split('?')[0].match(regexp).at(-1); }, UUIDv4Regexp); return focusedObjectUuid; diff --git a/e2e/helper/addInitExampleFaultProvider.js b/e2e/helper/addInitExampleFaultProvider.js new file mode 100644 index 0000000000..1bf7b02096 --- /dev/null +++ b/e2e/helper/addInitExampleFaultProvider.js @@ -0,0 +1,28 @@ +/***************************************************************************** + * 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. + *****************************************************************************/ + +// This should be used to install the Example Fault Provider, this will also install the FaultManagementPlugin (neither of which are installed by default). + +document.addEventListener('DOMContentLoaded', () => { + const openmct = window.openmct; + openmct.install(openmct.plugins.example.ExampleFaultSource()); +}); diff --git a/e2e/helper/addInitExampleFaultProviderStatic.js b/e2e/helper/addInitExampleFaultProviderStatic.js new file mode 100644 index 0000000000..fc7ec53979 --- /dev/null +++ b/e2e/helper/addInitExampleFaultProviderStatic.js @@ -0,0 +1,30 @@ +/***************************************************************************** + * 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. + *****************************************************************************/ + +// This should be used to install the Example Fault Provider, this will also install the FaultManagementPlugin (neither of which are installed by default). + +document.addEventListener('DOMContentLoaded', () => { + const openmct = window.openmct; + const staticFaults = true; + + openmct.install(openmct.plugins.example.ExampleFaultSource(staticFaults)); +}); diff --git a/e2e/helper/addInitFaultManagementPlugin.js b/e2e/helper/addInitFaultManagementPlugin.js new file mode 100644 index 0000000000..4f1c396fa4 --- /dev/null +++ b/e2e/helper/addInitFaultManagementPlugin.js @@ -0,0 +1,28 @@ +/***************************************************************************** + * 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. + *****************************************************************************/ + +// This should be used to install the Example Fault Provider, this will also install the FaultManagementPlugin (neither of which are installed by default). + +document.addEventListener('DOMContentLoaded', () => { + const openmct = window.openmct; + openmct.install(openmct.plugins.FaultManagement()); +}); diff --git a/e2e/helper/faultUtils.js b/e2e/helper/faultUtils.js new file mode 100644 index 0000000000..819c4b42b9 --- /dev/null +++ b/e2e/helper/faultUtils.js @@ -0,0 +1,277 @@ +/***************************************************************************** + * 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 path = require('path'); + +/** + * @param {import('@playwright/test').Page} page + */ +async function navigateToFaultManagementWithExample(page) { + // eslint-disable-next-line no-undef + await page.addInitScript({ path: path.join(__dirname, './', 'addInitExampleFaultProvider.js') }); + + await navigateToFaultItemInTree(page); +} + +/** + * @param {import('@playwright/test').Page} page + */ +async function navigateToFaultManagementWithStaticExample(page) { + // eslint-disable-next-line no-undef + await page.addInitScript({ path: path.join(__dirname, './', 'addInitExampleFaultProviderStatic.js') }); + + await navigateToFaultItemInTree(page); +} + +/** + * @param {import('@playwright/test').Page} page + */ +async function navigateToFaultManagementWithoutExample(page) { + // eslint-disable-next-line no-undef + await page.addInitScript({ path: path.join(__dirname, './', 'addInitFaultManagementPlugin.js') }); + + await navigateToFaultItemInTree(page); +} + +/** + * @param {import('@playwright/test').Page} page + */ +async function navigateToFaultItemInTree(page) { + await page.goto('./', { waitUntil: 'networkidle' }); + + // Click text=Fault Management + await page.click('text=Fault Management'); // this verifies the plugin has been added +} + +/** + * @param {import('@playwright/test').Page} page + */ +async function acknowledgeFault(page, rowNumber) { + await openFaultRowMenu(page, rowNumber); + await page.locator('.c-menu >> text="Acknowledge"').click(); + // Click [aria-label="Save"] + await page.locator('[aria-label="Save"]').click(); + +} + +/** + * @param {import('@playwright/test').Page} page + */ +async function shelveMultipleFaults(page, ...nums) { + const selectRows = nums.map((num) => { + return selectFaultItem(page, num); + }); + await Promise.all(selectRows); + + await page.locator('button:has-text("Shelve")').click(); + await page.locator('[aria-label="Save"]').click(); +} + +/** + * @param {import('@playwright/test').Page} page + */ +async function acknowledgeMultipleFaults(page, ...nums) { + const selectRows = nums.map((num) => { + return selectFaultItem(page, num); + }); + await Promise.all(selectRows); + + await page.locator('button:has-text("Acknowledge")').click(); + await page.locator('[aria-label="Save"]').click(); +} + +/** + * @param {import('@playwright/test').Page} page + */ +async function shelveFault(page, rowNumber) { + await openFaultRowMenu(page, rowNumber); + await page.locator('.c-menu >> text="Shelve"').click(); + // Click [aria-label="Save"] + await page.locator('[aria-label="Save"]').click(); +} + +/** + * @param {import('@playwright/test').Page} page + */ +async function changeViewTo(page, view) { + await page.locator('.c-fault-mgmt__search-row select').first().selectOption(view); +} + +/** + * @param {import('@playwright/test').Page} page + */ +async function sortFaultsBy(page, sort) { + await page.locator('.c-fault-mgmt__list-header-sortButton select').selectOption(sort); +} + +/** + * @param {import('@playwright/test').Page} page + */ +async function enterSearchTerm(page, term) { + await page.locator('.c-fault-mgmt-search [aria-label="Search Input"]').fill(term); +} + +/** + * @param {import('@playwright/test').Page} page + */ +async function clearSearch(page) { + await enterSearchTerm(page, ''); +} + +/** + * @param {import('@playwright/test').Page} page + */ +async function selectFaultItem(page, rowNumber) { + // eslint-disable-next-line playwright/no-force-option + await page.check(`.c-fault-mgmt-item > input >> nth=${rowNumber - 1}`, { force: true }); // this will not work without force true, saw this may be a pw bug +} + +/** + * @param {import('@playwright/test').Page} page + */ +async function getHighestSeverity(page) { + const criticalCount = await page.locator('[title=CRITICAL]').count(); + const warningCount = await page.locator('[title=WARNING]').count(); + + if (criticalCount > 0) { + return 'CRITICAL'; + } else if (warningCount > 0) { + return 'WARNING'; + } + + return 'WATCH'; +} + +/** + * @param {import('@playwright/test').Page} page + */ +async function getLowestSeverity(page) { + const warningCount = await page.locator('[title=WARNING]').count(); + const watchCount = await page.locator('[title=WATCH]').count(); + + if (watchCount > 0) { + return 'WATCH'; + } else if (warningCount > 0) { + return 'WARNING'; + } + + return 'CRITICAL'; +} + +/** + * @param {import('@playwright/test').Page} page + */ +async function getFaultResultCount(page) { + const count = await page.locator('.c-faults-list-view-item-body > .c-fault-mgmt__list').count(); + + return count; +} + +/** + * @param {import('@playwright/test').Page} page + */ +function getFault(page, rowNumber) { + const fault = page.locator(`.c-faults-list-view-item-body > .c-fault-mgmt__list >> nth=${rowNumber - 1}`); + + return fault; +} + +/** + * @param {import('@playwright/test').Page} page + */ +function getFaultByName(page, name) { + const fault = page.locator(`.c-fault-mgmt__list-faultname:has-text("${name}")`); + + return fault; +} + +/** + * @param {import('@playwright/test').Page} page + */ +async function getFaultName(page, rowNumber) { + const faultName = await page.locator(`.c-fault-mgmt__list-faultname >> nth=${rowNumber - 1}`).textContent(); + + return faultName; +} + +/** + * @param {import('@playwright/test').Page} page + */ +async function getFaultSeverity(page, rowNumber) { + const faultSeverity = await page.locator(`.c-faults-list-view-item-body .c-fault-mgmt__list-severity >> nth=${rowNumber - 1}`).getAttribute('title'); + + return faultSeverity; +} + +/** + * @param {import('@playwright/test').Page} page + */ +async function getFaultNamespace(page, rowNumber) { + const faultNamespace = await page.locator(`.c-fault-mgmt__list-path >> nth=${rowNumber - 1}`).textContent(); + + return faultNamespace; +} + +/** + * @param {import('@playwright/test').Page} page + */ +async function getFaultTriggerTime(page, rowNumber) { + const faultTriggerTime = await page.locator(`.c-fault-mgmt__list-trigTime >> nth=${rowNumber - 1} >> .c-fault-mgmt-item__value`).textContent(); + + return faultTriggerTime.toString().trim(); +} + +/** + * @param {import('@playwright/test').Page} page + */ +async function openFaultRowMenu(page, rowNumber) { + // select + await page.locator(`.c-fault-mgmt-item > .c-fault-mgmt__list-action-button >> nth=${rowNumber - 1}`).click(); + +} + +// eslint-disable-next-line no-undef +module.exports = { + navigateToFaultManagementWithExample, + navigateToFaultManagementWithStaticExample, + navigateToFaultManagementWithoutExample, + navigateToFaultItemInTree, + acknowledgeFault, + shelveMultipleFaults, + acknowledgeMultipleFaults, + shelveFault, + changeViewTo, + sortFaultsBy, + enterSearchTerm, + clearSearch, + selectFaultItem, + getHighestSeverity, + getLowestSeverity, + getFaultResultCount, + getFault, + getFaultByName, + getFaultName, + getFaultSeverity, + getFaultNamespace, + getFaultTriggerTime, + openFaultRowMenu +}; diff --git a/e2e/tests/functional/couchdb.e2e.spec.js b/e2e/tests/functional/couchdb.e2e.spec.js new file mode 100644 index 0000000000..7e8d539de1 --- /dev/null +++ b/e2e/tests/functional/couchdb.e2e.spec.js @@ -0,0 +1,108 @@ +/***************************************************************************** + * 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. + *****************************************************************************/ + +/* +* This test suite is meant to be executed against a couchdb container. More doc to come +* +*/ + +const { test, expect } = require('../../baseFixtures'); + +test.describe("CouchDB Status Indicator @couchdb", () => { + test.use({ failOnConsoleError: false }); + //TODO BeforeAll Verify CouchDB Connectivity with APIContext + test('Shows green if connected', async ({ page }) => { + await page.route('**/openmct/mine', route => { + route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({}) + }); + }); + + //Go to baseURL + await page.goto('./#/browse/mine?hideTree=true&hideInspector=true', { waitUntil: 'networkidle' }); + await expect(page.locator('div:has-text("CouchDB is connected")').nth(3)).toBeVisible(); + }); + test('Shows red if not connected', async ({ page }) => { + await page.route('**/openmct/**', route => { + route.fulfill({ + status: 503, + contentType: 'application/json', + body: JSON.stringify({}) + }); + }); + + //Go to baseURL + await page.goto('./#/browse/mine?hideTree=true&hideInspector=true', { waitUntil: 'networkidle' }); + await expect(page.locator('div:has-text("CouchDB is offline")').nth(3)).toBeVisible(); + }); + test('Shows unknown if it receives an unexpected response code', async ({ page }) => { + await page.route('**/openmct/mine', route => { + route.fulfill({ + status: 418, + contentType: 'application/json', + body: JSON.stringify({}) + }); + }); + + //Go to baseURL + await page.goto('./#/browse/mine?hideTree=true&hideInspector=true', { waitUntil: 'networkidle' }); + await expect(page.locator('div:has-text("CouchDB connectivity unknown")').nth(3)).toBeVisible(); + }); +}); + +test.describe("CouchDB initialization @couchdb", () => { + test.use({ failOnConsoleError: false }); + test("'My Items' folder is created if it doesn't exist", async ({ page }) => { + // Store any relevant PUT requests that happen on the page + const createMineFolderRequests = []; + page.on('request', req => { + // eslint-disable-next-line playwright/no-conditional-in-test + if (req.method() === 'PUT' && req.url().endsWith('openmct/mine')) { + createMineFolderRequests.push(req); + } + }); + + // Override the first request to GET openmct/mine to return a 404 + await page.route('**/openmct/mine', route => { + route.fulfill({ + status: 404, + contentType: 'application/json', + body: JSON.stringify({}) + }); + }, { times: 1 }); + + // Go to baseURL + await page.goto('./', { waitUntil: 'networkidle' }); + + // Verify that error banner is displayed + const bannerMessage = await page.locator('.c-message-banner__message').innerText(); + expect(bannerMessage).toEqual('Failed to retrieve object mine'); + + // Verify that a PUT request to create "My Items" folder was made + expect.poll(() => createMineFolderRequests.length, { + message: 'Verify that PUT request to create "mine" folder was made', + timeout: 1000 + }).toBeGreaterThanOrEqual(1); + }); +}); diff --git a/e2e/tests/functional/moveAndLinkObjects.e2e.spec.js b/e2e/tests/functional/moveAndLinkObjects.e2e.spec.js new file mode 100644 index 0000000000..78f20cb65f --- /dev/null +++ b/e2e/tests/functional/moveAndLinkObjects.e2e.spec.js @@ -0,0 +1,212 @@ +/***************************************************************************** + * 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. + *****************************************************************************/ + +/* +This test suite is dedicated to tests which verify the basic operations surrounding moving & linking objects. +*/ + +const { test, expect } = require('../../pluginFixtures'); +const { createDomainObjectWithDefaults } = require('../../appActions'); + +test.describe('Move & link item tests', () => { + test('Create a basic object and verify that it can be moved to another folder', async ({ page, openmctConfig }) => { + const { myItemsFolderName } = openmctConfig; + + // Go to Open MCT + await page.goto('./'); + + const parentFolder = await createDomainObjectWithDefaults(page, { + type: 'Folder', + name: 'Parent Folder' + }); + const childFolder = await createDomainObjectWithDefaults(page, { + type: 'Folder', + name: 'Child Folder', + parent: parentFolder.uuid + }); + await createDomainObjectWithDefaults(page, { + type: 'Folder', + name: 'Grandchild Folder', + parent: childFolder.uuid + }); + + // Attempt to move parent to its own grandparent + await page.locator(`text=Open MCT ${myItemsFolderName} >> span`).nth(3).click(); + await page.locator('.c-disclosure-triangle >> nth=0').click(); + + await page.locator(`a:has-text("Parent Folder") >> nth=0`).click({ + button: 'right' + }); + + await page.locator('li.icon-move').click(); + await page.locator('form[name="mctForm"] >> .c-disclosure-triangle >> nth=0').click(); + await page.locator('form[name="mctForm"] >> text=Parent Folder').click(); + await expect(page.locator('[aria-label="Save"]')).toBeDisabled(); + await page.locator('form[name="mctForm"] >> .c-disclosure-triangle >> nth=1').click(); + await page.locator('form[name="mctForm"] >> text=Child Folder').click(); + await expect(page.locator('[aria-label="Save"]')).toBeDisabled(); + await page.locator('form[name="mctForm"] >> .c-disclosure-triangle >> nth=2').click(); + await page.locator('form[name="mctForm"] >> text=Grandchild Folder').click(); + await expect(page.locator('[aria-label="Save"]')).toBeDisabled(); + await page.locator('form[name="mctForm"] >> text=Parent Folder').click(); + await expect(page.locator('[aria-label="Save"]')).toBeDisabled(); + await page.locator('[aria-label="Cancel"]').click(); + + // Move Child Folder from Parent Folder to My Items + await page.locator('.c-disclosure-triangle >> nth=0').click(); + await page.locator('.c-disclosure-triangle >> nth=1').click(); + + await page.locator(`a:has-text("Child Folder") >> nth=0`).click({ + button: 'right' + }); + await page.locator('li.icon-move').click(); + await page.locator(`form[name="mctForm"] >> text=${myItemsFolderName}`).click(); + + await page.locator('text=OK').click(); + + // Expect that Child Folder is in My Items, the root folder + expect(page.locator(`text=${myItemsFolderName} >> nth=0:has(text=Child Folder)`)).toBeTruthy(); + }); + test('Create a basic object and verify that it cannot be moved to telemetry object without Composition Provider', async ({ page, openmctConfig }) => { + const { myItemsFolderName } = openmctConfig; + + // Go to Open MCT + await page.goto('./'); + + // Create Telemetry Table + let telemetryTable = 'Test Telemetry Table'; + await page.locator('button:has-text("Create")').click(); + await page.locator('li:has-text("Telemetry Table")').click(); + await page.locator('text=Properties Title Notes >> input[type="text"]').click(); + await page.locator('text=Properties Title Notes >> input[type="text"]').fill(telemetryTable); + + await page.locator('text=OK').click(); + + // Finish editing and save Telemetry Table + await page.locator('.c-button--menu.c-button--major.icon-save').click(); + await page.locator('text=Save and Finish Editing').click(); + + // Create New Folder Basic Domain Object + let folder = 'Test Folder'; + await page.locator('button:has-text("Create")').click(); + await page.locator('li:has-text("Folder")').click(); + await page.locator('text=Properties Title Notes >> input[type="text"]').click(); + await page.locator('text=Properties Title Notes >> input[type="text"]').fill(folder); + + // See if it's possible to put the folder in the Telemetry object during creation (Soft Assert) + await page.locator(`form[name="mctForm"] >> text=${telemetryTable}`).click(); + let okButton = await page.locator('button.c-button.c-button--major:has-text("OK")'); + let okButtonStateDisabled = await okButton.isDisabled(); + expect.soft(okButtonStateDisabled).toBeTruthy(); + + // Continue test regardless of assertion and create it in My Items + await page.locator(`form[name="mctForm"] >> text=${myItemsFolderName}`).click(); + await page.locator('text=OK').click(); + + // Open My Items + await page.locator(`text=Open MCT ${myItemsFolderName} >> span`).nth(3).click(); + + // Select Folder Object and select Move from context menu + await Promise.all([ + page.waitForNavigation(), + page.locator(`a:has-text("${folder}")`).click() + ]); + await page.locator('.c-tree__item.is-navigated-object .c-tree__item__label .c-tree__item__type-icon').click({ + button: 'right' + }); + await page.locator('li.icon-move').click(); + + // See if it's possible to put the folder in the Telemetry object after creation + await page.locator(`text=Location Open MCT ${myItemsFolderName} >> span`).nth(3).click(); + await page.locator(`form[name="mctForm"] >> text=${telemetryTable}`).click(); + let okButton2 = await page.locator('button.c-button.c-button--major:has-text("OK")'); + let okButtonStateDisabled2 = await okButton2.isDisabled(); + expect(okButtonStateDisabled2).toBeTruthy(); + }); + + test('Create a basic object and verify that it can be linked to another folder', async ({ page, openmctConfig }) => { + const { myItemsFolderName } = openmctConfig; + + // Go to Open MCT + await page.goto('./'); + + const parentFolder = await createDomainObjectWithDefaults(page, { + type: 'Folder', + name: 'Parent Folder' + }); + const childFolder = await createDomainObjectWithDefaults(page, { + type: 'Folder', + name: 'Child Folder', + parent: parentFolder.uuid + }); + await createDomainObjectWithDefaults(page, { + type: 'Folder', + name: 'Grandchild Folder', + parent: childFolder.uuid + }); + + // Attempt to link parent to its own grandparent + await page.locator(`text=Open MCT ${myItemsFolderName} >> span`).nth(3).click(); + await page.locator('.c-disclosure-triangle >> nth=0').click(); + + await page.locator(`a:has-text("Parent Folder") >> nth=0`).click({ + button: 'right' + }); + + await page.locator('li.icon-link').click(); + await page.locator('form[name="mctForm"] >> .c-disclosure-triangle >> nth=0').click(); + await page.locator('form[name="mctForm"] >> text=Parent Folder').click(); + await expect(page.locator('[aria-label="Save"]')).toBeDisabled(); + await page.locator('form[name="mctForm"] >> .c-disclosure-triangle >> nth=1').click(); + await page.locator('form[name="mctForm"] >> text=Child Folder').click(); + await expect(page.locator('[aria-label="Save"]')).toBeDisabled(); + await page.locator('form[name="mctForm"] >> .c-disclosure-triangle >> nth=2').click(); + await page.locator('form[name="mctForm"] >> text=Grandchild Folder').click(); + await expect(page.locator('[aria-label="Save"]')).toBeDisabled(); + await page.locator('form[name="mctForm"] >> text=Parent Folder').click(); + await expect(page.locator('[aria-label="Save"]')).toBeDisabled(); + await page.locator('[aria-label="Cancel"]').click(); + + // Link Child Folder from Parent Folder to My Items + await page.locator('.c-disclosure-triangle >> nth=0').click(); + await page.locator('.c-disclosure-triangle >> nth=1').click(); + + await page.locator(`a:has-text("Child Folder") >> nth=0`).click({ + button: 'right' + }); + await page.locator('li.icon-link').click(); + await page.locator(`form[name="mctForm"] >> text=${myItemsFolderName}`).click(); + + await page.locator('text=OK').click(); + + // Expect that Child Folder is in My Items, the root folder + expect(page.locator(`text=${myItemsFolderName} >> nth=0:has(text=Child Folder)`)).toBeTruthy(); + }); +}); + +test.fixme('Cannot move a previously created domain object to non-peristable object in Move Modal', async ({ page }) => { + //Create a domain object + //Save Domain object + //Move Object and verify that cannot select non-persistable object + //Move Object to My Items + //Verify successful move +}); diff --git a/e2e/tests/functional/moveObjects.e2e.spec.js b/e2e/tests/functional/moveObjects.e2e.spec.js deleted file mode 100644 index f11f8c7b23..0000000000 --- a/e2e/tests/functional/moveObjects.e2e.spec.js +++ /dev/null @@ -1,148 +0,0 @@ -/***************************************************************************** - * 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. - *****************************************************************************/ - -/* -This test suite is dedicated to tests which verify the basic operations surrounding moving objects. -*/ - -const { test, expect } = require('../../pluginFixtures'); - -test.describe('Move item tests', () => { - test('Create a basic object and verify that it can be moved to another folder', async ({ page, openmctConfig }) => { - const { myItemsFolderName } = openmctConfig; - - // Go to Open MCT - await page.goto('./'); - - // Create a new folder in the root my items folder - let folder1 = "Folder1"; - await page.locator('button:has-text("Create")').click(); - await page.locator('li.icon-folder').click(); - - await page.locator('text=Properties Title Notes >> input[type="text"]').click(); - await page.locator('text=Properties Title Notes >> input[type="text"]').fill(folder1); - - await Promise.all([ - page.waitForNavigation(), - page.locator('text=OK').click(), - page.waitForSelector('.c-message-banner__message') - ]); - //Wait until Save Banner is gone - await page.locator('.c-message-banner__close-button').click(); - await page.waitForSelector('.c-message-banner__message', { state: 'detached'}); - - // Create another folder with a new name at default location, which is currently inside Folder 1 - let folder2 = "Folder2"; - await page.locator('button:has-text("Create")').click(); - await page.locator('li.icon-folder').click(); - await page.locator('text=Properties Title Notes >> input[type="text"]').click(); - await page.locator('text=Properties Title Notes >> input[type="text"]').fill(folder2); - - await Promise.all([ - page.waitForNavigation(), - page.locator('text=OK').click(), - page.waitForSelector('.c-message-banner__message') - ]); - //Wait until Save Banner is gone - await page.locator('.c-message-banner__close-button').click(); - await page.waitForSelector('.c-message-banner__message', { state: 'detached'}); - - // Move Folder 2 from Folder 1 to My Items - await page.locator(`text=Open MCT ${myItemsFolderName} >> span`).nth(3).click(); - await page.locator('.c-tree__scrollable div div:nth-child(2) .c-tree__item .c-tree__item__view-control').click(); - - await page.locator(`a:has-text("${folder2}")`).click({ - button: 'right' - }); - await page.locator('li.icon-move').click(); - await page.locator(`form[name="mctForm"] >> text=${myItemsFolderName}`).click(); - - await page.locator('text=OK').click(); - - // Expect that Folder 2 is in My Items, the root folder - expect(page.locator(`text=${myItemsFolderName} >> nth=0:has(text=${folder2})`)).toBeTruthy(); - }); - test('Create a basic object and verify that it cannot be moved to telemetry object without Composition Provider', async ({ page, openmctConfig }) => { - const { myItemsFolderName } = openmctConfig; - - // Go to Open MCT - await page.goto('./'); - - // Create Telemetry Table - let telemetryTable = 'Test Telemetry Table'; - await page.locator('button:has-text("Create")').click(); - await page.locator('li:has-text("Telemetry Table")').click(); - await page.locator('text=Properties Title Notes >> input[type="text"]').click(); - await page.locator('text=Properties Title Notes >> input[type="text"]').fill(telemetryTable); - - await page.locator('text=OK').click(); - - // Finish editing and save Telemetry Table - await page.locator('.c-button--menu.c-button--major.icon-save').click(); - await page.locator('text=Save and Finish Editing').click(); - - // Create New Folder Basic Domain Object - let folder = 'Test Folder'; - await page.locator('button:has-text("Create")').click(); - await page.locator('li:has-text("Folder")').click(); - await page.locator('text=Properties Title Notes >> input[type="text"]').click(); - await page.locator('text=Properties Title Notes >> input[type="text"]').fill(folder); - - // See if it's possible to put the folder in the Telemetry object during creation (Soft Assert) - await page.locator(`form[name="mctForm"] >> text=${telemetryTable}`).click(); - let okButton = await page.locator('button.c-button.c-button--major:has-text("OK")'); - let okButtonStateDisabled = await okButton.isDisabled(); - expect.soft(okButtonStateDisabled).toBeTruthy(); - - // Continue test regardless of assertion and create it in My Items - await page.locator(`form[name="mctForm"] >> text=${myItemsFolderName}`).click(); - await page.locator('text=OK').click(); - - // Open My Items - await page.locator(`text=Open MCT ${myItemsFolderName} >> span`).nth(3).click(); - - // Select Folder Object and select Move from context menu - await Promise.all([ - page.waitForNavigation(), - page.locator(`a:has-text("${folder}")`).click() - ]); - await page.locator('.c-tree__item.is-navigated-object .c-tree__item__label .c-tree__item__type-icon').click({ - button: 'right' - }); - await page.locator('li.icon-move').click(); - - // See if it's possible to put the folder in the Telemetry object after creation - await page.locator(`text=Location Open MCT ${myItemsFolderName} >> span`).nth(3).click(); - await page.locator(`form[name="mctForm"] >> text=${telemetryTable}`).click(); - let okButton2 = await page.locator('button.c-button.c-button--major:has-text("OK")'); - let okButtonStateDisabled2 = await okButton2.isDisabled(); - expect(okButtonStateDisabled2).toBeTruthy(); - }); -}); - -test.fixme('Cannot move a previously created domain object to non-peristable object in Move Modal', async ({ page }) => { - //Create a domain object - //Save Domain object - //Move Object and verify that cannot select non-persistable object - //Move Object to My Items - //Verify successful move -}); diff --git a/e2e/tests/functional/plugins/displayLayout/displayLayout.e2e.spec.js b/e2e/tests/functional/plugins/displayLayout/displayLayout.e2e.spec.js index 83090fc0e7..3d6456e2e0 100644 --- a/e2e/tests/functional/plugins/displayLayout/displayLayout.e2e.spec.js +++ b/e2e/tests/functional/plugins/displayLayout/displayLayout.e2e.spec.js @@ -93,6 +93,70 @@ test.describe('Testing Display Layout @unstable', () => { await expect(trimmedDisplayValue).toBe(formattedTelemetryValue); }); + test('items in a display layout can be removed with object tree context menu when viewing the display layout', 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(); + + expect.soft(await page.locator('.l-layout .l-layout__frame').count()).toEqual(1); + + // Expand the Display Layout so we can remove the sine wave generator + await page.locator('.c-tree__item.is-navigated-object .c-disclosure-triangle').click(); + + // Bring up context menu and remove + await page.locator('.c-tree__item.is-alias .c-tree__item__name:text("Test Sine Wave Generator")').first().click({ button: 'right' }); + await page.locator('text=Remove').click(); + await page.locator('text=OK').click(); + + // delete + + expect.soft(await page.locator('.l-layout .l-layout__frame').count()).toEqual(0); + }); + test('items in a display layout can be removed with object tree context menu when viewing another item', 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(); + + expect.soft(await page.locator('.l-layout .l-layout__frame').count()).toEqual(1); + + // Expand the Display Layout so we can remove the sine wave generator + await page.locator('.c-tree__item.is-navigated-object .c-disclosure-triangle').click(); + + // Click the original Sine Wave Generator to navigate away from the Display Layout + await page.locator('.c-tree__item .c-tree__item__name:text("Test Sine Wave Generator")').click(); + + // Bring up context menu and remove + await page.locator('.c-tree__item.is-alias .c-tree__item__name:text("Test Sine Wave Generator")').click({ button: 'right' }); + await page.locator('text=Remove').click(); + await page.locator('text=OK').click(); + + // navigate back to the display layout to confirm it has been removed + await page.locator('.c-tree__item .c-tree__item__name:text("Test Display Layout")').click(); + + expect.soft(await page.locator('.l-layout .l-layout__frame').count()).toEqual(0); + }); }); /** diff --git a/e2e/tests/functional/plugins/faultManagement/faultManagement.e2e.spec.js b/e2e/tests/functional/plugins/faultManagement/faultManagement.e2e.spec.js new file mode 100644 index 0000000000..8bf08e0c9b --- /dev/null +++ b/e2e/tests/functional/plugins/faultManagement/faultManagement.e2e.spec.js @@ -0,0 +1,237 @@ +/***************************************************************************** + * 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 utils = require('../../../../helper/faultUtils'); + +test.describe('The Fault Management Plugin using example faults', () => { + test.beforeEach(async ({ page }) => { + await utils.navigateToFaultManagementWithExample(page); + }); + + test('Shows a criticality icon for every fault', async ({ page }) => { + const faultCount = await page.locator('c-fault-mgmt__list').count(); + const criticalityIconCount = await page.locator('c-fault-mgmt__list-severity').count(); + + expect.soft(faultCount).toEqual(criticalityIconCount); + }); + + test('When selecting a fault, it has an "is-selected" class and it\'s information shows in the inspector', async ({ page }) => { + await utils.selectFaultItem(page, 1); + + const selectedFaultName = await page.locator('.c-fault-mgmt__list.is-selected .c-fault-mgmt__list-faultname').textContent(); + const inspectorFaultNameCount = await page.locator(`.c-inspector__properties >> :text("${selectedFaultName}")`).count(); + + await expect.soft(page.locator('.c-faults-list-view-item-body > .c-fault-mgmt__list').first()).toHaveClass(/is-selected/); + expect.soft(inspectorFaultNameCount).toEqual(1); + }); + + test('When selecting multiple faults, no specific fault information is shown in the inspector', async ({ page }) => { + await utils.selectFaultItem(page, 1); + await utils.selectFaultItem(page, 2); + + const selectedRows = page.locator('.c-fault-mgmt__list.is-selected .c-fault-mgmt__list-faultname'); + expect.soft(await selectedRows.count()).toEqual(2); + + const firstSelectedFaultName = await selectedRows.nth(0).textContent(); + const secondSelectedFaultName = await selectedRows.nth(1).textContent(); + const firstNameInInspectorCount = await page.locator(`.c-inspector__properties >> :text("${firstSelectedFaultName}")`).count(); + const secondNameInInspectorCount = await page.locator(`.c-inspector__properties >> :text("${secondSelectedFaultName}")`).count(); + + expect.soft(firstNameInInspectorCount).toEqual(0); + expect.soft(secondNameInInspectorCount).toEqual(0); + }); + + test('Allows you to shelve a fault', async ({ page }) => { + const shelvedFaultName = await utils.getFaultName(page, 2); + const beforeShelvedFault = utils.getFaultByName(page, shelvedFaultName); + + expect.soft(await beforeShelvedFault.count()).toBe(1); + + await utils.shelveFault(page, 2); + + // check it is removed from standard view + const afterShelvedFault = utils.getFaultByName(page, shelvedFaultName); + expect.soft(await afterShelvedFault.count()).toBe(0); + + await utils.changeViewTo(page, 'shelved'); + + const shelvedViewFault = utils.getFaultByName(page, shelvedFaultName); + + expect.soft(await shelvedViewFault.count()).toBe(1); + }); + + test('Allows you to acknowledge a fault', async ({ page }) => { + const acknowledgedFaultName = await utils.getFaultName(page, 3); + + await utils.acknowledgeFault(page, 3); + + const fault = utils.getFault(page, 3); + await expect.soft(fault).toHaveClass(/is-acknowledged/); + + await utils.changeViewTo(page, 'acknowledged'); + + const acknowledgedViewFaultName = await utils.getFaultName(page, 1); + expect.soft(acknowledgedFaultName).toEqual(acknowledgedViewFaultName); + }); + + test('Allows you to shelve multiple faults', async ({ page }) => { + const shelvedFaultNameOne = await utils.getFaultName(page, 1); + const shelvedFaultNameFour = await utils.getFaultName(page, 4); + + const beforeShelvedFaultOne = utils.getFaultByName(page, shelvedFaultNameOne); + const beforeShelvedFaultFour = utils.getFaultByName(page, shelvedFaultNameFour); + + expect.soft(await beforeShelvedFaultOne.count()).toBe(1); + expect.soft(await beforeShelvedFaultFour.count()).toBe(1); + + await utils.shelveMultipleFaults(page, 1, 4); + + // check it is removed from standard view + const afterShelvedFaultOne = utils.getFaultByName(page, shelvedFaultNameOne); + const afterShelvedFaultFour = utils.getFaultByName(page, shelvedFaultNameFour); + expect.soft(await afterShelvedFaultOne.count()).toBe(0); + expect.soft(await afterShelvedFaultFour.count()).toBe(0); + + await utils.changeViewTo(page, 'shelved'); + + const shelvedViewFaultOne = utils.getFaultByName(page, shelvedFaultNameOne); + const shelvedViewFaultFour = utils.getFaultByName(page, shelvedFaultNameFour); + + expect.soft(await shelvedViewFaultOne.count()).toBe(1); + expect.soft(await shelvedViewFaultFour.count()).toBe(1); + }); + + test('Allows you to acknowledge multiple faults', async ({ page }) => { + const acknowledgedFaultNameTwo = await utils.getFaultName(page, 2); + const acknowledgedFaultNameFive = await utils.getFaultName(page, 5); + + await utils.acknowledgeMultipleFaults(page, 2, 5); + + const faultTwo = utils.getFault(page, 2); + const faultFive = utils.getFault(page, 5); + + // check they have been acknowledged + await expect.soft(faultTwo).toHaveClass(/is-acknowledged/); + await expect.soft(faultFive).toHaveClass(/is-acknowledged/); + + await utils.changeViewTo(page, 'acknowledged'); + + const acknowledgedViewFaultTwo = utils.getFaultByName(page, acknowledgedFaultNameTwo); + const acknowledgedViewFaultFive = utils.getFaultByName(page, acknowledgedFaultNameFive); + + expect.soft(await acknowledgedViewFaultTwo.count()).toBe(1); + expect.soft(await acknowledgedViewFaultFive.count()).toBe(1); + }); + + test('Allows you to search faults', async ({ page }) => { + const faultThreeNamespace = await utils.getFaultNamespace(page, 3); + const faultTwoName = await utils.getFaultName(page, 2); + const faultFiveTriggerTime = await utils.getFaultTriggerTime(page, 5); + + // should be all faults (5) + let faultResultCount = await utils.getFaultResultCount(page); + expect.soft(faultResultCount).toEqual(5); + + // search namespace + await utils.enterSearchTerm(page, faultThreeNamespace); + + faultResultCount = await utils.getFaultResultCount(page); + expect.soft(faultResultCount).toEqual(1); + expect.soft(await utils.getFaultNamespace(page, 1)).toEqual(faultThreeNamespace); + + // all faults + await utils.clearSearch(page); + faultResultCount = await utils.getFaultResultCount(page); + expect.soft(faultResultCount).toEqual(5); + + // search name + await utils.enterSearchTerm(page, faultTwoName); + + faultResultCount = await utils.getFaultResultCount(page); + expect.soft(faultResultCount).toEqual(1); + expect.soft(await utils.getFaultName(page, 1)).toEqual(faultTwoName); + + // all faults + await utils.clearSearch(page); + faultResultCount = await utils.getFaultResultCount(page); + expect.soft(faultResultCount).toEqual(5); + + // search triggerTime + await utils.enterSearchTerm(page, faultFiveTriggerTime); + + faultResultCount = await utils.getFaultResultCount(page); + expect.soft(faultResultCount).toEqual(1); + expect.soft(await utils.getFaultTriggerTime(page, 1)).toEqual(faultFiveTriggerTime); + }); + + test('Allows you to sort faults', async ({ page }) => { + const highestSeverity = await utils.getHighestSeverity(page); + const lowestSeverity = await utils.getLowestSeverity(page); + const faultOneName = 'Example Fault 1'; + const faultFiveName = 'Example Fault 5'; + let firstFaultName = await utils.getFaultName(page, 1); + + expect.soft(firstFaultName).toEqual(faultOneName); + + await utils.sortFaultsBy(page, 'oldest-first'); + + firstFaultName = await utils.getFaultName(page, 1); + expect.soft(firstFaultName).toEqual(faultFiveName); + + await utils.sortFaultsBy(page, 'severity'); + + const sortedHighestSeverity = await utils.getFaultSeverity(page, 1); + const sortedLowestSeverity = await utils.getFaultSeverity(page, 5); + expect.soft(sortedHighestSeverity).toEqual(highestSeverity); + expect.soft(sortedLowestSeverity).toEqual(lowestSeverity); + }); + +}); + +test.describe('The Fault Management Plugin without using example faults', () => { + test.beforeEach(async ({ page }) => { + await utils.navigateToFaultManagementWithoutExample(page); + }); + + test('Shows no faults when no faults are provided', async ({ page }) => { + const faultCount = await page.locator('c-fault-mgmt__list').count(); + + expect.soft(faultCount).toEqual(0); + + await utils.changeViewTo(page, 'acknowledged'); + const acknowledgedCount = await page.locator('c-fault-mgmt__list').count(); + expect.soft(acknowledgedCount).toEqual(0); + + await utils.changeViewTo(page, 'shelved'); + const shelvedCount = await page.locator('c-fault-mgmt__list').count(); + expect.soft(shelvedCount).toEqual(0); + }); + + test('Will return no faults when searching', async ({ page }) => { + await utils.enterSearchTerm(page, 'fault'); + + const faultCount = await page.locator('c-fault-mgmt__list').count(); + + expect.soft(faultCount).toEqual(0); + }); +}); diff --git a/e2e/tests/functional/plugins/imagery/exampleImagery.e2e.spec.js b/e2e/tests/functional/plugins/imagery/exampleImagery.e2e.spec.js index 42e532b44c..7757c93192 100644 --- a/e2e/tests/functional/plugins/imagery/exampleImagery.e2e.spec.js +++ b/e2e/tests/functional/plugins/imagery/exampleImagery.e2e.spec.js @@ -25,7 +25,7 @@ This test suite is dedicated to tests which verify the basic operations surround but only assume that example imagery is present. */ /* globals process */ - +const { v4: uuid } = require('uuid'); const { waitForAnimations } = require('../../../../baseFixtures'); const { test, expect } = require('../../../../pluginFixtures'); const { createDomainObjectWithDefaults } = require('../../../../appActions'); @@ -573,6 +573,40 @@ test.describe('Example Imagery in Tabs view', () => { test.fixme('If the imagery view is not in pause mode, it should be updated when new images come in'); }); +test.describe('Example Imagery in Time Strip', () => { + test('ensure that clicking a thumbnail loads the image in large view', async ({ page, browserName }) => { + test.info().annotations.push({ + type: 'issue', + description: 'https://github.com/nasa/openmct/issues/5632' + }); + await page.goto('./', { waitUntil: 'networkidle' }); + const timeStripObject = await createDomainObjectWithDefaults(page, { + type: 'Time Strip', + name: 'Time Strip'.concat(' ', uuid()) + }); + + await createDomainObjectWithDefaults(page, { + type: 'Example Imagery', + name: 'Example Imagery'.concat(' ', uuid()), + parent: timeStripObject.uuid + }); + // Navigate to timestrip + await page.goto(timeStripObject.url); + + await page.locator('.c-imagery-tsv-container').hover(); + // get url of the hovered image + const hoveredImg = page.locator('.c-imagery-tsv div.c-imagery-tsv__image-wrapper:hover img'); + const hoveredImgSrc = await hoveredImg.getAttribute('src'); + expect(hoveredImgSrc).toBeTruthy(); + await page.locator('.c-imagery-tsv-container').click(); + // get image of view large container + const viewLargeImg = page.locator('img.c-imagery__main-image__image'); + const viewLargeImgSrc = await viewLargeImg.getAttribute('src'); + expect(viewLargeImgSrc).toBeTruthy(); + expect(viewLargeImgSrc).toEqual(hoveredImgSrc); + }); +}); + /** * @param {import('@playwright/test').Page} page */ diff --git a/e2e/tests/functional/plugins/notebook/restrictedNotebook.e2e.spec.js b/e2e/tests/functional/plugins/notebook/restrictedNotebook.e2e.spec.js index c076329d40..568061c89d 100644 --- a/e2e/tests/functional/plugins/notebook/restrictedNotebook.e2e.spec.js +++ b/e2e/tests/functional/plugins/notebook/restrictedNotebook.e2e.spec.js @@ -21,7 +21,7 @@ *****************************************************************************/ const { test, expect } = require('../../../../pluginFixtures'); -const { openObjectTreeContextMenu } = require('../../../../appActions'); +const { openObjectTreeContextMenu, createDomainObjectWithDefaults } = require('../../../../appActions'); const path = require('path'); const TEST_TEXT = 'Testing text for entries.'; @@ -30,8 +30,9 @@ const CUSTOM_NAME = 'CUSTOM_NAME'; const NOTEBOOK_DROP_AREA = '.c-notebook__drag-area'; test.describe('Restricted Notebook', () => { + let notebook; test.beforeEach(async ({ page }) => { - await startAndAddRestrictedNotebookObject(page); + notebook = await startAndAddRestrictedNotebookObject(page); }); test('Can be renamed @addInit', async ({ page }) => { @@ -39,9 +40,7 @@ test.describe('Restricted Notebook', () => { }); test('Can be deleted if there are no locked pages @addInit', async ({ page, openmctConfig }) => { - const { myItemsFolderName } = openmctConfig; - - await openObjectTreeContextMenu(page, myItemsFolderName, `Unnamed ${CUSTOM_NAME}`); + await openObjectTreeContextMenu(page, notebook.url); const menuOptions = page.locator('.c-menu ul'); await expect.soft(menuOptions).toContainText('Remove'); @@ -76,9 +75,9 @@ test.describe('Restricted Notebook', () => { }); test.describe('Restricted Notebook with at least one entry and with the page locked @addInit', () => { - + let notebook; test.beforeEach(async ({ page }) => { - await startAndAddRestrictedNotebookObject(page); + notebook = await startAndAddRestrictedNotebookObject(page); await enterTextEntry(page); await lockPage(page); @@ -86,9 +85,8 @@ test.describe('Restricted Notebook with at least one entry and with the page loc await page.locator('button.c-notebook__toggle-nav-button').click(); }); - test('Locked page should now be in a locked state @addInit @unstable', async ({ page, openmctConfig }, testInfo) => { + test('Locked page should now be in a locked state @addInit @unstable', async ({ page }, testInfo) => { test.fixme(testInfo.project === 'chrome-beta', "Test is unreliable on chrome-beta"); - const { myItemsFolderName } = openmctConfig; // main lock message on page const lockMessage = page.locator('text=This page has been committed and cannot be modified or removed'); expect.soft(await lockMessage.count()).toEqual(1); @@ -98,7 +96,7 @@ test.describe('Restricted Notebook with at least one entry and with the page loc expect.soft(await pageLockIcon.count()).toEqual(1); // no way to remove a restricted notebook with a locked page - await openObjectTreeContextMenu(page, myItemsFolderName, `Unnamed ${CUSTOM_NAME}`); + await openObjectTreeContextMenu(page, notebook.url); const menuOptions = page.locator('.c-menu ul'); await expect(menuOptions).not.toContainText('Remove'); @@ -178,13 +176,8 @@ async function startAndAddRestrictedNotebookObject(page) { // eslint-disable-next-line no-undef await page.addInitScript({ path: path.join(__dirname, '../../../../helper/', 'addInitRestrictedNotebook.js') }); await page.goto('./', { waitUntil: 'networkidle' }); - await page.click('button:has-text("Create")'); - await page.click(`text=${CUSTOM_NAME}`); // secondarily tests renamability also - // Click text=OK - await Promise.all([ - page.waitForNavigation({waitUntil: 'networkidle'}), - page.click('text=OK') - ]); + + return createDomainObjectWithDefaults(page, { type: CUSTOM_NAME }); } /** diff --git a/e2e/tests/functional/plugins/notebook/tags.e2e.spec.js b/e2e/tests/functional/plugins/notebook/tags.e2e.spec.js index c54233cee2..ada74ccad0 100644 --- a/e2e/tests/functional/plugins/notebook/tags.e2e.spec.js +++ b/e2e/tests/functional/plugins/notebook/tags.e2e.spec.js @@ -56,19 +56,23 @@ async function createNotebookEntryAndTags(page, iterations = 1) { await createNotebookAndEntry(page, iterations); for (let iteration = 0; iteration < iterations; iteration++) { - // Click text=To start a new entry, click here or drag and drop any object + // Hover and click "Add Tag" button + // Hover is needed here to "slow down" the actions while running in headless mode + await page.hover(`button:has-text("Add Tag") >> nth = ${iteration}`); await page.locator(`button:has-text("Add Tag") >> nth = ${iteration}`).click(); - // Click [placeholder="Type to select tag"] + // Click inside the tag search input await page.locator('[placeholder="Type to select tag"]').click(); - // Click text=Driving + // Select the "Driving" tag await page.locator('[aria-label="Autocomplete Options"] >> text=Driving').click(); - // Click button:has-text("Add Tag") + // Hover and click "Add Tag" button + // Hover is needed here to "slow down" the actions while running in headless mode + await page.hover(`button:has-text("Add Tag") >> nth = ${iteration}`); await page.locator(`button:has-text("Add Tag") >> nth = ${iteration}`).click(); - // Click [placeholder="Type to select tag"] + // Click inside the tag search input await page.locator('[placeholder="Type to select tag"]').click(); - // Click text=Science + // Select the "Science" tag await page.locator('[aria-label="Autocomplete Options"] >> text=Science').click(); } } @@ -130,7 +134,8 @@ test.describe('Tagging in Notebooks @addInit', () => { await createNotebookEntryAndTags(page); await page.locator('[aria-label="Notebook Entries"]').click(); // Delete Driving - await page.locator('text=Science Driving Add Tag >> button').nth(1).click(); + await page.hover('.c-tag__label:has-text("Driving")'); + await page.locator('.c-tag__label:has-text("Driving") ~ .c-completed-tag-deletion').click(); await expect(page.locator('[aria-label="Notebook Entry"]')).toContainText("Science"); await expect(page.locator('[aria-label="Notebook Entry"]')).not.toContainText("Driving"); diff --git a/e2e/tests/functional/plugins/plot/autoscale.e2e.spec.js-snapshots/autoscale-canvas-panned-chrome-darwin b/e2e/tests/functional/plugins/plot/autoscale.e2e.spec.js-snapshots/autoscale-canvas-panned-chrome-darwin index bea4d7c408..01850a3bc4 100644 Binary files a/e2e/tests/functional/plugins/plot/autoscale.e2e.spec.js-snapshots/autoscale-canvas-panned-chrome-darwin and b/e2e/tests/functional/plugins/plot/autoscale.e2e.spec.js-snapshots/autoscale-canvas-panned-chrome-darwin differ diff --git a/e2e/tests/functional/plugins/plot/autoscale.e2e.spec.js-snapshots/autoscale-canvas-panned-chrome-linux b/e2e/tests/functional/plugins/plot/autoscale.e2e.spec.js-snapshots/autoscale-canvas-panned-chrome-linux index 345901fcce..7fb1ec390d 100644 Binary files a/e2e/tests/functional/plugins/plot/autoscale.e2e.spec.js-snapshots/autoscale-canvas-panned-chrome-linux and b/e2e/tests/functional/plugins/plot/autoscale.e2e.spec.js-snapshots/autoscale-canvas-panned-chrome-linux differ diff --git a/e2e/tests/functional/plugins/plot/autoscale.e2e.spec.js-snapshots/autoscale-canvas-prepan-chrome-darwin b/e2e/tests/functional/plugins/plot/autoscale.e2e.spec.js-snapshots/autoscale-canvas-prepan-chrome-darwin index fc3db7c140..75b1c4d953 100644 Binary files a/e2e/tests/functional/plugins/plot/autoscale.e2e.spec.js-snapshots/autoscale-canvas-prepan-chrome-darwin and b/e2e/tests/functional/plugins/plot/autoscale.e2e.spec.js-snapshots/autoscale-canvas-prepan-chrome-darwin differ diff --git a/e2e/tests/functional/plugins/plot/autoscale.e2e.spec.js-snapshots/autoscale-canvas-prepan-chrome-linux b/e2e/tests/functional/plugins/plot/autoscale.e2e.spec.js-snapshots/autoscale-canvas-prepan-chrome-linux index 88e71e7895..031025d8e0 100644 Binary files a/e2e/tests/functional/plugins/plot/autoscale.e2e.spec.js-snapshots/autoscale-canvas-prepan-chrome-linux and b/e2e/tests/functional/plugins/plot/autoscale.e2e.spec.js-snapshots/autoscale-canvas-prepan-chrome-linux differ diff --git a/e2e/tests/functional/plugins/timeConductor/timeConductor.e2e.spec.js b/e2e/tests/functional/plugins/timeConductor/timeConductor.e2e.spec.js index 59d3171706..ea50e85301 100644 --- a/e2e/tests/functional/plugins/timeConductor/timeConductor.e2e.spec.js +++ b/e2e/tests/functional/plugins/timeConductor/timeConductor.e2e.spec.js @@ -147,4 +147,24 @@ test.describe('Time conductor input fields real-time mode', () => { expect(page.url()).toContain(`startDelta=${startDelta}`); expect(page.url()).toContain(`endDelta=${endDelta}`); }); + + test.fixme('time conductor history in fixed time mode will track changing start and end times', async ({ page }) => { + // change start time, verify it's tracked in history + // change end time, verify it's tracked in history + }); + + test.fixme('time conductor history in realtime mode will track changing start and end times', async ({ page }) => { + // change start offset, verify it's tracked in history + // change end offset, verify it's tracked in history + }); + + test.fixme('time conductor history allows you to set a historical timeframe', async ({ page }) => { + // make sure there are historical history options + // select an option and make sure the time conductor start and end bounds are updated correctly + }); + + test.fixme('time conductor history allows you to set a realtime offsets', async ({ page }) => { + // make sure there are realtime history options + // select an option and verify the offsets are updated correctly + }); }); diff --git a/e2e/tests/functional/plugins/timer/timer.e2e.spec.js b/e2e/tests/functional/plugins/timer/timer.e2e.spec.js index 0235a1d2c1..16c6b5defc 100644 --- a/e2e/tests/functional/plugins/timer/timer.e2e.spec.js +++ b/e2e/tests/functional/plugins/timer/timer.e2e.spec.js @@ -24,9 +24,10 @@ const { test, expect } = require('../../../../pluginFixtures'); const { openObjectTreeContextMenu, createDomainObjectWithDefaults } = require('../../../../appActions'); test.describe('Timer', () => { + let timer; test.beforeEach(async ({ page }) => { await page.goto('./', { waitUntil: 'networkidle' }); - await createDomainObjectWithDefaults(page, { type: 'timer' }); + timer = await createDomainObjectWithDefaults(page, { type: 'timer' }); }); test('Can perform actions on the Timer', async ({ page, openmctConfig }) => { @@ -35,13 +36,13 @@ test.describe('Timer', () => { description: 'https://github.com/nasa/openmct/issues/4313' }); - const { myItemsFolderName } = await openmctConfig; + const timerUrl = timer.url; await test.step("From the tree context menu", async () => { - await triggerTimerContextMenuAction(page, myItemsFolderName, 'Start'); - await triggerTimerContextMenuAction(page, myItemsFolderName, 'Pause'); - await triggerTimerContextMenuAction(page, myItemsFolderName, 'Restart at 0'); - await triggerTimerContextMenuAction(page, myItemsFolderName, 'Stop'); + await triggerTimerContextMenuAction(page, timerUrl, 'Start'); + await triggerTimerContextMenuAction(page, timerUrl, 'Pause'); + await triggerTimerContextMenuAction(page, timerUrl, 'Restart at 0'); + await triggerTimerContextMenuAction(page, timerUrl, 'Stop'); }); await test.step("From the 3dot menu", async () => { @@ -74,9 +75,9 @@ test.describe('Timer', () => { * @param {import('@playwright/test').Page} page * @param {TimerAction} action */ -async function triggerTimerContextMenuAction(page, myItemsFolderName, action) { +async function triggerTimerContextMenuAction(page, timerUrl, action) { const menuAction = `.c-menu ul li >> text="${action}"`; - await openObjectTreeContextMenu(page, myItemsFolderName, "Unnamed Timer"); + await openObjectTreeContextMenu(page, timerUrl); await page.locator(menuAction).click(); assertTimerStateAfterAction(page, action); } diff --git a/e2e/tests/functional/search.e2e.spec.js b/e2e/tests/functional/search.e2e.spec.js index fe488697c6..b654f44d10 100644 --- a/e2e/tests/functional/search.e2e.spec.js +++ b/e2e/tests/functional/search.e2e.spec.js @@ -24,6 +24,8 @@ */ const { test, expect } = require('../../pluginFixtures'); +const { createDomainObjectWithDefaults } = require('../../appActions'); +const { v4: uuid } = require('uuid'); test.describe('Grand Search', () => { test('Can search for objects, and subsequent search dropdown behaves properly', async ({ page, openmctConfig }) => { @@ -112,13 +114,16 @@ test.describe("Search Tests @unstable", () => { await expect(page.locator('text=No matching results.')).toBeVisible(); }); - test('Validate single object in search result', async ({ page }) => { + test('Validate single object in search result @couchdb', async ({ page }) => { //Go to baseURL await page.goto("./", { waitUntil: "networkidle" }); // Create a folder object - const folderName = 'testFolder'; - await createFolderObject(page, folderName); + const folderName = uuid(); + await createDomainObjectWithDefaults(page, { + type: 'folder', + name: folderName + }); // Full search for object await page.type("input[type=search]", folderName); @@ -127,7 +132,7 @@ test.describe("Search Tests @unstable", () => { await waitForSearchCompletion(page); // Get the search results - const searchResults = await page.locator(searchResultSelector); + const searchResults = page.locator(searchResultSelector); // Verify that one result is found expect(await searchResults.count()).toBe(1); diff --git a/e2e/tests/functional/tree.e2e.spec.js b/e2e/tests/functional/tree.e2e.spec.js new file mode 100644 index 0000000000..691f7f1277 --- /dev/null +++ b/e2e/tests/functional/tree.e2e.spec.js @@ -0,0 +1,138 @@ +/***************************************************************************** + * 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.js'); +const { + createDomainObjectWithDefaults, + openObjectTreeContextMenu +} = require('../../appActions.js'); + +test.describe('Tree operations', () => { + test('Renaming an object reorders the tree @unstable', async ({ page, openmctConfig }) => { + const { myItemsFolderName } = openmctConfig; + await page.goto('./', { waitUntil: 'networkidle' }); + + await createDomainObjectWithDefaults(page, { + type: 'Folder', + name: 'Foo' + }); + + await createDomainObjectWithDefaults(page, { + type: 'Folder', + name: 'Bar' + }); + + await createDomainObjectWithDefaults(page, { + type: 'Folder', + name: 'Baz' + }); + + const clock1 = await createDomainObjectWithDefaults(page, { + type: 'Clock', + name: 'aaa' + }); + + await createDomainObjectWithDefaults(page, { + type: 'Clock', + name: 'www' + }); + + // Expand the root folder + await expandTreePaneItemByName(page, myItemsFolderName); + + await test.step("Reorders objects with the same tree depth", async () => { + await getAndAssertTreeItems(page, ['aaa', 'Bar', 'Baz', 'Foo', 'www']); + await renameObjectFromContextMenu(page, clock1.url, 'zzz'); + await getAndAssertTreeItems(page, ['Bar', 'Baz', 'Foo', 'www', 'zzz']); + }); + + await test.step("Reorders links to objects as well as original objects", async () => { + await page.click('role=treeitem[name=/Bar/]'); + await page.dragAndDrop('role=treeitem[name=/www/]', '.c-object-view'); + await page.dragAndDrop('role=treeitem[name=/zzz/]', '.c-object-view'); + await page.click('role=treeitem[name=/Baz/]'); + await page.dragAndDrop('role=treeitem[name=/www/]', '.c-object-view'); + await page.dragAndDrop('role=treeitem[name=/zzz/]', '.c-object-view'); + await page.click('role=treeitem[name=/Foo/]'); + await page.dragAndDrop('role=treeitem[name=/www/]', '.c-object-view'); + await page.dragAndDrop('role=treeitem[name=/zzz/]', '.c-object-view'); + // Expand the unopened folders + await expandTreePaneItemByName(page, 'Bar'); + await expandTreePaneItemByName(page, 'Baz'); + await expandTreePaneItemByName(page, 'Foo'); + + await renameObjectFromContextMenu(page, clock1.url, '___'); + await getAndAssertTreeItems(page, + [ + "___", + "Bar", + "___", + "www", + "Baz", + "___", + "www", + "Foo", + "___", + "www", + "www" + ]); + }); + }); +}); + +/** + * @param {import('@playwright/test').Page} page + * @param {Array} expected + */ +async function getAndAssertTreeItems(page, expected) { + const treeItems = page.locator('[role="treeitem"]'); + const allTexts = await treeItems.allInnerTexts(); + // Get rid of root folder ('My Items') as its position will not change + allTexts.shift(); + expect(allTexts).toEqual(expected); +} + +/** + * @param {import('@playwright/test').Page} page + * @param {string} name + */ +async function expandTreePaneItemByName(page, name) { + const treePane = page.locator('#tree-pane'); + const treeItem = treePane.locator(`role=treeitem[expanded=false][name=/${name}/]`); + const expandTriangle = treeItem.locator('.c-disclosure-triangle'); + await expandTriangle.click(); +} + +/** + * @param {import('@playwright/test').Page} page + * @param {string} myItemsFolderName + * @param {string} url + * @param {string} newName + */ +async function renameObjectFromContextMenu(page, url, newName) { + await openObjectTreeContextMenu(page, url); + await page.click('li:text("Edit Properties")'); + const nameInput = page.locator('form[name="mctForm"] .first input[type="text"]'); + await nameInput.fill(""); + await nameInput.fill(newName); + await page.click('[aria-label="Save"]'); +} diff --git a/e2e/tests/visual/components/tree.visual.spec.js b/e2e/tests/visual/components/tree.visual.spec.js new file mode 100644 index 0000000000..0ad2aca75f --- /dev/null +++ b/e2e/tests/visual/components/tree.visual.spec.js @@ -0,0 +1,101 @@ +/***************************************************************************** + * 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 } = require('../../../pluginFixtures.js'); +const { createDomainObjectWithDefaults } = require('../../../appActions.js'); + +const percySnapshot = require('@percy/playwright'); + +test.describe('Visual - Tree Pane', () => { + test('Tree pane in various states @unstable', async ({ page, theme, openmctConfig }) => { + const { myItemsFolderName } = openmctConfig; + await page.goto('./#/browse/mine', { waitUntil: 'networkidle' }); + + const foo = await createDomainObjectWithDefaults(page, { + type: 'Folder', + name: "Foo Folder" + }); + + const bar = await createDomainObjectWithDefaults(page, { + type: 'Folder', + name: "Bar Folder", + parent: foo.uuid + }); + + const baz = await createDomainObjectWithDefaults(page, { + type: 'Folder', + name: "Baz Folder", + parent: bar.uuid + }); + + await createDomainObjectWithDefaults(page, { + type: 'Clock', + name: 'A Clock' + }); + + await createDomainObjectWithDefaults(page, { + type: 'Clock', + name: 'Z Clock' + }); + + const treePane = "#tree-pane"; + + await percySnapshot(page, `Tree Pane w/ collapsed tree (theme: ${theme})`, { + scope: treePane + }); + + await expandTreePaneItemByName(page, myItemsFolderName); + + await page.goto(foo.url); + await page.dragAndDrop('role=treeitem[name=/A Clock/]', '.c-object-view'); + await page.dragAndDrop('role=treeitem[name=/Z Clock/]', '.c-object-view'); + await page.goto(bar.url); + await page.dragAndDrop('role=treeitem[name=/A Clock/]', '.c-object-view'); + await page.dragAndDrop('role=treeitem[name=/Z Clock/]', '.c-object-view'); + await page.goto(baz.url); + await page.dragAndDrop('role=treeitem[name=/A Clock/]', '.c-object-view'); + await page.dragAndDrop('role=treeitem[name=/Z Clock/]', '.c-object-view'); + + await percySnapshot(page, `Tree Pane w/ single level expanded (theme: ${theme})`, { + scope: treePane + }); + + await expandTreePaneItemByName(page, foo.name); + await expandTreePaneItemByName(page, bar.name); + await expandTreePaneItemByName(page, baz.name); + + await percySnapshot(page, `Tree Pane w/ multiple levels expanded (theme: ${theme})`, { + scope: treePane + }); + }); +}); + +/** + * @param {import('@playwright/test').Page} page + * @param {string} name + */ +async function expandTreePaneItemByName(page, name) { + const treePane = page.locator('#tree-pane'); + const treeItem = treePane.locator(`role=treeitem[expanded=false][name=/${name}/]`); + const expandTriangle = treeItem.locator('.c-disclosure-triangle'); + await expandTriangle.click(); +} diff --git a/e2e/tests/visual/faultManagement.visual.spec.js b/e2e/tests/visual/faultManagement.visual.spec.js new file mode 100644 index 0000000000..ab6b34e34b --- /dev/null +++ b/e2e/tests/visual/faultManagement.visual.spec.js @@ -0,0 +1,78 @@ +/***************************************************************************** + * 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 path = require('path'); +const { test } = require('../../pluginFixtures'); +const percySnapshot = require('@percy/playwright'); + +const utils = require('../../helper/faultUtils'); + +test.describe('The Fault Management Plugin Visual Test', () => { + + test('icon test', async ({ page, theme }) => { + // eslint-disable-next-line no-undef + await page.addInitScript({ path: path.join(__dirname, '../../helper/', 'addInitFaultManagementPlugin.js') }); + await page.goto('./', { waitUntil: 'networkidle' }); + + await percySnapshot(page, `Fault Management icon appears in tree (theme: '${theme}')`); + }); + + test('fault list and acknowledged faults', async ({ page, theme }) => { + await utils.navigateToFaultManagementWithStaticExample(page); + + await percySnapshot(page, `Shows a list of faults in the standard view (theme: '${theme}')`); + + await utils.acknowledgeFault(page, 1); + await utils.changeViewTo(page, 'acknowledged'); + + await percySnapshot(page, `Acknowledged faults, have a checkmark on the fault icon and appear in the acknowldeged view (theme: '${theme}')`); + }); + + test('shelved faults', async ({ page, theme }) => { + await utils.navigateToFaultManagementWithStaticExample(page); + + await utils.shelveFault(page, 1); + await utils.changeViewTo(page, 'shelved'); + + await percySnapshot(page, `Shelved faults appear in the shelved view (theme: '${theme}')`); + + await utils.openFaultRowMenu(page, 1); + + await percySnapshot(page, `Shelved faults have a 3-dot menu with Unshelve option enabled (theme: '${theme}')`); + }); + + test('3-dot menu for fault', async ({ page, theme }) => { + await utils.navigateToFaultManagementWithStaticExample(page); + + await utils.openFaultRowMenu(page, 1); + + await percySnapshot(page, `Faults have a 3-dot menu with Acknowledge, Shelve and Unshelve (Unshelve is disabled) options (theme: '${theme}')`); + }); + + test('ability to acknowledge or shelve', async ({ page, theme }) => { + await utils.navigateToFaultManagementWithStaticExample(page); + + await utils.selectFaultItem(page, 1); + + await percySnapshot(page, `Selected faults highlight the ability to Acknowledge or Shelve above the fault list (theme: '${theme}')`); + }); +}); diff --git a/example/faultManagment/exampleFaultSource.js b/example/faultManagement/exampleFaultSource.js similarity index 55% rename from example/faultManagment/exampleFaultSource.js rename to example/faultManagement/exampleFaultSource.js index 338f0903b5..9e296ad7f6 100644 --- a/example/faultManagment/exampleFaultSource.js +++ b/example/faultManagement/exampleFaultSource.js @@ -20,59 +20,36 @@ * at runtime from the About dialog for additional information. *****************************************************************************/ -export default function () { +import utils from './utils'; + +export default function (staticFaults = false) { return function install(openmct) { openmct.install(openmct.plugins.FaultManagement()); + const faultsData = utils.randomFaults(staticFaults); + openmct.faults.addProvider({ request(domainObject, options) { - const faults = JSON.parse(localStorage.getItem('faults')); - - return Promise.resolve(faults.alarms); + return Promise.resolve(faultsData); }, subscribe(domainObject, callback) { - const faultsData = JSON.parse(localStorage.getItem('faults')).alarms; - - function getRandomIndex(start, end) { - return Math.floor(start + (Math.random() * (end - start + 1))); - } - - let id = setInterval(() => { - const index = getRandomIndex(0, faultsData.length - 1); - const randomFaultData = faultsData[index]; - const randomFault = randomFaultData.fault; - randomFault.currentValueInfo.value = Math.random(); - callback({ - fault: randomFault, - type: 'alarms' - }); - }, 300); - - return () => { - clearInterval(id); - }; + return () => {}; }, supportsRequest(domainObject) { - const faults = localStorage.getItem('faults'); - - return faults && domainObject.type === 'faultManagement'; + return domainObject.type === 'faultManagement'; }, supportsSubscribe(domainObject) { - const faults = localStorage.getItem('faults'); - - return faults && domainObject.type === 'faultManagement'; + return domainObject.type === 'faultManagement'; }, acknowledgeFault(fault, { comment = '' }) { - console.log('acknowledgeFault', fault); - console.log('comment', comment); + utils.acknowledgeFault(fault); return Promise.resolve({ success: true }); }, - shelveFault(fault, shelveData) { - console.log('shelveFault', fault); - console.log('shelveData', shelveData); + shelveFault(fault, duration) { + utils.shelveFault(fault, duration); return Promise.resolve({ success: true diff --git a/example/faultManagment/pluginSpec.js b/example/faultManagement/pluginSpec.js similarity index 100% rename from example/faultManagment/pluginSpec.js rename to example/faultManagement/pluginSpec.js diff --git a/example/faultManagement/utils.js b/example/faultManagement/utils.js new file mode 100644 index 0000000000..1287d570b4 --- /dev/null +++ b/example/faultManagement/utils.js @@ -0,0 +1,76 @@ +const SEVERITIES = ['WATCH', 'WARNING', 'CRITICAL']; +const NAMESPACE = '/Example/fault-'; +const getRandom = { + severity: () => SEVERITIES[Math.floor(Math.random() * 3)], + value: () => Math.random() + Math.floor(Math.random() * 21) - 10, + fault: (num, staticFaults) => { + let val = getRandom.value(); + let severity = getRandom.severity(); + let time = Date.now() - num; + + if (staticFaults) { + let severityIndex = num > 3 ? num % 3 : num; + + val = num; + severity = SEVERITIES[severityIndex - 1]; + time = num; + } + + return { + type: num, + fault: { + acknowledged: false, + currentValueInfo: { + value: val, + rangeCondition: severity, + monitoringResult: severity + }, + id: `id-${num}`, + name: `Example Fault ${num}`, + namespace: NAMESPACE + num, + seqNum: 0, + severity: severity, + shelved: false, + shortDescription: '', + triggerTime: time, + triggerValueInfo: { + value: val, + rangeCondition: severity, + monitoringResult: severity + } + } + }; + } +}; + +function shelveFault(fault, opts = { + shelved: true, + comment: '', + shelveDuration: 90000 +}) { + fault.shelved = true; + + setTimeout(() => { + fault.shelved = false; + }, opts.shelveDuration); +} + +function acknowledgeFault(fault) { + fault.acknowledged = true; +} + +function randomFaults(staticFaults, count = 5) { + let faults = []; + + for (let x = 1, y = count + 1; x < y; x++) { + faults.push(getRandom.fault(x, staticFaults)); + } + + return faults; +} + +export default { + randomFaults, + shelveFault, + acknowledgeFault +}; diff --git a/package.json b/package.json index 9041e15d33..462d4d0d2c 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "openmct", - "version": "2.0.8-SNAPSHOT", + "version": "2.0.8", "description": "The Open MCT core platform", "devDependencies": { "@babel/eslint-parser": "7.18.9", @@ -87,7 +87,8 @@ "test": "cross-env NODE_ENV=test NODE_OPTIONS=\"--max_old_space_size=4096\" karma start --single-run", "test:debug": "cross-env NODE_ENV=debug karma start --no-single-run", "test:e2e": "npx playwright test", - "test:e2e:stable": "npx playwright test --config=e2e/playwright-ci.config.js --project=chrome --grep-invert @unstable", + "test:e2e:couchdb": "npx playwright test --config=e2e/playwright-ci.config.js --project=chrome --grep @couchdb", + "test:e2e:stable": "npx playwright test --config=e2e/playwright-ci.config.js --project=chrome --grep-invert \"@unstable|@couchdb\"", "test:e2e:unstable": "npx playwright test --config=e2e/playwright-ci.config.js --project=chrome --grep @unstable", "test:e2e:local": "npx playwright test --config=e2e/playwright-local.config.js --project=chrome", "test:e2e:updatesnapshots": "npx playwright test --config=e2e/playwright-ci.config.js --project=chrome --grep @snapshot --update-snapshots", diff --git a/src/api/objects/InMemorySearchProvider.js b/src/api/objects/InMemorySearchProvider.js index 840b316187..6feadaf444 100644 --- a/src/api/objects/InMemorySearchProvider.js +++ b/src/api/objects/InMemorySearchProvider.js @@ -63,6 +63,8 @@ class InMemorySearchProvider { this.localSearchForTags = this.localSearchForTags.bind(this); this.localSearchForNotebookAnnotations = this.localSearchForNotebookAnnotations.bind(this); this.onAnnotationCreation = this.onAnnotationCreation.bind(this); + this.onCompositionAdded = this.onCompositionAdded.bind(this); + this.onCompositionRemoved = this.onCompositionRemoved.bind(this); this.onerror = this.onWorkerError.bind(this); this.startIndexing = this.startIndexing.bind(this); @@ -75,6 +77,12 @@ class InMemorySearchProvider { this.worker.port.close(); } + Object.keys(this.indexedCompositions).forEach(keyString => { + const composition = this.indexedCompositions[keyString]; + composition.off('add', this.onCompositionAdded); + composition.off('remove', this.onCompositionRemoved); + }); + this.destroyObservers(this.indexedIds); this.destroyObservers(this.indexedCompositions); }); @@ -259,7 +267,6 @@ class InMemorySearchProvider { } onAnnotationCreation(annotationObject) { - const objectProvider = this.openmct.objects.getProvider(annotationObject.identifier); if (objectProvider === undefined || objectProvider.search === undefined) { const provider = this; @@ -281,17 +288,34 @@ class InMemorySearchProvider { provider.index(domainObject); } - onCompositionMutation(domainObject, composition) { + onCompositionAdded(newDomainObjectToIndex) { const provider = this; - const indexedComposition = domainObject.composition; - const identifiersToIndex = composition - .filter(identifier => !indexedComposition - .some(indexedIdentifier => this.openmct.objects - .areIdsEqual([identifier, indexedIdentifier]))); + // The object comes in as a mutable domain object, which has functions, + // which the index function cannot handle as it will eventually be serialized + // using structuredClone. Thus we're using JSON.parse/JSON.stringify to discard + // those functions. + const nonMutableDomainObject = JSON.parse(JSON.stringify(newDomainObjectToIndex)); - identifiersToIndex.forEach(identifier => { - this.openmct.objects.get(identifier).then(objectToIndex => provider.index(objectToIndex)); - }); + const objectProvider = this.openmct.objects.getProvider(nonMutableDomainObject.identifier); + if (objectProvider === undefined || objectProvider.search === undefined) { + provider.index(nonMutableDomainObject); + } + } + + onCompositionRemoved(domainObjectToRemoveIdentifier) { + const keyString = this.openmct.objects.makeKeyString(domainObjectToRemoveIdentifier); + if (this.indexedIds[keyString]) { + // we store the unobserve function in the indexedId map + this.indexedIds[keyString](); + delete this.indexedIds[keyString]; + } + + const composition = this.indexedCompositions[keyString]; + if (composition) { + composition.off('add', this.onCompositionAdded); + composition.off('remove', this.onCompositionRemoved); + delete this.indexedCompositions[keyString]; + } } /** @@ -305,6 +329,7 @@ class InMemorySearchProvider { async index(domainObject) { const provider = this; const keyString = this.openmct.objects.makeKeyString(domainObject.identifier); + const composition = this.openmct.composition.get(domainObject); if (!this.indexedIds[keyString]) { this.indexedIds[keyString] = this.openmct.objects.observe( @@ -312,11 +337,12 @@ class InMemorySearchProvider { 'name', this.onNameMutation.bind(this, domainObject) ); - this.indexedCompositions[keyString] = this.openmct.objects.observe( - domainObject, - 'composition', - this.onCompositionMutation.bind(this, domainObject) - ); + if (composition) { + composition.on('add', this.onCompositionAdded); + composition.on('remove', this.onCompositionRemoved); + this.indexedCompositions[keyString] = composition; + } + if (domainObject.type === 'annotation') { this.indexedTags[keyString] = this.openmct.objects.observe( domainObject, @@ -338,8 +364,6 @@ class InMemorySearchProvider { } } - const composition = this.openmct.composition.get(domainObject); - if (composition !== undefined) { const children = await composition.load(); diff --git a/src/api/objects/ObjectAPI.js b/src/api/objects/ObjectAPI.js index b82bc61591..64167f3c75 100644 --- a/src/api/objects/ObjectAPI.js +++ b/src/api/objects/ObjectAPI.js @@ -230,15 +230,10 @@ export default class ObjectAPI { return result; }).catch((result) => { console.warn(`Failed to retrieve ${keystring}:`, result); - this.openmct.notifications.error(`Failed to retrieve object ${keystring}`); delete this.cache[keystring]; - if (!result) { - //no result means resource either doesn't exist or is missing - //otherwise it's an error, and we shouldn't apply interceptors - result = this.applyGetInterceptors(identifier); - } + result = this.applyGetInterceptors(identifier); return result; }); diff --git a/src/plugins/charts/scatter/ScatterPlotView.vue b/src/plugins/charts/scatter/ScatterPlotView.vue index f6a69228e9..129a3bca98 100644 --- a/src/plugins/charts/scatter/ScatterPlotView.vue +++ b/src/plugins/charts/scatter/ScatterPlotView.vue @@ -97,11 +97,11 @@ export default { }, followTimeContext() { - this.timeContext.on('bounds', this.reloadTelemetry); + this.timeContext.on('bounds', this.reloadTelemetryOnBoundsChange); }, stopFollowingTimeContext() { if (this.timeContext) { - this.timeContext.off('bounds', this.reloadTelemetry); + this.timeContext.off('bounds', this.reloadTelemetryOnBoundsChange); } }, addToComposition(telemetryObject) { @@ -181,6 +181,11 @@ export default { this.composition.on('remove', this.removeTelemetryObject); this.composition.load(); }, + reloadTelemetryOnBoundsChange(bounds, isTick) { + if (!isTick) { + this.reloadTelemetry(); + } + }, reloadTelemetry() { this.valuesByTimestamp = {}; diff --git a/src/plugins/displayLayout/components/DisplayLayout.vue b/src/plugins/displayLayout/components/DisplayLayout.vue index bc29b615a9..98afcba651 100644 --- a/src/plugins/displayLayout/components/DisplayLayout.vue +++ b/src/plugins/displayLayout/components/DisplayLayout.vue @@ -517,7 +517,19 @@ export default { initializeItems() { this.telemetryViewMap = {}; this.objectViewMap = {}; - this.layoutItems.forEach(this.trackItem); + + let removedItems = []; + this.layoutItems.forEach((item) => { + if (item.identifier) { + if (this.containsObject(item.identifier)) { + this.trackItem(item); + } else { + removedItems.push(this.openmct.objects.makeKeyString(item.identifier)); + } + } + }); + + removedItems.forEach(this.removeFromConfiguration); }, isItemAlreadyTracked(child) { let found = false; diff --git a/src/plugins/displayLayout/components/TelemetryView.vue b/src/plugins/displayLayout/components/TelemetryView.vue index 3c5e5eba2d..19036b26e2 100644 --- a/src/plugins/displayLayout/components/TelemetryView.vue +++ b/src/plugins/displayLayout/components/TelemetryView.vue @@ -232,10 +232,12 @@ export default { this.removeSelectable(); } - this.telemetryCollection.off('add', this.setLatestValues); - this.telemetryCollection.off('clear', this.refreshData); + if (this.telemetryCollection) { + this.telemetryCollection.off('add', this.setLatestValues); + this.telemetryCollection.off('clear', this.refreshData); - this.telemetryCollection.destroy(); + this.telemetryCollection.destroy(); + } if (this.mutablePromise) { this.mutablePromise.then(() => { diff --git a/src/plugins/displayLayout/pluginSpec.js b/src/plugins/displayLayout/pluginSpec.js index 8e6a56e5f2..e70e754b6e 100644 --- a/src/plugins/displayLayout/pluginSpec.js +++ b/src/plugins/displayLayout/pluginSpec.js @@ -21,6 +21,7 @@ *****************************************************************************/ import { createOpenMct, resetApplicationState } from 'utils/testing'; +import Vue from 'vue'; import DisplayLayoutPlugin from './plugin'; describe('the plugin', function () { @@ -117,6 +118,59 @@ describe('the plugin', function () { }); + describe('on load', () => { + let displayLayoutItem; + let item; + + beforeEach((done) => { + item = { + 'width': 32, + 'height': 18, + 'x': 78, + 'y': 8, + 'identifier': { + 'namespace': '', + 'key': 'bdeb91ab-3a7e-4a71-9dd2-39d73644e136' + }, + 'hasFrame': true, + 'type': 'line-view', // so no telemetry functionality is triggered, just want to test the sync + 'id': 'c0ff485a-344c-4e70-8d83-a9d9998a69fc' + + }; + displayLayoutItem = { + 'composition': [ + // no item in compostion, but item in configuration items + ], + 'configuration': { + 'items': [ + item + ], + 'layoutGrid': [ + 10, + 10 + ] + }, + 'name': 'Display Layout', + 'type': 'layout', + 'identifier': { + 'namespace': '', + 'key': 'c5e636c1-6771-4c9c-b933-8665cab189b3' + } + }; + + const applicableViews = openmct.objectViews.get(displayLayoutItem, []); + const displayLayoutViewProvider = applicableViews.find((viewProvider) => viewProvider.key === 'layout.view'); + const view = displayLayoutViewProvider.view(displayLayoutItem); + view.show(child, false); + + Vue.nextTick(done); + }); + + it('will sync compostion and layout items', () => { + expect(displayLayoutItem.configuration.items.length).toBe(0); + }); + }); + describe('the alpha numeric format view', () => { let displayLayoutItem; let telemetryItem; diff --git a/src/plugins/faultManagement/FaultManagementListView.vue b/src/plugins/faultManagement/FaultManagementListView.vue index f07dc839a2..be19cbfe50 100644 --- a/src/plugins/faultManagement/FaultManagementListView.vue +++ b/src/plugins/faultManagement/FaultManagementListView.vue @@ -71,6 +71,8 @@ import FaultManagementToolbar from './FaultManagementToolbar.vue'; import { FAULT_MANAGEMENT_SHELVE_DURATIONS_IN_MS, FILTER_ITEMS, SORT_ITEMS } from './constants'; +const SEARCH_KEYS = ['id', 'triggerValueInfo', 'currentValueInfo', 'triggerTime', 'severity', 'name', 'shortDescription', 'namespace']; + export default { components: { FaultManagementListHeader, @@ -125,27 +127,19 @@ export default { }, methods: { filterUsingSearchTerm(fault) { - if (fault?.id?.toString().toLowerCase().includes(this.searchTerm)) { - return true; + if (!fault) { + return false; } - if (fault?.triggerValueInfo?.toString().toLowerCase().includes(this.searchTerm)) { - return true; - } + let match = false; - if (fault?.currentValueInfo?.toString().toLowerCase().includes(this.searchTerm)) { - return true; - } + SEARCH_KEYS.forEach((key) => { + if (fault[key]?.toString().toLowerCase().includes(this.searchTerm)) { + match = true; + } + }); - if (fault?.triggerTime.toString().toLowerCase().includes(this.searchTerm)) { - return true; - } - - if (fault?.severity.toString().toLowerCase().includes(this.searchTerm)) { - return true; - } - - return false; + return match; }, isSelected(fault) { return Boolean(this.selectedFaults[fault.id]); diff --git a/src/plugins/faultManagement/pluginSpec.js b/src/plugins/faultManagement/pluginSpec.js index 07ad9664c3..29169c05c7 100644 --- a/src/plugins/faultManagement/pluginSpec.js +++ b/src/plugins/faultManagement/pluginSpec.js @@ -24,10 +24,22 @@ import { createOpenMct, resetApplicationState } from '../../utils/testing'; -import { FAULT_MANAGEMENT_TYPE } from './constants'; +import { + FAULT_MANAGEMENT_TYPE, + FAULT_MANAGEMENT_VIEW, + FAULT_MANAGEMENT_NAMESPACE +} from './constants'; describe("The Fault Management Plugin", () => { let openmct; + const faultDomainObject = { + name: 'it is not your fault', + type: FAULT_MANAGEMENT_TYPE, + identifier: { + key: 'nobodies', + namespace: 'fault' + } + }; beforeEach(() => { openmct = createOpenMct(); @@ -38,15 +50,54 @@ describe("The Fault Management Plugin", () => { }); it('is not installed by default', () => { - let typeDef = openmct.types.get(FAULT_MANAGEMENT_TYPE).definition; + const typeDef = openmct.types.get(FAULT_MANAGEMENT_TYPE).definition; expect(typeDef.name).toBe('Unknown Type'); }); it('can be installed', () => { openmct.install(openmct.plugins.FaultManagement()); - let typeDef = openmct.types.get(FAULT_MANAGEMENT_TYPE).definition; + const typeDef = openmct.types.get(FAULT_MANAGEMENT_TYPE).definition; expect(typeDef.name).toBe('Fault Management'); }); + + describe('once it is installed', () => { + beforeEach(() => { + openmct.install(openmct.plugins.FaultManagement()); + }); + + it('provides a view for fault management types', () => { + const applicableViews = openmct.objectViews.get(faultDomainObject, []); + const faultManagementView = applicableViews.find( + (viewProvider) => viewProvider.key === FAULT_MANAGEMENT_VIEW + ); + + expect(applicableViews.length).toEqual(1); + expect(faultManagementView).toBeDefined(); + }); + + it('provides an inspector view for fault management types', () => { + const faultDomainObjectSelection = [[ + { + context: { + item: faultDomainObject + } + } + ]]; + const applicableInspectorViews = openmct.inspectorViews.get(faultDomainObjectSelection); + + expect(applicableInspectorViews.length).toEqual(1); + }); + + it('creates a root object for fault management', async () => { + const root = await openmct.objects.getRoot(); + const rootCompositionCollection = openmct.composition.get(root); + const rootComposition = await rootCompositionCollection.load(); + const faultObject = rootComposition.find(obj => obj.identifier.namespace === FAULT_MANAGEMENT_NAMESPACE); + + expect(faultObject).toBeDefined(); + }); + + }); }); diff --git a/src/plugins/imagery/components/ImageryView.vue b/src/plugins/imagery/components/ImageryView.vue index 8814153342..b6bed994b5 100644 --- a/src/plugins/imagery/components/ImageryView.vue +++ b/src/plugins/imagery/components/ImageryView.vue @@ -519,20 +519,17 @@ export default { }, watch: { imageHistory: { - handler(newHistory, oldHistory) { + handler(newHistory, _oldHistory) { const newSize = newHistory.length; - let imageIndex; + let imageIndex = newSize > 0 ? newSize - 1 : undefined; if (this.focusedImageTimestamp !== undefined) { const foundImageIndex = newHistory.findIndex(img => img.time === this.focusedImageTimestamp); - imageIndex = foundImageIndex > -1 - ? foundImageIndex - : newSize - 1; - } else { - imageIndex = newSize > 0 - ? newSize - 1 - : undefined; + if (foundImageIndex > -1) { + imageIndex = foundImageIndex; + } } + this.setFocusedImage(imageIndex); this.nextImageIndex = imageIndex; if (this.previousFocusedImage && newHistory.length) { diff --git a/src/plugins/interceptors/missingObjectInterceptor.js b/src/plugins/interceptors/missingObjectInterceptor.js index 4a21670809..9eb2134d1d 100644 --- a/src/plugins/interceptors/missingObjectInterceptor.js +++ b/src/plugins/interceptors/missingObjectInterceptor.js @@ -27,10 +27,13 @@ export default function MissingObjectInterceptor(openmct) { }, invoke: (identifier, object) => { if (object === undefined) { + const keyString = openmct.objects.makeKeyString(identifier); + openmct.notifications.error(`Failed to retrieve object ${keyString}`); + return { identifier, type: 'unknown', - name: 'Missing: ' + openmct.objects.makeKeyString(identifier) + name: 'Missing: ' + keyString }; } diff --git a/src/plugins/linkAction/LinkAction.js b/src/plugins/linkAction/LinkAction.js index 9390b3e5a4..576e53d35a 100644 --- a/src/plugins/linkAction/LinkAction.js +++ b/src/plugins/linkAction/LinkAction.js @@ -83,7 +83,6 @@ export default class LinkAction { } ] }; - this.openmct.forms.showForm(formStructure) .then(this.onSave.bind(this)); } @@ -91,8 +90,8 @@ export default class LinkAction { validate(currentParent) { return (data) => { - // default current parent to ROOT, if it's undefined, then it's a root level item - if (currentParent === undefined) { + // default current parent to ROOT, if it's null, then it's a root level item + if (!currentParent) { currentParent = { identifier: { key: 'ROOT', @@ -101,24 +100,23 @@ export default class LinkAction { }; } - const parentCandidate = data.value[0]; - const currentParentKeystring = this.openmct.objects.makeKeyString(currentParent.identifier); - const parentCandidateKeystring = this.openmct.objects.makeKeyString(parentCandidate.identifier); + const parentCandidatePath = data.value; + const parentCandidate = parentCandidatePath[0]; const objectKeystring = this.openmct.objects.makeKeyString(this.object.identifier); if (!this.openmct.objects.isPersistable(parentCandidate.identifier)) { return false; } - if (!parentCandidateKeystring || !currentParentKeystring) { + // check if moving to same place + if (this.openmct.objects.areIdsEqual(parentCandidate.identifier, currentParent.identifier)) { return false; } - if (parentCandidateKeystring === currentParentKeystring) { - return false; - } - - if (parentCandidateKeystring === objectKeystring) { + // check if moving to a child + if (parentCandidatePath.some(candidatePath => { + return this.openmct.objects.areIdsEqual(candidatePath.identifier, this.object.identifier); + })) { return false; } diff --git a/src/plugins/move/MoveAction.js b/src/plugins/move/MoveAction.js index d9a4d144ea..594317c3b0 100644 --- a/src/plugins/move/MoveAction.js +++ b/src/plugins/move/MoveAction.js @@ -145,26 +145,24 @@ export default class MoveAction { const parentCandidatePath = data.value; const parentCandidate = parentCandidatePath[0]; + // check if moving to same place + if (this.openmct.objects.areIdsEqual(parentCandidate.identifier, currentParent.identifier)) { + return false; + } + + // check if moving to a child + if (parentCandidatePath.some(candidatePath => { + return this.openmct.objects.areIdsEqual(candidatePath.identifier, this.object.identifier); + })) { + return false; + } + if (!this.openmct.objects.isPersistable(parentCandidate.identifier)) { return false; } - let currentParentKeystring = this.openmct.objects.makeKeyString(currentParent.identifier); - let parentCandidateKeystring = this.openmct.objects.makeKeyString(parentCandidate.identifier); let objectKeystring = this.openmct.objects.makeKeyString(this.object.identifier); - if (!parentCandidateKeystring || !currentParentKeystring) { - return false; - } - - if (parentCandidateKeystring === currentParentKeystring) { - return false; - } - - if (parentCandidateKeystring === objectKeystring) { - return false; - } - const parentCandidateComposition = parentCandidate.composition; if (parentCandidateComposition && parentCandidateComposition.indexOf(objectKeystring) !== -1) { return false; diff --git a/src/plugins/myItems/pluginSpec.js b/src/plugins/myItems/pluginSpec.js index b463b0a5e1..867fbf2432 100644 --- a/src/plugins/myItems/pluginSpec.js +++ b/src/plugins/myItems/pluginSpec.js @@ -69,27 +69,27 @@ describe("the plugin", () => { }); describe('adds an interceptor that returns a "My Items" model for', () => { - let myItemsMissing; - let mockMissingProvider; + let myItemsObject; + let mockNotFoundProvider; let activeProvider; beforeEach(async () => { - mockMissingProvider = { - get: () => Promise.resolve(missingObj), + mockNotFoundProvider = { + get: () => Promise.reject(new Error('Not found')), create: () => Promise.resolve(missingObj), update: () => Promise.resolve(missingObj) }; - activeProvider = mockMissingProvider; + activeProvider = mockNotFoundProvider; spyOn(openmct.objects, 'getProvider').and.returnValue(activeProvider); - myItemsMissing = await openmct.objects.get(myItemsIdentifier); + myItemsObject = await openmct.objects.get(myItemsIdentifier); }); it('missing objects', () => { - let idsMatchMissing = openmct.objects.areIdsEqual(myItemsMissing.identifier, myItemsIdentifier); + let idsMatch = openmct.objects.areIdsEqual(myItemsObject.identifier, myItemsIdentifier); - expect(myItemsMissing).toBeDefined(); - expect(idsMatchMissing).toBeTrue(); + expect(myItemsObject).toBeDefined(); + expect(idsMatch).toBeTrue(); }); }); diff --git a/src/plugins/persistence/couch/.env.ci b/src/plugins/persistence/couch/.env.ci new file mode 100644 index 0000000000..104d70d6e7 --- /dev/null +++ b/src/plugins/persistence/couch/.env.ci @@ -0,0 +1,5 @@ +OPENMCT_DATABASE_NAME=openmct +COUCH_ADMIN_USER=admin +COUCH_ADMIN_PASSWORD=password +COUCH_BASE_LOCAL=http://localhost:5984 +COUCH_NODE_NAME=nonode@nohost \ No newline at end of file diff --git a/src/plugins/persistence/couch/README.md b/src/plugins/persistence/couch/README.md index fc5cf795b4..8b17e14781 100644 --- a/src/plugins/persistence/couch/README.md +++ b/src/plugins/persistence/couch/README.md @@ -1,52 +1,145 @@ -# Introduction -These instructions are for setting up CouchDB for a **development** environment. For a production environment, we recommend running Open MCT behind a proxy server (e.g., Nginx or Apache), and securing the CouchDB server properly: -https://docs.couchdb.org/en/main/intro/security.html # Installing CouchDB -## macOS -### Installing with admin privileges to your computer + +## Introduction + +These instructions are for setting up CouchDB for a **development** environment. For a production environment, we recommend running Open MCT behind a proxy server (e.g., Nginx or Apache), and securing the CouchDB server properly: + + +## Docker Quickstart + +The following process is the preferred way of using CouchDB as it is automatic and closely resembles a production environment. + +Requirement: +Get docker compose (or recent version of docker) installed on your machine. We recommend [Docker Desktop](https://www.docker.com/products/docker-desktop/) + +1. Open a terminal to this current working directory (`cd openmct/src/plugins/persistence/couch`) +2. Create and start the `couchdb` container: + +```sh +docker compose -f ./couchdb-compose.yaml up --detach +``` +3. Copy `.env.ci` file to file named `.env.local` +4. (Optional) Change the values of `.env.local` if desired +5. Set the environment variables in bash by sourcing the env file + +```sh +export $(cat .env.local | xargs) +``` + +6. Execute the configuration script: + +```sh +sh ./setup-couchdb.sh +``` + +7. `cd` to the workspace root directory (the same directory as `index.html`) +8. Update `index.html` to use the CouchDB plugin as persistence store: + +```sh +sh ./src/plugins/persistence/couch/replace-localstorage-with-couchdb-indexhtml.sh +``` +9. ✅ Done! + +Open MCT will now use your local CouchDB container as its persistence store. Access the CouchDB instance manager by visiting . + +## macOS + +While we highly recommend using the CouchDB docker-compose installation, it is still possible to install CouchDB through other means. + +### Installing CouchDB + 1. Install CouchDB using: `brew install couchdb`. 2. Edit `/usr/local/etc/local.ini` and add the following settings: - ``` + + ```txt [admins] admin = youradminpassword ``` + And set the server up for single node: - ``` + + ```txt [couchdb] single_node=true ``` + Enable CORS - ``` + + ```txt [chttpd] enable_cors = true [cors] origins = http://localhost:8080 ``` -### Installing without admin privileges to your computer -1. Install CouchDB following these instructions: https://docs.brew.sh/Installation#untar-anywhere. + + +### Installing CouchDB without admin privileges to your computer + +If `brew` is not available on your mac machine, you'll need to get the CouchDB installed using the official sourcefiles. +1. Install CouchDB following these instructions: . 1. Edit `local.ini` in Homebrew's `/etc/` directory as directed above in the 'Installing with admin privileges to your computer' section. + ## Other Operating Systems -Follow the installation instructions from the CouchDB installation guide: https://docs.couchdb.org/en/stable/install/index.html + +Follow the installation instructions from the CouchDB installation guide: + # Configuring CouchDB + +## Configuration script + +The simplest way to config a CouchDB instance is to use our provided tooling: +1. Copy `.env.ci` file to file named `.env.local` +2. Set the environment variables in bash by sourcing the env file + +```sh +export $(cat .env.local | xargs) +``` + +3. Execute the configuration script: + +```sh +sh ./setup-couchdb.sh +``` + +## Manual Configuration + 1. Start CouchDB by running: `couchdb`. 2. Add the `_global_changes` database using `curl` (note the `youradminpassword` should be changed to what you set above 👆): `curl -X PUT http://admin:youradminpassword@127.0.0.1:5984/_global_changes` -3. Navigate to http://localhost:5984/_utils +3. Navigate to 4. Create a database called `openmct` -5. Navigate to http://127.0.0.1:5984/_utils/#/database/openmct/permissions +5. Navigate to 6. Remove permission restrictions in CouchDB from Open MCT by deleting `_admin` roles for both `Admin` and `Member`. -# Configuring Open MCT +# Configuring Open MCT to use CouchDB + +## Configuration script +The simplest way to config a CouchDB instance is to use our provided tooling: +1. `cd` to the workspace root directory (the same directory as `index.html`) +2. Update `index.html` to use the CouchDB plugin as persistence store: + +```sh +sh ./src/plugins/persistence/couch/replace-localstorage-with-couchdb-indexhtml.sh +``` + +## Manual Configuration + 1. Edit `openmct/index.html` comment out the following line: -``` -openmct.install(openmct.plugins.LocalStorage()); -``` -Add a line to install the CouchDB plugin for Open MCT: -``` -openmct.install(openmct.plugins.CouchDB("http://localhost:5984/openmct")); -``` -2. Start Open MCT by running `npm start` in the `openmct` path. -3. Navigate to http://localhost:8080/ and create a random object in Open MCT (e.g., a 'Clock') and save. You may get an error saying that the object failed to persist - this is a known error that you can ignore, and will only happen the first time you save - just try again. -4. Navigate to: http://127.0.0.1:5984/_utils/#database/openmct/_all_docs -5. Look at the 'JSON' tab and ensure you can see the specific object you created above. -6. All done! 🏆 + + ```js + openmct.install(openmct.plugins.LocalStorage()); + ``` + + Add a line to install the CouchDB plugin for Open MCT: + + ```js + openmct.install(openmct.plugins.CouchDB("http://localhost:5984/openmct")); + ``` + +# Validating a successful Installation + +1. Start Open MCT by running `npm start` in the `openmct` path. +2. Navigate to and create a random object in Open MCT (e.g., a 'Clock') and save. You may get an error saying that the object failed to persist - this is a known error that you can ignore, and will only happen the first time you save - just try again. +3. Navigate to: +4. Look at the 'JSON' tab and ensure you can see the specific object you created above. +5. All done! 🏆 diff --git a/src/plugins/persistence/couch/couchdb-compose.yaml b/src/plugins/persistence/couch/couchdb-compose.yaml new file mode 100644 index 0000000000..40e58ff0ab --- /dev/null +++ b/src/plugins/persistence/couch/couchdb-compose.yaml @@ -0,0 +1,14 @@ +version: "3" +services: + couchdb: + image: couchdb:${COUCHDB_IMAGE_TAG:-3.2.1} + ports: + - "5984:5984" + - "5986:5986" + volumes: + - couchdb:/opt/couchdb/data + environment: + COUCHDB_USER: admin + COUCHDB_PASSWORD: password +volumes: + couchdb: diff --git a/src/plugins/persistence/couch/replace-localstorage-with-couchdb-indexhtml.sh b/src/plugins/persistence/couch/replace-localstorage-with-couchdb-indexhtml.sh new file mode 100644 index 0000000000..4fbc50d4ec --- /dev/null +++ b/src/plugins/persistence/couch/replace-localstorage-with-couchdb-indexhtml.sh @@ -0,0 +1,3 @@ +#!/bin/bash -e + +sed -i'.bak' -e 's/LocalStorage()/CouchDB("http:\/\/localhost:5984\/openmct")/g' index.html diff --git a/src/plugins/persistence/couch/setup-couchdb.sh b/src/plugins/persistence/couch/setup-couchdb.sh new file mode 100644 index 0000000000..f07fc9470f --- /dev/null +++ b/src/plugins/persistence/couch/setup-couchdb.sh @@ -0,0 +1,125 @@ +#!/bin/bash -e + +# Do a couple checks for environment variables we expect to have a value. + +if [ -z "${OPENMCT_DATABASE_NAME}" ] ; then + echo "OPENMCT_DATABASE_NAME has no value" 1>&2 + exit 1 +fi + +if [ -z "${COUCH_ADMIN_USER}" ] ; then + echo "COUCH_ADMIN_USER has no value" 1>&2 + exit 1 +fi + +if [ -z "${COUCH_BASE_LOCAL}" ] ; then + echo "COUCH_BASE_LOCAL has no value" 1>&2 + exit 1 +fi + +# Come up with what we'll be providing to curl's -u option. Always supply the username from the environment, +# and optionally supply the password from the environment, if it has a value. +CURL_USERPASS_ARG="${COUCH_ADMIN_USER}" +if [ "${COUCH_ADMIN_PASSWORD}" ] ; then + CURL_USERPASS_ARG+=":${COUCH_ADMIN_PASSWORD}" +fi + +system_tables_exist () { + resource_exists $COUCH_BASE_LOCAL/_users +} + +create_users_db () { + curl -su "${CURL_USERPASS_ARG}" -X PUT $COUCH_BASE_LOCAL/_users +} + +create_replicator_db () { + curl -su "${CURL_USERPASS_ARG}" -X PUT $COUCH_BASE_LOCAL/_replicator +} + +setup_system_tables () { + users_db_response=$(create_users_db) + if [ "{\"ok\":true}" == "${users_db_response}" ]; then + echo Successfully created users db + replicator_db_response=$(create_replicator_db) + if [ "{\"ok\":true}" == "${replicator_db_response}" ]; then + echo Successfully created replicator DB + else + echo Unable to create replicator DB + fi + else + echo Unable to create users db + fi +} + +resource_exists () { + response=$(curl -u "${CURL_USERPASS_ARG}" -s -o /dev/null -I -w "%{http_code}" $1); + if [ "200" == "${response}" ]; then + echo "TRUE" + else + echo "FALSE"; + fi +} + +db_exists () { + resource_exists $COUCH_BASE_LOCAL/$OPENMCT_DATABASE_NAME +} + +create_db () { + response=$(curl -su "${CURL_USERPASS_ARG}" -XPUT $COUCH_BASE_LOCAL/$OPENMCT_DATABASE_NAME); + echo $response +} + +admin_user_exists () { + response=$(curl -su "${CURL_USERPASS_ARG}" -o /dev/null -I -w "%{http_code}" $COUCH_BASE_LOCAL/_node/$COUCH_NODE_NAME/_config/admins/$COUCH_ADMIN_USER); + if [ "200" == "${response}" ]; then + echo "TRUE" + else + echo "FALSE"; + fi +} + +create_admin_user () { + echo Creating admin user + curl -X PUT $COUCH_BASE_LOCAL/_node/$COUCH_NODE_NAME/_config/admins/$COUCH_ADMIN_USER -d \'"$COUCH_ADMIN_PASSWORD"\' +} + +if [ "$(admin_user_exists)" == "FALSE" ]; then + echo "Admin user does not exist, creating..." + create_admin_user +else + echo "Admin user exists" +fi + +if [ "TRUE" == $(system_tables_exist) ]; then + echo System tables exist, skipping creation +else + echo Is fresh install, creating system tables + setup_system_tables +fi + +if [ "FALSE" == $(db_exists) ]; then + response=$(create_db) + if [ "{\"ok\":true}" == "${response}" ]; then + echo Database successfully created + else + echo Database creation failed + fi +else + echo Database already exists, nothing to do +fi + +echo "Updating _replicator database permissions" +response=$(curl -su "${CURL_USERPASS_ARG}" --location --request PUT $COUCH_BASE_LOCAL/_replicator/_security --header 'Content-Type: application/json' --data-raw '{ "admins": {"roles": []},"members": {"roles": []}}'); +if [ "{\"ok\":true}" == "${response}" ]; then + echo "Database permissions successfully updated" +else + echo "Database permissions not updated" +fi + +echo "Updating ${OPENMCT_DATABASE_NAME} database permissions" +response=$(curl -su "${CURL_USERPASS_ARG}" --location --request PUT $COUCH_BASE_LOCAL/$OPENMCT_DATABASE_NAME/_security --header 'Content-Type: application/json' --data-raw '{ "admins": {"roles": []},"members": {"roles": []}}'); +if [ "{\"ok\":true}" == "${response}" ]; then + echo "Database permissions successfully updated" +else + echo "Database permissions not updated" +fi diff --git a/src/plugins/plugins.js b/src/plugins/plugins.js index 48449f6915..c39e6c322c 100644 --- a/src/plugins/plugins.js +++ b/src/plugins/plugins.js @@ -32,7 +32,7 @@ define([ './autoflow/AutoflowTabularPlugin', './timeConductor/plugin', '../../example/imagery/plugin', - '../../example/faultManagment/exampleFaultSource', + '../../example/faultManagement/exampleFaultSource', './imagery/plugin', './summaryWidget/plugin', './URLIndicatorPlugin/URLIndicatorPlugin', diff --git a/src/plugins/timeConductor/ConductorHistory.vue b/src/plugins/timeConductor/ConductorHistory.vue index a91accfa2d..8df1925e85 100644 --- a/src/plugins/timeConductor/ConductorHistory.vue +++ b/src/plugins/timeConductor/ConductorHistory.vue @@ -39,7 +39,7 @@ const DEFAULT_DURATION_FORMATTER = 'duration'; const LOCAL_STORAGE_HISTORY_KEY_FIXED = 'tcHistory'; const LOCAL_STORAGE_HISTORY_KEY_REALTIME = 'tcHistoryRealtime'; -const DEFAULT_RECORDS = 10; +const DEFAULT_RECORDS_LENGTH = 10; import { millisecondsToDHMS } from "utils/duration"; import UTCTimeFormat from "../utcTimeSystem/UTCTimeFormat.js"; @@ -79,16 +79,14 @@ export default { * @timespans {start, end} number representing timestamp */ fixedHistory: {}, - presets: [] + presets: [], + isFixed: this.openmct.time.clock() === undefined }; }, computed: { currentHistory() { return this.mode + 'History'; }, - isFixed() { - return this.openmct.time.clock() === undefined; - }, historyForCurrentTimeSystem() { const history = this[this.currentHistory][this.timeSystem.key]; @@ -96,7 +94,7 @@ export default { }, storageKey() { let key = LOCAL_STORAGE_HISTORY_KEY_FIXED; - if (this.mode !== 'fixed') { + if (!this.isFixed) { key = LOCAL_STORAGE_HISTORY_KEY_REALTIME; } @@ -108,6 +106,7 @@ export default { handler() { // only for fixed time since we track offsets for realtime if (this.isFixed) { + this.updateMode(); this.addTimespan(); } }, @@ -115,28 +114,35 @@ export default { }, offsets: { handler() { + this.updateMode(); this.addTimespan(); }, deep: true }, timeSystem: { handler(ts) { + this.updateMode(); this.loadConfiguration(); this.addTimespan(); }, deep: true }, mode: function () { - this.getHistoryFromLocalStorage(); - this.initializeHistoryIfNoHistory(); + this.updateMode(); this.loadConfiguration(); } }, mounted() { + this.updateMode(); this.getHistoryFromLocalStorage(); this.initializeHistoryIfNoHistory(); }, methods: { + updateMode() { + this.isFixed = this.openmct.time.clock() === undefined; + this.getHistoryFromLocalStorage(); + this.initializeHistoryIfNoHistory(); + }, getHistoryMenuItems() { const history = this.historyForCurrentTimeSystem.map(timespan => { let name; @@ -203,8 +209,8 @@ export default { currentHistory = currentHistory.filter(ts => !(ts.start === timespan.start && ts.end === timespan.end)); currentHistory.unshift(timespan); // add to front - if (currentHistory.length > this.records) { - currentHistory.length = this.records; + if (currentHistory.length > this.MAX_RECORDS_LENGTH) { + currentHistory.length = this.MAX_RECORDS_LENGTH; } this.$set(this[this.currentHistory], key, currentHistory); @@ -231,7 +237,7 @@ export default { .filter(option => option.timeSystem === this.timeSystem.key); this.presets = this.loadPresets(configurations); - this.records = this.loadRecords(configurations); + this.MAX_RECORDS_LENGTH = this.loadRecords(configurations); }, loadPresets(configurations) { const configuration = configurations.find(option => { @@ -243,9 +249,9 @@ export default { }, loadRecords(configurations) { const configuration = configurations.find(option => option.records); - const records = configuration ? configuration.records : DEFAULT_RECORDS; + const maxRecordsLength = configuration ? configuration.records : DEFAULT_RECORDS_LENGTH; - return records; + return maxRecordsLength; }, formatTime(time) { let format = this.timeSystem.timeFormat; diff --git a/src/plugins/timelist/inspector/TimelistPropertiesView.vue b/src/plugins/timelist/inspector/TimelistPropertiesView.vue index 57a1747b7d..986325f99f 100644 --- a/src/plugins/timelist/inspector/TimelistPropertiesView.vue +++ b/src/plugins/timelist/inspector/TimelistPropertiesView.vue @@ -32,7 +32,7 @@
These settings are not previewed and will be applied after editing is completed.
+ >These settings don't affect the view while editing, but will be applied after editing is finished.
{ - mockComposition.emit('add', planObject); - - return Promise.resolve([planObject]); + // eslint-disable-next-line require-await + mockComposition.load = async () => { + return [planObject]; }; spyOn(openmct.composition, 'get').and.returnValue(mockComposition); - openmct.on('start', done); openmct.start(appHolder); }); @@ -268,6 +266,8 @@ describe('the plugin', function () { }); it('loads the plan from composition', () => { + mockComposition.emit('add', planObject); + return Vue.nextTick(() => { const items = element.querySelectorAll(LIST_ITEM_CLASS); expect(items.length).toEqual(2); @@ -319,6 +319,8 @@ describe('the plugin', function () { }); it('activities', () => { + mockComposition.emit('add', planObject); + return Vue.nextTick(() => { const items = element.querySelectorAll(LIST_ITEM_CLASS); expect(items.length).toEqual(1); @@ -370,6 +372,8 @@ describe('the plugin', function () { }); it('hides past events', () => { + mockComposition.emit('add', planObject); + return Vue.nextTick(() => { const items = element.querySelectorAll(LIST_ITEM_CLASS); expect(items.length).toEqual(1); diff --git a/src/plugins/timelist/timelist.scss b/src/plugins/timelist/timelist.scss index eea87d114d..6ee7a50abc 100644 --- a/src/plugins/timelist/timelist.scss +++ b/src/plugins/timelist/timelist.scss @@ -32,6 +32,12 @@ .c-list-item { /* Time Lists */ + td { + $p: $interiorMarginSm; + padding-top: $p; + padding-bottom: $p; + } + &.--is-current { background-color: $colorCurrentBg; border-top: 1px solid $colorCurrentBorder !important; diff --git a/src/ui/layout/Layout.vue b/src/ui/layout/Layout.vue index ee52964363..87db0617c0 100644 --- a/src/ui/layout/Layout.vue +++ b/src/ui/layout/Layout.vue @@ -53,6 +53,7 @@ type="horizontal" >
@@ -467,7 +469,7 @@ export default { } }, scrollEndEvent() { - if (!this.$refs.srcrollable) { + if (!this.$refs.scrollable) { return; } @@ -576,14 +578,17 @@ export default { }; }, addTreeItemObserver(domainObject, parentObjectPath) { - if (this.observers[domainObject.identifier.key]) { - this.observers[domainObject.identifier.key](); + const objectPath = [domainObject].concat(parentObjectPath); + const navigationPath = this.buildNavigationPath(objectPath); + + if (this.observers[navigationPath]) { + this.observers[navigationPath](); } - this.observers[domainObject.identifier.key] = this.openmct.objects.observe( + this.observers[navigationPath] = this.openmct.objects.observe( domainObject, 'name', - this.updateTreeItems.bind(this, parentObjectPath) + this.sortTreeItems.bind(this, parentObjectPath) ); }, async updateTreeItems(parentObjectPath) { @@ -610,6 +615,44 @@ export default { } } }, + sortTreeItems(parentObjectPath) { + const navigationPath = this.buildNavigationPath(parentObjectPath); + const parentItem = this.getTreeItemByPath(navigationPath); + + // If the parent is not sortable, skip sorting + if (!this.isSortable(parentObjectPath)) { + return; + } + + // Sort the renamed object and its siblings (direct descendants of the parent) + const directDescendants = this.getChildrenInTreeFor(parentItem, false); + directDescendants.sort(this.sortNameAscending); + + // Take a copy of the sorted descendants array + const sortedTreeItems = directDescendants.slice(); + + directDescendants.forEach(descendant => { + const parent = this.getTreeItemByPath(descendant.navigationPath); + + // If descendant is not open, skip + if (!this.isTreeItemOpen(parent)) { + return; + } + + // If descendant is open but has no children, skip + const children = this.getChildrenInTreeFor(parent, true); + if (children.length === 0) { + return; + } + + // Splice in the children of the descendant + const parentIndex = sortedTreeItems.map(item => item.navigationPath).indexOf(parent.navigationPath); + sortedTreeItems.splice(parentIndex + 1, 0, ...children); + }); + + // Splice in all of the sorted descendants + this.treeItems.splice(this.treeItems.indexOf(parentItem) + 1, sortedTreeItems.length, ...sortedTreeItems); + }, buildNavigationPath(objectPath) { return '/browse/' + [...objectPath].reverse() .map((object) => this.openmct.objects.makeKeyString(object.identifier)) diff --git a/src/ui/layout/search/GrandSearchSpec.js b/src/ui/layout/search/GrandSearchSpec.js index 32d719b11f..5235fc2b34 100644 --- a/src/ui/layout/search/GrandSearchSpec.js +++ b/src/ui/layout/search/GrandSearchSpec.js @@ -42,6 +42,8 @@ describe("GrandSearch", () => { let mockAnotherFolderObject; let mockTopObject; let originalRouterPath; + let mockNewObject; + let mockObjectProvider; beforeEach((done) => { openmct = createOpenMct(); @@ -55,6 +57,7 @@ describe("GrandSearch", () => { mockDomainObject = { type: 'notebook', name: 'fooRabbitNotebook', + location: 'fooNameSpace:topObject', identifier: { key: 'some-object', namespace: 'fooNameSpace' @@ -75,6 +78,7 @@ describe("GrandSearch", () => { mockTopObject = { type: 'root', name: 'Top Folder', + composition: [], identifier: { key: 'topObject', namespace: 'fooNameSpace' @@ -83,6 +87,7 @@ describe("GrandSearch", () => { mockAnotherFolderObject = { type: 'folder', name: 'Another Test Folder', + composition: [], location: 'fooNameSpace:topObject', identifier: { key: 'someParent', @@ -92,6 +97,7 @@ describe("GrandSearch", () => { mockFolderObject = { type: 'folder', name: 'Test Folder', + composition: [], location: 'fooNameSpace:someParent', identifier: { key: 'someFolder', @@ -101,6 +107,7 @@ describe("GrandSearch", () => { mockDisplayLayout = { type: 'layout', name: 'Bar Layout', + composition: [], identifier: { key: 'some-layout', namespace: 'fooNameSpace' @@ -125,9 +132,19 @@ describe("GrandSearch", () => { } } }; + mockNewObject = { + type: 'folder', + name: 'New Apple Test Folder', + composition: [], + location: 'fooNameSpace:topObject', + identifier: { + key: 'newApple', + namespace: 'fooNameSpace' + } + }; openmct.router.isNavigatedObject = jasmine.createSpy().and.returnValue(false); - const mockObjectProvider = jasmine.createSpyObj("mock object provider", [ + mockObjectProvider = jasmine.createSpyObj("mock object provider", [ "create", "update", "get" @@ -146,6 +163,8 @@ describe("GrandSearch", () => { return mockAnotherFolderObject; } else if (identifier.key === mockTopObject.identifier.key) { return mockTopObject; + } else if (identifier.key === mockNewObject.identifier.key) { + return mockNewObject; } else { return null; } @@ -168,6 +187,7 @@ describe("GrandSearch", () => { // use local worker sharedWorkerToRestore = openmct.objects.inMemorySearchProvider.worker; openmct.objects.inMemorySearchProvider.worker = null; + await openmct.objects.inMemorySearchProvider.index(mockTopObject); await openmct.objects.inMemorySearchProvider.index(mockDomainObject); await openmct.objects.inMemorySearchProvider.index(mockDisplayLayout); await openmct.objects.inMemorySearchProvider.index(mockFolderObject); @@ -196,6 +216,7 @@ describe("GrandSearch", () => { openmct.objects.inMemorySearchProvider.worker = sharedWorkerToRestore; openmct.router.path = originalRouterPath; grandSearchComponent.$destroy(); + document.body.removeChild(parent); return resetApplicationState(openmct); }); @@ -203,25 +224,62 @@ describe("GrandSearch", () => { it("should render an object search result", async () => { await grandSearchComponent.$children[0].searchEverything('foo'); await Vue.nextTick(); - const searchResult = document.querySelector('[aria-label="fooRabbitNotebook notebook result"]'); - expect(searchResult).toBeDefined(); + const searchResults = document.querySelectorAll('[aria-label="fooRabbitNotebook notebook result"]'); + expect(searchResults.length).toBe(1); + expect(searchResults[0].innerText).toContain('Rabbit'); + }); + + it("should render an object search result if new object added", async () => { + const composition = openmct.composition.get(mockFolderObject); + composition.add(mockNewObject); + await grandSearchComponent.$children[0].searchEverything('apple'); + await Vue.nextTick(); + const searchResults = document.querySelectorAll('[aria-label="New Apple Test Folder folder result"]'); + expect(searchResults.length).toBe(1); + expect(searchResults[0].innerText).toContain('Apple'); + }); + + it("should not use InMemorySearch provider if object provider provides search", async () => { + // eslint-disable-next-line require-await + mockObjectProvider.search = async (query, abortSignal, searchType) => { + if (searchType === openmct.objects.SEARCH_TYPES.OBJECTS) { + return mockNewObject; + } else { + return []; + } + }; + + mockObjectProvider.supportsSearchType = (someType) => { + return true; + }; + + const composition = openmct.composition.get(mockFolderObject); + composition.add(mockNewObject); + await grandSearchComponent.$children[0].searchEverything('apple'); + await Vue.nextTick(); + const searchResults = document.querySelectorAll('[aria-label="New Apple Test Folder folder result"]'); + // This will be of length 2 (doubles) if we're incorrectly searching with InMemorySearchProvider as well + expect(searchResults.length).toBe(1); + expect(searchResults[0].innerText).toContain('Apple'); }); it("should render an annotation search result", async () => { await grandSearchComponent.$children[0].searchEverything('S'); await Vue.nextTick(); - const annotationResult = document.querySelector('[aria-label="Search Result"]'); - expect(annotationResult).toBeDefined(); + const annotationResults = document.querySelectorAll('[aria-label="Search Result"]'); + expect(annotationResults.length).toBe(2); + expect(annotationResults[1].innerText).toContain('Driving'); }); it("should preview object search results in edit mode if object clicked", async () => { await grandSearchComponent.$children[0].searchEverything('Folder'); grandSearchComponent._provided.openmct.router.path = [mockDisplayLayout]; await Vue.nextTick(); - const searchResult = document.querySelector('[name="Test Folder"]'); - expect(searchResult).toBeDefined(); - searchResult.click(); + const searchResults = document.querySelectorAll('[name="Test Folder"]'); + expect(searchResults.length).toBe(1); + expect(searchResults[0].innerText).toContain('Folder'); + searchResults[0].click(); const previewWindow = document.querySelector('.js-preview-window'); - expect(previewWindow).toBeDefined(); + expect(previewWindow.innerText).toContain('Snapshot'); }); }); diff --git a/src/ui/layout/tree-item.vue b/src/ui/layout/tree-item.vue index 7283e3bd30..5c0712f957 100644 --- a/src/ui/layout/tree-item.vue +++ b/src/ui/layout/tree-item.vue @@ -1,7 +1,9 @@