Compare commits
	
		
			28 Commits
		
	
	
		
			fix-couchd
			...
			mutation-p
		
	
	| Author | SHA1 | Date | |
|---|---|---|---|
|   | 5e1d558cf7 | ||
|   | 488cd82ae1 | ||
|   | d85be3b88e | ||
|   | 4d48cf3180 | ||
|   | 413cb13edf | ||
|   | 70115be727 | ||
|   | 97f5528dfc | ||
|   | 0e1cc5dc30 | ||
|   | 0062191416 | ||
|   | eedc523078 | ||
|   | db97acb61e | ||
|   | 43a4bf9606 | ||
|   | 0f352087f5 | ||
| ![dependabot[bot]](/assets/img/avatar_default.png)  | 8ce15521de | ||
| ![dependabot[bot]](/assets/img/avatar_default.png)  | c0b0c44dc2 | ||
| ![dependabot[bot]](/assets/img/avatar_default.png)  | b9a644cd4f | ||
| ![dependabot[bot]](/assets/img/avatar_default.png)  | 1afc5ef245 | ||
|   | 34442c53c6 | ||
| ![dependabot[bot]](/assets/img/avatar_default.png)  | 451ca075fe | ||
|   | 84f1a61a8d | ||
| ![dependabot[bot]](/assets/img/avatar_default.png)  | ea041aaaf9 | ||
| ![dependabot[bot]](/assets/img/avatar_default.png)  | ac9420bfa1 | ||
| ![dependabot[bot]](/assets/img/avatar_default.png)  | 0ebab10578 | ||
|   | cbdb9fc437 | ||
|   | 63d2246345 | ||
|   | 78002f0a24 | ||
|   | f08fd58486 | ||
|   | 730272e165 | 
							
								
								
									
										3
									
								
								.github/workflows/e2e-pr.yml
									
									
									
									
										vendored
									
									
								
							
							
						
						| @@ -30,7 +30,8 @@ jobs: | ||||
|       - uses: actions/setup-node@v3 | ||||
|         with: | ||||
|           node-version: '16' | ||||
|       - run: npx playwright@1.21.1 install | ||||
|       - run: npx playwright@1.23.0 install | ||||
|       - run: npx playwright install chrome-beta | ||||
|       - run: npm install | ||||
|       - run: npm run test:e2e:full | ||||
|       - name: Archive test results | ||||
|   | ||||
							
								
								
									
										2
									
								
								.github/workflows/e2e-visual.yml
									
									
									
									
										vendored
									
									
								
							
							
						
						| @@ -17,7 +17,7 @@ jobs: | ||||
|       - uses: actions/setup-node@v3 | ||||
|         with: | ||||
|           node-version: '16' | ||||
|       - run: npx playwright@1.21.1 install | ||||
|       - run: npx playwright@1.23.0 install | ||||
|       - run: npm install | ||||
|       - name: Run the e2e visual tests | ||||
|         run: npm run test:e2e:visual | ||||
|   | ||||
							
								
								
									
										2
									
								
								.github/workflows/prcop-config.json
									
									
									
									
										vendored
									
									
								
							
							
						
						| @@ -3,7 +3,7 @@ | ||||
|     { | ||||
|       "name": "descriptionRegexp", | ||||
|       "config": { | ||||
|         "regexp": "x] Testing instructions", | ||||
|         "regexp": "[x|X]] Testing instructions", | ||||
|         "errorMessage": ":police_officer: PR Description does not confirm that associated issue(s) contain Testing instructions" | ||||
|       } | ||||
|     }, | ||||
|   | ||||
							
								
								
									
										32
									
								
								app.js
									
									
									
									
									
								
							
							
						
						| @@ -12,6 +12,7 @@ const express = require('express'); | ||||
| const app = express(); | ||||
| const fs = require('fs'); | ||||
| const request = require('request'); | ||||
| const __DEV__ = !process.env.NODE_ENV || process.env.NODE_ENV === 'development'; | ||||
|  | ||||
| // Defaults | ||||
| options.port = options.port || options.p || 8080; | ||||
| @@ -49,14 +50,18 @@ class WatchRunPlugin { | ||||
| } | ||||
|  | ||||
| const webpack = require('webpack'); | ||||
| const webpackConfig = process.env.CI ? require('./webpack.coverage.js') : require('./webpack.dev.js'); | ||||
| webpackConfig.plugins.push(new webpack.HotModuleReplacementPlugin()); | ||||
| webpackConfig.plugins.push(new WatchRunPlugin()); | ||||
|  | ||||
| webpackConfig.entry.openmct = [ | ||||
|     'webpack-hot-middleware/client?reload=true', | ||||
|     webpackConfig.entry.openmct | ||||
| ]; | ||||
| let webpackConfig; | ||||
| if (__DEV__) { | ||||
|     webpackConfig = require('./webpack.dev'); | ||||
|     webpackConfig.plugins.push(new webpack.HotModuleReplacementPlugin()); | ||||
|     webpackConfig.entry.openmct = [ | ||||
|         'webpack-hot-middleware/client?reload=true', | ||||
|         webpackConfig.entry.openmct | ||||
|     ]; | ||||
|     webpackConfig.plugins.push(new WatchRunPlugin()); | ||||
| } else { | ||||
|     webpackConfig = require('./webpack.coverage'); | ||||
| } | ||||
|  | ||||
| const compiler = webpack(webpackConfig); | ||||
|  | ||||
| @@ -68,10 +73,12 @@ app.use(require('webpack-dev-middleware')( | ||||
|     } | ||||
| )); | ||||
|  | ||||
| app.use(require('webpack-hot-middleware')( | ||||
|     compiler, | ||||
|     {} | ||||
| )); | ||||
| if (__DEV__) { | ||||
|     app.use(require('webpack-hot-middleware')( | ||||
|         compiler, | ||||
|         {} | ||||
|     )); | ||||
| } | ||||
|  | ||||
| // Expose index.html for development users. | ||||
| app.get('/', function (req, res) { | ||||
| @@ -82,3 +89,4 @@ app.get('/', function (req, res) { | ||||
| app.listen(options.port, options.host, function () { | ||||
|     console.log('Open MCT application running at %s:%s', options.host, options.port); | ||||
| }); | ||||
|  | ||||
|   | ||||
							
								
								
									
										122
									
								
								e2e/README.md
									
									
									
									
									
										Normal file
									
								
							
							
						
						| @@ -0,0 +1,122 @@ | ||||
| # e2e testing | ||||
|  | ||||
| This document captures information specific to the e2e testing of Open MCT. For general information about testing, please see [the Open MCT README](https://github.com/nasa/openmct/blob/master/README.md#tests). | ||||
|  | ||||
| ## Overview | ||||
|  | ||||
| This document is designed to capture on the What, Why, and How's of writing and running e2e tests in Open MCT. | ||||
|  | ||||
| ### About e2e testing | ||||
|  | ||||
| e2e testing is an industry-standard approach to automating the testing of web-based UIs such as Open MCT. Broadly speaking, e2e tests differentiate themselves from unit tests by preferring replication of real user interactions over execution of raw JavaScript functions. | ||||
|  | ||||
| Historically, the abstraction necessary to replicate real user behavior meant that: | ||||
|  | ||||
| - e2e tests were "expensive" due to how much code each test executed. The closer a test replicates the user, the more code is needed run during test execution. Unit tests could run smaller units of code more effeciently. | ||||
| - e2e tests were flaky due to network conditions or the underlying protocols associated with testing a browser. | ||||
| - e2e frameworks relied on a browser communication standard which lacked the observability and controls necessary needed to reach the code paths possible with unit and integration tests. | ||||
| - e2e frameworks provided insufficient debug information on test failure | ||||
|  | ||||
| However, as the web ecosystem has matured to the point where mission-critical UIs can be written for the web (Open MCT), the e2e testing tools have matured as well. There are now fewer "trade-offs" when choosing to write an e2e test over any other type of test. | ||||
|  | ||||
| Modern e2e frameworks: | ||||
|  | ||||
| - Bypass the surface layer of the web-application-under-test and use a raw debugging protocol to observe and control application and browser state. | ||||
| - These new browser-internal protocols enable near-instant, bi-directional communication between test code and the browser, speeding up test execution and making the tests as reliable as the application itself. | ||||
| - Provide test debug tooling which enables developers to pinpoint failure | ||||
|  | ||||
| Furthermore, the abstraction necessary to run e2e tests as a user enables them to be extended to run within a variety of contexts. This matches the extensible design of Open MCT.  | ||||
|  | ||||
| A single e2e test in Open MCT is extended to run: | ||||
|  | ||||
| - Against a matrix of browser versions. | ||||
| - Against a matrix of OS platforms. | ||||
| - Against a local development version of Open MCT. | ||||
| - A version of Open MCT loaded as a dependency (VIPER, VISTA, etc) | ||||
| - Against a variety of data sources or telemetry endpoints. | ||||
|  | ||||
| ### Why Playwright? | ||||
|  | ||||
| [Playwright](https://playwright.dev/) was chosen as our e2e framework because it solves a few VIPER Mission needs: | ||||
| 1. First-class support for Automated Performance Testing | ||||
| 2. Official Chrome, Chrome Canary, and iPad Capabilities | ||||
| 3. Support for Browserless.io | ||||
| 4. Ability to generate code coverage reports | ||||
|  | ||||
| ## Getting Started | ||||
|  | ||||
| ### Getting started with Playwright | ||||
|  | ||||
| ### Getting started with Open MCT's implementation of Playwright | ||||
|  | ||||
| ## Types of Testing | ||||
|  | ||||
| ### (TBD) Visual Testing | ||||
|  | ||||
| - Visual tests leverage [Percy](https://percy.io/). | ||||
| - Visual tests should be written within the `./tests/visual` folder so that they can be ignored during git clones to avoid leaking credentials when executing percy cli | ||||
|  | ||||
| #### (TBD) How to write a good visual test | ||||
|  | ||||
| ### (TBD) Snapshot Testing | ||||
|  | ||||
| <https://playwright.dev/docs/test-snapshots> | ||||
|  | ||||
| ### (TBD) Mobile Testing | ||||
|  | ||||
| ### (TBD) Performance Testing | ||||
|  | ||||
| ### (FUTURE) Component Testing | ||||
|  | ||||
| - Component testing is currrently possible in Playwright but not enabled on this project. For more, please see: <https://playwright.dev/docs/test-components> | ||||
|  | ||||
| ## Architecture, Test Design and Best Practices | ||||
|  | ||||
| ### (TBD) Architecture | ||||
|  | ||||
| #### (TBD)  Continuous Integration | ||||
|  | ||||
| - Test maturation | ||||
| - Difference between full and e2e-ci suites | ||||
| - Platforms | ||||
|  | ||||
| ### (TBD) Multi-browser and Multi-operating system | ||||
|  | ||||
| - Where is it tested | ||||
| - What's supported | ||||
|  | ||||
| ### (TBD) Test Design | ||||
|  | ||||
| - Re-usable tests for VISTA, VIPER, etc. | ||||
|  | ||||
| #### Annotations | ||||
|  | ||||
| - Annotations are a great way of organizing tests outside of a file structure. | ||||
| - Current list of annotations: | ||||
|   - `@ipad` - Mobile execution possible with Playwright's iPad support. | ||||
|   - `@gds` - Executes a GDS Test Case. Used to track in VIPER Mission. | ||||
|   - `@addInit` - Initializes the browser with an injected and artificial state. Useful for non-default plugins. | ||||
|   - `@localStorage` - Captures or generates session storage to manipulate browser state. Useful for excluding in tests which require a persistent backend (i.e. CouchDB). | ||||
|   - `@snapshot` - Uses Playwright's snapshot functionality to record a copy of the DOM for direct comparison. Must be run inside of a container. | ||||
|  | ||||
| ### (TBD) Best Practices | ||||
|  | ||||
| ### (TBD) Reporting | ||||
|  | ||||
| ### (TBD) Code Coverage | ||||
|  | ||||
| Code coverage is collected during test execution and reported with [nyc](https://github.com/istanbuljs/nyc) and [codecov.io](https://about.codecov.io/) | ||||
|  | ||||
| ## Other | ||||
|  | ||||
| ### FAQ | ||||
|  | ||||
| - How does this help NASA missions? | ||||
| - When should I write an e2e test instead of a unit test? | ||||
| - When should I write a functional vs visual test? | ||||
| - How is Open MCT extending default Playwright functionality? | ||||
|  | ||||
| ### Troubleshooting | ||||
|  | ||||
| - Why is my test failing on CI and not locally? | ||||
| - How can I view the failing tests on CI? | ||||
							
								
								
									
										18
									
								
								e2e/commonActions.js
									
									
									
									
									
										Normal file
									
								
							
							
						
						| @@ -0,0 +1,18 @@ | ||||
| /** | ||||
|  * Wait for all animations within the given element and subtrees to finish | ||||
|  * See: https://github.com/microsoft/playwright/issues/15660#issuecomment-1184911658 | ||||
|  * @param {import('@playwright/test').Locator} locator | ||||
|  */ | ||||
| function waitForAnimations(locator) { | ||||
|     return locator | ||||
|         .evaluate((element) => | ||||
|             Promise.all( | ||||
|                 element | ||||
|                     .getAnimations({ subtree: true }) | ||||
|                     .map((animation) => animation.finished))); | ||||
| } | ||||
|  | ||||
| // eslint-disable-next-line no-undef | ||||
| module.exports = { | ||||
|     waitForAnimations | ||||
| }; | ||||
| @@ -4,6 +4,8 @@ | ||||
|  | ||||
| // eslint-disable-next-line no-unused-vars | ||||
| const { devices } = require('@playwright/test'); | ||||
| const MAX_FAILURES = 5; | ||||
| const NUM_WORKERS = 2; | ||||
|  | ||||
| /** @type {import('@playwright/test').PlaywrightTestConfig} */ | ||||
| const config = { | ||||
| @@ -12,20 +14,20 @@ const config = { | ||||
|     testIgnore: '**/*.perf.spec.js', //Ignore performance tests and define in playwright-perfromance.config.js | ||||
|     timeout: 60 * 1000, | ||||
|     webServer: { | ||||
|         command: 'npm run start', | ||||
|         command: 'cross-env NODE_ENV=test npm run start', | ||||
|         url: 'http://localhost:8080/#', | ||||
|         timeout: 200 * 1000, | ||||
|         reuseExistingServer: !process.env.CI | ||||
|         reuseExistingServer: false | ||||
|     }, | ||||
|     maxFailures: process.env.CI ? 5 : undefined, //Limits failures to 5 to reduce CI Waste | ||||
|     workers: 2, //Limit to 2 for CircleCI Agent | ||||
|     maxFailures: MAX_FAILURES, //Limits failures to 5 to reduce CI Waste | ||||
|     workers: NUM_WORKERS, //Limit to 2 for CircleCI Agent | ||||
|     use: { | ||||
|         baseURL: 'http://localhost:8080/', | ||||
|         headless: true, | ||||
|         ignoreHTTPSErrors: true, | ||||
|         screenshot: 'only-on-failure', | ||||
|         trace: 'on-first-retry', | ||||
|         video: 'on-first-retry' | ||||
|         video: 'off' | ||||
|     }, | ||||
|     projects: [ | ||||
|         { | ||||
|   | ||||
| @@ -12,10 +12,10 @@ const config = { | ||||
|     testIgnore: '**/*.perf.spec.js', | ||||
|     timeout: 30 * 1000, | ||||
|     webServer: { | ||||
|         command: 'npm run start', | ||||
|         command: 'cross-env NODE_ENV=test npm run start', | ||||
|         url: 'http://localhost:8080/#', | ||||
|         timeout: 120 * 1000, | ||||
|         reuseExistingServer: !process.env.CI | ||||
|         reuseExistingServer: true | ||||
|     }, | ||||
|     workers: 1, | ||||
|     use: { | ||||
| @@ -25,7 +25,7 @@ const config = { | ||||
|         ignoreHTTPSErrors: true, | ||||
|         screenshot: 'only-on-failure', | ||||
|         trace: 'retain-on-failure', | ||||
|         video: 'retain-on-failure' | ||||
|         video: 'off' | ||||
|     }, | ||||
|     projects: [ | ||||
|         { | ||||
|   | ||||
| @@ -2,6 +2,8 @@ | ||||
| // playwright.config.js | ||||
| // @ts-check | ||||
|  | ||||
| const CI = process.env.CI === 'true'; | ||||
|  | ||||
| /** @type {import('@playwright/test').PlaywrightTestConfig} */ | ||||
| const config = { | ||||
|     retries: 1, //Only for debugging purposes because trace is enabled only on first retry | ||||
| @@ -9,15 +11,15 @@ const config = { | ||||
|     timeout: 60 * 1000, | ||||
|     workers: 1, //Only run in serial with 1 worker | ||||
|     webServer: { | ||||
|         command: 'npm run start', | ||||
|         command: 'cross-env NODE_ENV=test npm run start', | ||||
|         url: 'http://localhost:8080/#', | ||||
|         timeout: 200 * 1000, | ||||
|         reuseExistingServer: !process.env.CI | ||||
|         reuseExistingServer: !CI | ||||
|     }, | ||||
|     use: { | ||||
|         browserName: "chromium", | ||||
|         baseURL: 'http://localhost:8080/', | ||||
|         headless: Boolean(process.env.CI), //Only if running locally | ||||
|         headless: CI, //Only if running locally | ||||
|         ignoreHTTPSErrors: true, | ||||
|         screenshot: 'off', | ||||
|         trace: 'on-first-retry', | ||||
|   | ||||
| @@ -9,7 +9,7 @@ const config = { | ||||
|     timeout: 90 * 1000, | ||||
|     workers: 1, // visual tests should never run in parallel due to test pollution | ||||
|     webServer: { | ||||
|         command: 'npm run start', | ||||
|         command: 'cross-env NODE_ENV=test npm run start', | ||||
|         url: 'http://localhost:8080/#', | ||||
|         timeout: 200 * 1000, | ||||
|         reuseExistingServer: !process.env.CI | ||||
| @@ -21,7 +21,7 @@ const config = { | ||||
|         ignoreHTTPSErrors: true, | ||||
|         screenshot: 'on', | ||||
|         trace: 'off', | ||||
|         video: 'on' | ||||
|         video: 'off' | ||||
|     }, | ||||
|     reporter: [ | ||||
|         ['list'], | ||||
|   | ||||
| @@ -36,7 +36,7 @@ test.describe('Branding tests', () => { | ||||
|         await page.click('.l-shell__app-logo'); | ||||
|  | ||||
|         // Verify that the NASA Logo Appears | ||||
|         await expect(await page.locator('.c-about__image')).toBeVisible(); | ||||
|         await expect(page.locator('.c-about__image')).toBeVisible(); | ||||
|  | ||||
|         // Modify the Build information in 'about' Modal | ||||
|         const versionInformationLocator = page.locator('ul.t-info.l-info.s-info'); | ||||
|   | ||||
							
								
								
									
										55
									
								
								e2e/tests/framework.e2e.spec.js
									
									
									
									
									
										Normal file
									
								
							
							
						
						| @@ -0,0 +1,55 @@ | ||||
| /***************************************************************************** | ||||
|  * 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 testing our use of the playwright framework as it | ||||
| relates to how we've extended it (i.e. ./e2e/fixtures.js) and assumptions made in our dev environment | ||||
| (app.js and ./e2e/webpack-dev-middleware.js) | ||||
| */ | ||||
|  | ||||
| const { test } = require('../fixtures.js'); | ||||
|  | ||||
| test.describe('fixtures.js tests', () => { | ||||
|     test('Verify that tests fail if console.error is thrown', async ({ page }) => { | ||||
|         test.fail(); | ||||
|         //Go to baseURL | ||||
|         await page.goto('/', { waitUntil: 'networkidle' }); | ||||
|  | ||||
|         //Verify that ../fixtures.js detects console log errors | ||||
|         await Promise.all([ | ||||
|             page.evaluate(() => console.error('This should result in a failure')), | ||||
|             page.waitForEvent('console') // always wait for the event to happen while triggering it! | ||||
|         ]); | ||||
|  | ||||
|     }); | ||||
|     test('Verify that tests pass if console.warn is thrown', async ({ page }) => { | ||||
|         //Go to baseURL | ||||
|         await page.goto('/', { waitUntil: 'networkidle' }); | ||||
|  | ||||
|         //Verify that ../fixtures.js detects console log errors | ||||
|         await Promise.all([ | ||||
|             page.evaluate(() => console.warn('This should result in a pass')), | ||||
|             page.waitForEvent('console') // always wait for the event to happen while triggering it! | ||||
|         ]); | ||||
|  | ||||
|     }); | ||||
| }); | ||||
| @@ -55,16 +55,14 @@ test.describe.serial('Condition Set CRUD Operations on @localStorage', () => { | ||||
|         await context.storageState({ path: './e2e/test-data/recycled_local_storage.json' }); | ||||
|  | ||||
|         //Set object identifier from url | ||||
|         conditionSetUrl = await page.url(); | ||||
|         conditionSetUrl = page.url(); | ||||
|         console.log('conditionSetUrl ' + conditionSetUrl); | ||||
|  | ||||
|         getConditionSetIdentifierFromUrl = await conditionSetUrl.split('/').pop().split('?')[0]; | ||||
|         getConditionSetIdentifierFromUrl = conditionSetUrl.split('/').pop().split('?')[0]; | ||||
|         console.debug('getConditionSetIdentifierFromUrl ' + getConditionSetIdentifierFromUrl); | ||||
|         await page.close(); | ||||
|     }); | ||||
|     test.afterAll(async ({ browser }) => { | ||||
|         await browser.close(); | ||||
|     }); | ||||
|  | ||||
|     //Load localStorage for subsequent tests | ||||
|     test.use({ storageState: './e2e/test-data/recycled_local_storage.json' }); | ||||
|     //Begin suite of tests again localStorage | ||||
| @@ -76,7 +74,7 @@ test.describe.serial('Condition Set CRUD Operations on @localStorage', () => { | ||||
|         await expect.soft(page.locator('.l-browse-bar__object-name')).toContainText('Unnamed Condition Set'); | ||||
|  | ||||
|         //Assertions on loaded Condition Set in Inspector | ||||
|         await expect.soft(page.locator('_vue=item.name=Unnamed Condition Set')).toBeTruthy; | ||||
|         expect.soft(page.locator('_vue=item.name=Unnamed Condition Set')).toBeTruthy(); | ||||
|  | ||||
|         //Reload Page | ||||
|         await Promise.all([ | ||||
| @@ -87,7 +85,7 @@ test.describe.serial('Condition Set CRUD Operations on @localStorage', () => { | ||||
|         //Re-verify after reload | ||||
|         await expect.soft(page.locator('.l-browse-bar__object-name')).toContainText('Unnamed Condition Set'); | ||||
|         //Assertions on loaded Condition Set in Inspector | ||||
|         await expect.soft(page.locator('_vue=item.name=Unnamed Condition Set')).toBeTruthy; | ||||
|         expect.soft(page.locator('_vue=item.name=Unnamed Condition Set')).toBeTruthy(); | ||||
|  | ||||
|     }); | ||||
|     test('condition set object can be modified on @localStorage', async ({ page }) => { | ||||
| @@ -113,18 +111,18 @@ test.describe.serial('Condition Set CRUD Operations on @localStorage', () => { | ||||
|  | ||||
|         // Verify Inspector properties | ||||
|         // Verify Inspector has updated Name property | ||||
|         await expect.soft(page.locator('text=Renamed Condition Set').nth(1)).toBeTruthy(); | ||||
|         expect.soft(page.locator('text=Renamed Condition Set').nth(1)).toBeTruthy(); | ||||
|         // Verify Inspector Details has updated Name property | ||||
|         await expect.soft(page.locator('text=Renamed Condition Set').nth(2)).toBeTruthy(); | ||||
|         expect.soft(page.locator('text=Renamed Condition Set').nth(2)).toBeTruthy(); | ||||
|  | ||||
|         // Verify Tree reflects updated Name proprety | ||||
|         // Expand Tree | ||||
|         await page.locator('text=Open MCT My Items >> span >> nth=3').click(); | ||||
|         // Verify Condition Set Object is renamed in Tree | ||||
|         await expect(page.locator('a:has-text("Renamed Condition Set")')).toBeTruthy(); | ||||
|         expect(page.locator('a:has-text("Renamed Condition Set")')).toBeTruthy(); | ||||
|         // Verify Search Tree reflects renamed Name property | ||||
|         await page.locator('[aria-label="OpenMCT Search"] input[type="search"]').fill('Renamed'); | ||||
|         await expect(page.locator('a:has-text("Renamed Condition Set")')).toBeTruthy(); | ||||
|         expect(page.locator('a:has-text("Renamed Condition Set")')).toBeTruthy(); | ||||
|  | ||||
|         //Reload Page | ||||
|         await Promise.all([ | ||||
| @@ -137,18 +135,18 @@ test.describe.serial('Condition Set CRUD Operations on @localStorage', () => { | ||||
|  | ||||
|         // Verify Inspector properties | ||||
|         // Verify Inspector has updated Name property | ||||
|         await expect.soft(page.locator('text=Renamed Condition Set').nth(1)).toBeTruthy(); | ||||
|         expect.soft(page.locator('text=Renamed Condition Set').nth(1)).toBeTruthy(); | ||||
|         // Verify Inspector Details has updated Name property | ||||
|         await expect.soft(page.locator('text=Renamed Condition Set').nth(2)).toBeTruthy(); | ||||
|         expect.soft(page.locator('text=Renamed Condition Set').nth(2)).toBeTruthy(); | ||||
|  | ||||
|         // Verify Tree reflects updated Name proprety | ||||
|         // Expand Tree | ||||
|         await page.locator('text=Open MCT My Items >> span >> nth=3').click(); | ||||
|         // Verify Condition Set Object is renamed in Tree | ||||
|         await expect(page.locator('a:has-text("Renamed Condition Set")')).toBeTruthy(); | ||||
|         expect(page.locator('a:has-text("Renamed Condition Set")')).toBeTruthy(); | ||||
|         // Verify Search Tree reflects renamed Name property | ||||
|         await page.locator('[aria-label="OpenMCT Search"] input[type="search"]').fill('Renamed'); | ||||
|         await expect(page.locator('a:has-text("Renamed Condition Set")')).toBeTruthy(); | ||||
|         expect(page.locator('a:has-text("Renamed Condition Set")')).toBeTruthy(); | ||||
|     }); | ||||
|     test('condition set object can be deleted by Search Tree Actions menu on @localStorage', async ({ page }) => { | ||||
|         //Navigate to baseURL | ||||
|   | ||||
| @@ -28,6 +28,7 @@ but only assume that example imagery is present. | ||||
|  | ||||
| const { test } = require('../../../fixtures.js'); | ||||
| const { expect } = require('@playwright/test'); | ||||
| const { waitForAnimations } = require('../../../commonActions.js'); | ||||
|  | ||||
| const backgroundImageSelector = '.c-imagery__main-image__background-image'; | ||||
|  | ||||
| @@ -81,7 +82,16 @@ test.describe('Example Imagery Object', () => { | ||||
|         expect(imageMouseZoomedIn.width).toBeGreaterThan(originalImageDimensions.width); | ||||
|         expect(imageMouseZoomedOut.height).toBeLessThan(imageMouseZoomedIn.height); | ||||
|         expect(imageMouseZoomedOut.width).toBeLessThan(imageMouseZoomedIn.width); | ||||
|     }); | ||||
|  | ||||
|     test('Can adjust image brightness/contrast by dragging the sliders', async ({ page, browserName }) => { | ||||
|         test.fixme(browserName === 'firefox', 'This test needs to be updated to work with firefox'); | ||||
|         // Open the image filter menu | ||||
|         await page.locator('[role=toolbar] button[title="Brightness and contrast"]').click(); | ||||
|  | ||||
|         // Drag the brightness and contrast sliders around and assert filter values | ||||
|         await dragBrightnessSliderAndAssertFilterValues(page); | ||||
|         await dragContrastSliderAndAssertFilterValues(page); | ||||
|     }); | ||||
|  | ||||
|     test('Can use alt+drag to move around image once zoomed in', async ({ page }) => { | ||||
| @@ -150,76 +160,65 @@ test.describe('Example Imagery Object', () => { | ||||
|     }); | ||||
|  | ||||
|     test('Can use + - buttons to zoom on the image', async ({ page }) => { | ||||
|         await page.locator(backgroundImageSelector).hover({trial: true}); | ||||
|         const zoomInBtn = page.locator('.t-btn-zoom-in').nth(0); | ||||
|         const zoomOutBtn = page.locator('.t-btn-zoom-out').nth(0); | ||||
|         // Get initial image dimensions | ||||
|         const initialBoundingBox = await page.locator(backgroundImageSelector).boundingBox(); | ||||
|  | ||||
|         await zoomInBtn.click(); | ||||
|         await zoomInBtn.click(); | ||||
|         // wait for zoom animation to finish | ||||
|         await page.locator(backgroundImageSelector).hover({trial: true}); | ||||
|         // Zoom in twice via button | ||||
|         await zoomIntoImageryByButton(page); | ||||
|         await zoomIntoImageryByButton(page); | ||||
|  | ||||
|         // Get and assert zoomed in image dimensions | ||||
|         const zoomedInBoundingBox = await page.locator(backgroundImageSelector).boundingBox(); | ||||
|         expect(zoomedInBoundingBox.height).toBeGreaterThan(initialBoundingBox.height); | ||||
|         expect(zoomedInBoundingBox.width).toBeGreaterThan(initialBoundingBox.width); | ||||
|  | ||||
|         await zoomOutBtn.click(); | ||||
|         // wait for zoom animation to finish | ||||
|         await page.locator(backgroundImageSelector).hover({trial: true}); | ||||
|         // Zoom out once via button | ||||
|         await zoomOutOfImageryByButton(page); | ||||
|  | ||||
|         // Get and assert zoomed out image dimensions | ||||
|         const zoomedOutBoundingBox = await page.locator(backgroundImageSelector).boundingBox(); | ||||
|         expect(zoomedOutBoundingBox.height).toBeLessThan(zoomedInBoundingBox.height); | ||||
|         expect(zoomedOutBoundingBox.width).toBeLessThan(zoomedInBoundingBox.width); | ||||
|  | ||||
|         // Zoom out again via button, assert against the initial image dimensions | ||||
|         await zoomOutOfImageryByButton(page); | ||||
|         const finalBoundingBox = await page.locator(backgroundImageSelector).boundingBox(); | ||||
|         expect(finalBoundingBox).toEqual(initialBoundingBox); | ||||
|     }); | ||||
|  | ||||
|     test('Can use the reset button to reset the image', async ({ page }) => { | ||||
|         // wait for zoom animation to finish | ||||
|         await page.locator(backgroundImageSelector).hover({trial: true}); | ||||
|  | ||||
|         const zoomInBtn = page.locator('.t-btn-zoom-in').nth(0); | ||||
|         const zoomResetBtn = page.locator('.t-btn-zoom-reset').nth(0); | ||||
|     test('Can use the reset button to reset the image', async ({ page }, testInfo) => { | ||||
|         test.slow(testInfo.project === 'chrome-beta', "This test is slow in chrome-beta"); | ||||
|         // Get initial image dimensions | ||||
|         const initialBoundingBox = await page.locator(backgroundImageSelector).boundingBox(); | ||||
|  | ||||
|         await zoomInBtn.click(); | ||||
|         // wait for zoom animation to finish | ||||
|         await page.locator(backgroundImageSelector).hover({trial: true}); | ||||
|         await zoomInBtn.click(); | ||||
|         // wait for zoom animation to finish | ||||
|         await page.locator(backgroundImageSelector).hover({trial: true}); | ||||
|         // Zoom in twice via button | ||||
|         await zoomIntoImageryByButton(page); | ||||
|         await zoomIntoImageryByButton(page); | ||||
|  | ||||
|         // Get and assert zoomed in image dimensions | ||||
|         const zoomedInBoundingBox = await page.locator(backgroundImageSelector).boundingBox(); | ||||
|         expect.soft(zoomedInBoundingBox.height).toBeGreaterThan(initialBoundingBox.height); | ||||
|         expect.soft(zoomedInBoundingBox.width).toBeGreaterThan(initialBoundingBox.width); | ||||
|  | ||||
|         await zoomResetBtn.click(); | ||||
|         // wait for zoom animation to finish | ||||
|         await page.locator(backgroundImageSelector).hover({trial: true}); | ||||
|  | ||||
|         const resetBoundingBox = await page.locator(backgroundImageSelector).boundingBox(); | ||||
|         expect.soft(resetBoundingBox.height).toBeLessThan(zoomedInBoundingBox.height); | ||||
|         expect.soft(resetBoundingBox.width).toBeLessThan(zoomedInBoundingBox.width); | ||||
|  | ||||
|         expect.soft(resetBoundingBox.height).toEqual(initialBoundingBox.height); | ||||
|         expect(resetBoundingBox.width).toEqual(initialBoundingBox.width); | ||||
|         // Reset pan and zoom and assert against initial image dimensions | ||||
|         await resetImageryPanAndZoom(page); | ||||
|         const finalBoundingBox = await page.locator(backgroundImageSelector).boundingBox(); | ||||
|         expect(finalBoundingBox).toEqual(initialBoundingBox); | ||||
|     }); | ||||
|  | ||||
|     test('Using the zoom features does not pause telemetry', async ({ page }) => { | ||||
|         const pausePlayButton = page.locator('.c-button.pause-play'); | ||||
|         // wait for zoom animation to finish | ||||
|         await page.locator(backgroundImageSelector).hover({trial: true}); | ||||
|  | ||||
|         // open the time conductor drop down | ||||
|         await page.locator('button:has-text("Fixed Timespan")').click(); | ||||
|  | ||||
|         // Click local clock | ||||
|         await page.locator('[data-testid="conductor-modeOption-realtime"]').click(); | ||||
|  | ||||
|         await expect.soft(pausePlayButton).not.toHaveClass(/is-paused/); | ||||
|         const zoomInBtn = page.locator('.t-btn-zoom-in').nth(0); | ||||
|         await zoomInBtn.click(); | ||||
|         // wait for zoom animation to finish | ||||
|         await page.locator(backgroundImageSelector).hover({trial: true}); | ||||
|  | ||||
|         return expect(pausePlayButton).not.toHaveClass(/is-paused/); | ||||
|         // Zoom in via button | ||||
|         await zoomIntoImageryByButton(page); | ||||
|         await expect(pausePlayButton).not.toHaveClass(/is-paused/); | ||||
|     }); | ||||
|  | ||||
| }); | ||||
| @@ -247,10 +246,8 @@ test('Example Imagery in Display layout', async ({ page, browserName }) => { | ||||
|     await page.click('text=Example Imagery'); | ||||
|  | ||||
|     // Clear and set Image load delay to minimum value | ||||
|     // FIXME: Update the value to 5000 ms when this bug is fixed. | ||||
|     // See: https://github.com/nasa/openmct/issues/5265 | ||||
|     await page.locator('input[type="number"]').fill(''); | ||||
|     await page.locator('input[type="number"]').fill('0'); | ||||
|     await page.locator('input[type="number"]').fill('5000'); | ||||
|  | ||||
|     // Click text=OK | ||||
|     await Promise.all([ | ||||
| @@ -334,6 +331,19 @@ test('Example Imagery in Display layout', async ({ page, browserName }) => { | ||||
|  | ||||
|     // Verify selected image is still displayed | ||||
|     await expect(selectedImage).toBeVisible(); | ||||
|  | ||||
|     // Unpause imagery | ||||
|     await page.locator('.pause-play').click(); | ||||
|  | ||||
|     //Get background-image url from background-image css prop | ||||
|     await assertBackgroundImageUrlFromBackgroundCss(page); | ||||
|  | ||||
|     // Open the image filter menu | ||||
|     await page.locator('[role=toolbar] button[title="Brightness and contrast"]').click(); | ||||
|  | ||||
|     // Drag the brightness and contrast sliders around and assert filter values | ||||
|     await dragBrightnessSliderAndAssertFilterValues(page); | ||||
|     await dragContrastSliderAndAssertFilterValues(page); | ||||
| }); | ||||
|  | ||||
| test.describe('Example imagery thumbnails resize in display layouts', () => { | ||||
| @@ -424,6 +434,11 @@ test.describe('Example imagery thumbnails resize in display layouts', () => { | ||||
|     }); | ||||
| }); | ||||
|  | ||||
| // test.fixme('Can use Mouse Wheel to zoom in and out of previous image'); | ||||
| // test.fixme('Can use alt+drag to move around image once zoomed in'); | ||||
| // test.fixme('Clicking on the left arrow should pause the imagery and go to previous image'); | ||||
| // test.fixme('If the imagery view is in pause mode, images still come in'); | ||||
| // 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 Flexible layout', () => { | ||||
|     test('Example Imagery in Flexible layout', async ({ page, browserName }) => { | ||||
|         test.fixme(browserName === 'firefox', 'This test needs to be updated to work with firefox'); | ||||
| @@ -634,22 +649,6 @@ async function assertBackgroundImageBrightness(page, expected) { | ||||
|     expect(actual).toBe(expected); | ||||
| } | ||||
|  | ||||
| /** | ||||
|  * Gets the filter:contrast value of the current background-image and | ||||
|  * asserts against an expected value | ||||
|  * @param {import('@playwright/test').Page} page | ||||
|  * @param {String} expected The expected contrast value | ||||
|  */ | ||||
| async function assertBackgroundImageContrast(page, expected) { | ||||
|     const backgroundImage = page.locator('.c-imagery__main-image__background-image'); | ||||
|  | ||||
|     // Get the contrast filter value (i.e: filter: contrast(500%) => "500") | ||||
|     const actual = await backgroundImage.evaluate((el) => { | ||||
|         return el.style.filter.match(/contrast\((\d{1,3})%\)/)[1]; | ||||
|     }); | ||||
|     expect(actual).toBe(expected); | ||||
| } | ||||
|  | ||||
| /** | ||||
|  * @param {import('@playwright/test').Page} page | ||||
|  */ | ||||
| @@ -749,3 +748,70 @@ async function mouseZoomIn(page) { | ||||
|     expect(imageMouseZoomedIn.height).toBeGreaterThan(originalImageDimensions.height); | ||||
|     expect(imageMouseZoomedIn.width).toBeGreaterThan(originalImageDimensions.width); | ||||
| } | ||||
|  | ||||
| /** | ||||
|  * Gets the filter:contrast value of the current background-image and | ||||
|  * asserts against an expected value | ||||
|  * @param {import('@playwright/test').Page} page | ||||
|  * @param {String} expected The expected contrast value | ||||
|  */ | ||||
| async function assertBackgroundImageContrast(page, expected) { | ||||
|     const backgroundImage = page.locator('.c-imagery__main-image__background-image'); | ||||
|  | ||||
|     // Get the contrast filter value (i.e: filter: contrast(500%) => "500") | ||||
|     const actual = await backgroundImage.evaluate((el) => { | ||||
|         return el.style.filter.match(/contrast\((\d{1,3})%\)/)[1]; | ||||
|     }); | ||||
|     expect(actual).toBe(expected); | ||||
| } | ||||
|  | ||||
| /** | ||||
|  * Use the '+' button to zoom in. Hovers first if the toolbar is not visible | ||||
|  * and waits for the zoom animation to finish afterwards. | ||||
|  * @param {import('@playwright/test').Page} page | ||||
|  */ | ||||
| async function zoomIntoImageryByButton(page) { | ||||
|     // FIXME: There should only be one set of imagery buttons, but there are two? | ||||
|     const zoomInBtn = page.locator("[role='toolbar'][aria-label='Image controls'] .t-btn-zoom-in").nth(0); | ||||
|     const backgroundImage = page.locator(backgroundImageSelector); | ||||
|     if (!(await zoomInBtn.isVisible())) { | ||||
|         await backgroundImage.hover({trial: true}); | ||||
|     } | ||||
|  | ||||
|     await zoomInBtn.click(); | ||||
|     await waitForAnimations(backgroundImage); | ||||
| } | ||||
|  | ||||
| /** | ||||
|  * Use the '-' button to zoom out. Hovers first if the toolbar is not visible | ||||
|  * and waits for the zoom animation to finish afterwards. | ||||
|  * @param {import('@playwright/test').Page} page | ||||
|  */ | ||||
| async function zoomOutOfImageryByButton(page) { | ||||
|     // FIXME: There should only be one set of imagery buttons, but there are two? | ||||
|     const zoomOutBtn = page.locator("[role='toolbar'][aria-label='Image controls'] .t-btn-zoom-out").nth(0); | ||||
|     const backgroundImage = page.locator(backgroundImageSelector); | ||||
|     if (!(await zoomOutBtn.isVisible())) { | ||||
|         await backgroundImage.hover({trial: true}); | ||||
|     } | ||||
|  | ||||
|     await zoomOutBtn.click(); | ||||
|     await waitForAnimations(backgroundImage); | ||||
| } | ||||
|  | ||||
| /** | ||||
|  * Use the reset button to reset image pan and zoom. Hovers first if the toolbar is not visible | ||||
|  * and waits for the zoom animation to finish afterwards. | ||||
|  * @param {import('@playwright/test').Page} page | ||||
|  */ | ||||
| async function resetImageryPanAndZoom(page) { | ||||
|     // FIXME: There should only be one set of imagery buttons, but there are two? | ||||
|     const panZoomResetBtn = page.locator("[role='toolbar'][aria-label='Image controls'] .t-btn-zoom-reset").nth(0); | ||||
|     const backgroundImage = page.locator(backgroundImageSelector); | ||||
|     if (!(await panZoomResetBtn.isVisible())) { | ||||
|         await backgroundImage.hover({trial: true}); | ||||
|     } | ||||
|  | ||||
|     await panZoomResetBtn.click(); | ||||
|     await waitForAnimations(backgroundImage); | ||||
| } | ||||
|   | ||||
| @@ -35,7 +35,7 @@ test.describe('Restricted Notebook', () => { | ||||
|     }); | ||||
|  | ||||
|     test('Can be renamed @addInit', async ({ page }) => { | ||||
|         await expect.soft(page.locator('.l-browse-bar__object-name')).toContainText(`Unnamed ${CUSTOM_NAME}`); | ||||
|         await expect(page.locator('.l-browse-bar__object-name')).toContainText(`Unnamed ${CUSTOM_NAME}`); | ||||
|     }); | ||||
|  | ||||
|     test('Can be deleted if there are no locked pages @addInit', async ({ page }) => { | ||||
| @@ -52,16 +52,15 @@ test.describe('Restricted Notebook', () => { | ||||
|         // Click Remove Text | ||||
|         await page.locator('text=Remove').click(); | ||||
|  | ||||
|         //Wait until Save Banner is gone | ||||
|         // Click 'OK' on confirmation window and wait for save banner to appear | ||||
|         await Promise.all([ | ||||
|             page.waitForNavigation(), | ||||
|             page.locator('text=OK').click(), | ||||
|             page.waitForSelector('.c-message-banner__message') | ||||
|         ]); | ||||
|         await page.locator('.c-message-banner__close-button').click(); | ||||
|  | ||||
|         // has been deleted | ||||
|         expect.soft(await restrictedNotebookTreeObject.count()).toEqual(0); | ||||
|         expect(await restrictedNotebookTreeObject.count()).toEqual(0); | ||||
|     }); | ||||
|  | ||||
|     test('Can be locked if at least one page has one entry @addInit', async ({ page }) => { | ||||
| @@ -69,7 +68,7 @@ test.describe('Restricted Notebook', () => { | ||||
|         await enterTextEntry(page); | ||||
|  | ||||
|         const commitButton = page.locator('button:has-text("Commit Entries")'); | ||||
|         expect.soft(await commitButton.count()).toEqual(1); | ||||
|         expect(await commitButton.count()).toEqual(1); | ||||
|     }); | ||||
|  | ||||
| }); | ||||
| @@ -81,11 +80,17 @@ test.describe('Restricted Notebook with at least one entry and with the page loc | ||||
|         await enterTextEntry(page); | ||||
|         await lockPage(page); | ||||
|  | ||||
|         // FIXME: Give ample time for the mutation to happen | ||||
|         // https://github.com/nasa/openmct/issues/5409 | ||||
|         // eslint-disable-next-line playwright/no-wait-for-timeout | ||||
|         await page.waitForTimeout(1 * 1000); | ||||
|  | ||||
|         // open sidebar | ||||
|         await page.locator('button.c-notebook__toggle-nav-button').click(); | ||||
|     }); | ||||
|  | ||||
|     test('Locked page should now be in a locked state @addInit', async ({ page }) => { | ||||
|     test('Locked page should now be in a locked state @addInit', async ({ page }, testInfo) => { | ||||
|         test.fixme(testInfo.project === 'chrome-beta', "Test is unreliable on chrome-beta"); | ||||
|         // 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); | ||||
| @@ -96,11 +101,9 @@ test.describe('Restricted Notebook with at least one entry and with the page loc | ||||
|  | ||||
|         // no way to remove a restricted notebook with a locked page | ||||
|         await openContextMenuRestrictedNotebook(page); | ||||
|  | ||||
|         const menuOptions = page.locator('.c-menu ul'); | ||||
|  | ||||
|         await expect.soft(menuOptions).not.toContainText('Remove'); | ||||
|  | ||||
|         await expect(menuOptions).not.toContainText('Remove'); | ||||
|     }); | ||||
|  | ||||
|     test('Can still: add page, rename, add entry, delete unlocked pages @addInit', async ({ page }) => { | ||||
| @@ -139,7 +142,7 @@ test.describe('Restricted Notebook with at least one entry and with the page loc | ||||
|  | ||||
|         // deleted page, should no longer exist | ||||
|         const deletedPageElement = page.locator(`text=${TEST_TEXT_NAME}`); | ||||
|         expect.soft(await deletedPageElement.count()).toEqual(0); | ||||
|         expect(await deletedPageElement.count()).toEqual(0); | ||||
|     }); | ||||
| }); | ||||
|  | ||||
| @@ -155,7 +158,7 @@ test.describe('Restricted Notebook with a page locked and with an embed @addInit | ||||
|         await page.locator('.c-ne__embed__name .c-popup-menu-button').click(); // embed popup menu | ||||
|  | ||||
|         const embedMenu = page.locator('body >> .c-menu'); | ||||
|         await expect.soft(embedMenu).toContainText('Remove This Embed'); | ||||
|         await expect(embedMenu).toContainText('Remove This Embed'); | ||||
|     }); | ||||
|  | ||||
|     test('Disallows embeds to be deleted if page locked @addInit', async ({ page }) => { | ||||
| @@ -164,7 +167,7 @@ test.describe('Restricted Notebook with a page locked and with an embed @addInit | ||||
|         await page.locator('.c-ne__embed__name .c-popup-menu-button').click(); // embed popup menu | ||||
|  | ||||
|         const embedMenu = page.locator('body >> .c-menu'); | ||||
|         await expect.soft(embedMenu).not.toContainText('Remove This Embed'); | ||||
|         await expect(embedMenu).not.toContainText('Remove This Embed'); | ||||
|     }); | ||||
|  | ||||
| }); | ||||
| @@ -232,28 +235,18 @@ async function lockPage(page) { | ||||
|     await commitButton.click(); | ||||
|  | ||||
|     //Wait until Lock Banner is visible | ||||
|     await Promise.all([ | ||||
|         page.locator('text=Lock Page').click(), | ||||
|         page.waitForSelector('.c-message-banner__message') | ||||
|     ]); | ||||
|     // Close Lock Banner | ||||
|     await page.locator('.c-message-banner__close-button').click(); | ||||
|  | ||||
|     //artifically wait to avoid mutation delay TODO: https://github.com/nasa/openmct/issues/5409 | ||||
|     // eslint-disable-next-line playwright/no-wait-for-timeout | ||||
|     await page.waitForTimeout(1 * 1000); | ||||
|     await page.locator('text=Lock Page').click(); | ||||
| } | ||||
|  | ||||
| /** | ||||
|  * @param {import('@playwright/test').Page} page | ||||
|  */ | ||||
| async function openContextMenuRestrictedNotebook(page) { | ||||
|     // Click text=Open MCT My Items (This expands the My Items folder to show it's chilren in the tree) | ||||
|     await page.locator('text=Open MCT My Items >> span').nth(3).click(); | ||||
|  | ||||
|     //artifically wait to avoid mutation delay TODO: https://github.com/nasa/openmct/issues/5409 | ||||
|     // eslint-disable-next-line playwright/no-wait-for-timeout | ||||
|     await page.waitForTimeout(1 * 1000); | ||||
|     const myItemsFolder = page.locator('text=Open MCT My Items >> span').nth(3); | ||||
|     const className = await myItemsFolder.getAttribute('class'); | ||||
|     if (!className.includes('c-disclosure-triangle--expanded')) { | ||||
|         await myItemsFolder.click(); | ||||
|     } | ||||
|  | ||||
|     // Click a:has-text("Unnamed CUSTOM_NAME") | ||||
|     await page.locator(`a:has-text("Unnamed ${CUSTOM_NAME}")`).click({ | ||||
|   | ||||
| Before Width: | Height: | Size: 16 KiB After Width: | Height: | Size: 18 KiB | 
| Before Width: | Height: | Size: 15 KiB After Width: | Height: | Size: 18 KiB | 
| Before Width: | Height: | Size: 18 KiB After Width: | Height: | Size: 21 KiB | 
| Before Width: | Height: | Size: 18 KiB After Width: | Height: | Size: 21 KiB | 
| @@ -28,11 +28,12 @@ const { test } = require('../../../fixtures.js'); | ||||
| const { expect } = require('@playwright/test'); | ||||
|  | ||||
| test.describe('Handle missing object for plots', () => { | ||||
|     test('Displays empty div for missing stacked plot item', async ({ page }) => { | ||||
|     test('Displays empty div for missing stacked plot item', async ({ page, browserName }) => { | ||||
|         test.fixme(browserName === 'firefox', 'Firefox failing due to console events being missed'); | ||||
|         const errorLogs = []; | ||||
|  | ||||
|         page.on("console", (message) => { | ||||
|             if (message.type() === 'warning') { | ||||
|             if (message.type() === 'warning' && message.text().includes('Missing domain object')) { | ||||
|                 errorLogs.push(message.text()); | ||||
|             } | ||||
|         }); | ||||
| @@ -71,7 +72,7 @@ test.describe('Handle missing object for plots', () => { | ||||
|         //Check that there is only one stacked item plot with a plot, the missing one will be empty | ||||
|         await expect(page.locator(".c-plot--stacked-container:has(.gl-plot)")).toHaveCount(1); | ||||
|         //Verify that console.warn is thrown | ||||
|         await expect(errorLogs).toHaveLength(1); | ||||
|         expect(errorLogs).toHaveLength(1); | ||||
|     }); | ||||
| }); | ||||
|  | ||||
| @@ -94,10 +95,6 @@ async function makeStackedPlot(page) { | ||||
|         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'}); | ||||
|  | ||||
|     // save the stacked plot | ||||
|     await saveStackedPlot(page); | ||||
|  | ||||
| @@ -155,7 +152,4 @@ async function createSineWaveGenerator(page) { | ||||
|         //Wait for Save Banner to appear | ||||
|         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'}); | ||||
| } | ||||
|   | ||||
| @@ -140,6 +140,7 @@ async function triggerTimer3dotMenuAction(page, action) { | ||||
|  * @param {TimerViewAction} action | ||||
|  */ | ||||
| async function triggerTimerViewAction(page, action) { | ||||
|     await page.locator('.c-timer').hover({trial: true}); | ||||
|     const buttonTitle = buttonTitleFromAction(action); | ||||
|     await page.click(`button[title="${buttonTitle}"]`); | ||||
|     assertTimerStateAfterAction(page, action); | ||||
|   | ||||
| @@ -31,7 +31,7 @@ to "fail" on assertions. Instead, they should be used to detect changes between | ||||
| Note: Larger testsuite sizes are OK due to the setup time associated with these tests. | ||||
| */ | ||||
|  | ||||
| const { test } = require('@playwright/test'); | ||||
| const { test } = require('../../fixtures.js'); | ||||
| const percySnapshot = require('@percy/playwright'); | ||||
| const path = require('path'); | ||||
| const sinon = require('sinon'); | ||||
|   | ||||
| @@ -32,7 +32,8 @@ to "fail" on assertions. Instead, they should be used to detect changes between | ||||
| Note: Larger testsuite sizes are OK due to the setup time associated with these tests. | ||||
| */ | ||||
|  | ||||
| const { test, expect } = require('@playwright/test'); | ||||
| const { test } = require('../../fixtures.js'); | ||||
| const { expect } = require('@playwright/test'); | ||||
| const percySnapshot = require('@percy/playwright'); | ||||
| const path = require('path'); | ||||
| const sinon = require('sinon'); | ||||
|   | ||||
| @@ -52,9 +52,6 @@ test('Generate Visual Test Data @localStorage', async ({ page, context }) => { | ||||
|         //Wait for Save Banner to appear1 | ||||
|         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'}); | ||||
|  | ||||
|     // save (exit edit mode) | ||||
|     await page.locator('text=Snapshot Save and Finish Editing Save and Continue Editing >> button').nth(1).click(); | ||||
| @@ -69,18 +66,12 @@ test('Generate Visual Test Data @localStorage', async ({ page, context }) => { | ||||
|     //Add a 5000 ms Delay | ||||
|     await page.locator('[aria-label="Loading Delay \\(ms\\)"]').fill('5000'); | ||||
|  | ||||
|     // Click on My Items in Tree. Workaround for https://github.com/nasa/openmct/issues/5184 | ||||
|     await page.click('form[name="mctForm"] a:has-text("Overlay Plot")'); | ||||
|  | ||||
|     await Promise.all([ | ||||
|         page.waitForNavigation(), | ||||
|         page.locator('text=OK').click(), | ||||
|         //Wait for Save Banner to appear1 | ||||
|         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'}); | ||||
|  | ||||
|     // focus the overlay plot | ||||
|     await page.locator('text=Open MCT My Items >> span').nth(3).click(); | ||||
|   | ||||
| @@ -24,7 +24,8 @@ | ||||
| This test suite is dedicated to tests which verify search functionality. | ||||
| */ | ||||
|  | ||||
| const { test, expect } = require('@playwright/test'); | ||||
| const { test } = require('../../fixtures.js'); | ||||
| const { expect } = require('@playwright/test'); | ||||
| const percySnapshot = require('@percy/playwright'); | ||||
|  | ||||
| /** | ||||
|   | ||||
| @@ -31,7 +31,7 @@ const STATUSES = [{ | ||||
|     iconClassPoll: "icon-status-poll-question-mark" | ||||
| }, { | ||||
|     key: "GO", | ||||
|     label: "GO", | ||||
|     label: "Go", | ||||
|     iconClass: "icon-check", | ||||
|     iconClassPoll: "icon-status-poll-question-mark", | ||||
|     statusClass: "s-status-ok", | ||||
| @@ -39,7 +39,7 @@ const STATUSES = [{ | ||||
|     statusFgColor: "#000" | ||||
| }, { | ||||
|     key: "MAYBE", | ||||
|     label: "MAYBE", | ||||
|     label: "Maybe", | ||||
|     iconClass: "icon-alert-triangle", | ||||
|     iconClassPoll: "icon-status-poll-question-mark", | ||||
|     statusClass: "s-status-warning", | ||||
| @@ -47,7 +47,7 @@ const STATUSES = [{ | ||||
|     statusFgColor: "#000" | ||||
| }, { | ||||
|     key: "NO_GO", | ||||
|     label: "NO GO", | ||||
|     label: "No go", | ||||
|     iconClass: "icon-circle-slash", | ||||
|     iconClassPoll: "icon-status-poll-question-mark", | ||||
|     statusClass: "s-status-error", | ||||
|   | ||||
							
								
								
									
										30
									
								
								package.json
									
									
									
									
									
								
							
							
						
						| @@ -1,6 +1,6 @@ | ||||
| { | ||||
|   "name": "openmct", | ||||
|   "version": "2.0.5", | ||||
|   "version": "2.1.0-SNAPSHOT", | ||||
|   "description": "The Open MCT core platform", | ||||
|   "devDependencies": { | ||||
|     "@babel/eslint-parser": "7.18.2", | ||||
| @@ -13,7 +13,7 @@ | ||||
|     "@types/karma": "^6.3.2", | ||||
|     "@types/lodash": "^4.14.178", | ||||
|     "@types/mocha": "^9.1.0", | ||||
|     "babel-loader": "8.2.3", | ||||
|     "babel-loader": "8.2.5", | ||||
|     "babel-plugin-istanbul": "6.1.1", | ||||
|     "comma-separated-values": "3.6.4", | ||||
|     "codecov":"3.8.3", | ||||
| @@ -23,10 +23,10 @@ | ||||
|     "d3-axis": "3.0.0", | ||||
|     "d3-scale": "3.3.0", | ||||
|     "d3-selection": "3.0.0", | ||||
|     "eslint": "8.13.0", | ||||
|     "eslint": "8.18.0", | ||||
|     "eslint-plugin-compat": "4.0.2", | ||||
|     "eslint-plugin-playwright": "0.9.0", | ||||
|     "eslint-plugin-vue": "9.1.0", | ||||
|     "eslint-plugin-vue": "9.1.1", | ||||
|     "eslint-plugin-you-dont-need-lodash-underscore": "6.12.0", | ||||
|     "eventemitter3": "1.2.0", | ||||
|     "express": "4.13.1", | ||||
| @@ -34,7 +34,7 @@ | ||||
|     "git-rev-sync": "3.0.2", | ||||
|     "html2canvas": "1.4.1", | ||||
|     "imports-loader": "0.8.0", | ||||
|     "jasmine-core": "4.1.1", | ||||
|     "jasmine-core": "4.2.0", | ||||
|     "jsdoc": "3.5.5", | ||||
|     "karma": "6.3.20", | ||||
|     "karma-chrome-launcher": "3.1.1", | ||||
| @@ -42,7 +42,7 @@ | ||||
|     "karma-coverage": "2.2.0", | ||||
|     "karma-coverage-istanbul-reporter": "3.0.3", | ||||
|     "karma-firefox-launcher": "2.1.2", | ||||
|     "karma-jasmine": "4.0.1", | ||||
|     "karma-jasmine": "5.1.0", | ||||
|     "karma-junit-reporter": "2.0.1", | ||||
|     "karma-sourcemap-loader": "0.3.8", | ||||
|     "karma-spec-reporter": "0.0.34", | ||||
| @@ -50,20 +50,20 @@ | ||||
|     "lighthouse": "9.6.1", | ||||
|     "location-bar": "3.0.1", | ||||
|     "lodash": "4.17.21", | ||||
|     "mini-css-extract-plugin": "2.6.0", | ||||
|     "moment": "2.29.3", | ||||
|     "mini-css-extract-plugin": "2.6.1", | ||||
|     "moment": "2.29.4", | ||||
|     "moment-duration-format": "2.3.2", | ||||
|     "moment-timezone": "0.5.34", | ||||
|     "node-bourbon": "4.2.3", | ||||
|     "painterro": "1.2.56", | ||||
|     "nyc":"15.1.0", | ||||
|     "painterro": "1.2.78", | ||||
|     "plotly.js-basic-dist": "2.12.0", | ||||
|     "plotly.js-gl2d-dist": "2.12.0", | ||||
|     "printj": "1.3.1", | ||||
|     "request": "2.88.2", | ||||
|     "resolve-url-loader": "5.0.0", | ||||
|     "sass": "1.52.2", | ||||
|     "sass-loader": "12.6.0", | ||||
|     "sass-loader": "13.0.2", | ||||
|     "sinon": "14.0.0", | ||||
|     "style-loader": "^1.0.1", | ||||
|     "uuid": "8.3.2", | ||||
| @@ -72,7 +72,7 @@ | ||||
|     "vue-loader": "15.9.8", | ||||
|     "vue-template-compiler": "2.6.14", | ||||
|     "webpack": "5.68.0", | ||||
|     "webpack-cli": "4.9.2", | ||||
|     "webpack-cli": "4.10.0", | ||||
|     "webpack-dev-middleware": "5.3.3", | ||||
|     "webpack-hot-middleware": "2.25.1", | ||||
|     "webpack-merge": "5.8.0" | ||||
| @@ -88,17 +88,17 @@ | ||||
|     "build:coverage": "webpack --config webpack.coverage.js", | ||||
|     "build:watch": "webpack --config webpack.dev.js --watch", | ||||
|     "info": "npx envinfo --system --browsers --npmPackages --binaries --languages --markdown", | ||||
|     "test": "cross-env NODE_OPTIONS=\"--max_old_space_size=4096\" karma start --single-run", | ||||
|     "test:firefox": "cross-env NODE_OPTIONS=\"--max_old_space_size=4096\" karma start --single-run --browsers=FirefoxHeadless", | ||||
|     "test": "cross-env NODE_ENV=test NODE_OPTIONS=\"--max_old_space_size=4096\" karma start --single-run", | ||||
|     "test:firefox": "cross-env NODE_ENV=test NODE_OPTIONS=\"--max_old_space_size=4096\" karma start --single-run --browsers=FirefoxHeadless", | ||||
|     "test:debug": "cross-env NODE_ENV=debug karma start --no-single-run", | ||||
|     "test:e2e": "npx playwright test", | ||||
|     "test:e2e:ci": "npx playwright test --config=e2e/playwright-ci.config.js --project=chrome smoke branding default condition timeConductor clock exampleImagery persistence performance grandsearch notebook/tags", | ||||
|     "test:e2e:ci": "npx playwright test --config=e2e/playwright-ci.config.js --project=chrome visual smoke branding default condition timeConductor clock persistence performance grandsearch tags", | ||||
|     "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", | ||||
|     "test:e2e:visual": "percy exec --config ./e2e/.percy.yml -- npx playwright test --config=e2e/playwright-visual.config.js", | ||||
|     "test:e2e:full": "npx playwright test --config=e2e/playwright-ci.config.js", | ||||
|     "test:perf": "npx playwright test --config=e2e/playwright-performance.config.js", | ||||
|     "test:watch": "cross-env NODE_OPTIONS=\"--max_old_space_size=4096\" karma start --no-single-run", | ||||
|     "test:watch": "cross-env NODE_ENV=test NODE_OPTIONS=\"--max_old_space_size=4096\" karma start --no-single-run", | ||||
|     "jsdoc": "jsdoc -c jsdoc.json -R API.md -r -d dist/docs/api", | ||||
|     "update-about-dialog-copyright": "perl -pi -e 's/20\\d\\d\\-202\\d/2014\\-2022/gm' ./src/ui/layout/AboutDialog.vue", | ||||
|     "update-copyright-date": "npm run update-about-dialog-copyright && grep -lr --null --include=*.{js,scss,vue,ts,sh,html,md,frag} 'Copyright (c) 20' . | xargs -r0 perl -pi -e 's/Copyright\\s\\(c\\)\\s20\\d\\d\\-20\\d\\d/Copyright \\(c\\)\\ 2014\\-2022/gm'", | ||||
|   | ||||
| @@ -52,8 +52,8 @@ class MutableDomainObject { | ||||
|                 // Property should not be serialized | ||||
|                 enumerable: false | ||||
|             }, | ||||
|             _observers: { | ||||
|                 value: [], | ||||
|             _callbacksForPaths: { | ||||
|                 value: {}, | ||||
|                 // Property should not be serialized | ||||
|                 enumerable: false | ||||
|             }, | ||||
| @@ -64,15 +64,31 @@ class MutableDomainObject { | ||||
|             } | ||||
|         }); | ||||
|     } | ||||
|     /** | ||||
|      * BRAND new approach | ||||
|      * - Register a listener on $_synchronize_model | ||||
|      * - The $_synchronize_model event provides the path. Figure out whether the mutated path is equal to, or a parent of the observed path. | ||||
|      * - If so, trigger callback with new value | ||||
|      * - As an optimization, ONLY trigger if value has actually changed. Could be deferred until later? | ||||
|      */ | ||||
|  | ||||
|     $observe(path, callback) { | ||||
|         let fullPath = qualifiedEventName(this, path); | ||||
|         let eventOff = | ||||
|             this._globalEventEmitter.off.bind(this._globalEventEmitter, fullPath, callback); | ||||
|         let callbacksForPath = this._callbacksForPaths[path]; | ||||
|         if (callbacksForPath === undefined) { | ||||
|             callbacksForPath = []; | ||||
|             this._callbacksForPaths[path] = callbacksForPath; | ||||
|         } | ||||
|  | ||||
|         this._globalEventEmitter.on(fullPath, callback); | ||||
|         this._observers.push(eventOff); | ||||
|         callbacksForPath.push(callback); | ||||
|  | ||||
|         return function unlisten() { | ||||
|             let index = callbacksForPath.indexOf(callback); | ||||
|             callbacksForPath.splice(index, 1); | ||||
|             if (callbacksForPath.length === 0) { | ||||
|                 delete this._callbacksForPaths[path]; | ||||
|             } | ||||
|         }.bind(this); | ||||
|  | ||||
|         return eventOff; | ||||
|     } | ||||
|     $set(path, value) { | ||||
|         _.set(this, path, value); | ||||
| @@ -88,25 +104,14 @@ class MutableDomainObject { | ||||
|         this._globalEventEmitter.emit(ANY_OBJECT_EVENT, this); | ||||
|         //Emit wildcard event, with path so that callback knows what changed | ||||
|         this._globalEventEmitter.emit(qualifiedEventName(this, '*'), this, path, value); | ||||
|  | ||||
|         //Emit events specific to properties affected | ||||
|         let parentPropertiesList = path.split('.'); | ||||
|         for (let index = parentPropertiesList.length; index > 0; index--) { | ||||
|             let parentPropertyPath = parentPropertiesList.slice(0, index).join('.'); | ||||
|             this._globalEventEmitter.emit(qualifiedEventName(this, parentPropertyPath), _.get(this, parentPropertyPath)); | ||||
|         } | ||||
|  | ||||
|         //TODO: Emit events for listeners of child properties when parent changes. | ||||
|         // Do it at observer time - also register observers for parent attribute path. | ||||
|     } | ||||
|  | ||||
|     $refresh(model) { | ||||
|         //TODO: Currently we are updating the entire object. | ||||
|         // In the future we could update a specific property of the object using the 'path' parameter. | ||||
|         const clone = JSON.parse(JSON.stringify(this)); | ||||
|         this._globalEventEmitter.emit(qualifiedEventName(this, '$_synchronize_model'), model); | ||||
|  | ||||
|         //Emit wildcard event, with path so that callback knows what changed | ||||
|         this._globalEventEmitter.emit(qualifiedEventName(this, '*'), this); | ||||
|         //Emit wildcard event | ||||
|         this._globalEventEmitter.emit(qualifiedEventName(this, '*'), this, '*', this, clone); | ||||
|     } | ||||
|  | ||||
|     $on(event, callback) { | ||||
| @@ -114,23 +119,53 @@ class MutableDomainObject { | ||||
|  | ||||
|         return () => this._instanceEventEmitter.off(event, callback); | ||||
|     } | ||||
|     $destroy() { | ||||
|         while (this._observers.length > 0) { | ||||
|             const observer = this._observers.pop(); | ||||
|             observer(); | ||||
|         } | ||||
|     $updateListenersOnPath(updatedModel, mutatedPath, newValue, oldModel) { | ||||
|         const isRefresh = mutatedPath === '*'; | ||||
|  | ||||
|         Object.entries(this._callbacksForPaths).forEach(([observedPath, callbacks]) => { | ||||
|             if (isChildOf(observedPath, mutatedPath) | ||||
|                 || isParentOf(observedPath, mutatedPath)) { | ||||
|                 let newValueOfObservedPath; | ||||
|  | ||||
|                 if (observedPath === '*') { | ||||
|                     newValueOfObservedPath = updatedModel; | ||||
|  | ||||
|                 } else { | ||||
|                     newValueOfObservedPath = _.get(updatedModel, observedPath); | ||||
|                 } | ||||
|  | ||||
|                 if (isRefresh && observedPath !== '*') { | ||||
|                     const oldValueOfObservedPath = _.get(oldModel, observedPath); | ||||
|                     if (!_.isEqual(newValueOfObservedPath, oldValueOfObservedPath)) { | ||||
|                         callbacks.forEach(callback => callback(newValueOfObservedPath)); | ||||
|                     } | ||||
|                 } else { | ||||
|                     //Assumed to be different if result of mutation. | ||||
|                     callbacks.forEach(callback => callback(newValueOfObservedPath)); | ||||
|                 } | ||||
|             } | ||||
|         }); | ||||
|     } | ||||
|     $synchronizeModel(updatedObject) { | ||||
|         let clone = JSON.parse(JSON.stringify(updatedObject)); | ||||
|         utils.refresh(this, clone); | ||||
|     } | ||||
|     $destroy() { | ||||
|         Object.keys(this._callbacksForPaths).forEach(key => delete this._callbacksForPaths[key]); | ||||
|         this._instanceEventEmitter.emit('$_destroy'); | ||||
|         this._globalEventEmitter.off(qualifiedEventName(this, '$_synchronize_model'), this.$synchronizeModel); | ||||
|         this._globalEventEmitter.off(qualifiedEventName(this, '*'), this.$updateListenersOnPath); | ||||
|     } | ||||
|  | ||||
|     static createMutable(object, mutationTopic) { | ||||
|         let mutable = Object.create(new MutableDomainObject(mutationTopic)); | ||||
|         Object.assign(mutable, object); | ||||
|  | ||||
|         mutable.$observe('$_synchronize_model', (updatedObject) => { | ||||
|             let clone = JSON.parse(JSON.stringify(updatedObject)); | ||||
|             utils.refresh(mutable, clone); | ||||
|         }); | ||||
|         mutable.$updateListenersOnPath = mutable.$updateListenersOnPath.bind(mutable); | ||||
|         mutable.$synchronizeModel = mutable.$synchronizeModel.bind(mutable); | ||||
|  | ||||
|         mutable._globalEventEmitter.on(qualifiedEventName(mutable, '$_synchronize_model'), mutable.$synchronizeModel); | ||||
|         mutable._globalEventEmitter.on(qualifiedEventName(mutable, '*'), mutable.$updateListenersOnPath); | ||||
|  | ||||
|         return mutable; | ||||
|     } | ||||
| @@ -147,4 +182,12 @@ function qualifiedEventName(object, eventName) { | ||||
|     return [keystring, eventName].join(':'); | ||||
| } | ||||
|  | ||||
| function isChildOf(observedPath, mutatedPath) { | ||||
|     return Boolean(mutatedPath === '*' || observedPath?.startsWith(mutatedPath)); | ||||
| } | ||||
|  | ||||
| function isParentOf(observedPath, mutatedPath) { | ||||
|     return Boolean(observedPath === '*' || mutatedPath?.startsWith(observedPath)); | ||||
| } | ||||
|  | ||||
| export default MutableDomainObject; | ||||
|   | ||||
| @@ -233,7 +233,11 @@ export default class ObjectAPI { | ||||
|  | ||||
|             delete this.cache[keystring]; | ||||
|  | ||||
|             result = this.applyGetInterceptors(identifier); | ||||
|             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); | ||||
|             } | ||||
|  | ||||
|             return result; | ||||
|         }); | ||||
|   | ||||
| @@ -1,7 +1,7 @@ | ||||
| import ObjectAPI from './ObjectAPI.js'; | ||||
| import { createOpenMct, resetApplicationState } from '../../utils/testing'; | ||||
|  | ||||
| describe("The Object API", () => { | ||||
| fdescribe("The Object API", () => { | ||||
|     let objectAPI; | ||||
|     let typeRegistry; | ||||
|     let openmct = {}; | ||||
| @@ -287,53 +287,167 @@ describe("The Object API", () => { | ||||
|                 mutableSecondInstance.$destroy(); | ||||
|             }); | ||||
|  | ||||
|             it('to stay synchronized when mutated', function () { | ||||
|                 objectAPI.mutate(mutable, 'otherAttribute', 'new-attribute-value'); | ||||
|                 expect(mutableSecondInstance.otherAttribute).toBe('new-attribute-value'); | ||||
|             }); | ||||
|  | ||||
|             it('to indicate when a property changes', function () { | ||||
|                 let mutationCallback = jasmine.createSpy('mutation-callback'); | ||||
|                 let unlisten; | ||||
|  | ||||
|                 return new Promise(function (resolve) { | ||||
|                     mutationCallback.and.callFake(resolve); | ||||
|                     unlisten = objectAPI.observe(mutableSecondInstance, 'otherAttribute', mutationCallback); | ||||
|                     objectAPI.mutate(mutable, 'otherAttribute', 'some-new-value'); | ||||
|                 }).then(function () { | ||||
|                     expect(mutationCallback).toHaveBeenCalledWith('some-new-value'); | ||||
|                     unlisten(); | ||||
|             describe('on mutation', () => { | ||||
|                 it('to stay synchronized', function () { | ||||
|                     objectAPI.mutate(mutable, 'otherAttribute', 'new-attribute-value'); | ||||
|                     expect(mutableSecondInstance.otherAttribute).toBe('new-attribute-value'); | ||||
|                 }); | ||||
|             }); | ||||
|  | ||||
|             it('to indicate when a child property has changed', function () { | ||||
|                 let embeddedKeyCallback = jasmine.createSpy('embeddedKeyCallback'); | ||||
|                 let embeddedObjectCallback = jasmine.createSpy('embeddedObjectCallback'); | ||||
|                 let objectAttributeCallback = jasmine.createSpy('objectAttribute'); | ||||
|                 let listeners = []; | ||||
|                 it('to indicate when a property changes', function () { | ||||
|                     let mutationCallback = jasmine.createSpy('mutation-callback'); | ||||
|                     let unlisten; | ||||
|  | ||||
|                 return new Promise(function (resolve) { | ||||
|                     objectAttributeCallback.and.callFake(resolve); | ||||
|  | ||||
|                     listeners.push(objectAPI.observe(mutableSecondInstance, 'objectAttribute.embeddedObject.embeddedKey', embeddedKeyCallback)); | ||||
|                     listeners.push(objectAPI.observe(mutableSecondInstance, 'objectAttribute.embeddedObject', embeddedObjectCallback)); | ||||
|                     listeners.push(objectAPI.observe(mutableSecondInstance, 'objectAttribute', objectAttributeCallback)); | ||||
|  | ||||
|                     objectAPI.mutate(mutable, 'objectAttribute.embeddedObject.embeddedKey', 'updated-embedded-value'); | ||||
|                 }).then(function () { | ||||
|                     expect(embeddedKeyCallback).toHaveBeenCalledWith('updated-embedded-value'); | ||||
|                     expect(embeddedObjectCallback).toHaveBeenCalledWith({ | ||||
|                         embeddedKey: 'updated-embedded-value' | ||||
|                     return new Promise(function (resolve) { | ||||
|                         mutationCallback.and.callFake(resolve); | ||||
|                         unlisten = objectAPI.observe(mutableSecondInstance, 'otherAttribute', mutationCallback); | ||||
|                         objectAPI.mutate(mutable, 'otherAttribute', 'some-new-value'); | ||||
|                     }).then(function () { | ||||
|                         expect(mutationCallback).toHaveBeenCalledWith('some-new-value'); | ||||
|                         unlisten(); | ||||
|                     }); | ||||
|                     expect(objectAttributeCallback).toHaveBeenCalledWith({ | ||||
|                         embeddedObject: { | ||||
|                 }); | ||||
|  | ||||
|                 it('to indicate when a child property has changed', function () { | ||||
|                     let embeddedKeyCallback = jasmine.createSpy('embeddedKeyCallback'); | ||||
|                     let embeddedObjectCallback = jasmine.createSpy('embeddedObjectCallback'); | ||||
|                     let objectAttributeCallback = jasmine.createSpy('objectAttribute'); | ||||
|                     let listeners = []; | ||||
|  | ||||
|                     return new Promise(function (resolve) { | ||||
|                         objectAttributeCallback.and.callFake(resolve); | ||||
|  | ||||
|                         listeners.push(objectAPI.observe(mutableSecondInstance, 'objectAttribute.embeddedObject.embeddedKey', embeddedKeyCallback)); | ||||
|                         listeners.push(objectAPI.observe(mutableSecondInstance, 'objectAttribute.embeddedObject', embeddedObjectCallback)); | ||||
|                         listeners.push(objectAPI.observe(mutableSecondInstance, 'objectAttribute', objectAttributeCallback)); | ||||
|  | ||||
|                         objectAPI.mutate(mutable, 'objectAttribute.embeddedObject.embeddedKey', 'updated-embedded-value'); | ||||
|                     }).then(function () { | ||||
|                         expect(embeddedKeyCallback).toHaveBeenCalledWith('updated-embedded-value'); | ||||
|                         expect(embeddedObjectCallback).toHaveBeenCalledWith({ | ||||
|                             embeddedKey: 'updated-embedded-value' | ||||
|                         } | ||||
|                     }); | ||||
|                         }); | ||||
|                         expect(objectAttributeCallback).toHaveBeenCalledWith({ | ||||
|                             embeddedObject: { | ||||
|                                 embeddedKey: 'updated-embedded-value' | ||||
|                             } | ||||
|                         }); | ||||
|  | ||||
|                     listeners.forEach(listener => listener()); | ||||
|                         listeners.forEach(listener => listener()); | ||||
|                     }); | ||||
|                 }); | ||||
|  | ||||
|                 it('to indicate when a parent property has changed', function () { | ||||
|                     let embeddedKeyCallback = jasmine.createSpy('embeddedKeyCallback'); | ||||
|                     let embeddedObjectCallback = jasmine.createSpy('embeddedObjectCallback'); | ||||
|                     let objectAttributeCallback = jasmine.createSpy('objectAttribute'); | ||||
|                     let listeners = []; | ||||
|  | ||||
|                     return new Promise(function (resolve) { | ||||
|                         objectAttributeCallback.and.callFake(resolve); | ||||
|  | ||||
|                         listeners.push(objectAPI.observe(mutableSecondInstance, 'objectAttribute.embeddedObject.embeddedKey', embeddedKeyCallback)); | ||||
|                         listeners.push(objectAPI.observe(mutableSecondInstance, 'objectAttribute.embeddedObject', embeddedObjectCallback)); | ||||
|                         listeners.push(objectAPI.observe(mutableSecondInstance, 'objectAttribute', objectAttributeCallback)); | ||||
|  | ||||
|                         objectAPI.mutate(mutable, 'objectAttribute.embeddedObject', 'updated-embedded-value'); | ||||
|                     }).then(function () { | ||||
|                         expect(embeddedKeyCallback).toHaveBeenCalledWith(undefined); | ||||
|                         expect(embeddedObjectCallback).toHaveBeenCalledWith('updated-embedded-value'); | ||||
|                         expect(objectAttributeCallback).toHaveBeenCalledWith({ | ||||
|                             embeddedObject: 'updated-embedded-value' | ||||
|                         }); | ||||
|  | ||||
|                         listeners.forEach(listener => listener()); | ||||
|                     }); | ||||
|                 }); | ||||
|             }); | ||||
|  | ||||
|             describe('on refresh', () => { | ||||
|                 let refreshModel; | ||||
|  | ||||
|                 beforeEach(() => { | ||||
|                     refreshModel = JSON.parse(JSON.stringify(mutable)); | ||||
|                 }); | ||||
|  | ||||
|                 it('to stay synchronized', function () { | ||||
|                     refreshModel.otherAttribute = 'new-attribute-value'; | ||||
|                     mutable.$refresh(refreshModel); | ||||
|                     expect(mutableSecondInstance.otherAttribute).toBe('new-attribute-value'); | ||||
|                 }); | ||||
|  | ||||
|                 it('to indicate when a property changes', function () { | ||||
|                     let mutationCallback = jasmine.createSpy('mutation-callback'); | ||||
|                     let unlisten; | ||||
|  | ||||
|                     return new Promise(function (resolve) { | ||||
|                         mutationCallback.and.callFake(resolve); | ||||
|                         unlisten = objectAPI.observe(mutableSecondInstance, 'otherAttribute', mutationCallback); | ||||
|                         refreshModel.otherAttribute = 'some-new-value'; | ||||
|                         mutable.$refresh(refreshModel); | ||||
|                     }).then(function () { | ||||
|                         expect(mutationCallback).toHaveBeenCalledWith('some-new-value'); | ||||
|                         unlisten(); | ||||
|                     }); | ||||
|                 }); | ||||
|  | ||||
|                 it('to indicate when a child property has changed', function () { | ||||
|                     let embeddedKeyCallback = jasmine.createSpy('embeddedKeyCallback'); | ||||
|                     let embeddedObjectCallback = jasmine.createSpy('embeddedObjectCallback'); | ||||
|                     let objectAttributeCallback = jasmine.createSpy('objectAttribute'); | ||||
|                     let listeners = []; | ||||
|  | ||||
|                     return new Promise(function (resolve) { | ||||
|                         objectAttributeCallback.and.callFake(resolve); | ||||
|  | ||||
|                         listeners.push(objectAPI.observe(mutableSecondInstance, 'objectAttribute.embeddedObject.embeddedKey', embeddedKeyCallback)); | ||||
|                         listeners.push(objectAPI.observe(mutableSecondInstance, 'objectAttribute.embeddedObject', embeddedObjectCallback)); | ||||
|                         listeners.push(objectAPI.observe(mutableSecondInstance, 'objectAttribute', objectAttributeCallback)); | ||||
|  | ||||
|                         refreshModel.objectAttribute.embeddedObject.embeddedKey = 'updated-embedded-value'; | ||||
|                         mutable.$refresh(refreshModel); | ||||
|                     }).then(function () { | ||||
|                         expect(embeddedKeyCallback).toHaveBeenCalledWith('updated-embedded-value'); | ||||
|                         expect(embeddedObjectCallback).toHaveBeenCalledWith({ | ||||
|                             embeddedKey: 'updated-embedded-value' | ||||
|                         }); | ||||
|                         expect(objectAttributeCallback).toHaveBeenCalledWith({ | ||||
|                             embeddedObject: { | ||||
|                                 embeddedKey: 'updated-embedded-value' | ||||
|                             } | ||||
|                         }); | ||||
|  | ||||
|                         listeners.forEach(listener => listener()); | ||||
|                     }); | ||||
|                 }); | ||||
|  | ||||
|                 it('to indicate when a parent property has changed', function () { | ||||
|                     let embeddedKeyCallback = jasmine.createSpy('embeddedKeyCallback'); | ||||
|                     let embeddedObjectCallback = jasmine.createSpy('embeddedObjectCallback'); | ||||
|                     let objectAttributeCallback = jasmine.createSpy('objectAttribute'); | ||||
|                     let listeners = []; | ||||
|  | ||||
|                     return new Promise(function (resolve) { | ||||
|                         objectAttributeCallback.and.callFake(resolve); | ||||
|  | ||||
|                         listeners.push(objectAPI.observe(mutableSecondInstance, 'objectAttribute.embeddedObject.embeddedKey', embeddedKeyCallback)); | ||||
|                         listeners.push(objectAPI.observe(mutableSecondInstance, 'objectAttribute.embeddedObject', embeddedObjectCallback)); | ||||
|                         listeners.push(objectAPI.observe(mutableSecondInstance, 'objectAttribute', objectAttributeCallback)); | ||||
|  | ||||
|                         refreshModel.objectAttribute.embeddedObject = 'updated-embedded-value'; | ||||
|  | ||||
|                         mutable.$refresh(refreshModel); | ||||
|                     }).then(function () { | ||||
|                         expect(embeddedKeyCallback).toHaveBeenCalledWith(undefined); | ||||
|                         expect(embeddedObjectCallback).toHaveBeenCalledWith('updated-embedded-value'); | ||||
|                         expect(objectAttributeCallback).toHaveBeenCalledWith({ | ||||
|                             embeddedObject: 'updated-embedded-value' | ||||
|                         }); | ||||
|  | ||||
|                         listeners.forEach(listener => listener()); | ||||
|                     }); | ||||
|                 }); | ||||
|             }); | ||||
|  | ||||
|         }); | ||||
|     }); | ||||
|  | ||||
|   | ||||
| @@ -155,7 +155,7 @@ describe("The LAD Table", () => { | ||||
|         // add another telemetry object as composition in lad table to test multi rows | ||||
|         mockObj.ladTable.composition.push(anotherTelemetryObj.identifier); | ||||
|  | ||||
|         beforeEach(async (done) => { | ||||
|         beforeEach(async () => { | ||||
|             let telemetryRequestResolve; | ||||
|             let telemetryObjectResolve; | ||||
|             let anotherTelemetryObjectResolve; | ||||
| @@ -204,8 +204,6 @@ describe("The LAD Table", () => { | ||||
|  | ||||
|             await Promise.all([telemetryRequestPromise, telemetryObjectPromise, anotherTelemetryObjectPromise]); | ||||
|             await Vue.nextTick(); | ||||
|  | ||||
|             done(); | ||||
|         }); | ||||
|  | ||||
|         it("should show one row per object in the composition", () => { | ||||
|   | ||||
| @@ -178,6 +178,26 @@ export default { | ||||
|             this.requestDataFor(telemetryObject); | ||||
|             this.subscribeToObject(telemetryObject); | ||||
|         }, | ||||
|         setTrace(key, name, axisMetadata, xValues, yValues) { | ||||
|             let trace = { | ||||
|                 key, | ||||
|                 name: name, | ||||
|                 x: xValues, | ||||
|                 y: yValues, | ||||
|                 xAxisMetadata: {}, | ||||
|                 yAxisMetadata: axisMetadata.yAxisMetadata, | ||||
|                 type: this.domainObject.configuration.useBar ? 'bar' : 'scatter', | ||||
|                 mode: 'lines', | ||||
|                 line: { | ||||
|                     shape: this.domainObject.configuration.useInterpolation | ||||
|                 }, | ||||
|                 marker: { | ||||
|                     color: this.domainObject.configuration.barStyles.series[key].color | ||||
|                 }, | ||||
|                 hoverinfo: this.domainObject.configuration.useBar ? 'skip' : 'x+y' | ||||
|             }; | ||||
|             this.addTrace(trace, key); | ||||
|         }, | ||||
|         addTrace(trace, key) { | ||||
|             if (!this.trace.length) { | ||||
|                 this.trace = this.trace.concat([trace]); | ||||
| @@ -236,7 +256,15 @@ export default { | ||||
|         refreshData(bounds, isTick) { | ||||
|             if (!isTick) { | ||||
|                 const telemetryObjects = Object.values(this.telemetryObjects); | ||||
|                 telemetryObjects.forEach(this.requestDataFor); | ||||
|                 telemetryObjects.forEach((telemetryObject) => { | ||||
|                     //clear existing data | ||||
|                     const key = this.openmct.objects.makeKeyString(telemetryObject.identifier); | ||||
|                     const axisMetadata = this.getAxisMetadata(telemetryObject); | ||||
|                     this.setTrace(key, telemetryObject.name, axisMetadata, [], []); | ||||
|                     //request new data | ||||
|                     this.requestDataFor(telemetryObject); | ||||
|                     this.subscribeToObject(telemetryObject); | ||||
|                 }); | ||||
|             } | ||||
|         }, | ||||
|         removeAllSubscriptions() { | ||||
| @@ -320,25 +348,7 @@ export default { | ||||
|                 }); | ||||
|             } | ||||
|  | ||||
|             let trace = { | ||||
|                 key, | ||||
|                 name: telemetryObject.name, | ||||
|                 x: xValues, | ||||
|                 y: yValues, | ||||
|                 xAxisMetadata: xAxisMetadata, | ||||
|                 yAxisMetadata: axisMetadata.yAxisMetadata, | ||||
|                 type: this.domainObject.configuration.useBar ? 'bar' : 'scatter', | ||||
|                 mode: 'lines', | ||||
|                 line: { | ||||
|                     shape: this.domainObject.configuration.useInterpolation | ||||
|                 }, | ||||
|                 marker: { | ||||
|                     color: this.domainObject.configuration.barStyles.series[key].color | ||||
|                 }, | ||||
|                 hoverinfo: this.domainObject.configuration.useBar ? 'skip' : 'x+y' | ||||
|             }; | ||||
|  | ||||
|             this.addTrace(trace, key); | ||||
|             this.setTrace(key, telemetryObject.name, axisMetadata, xValues, yValues); | ||||
|         }, | ||||
|         isDataInTimeRange(datum, key, telemetryObject) { | ||||
|             const timeSystemKey = this.timeContext.timeSystem().key; | ||||
|   | ||||
| @@ -66,12 +66,15 @@ export default function BarGraphViewProvider(openmct) { | ||||
|                                 } | ||||
|                             }; | ||||
|                         }, | ||||
|                         template: '<bar-graph-view :options="options"></bar-graph-view>' | ||||
|                         template: '<bar-graph-view ref="graphComponent" :options="options"></bar-graph-view>' | ||||
|                     }); | ||||
|                 }, | ||||
|                 destroy: function () { | ||||
|                     component.$destroy(); | ||||
|                     component = undefined; | ||||
|                 }, | ||||
|                 onClearData() { | ||||
|                     component.$refs.graphComponent.refreshData(); | ||||
|                 } | ||||
|             }; | ||||
|         } | ||||
|   | ||||
| @@ -316,11 +316,16 @@ export default { | ||||
|                     } | ||||
|                 } else { | ||||
|                     if (this.yKey === undefined) { | ||||
|                         yKeyOptionIndex = this.yKeyOptions.findIndex((option, index) => index !== xKeyOptionIndex); | ||||
|                         if (yKeyOptionIndex > -1) { | ||||
|                         if (metadataValues.length && metadataArrayValues.length === 0) { | ||||
|                             update = true; | ||||
|                             this.yKey = this.yKeyOptions[yKeyOptionIndex].value; | ||||
|                             this.yKeyLabel = this.yKeyOptions[yKeyOptionIndex].name; | ||||
|                             this.yKey = 'none'; | ||||
|                         } else { | ||||
|                             yKeyOptionIndex = this.yKeyOptions.findIndex((option, index) => index !== xKeyOptionIndex); | ||||
|                             if (yKeyOptionIndex > -1) { | ||||
|                                 update = true; | ||||
|                                 this.yKey = this.yKeyOptions[yKeyOptionIndex].value; | ||||
|                                 this.yKeyLabel = this.yKeyOptions[yKeyOptionIndex].name; | ||||
|                             } | ||||
|                         } | ||||
|                     } | ||||
|                 } | ||||
|   | ||||
| @@ -28,9 +28,9 @@ export default function () { | ||||
|     return function install(openmct) { | ||||
|         openmct.types.addType(BAR_GRAPH_KEY, { | ||||
|             key: BAR_GRAPH_KEY, | ||||
|             name: "Graph (Bar or Line)", | ||||
|             name: "Graph", | ||||
|             cssClass: "icon-bar-chart", | ||||
|             description: "View data as a bar graph. Can be added to Display Layouts.", | ||||
|             description: "Visualize data as a bar or line graph.", | ||||
|             creatable: true, | ||||
|             initialize: function (domainObject) { | ||||
|                 domainObject.composition = []; | ||||
|   | ||||
| @@ -300,8 +300,11 @@ export default class ConditionManager extends EventEmitter { | ||||
|         return this.compositionLoad.then(() => { | ||||
|             let latestTimestamp; | ||||
|             let conditionResults = {}; | ||||
|             let nextLegOptions = {...options}; | ||||
|             delete nextLegOptions.onPartialResponse; | ||||
|  | ||||
|             const conditionRequests = this.conditions | ||||
|                 .map(condition => condition.requestLADConditionResult(options)); | ||||
|                 .map(condition => condition.requestLADConditionResult(nextLegOptions)); | ||||
|  | ||||
|             return Promise.all(conditionRequests) | ||||
|                 .then((results) => { | ||||
|   | ||||
| @@ -23,12 +23,7 @@ | ||||
| <template> | ||||
| <div | ||||
|     class="c-fault-mgmt__list data-selectable" | ||||
|     :class="[ | ||||
|         {'is-selected': isSelected}, | ||||
|         {'is-unacknowledged': !fault.acknowledged}, | ||||
|         {'is-shelved': fault.shelved}, | ||||
|         {'is-acknowledged': fault.acknowledged} | ||||
|     ]" | ||||
|     :class="classesFromState" | ||||
| > | ||||
|     <div class="c-fault-mgmt-item c-fault-mgmt__list-checkbox"> | ||||
|         <input | ||||
| @@ -113,6 +108,36 @@ export default { | ||||
|         } | ||||
|     }, | ||||
|     computed: { | ||||
|         classesFromState() { | ||||
|             const exclusiveStates = [ | ||||
|                 { | ||||
|                     className: 'is-shelved', | ||||
|                     test: () => this.fault.shelved | ||||
|                 }, | ||||
|                 { | ||||
|                     className: 'is-unacknowledged', | ||||
|                     test: () => !this.fault.acknowledged && !this.fault.shelved | ||||
|                 }, | ||||
|                 { | ||||
|                     className: 'is-acknowledged', | ||||
|                     test: () => this.fault.acknowledged && !this.fault.shelved | ||||
|                 } | ||||
|             ]; | ||||
|  | ||||
|             const classes = []; | ||||
|  | ||||
|             if (this.isSelected) { | ||||
|                 classes.push('is-selected'); | ||||
|             } | ||||
|  | ||||
|             const matchingState = exclusiveStates.find(stateDefinition => stateDefinition.test()); | ||||
|  | ||||
|             if (matchingState !== undefined) { | ||||
|                 classes.push(matchingState.className); | ||||
|             } | ||||
|  | ||||
|             return classes; | ||||
|         }, | ||||
|         liveValueClassname() { | ||||
|             const currentValueInfo = this.fault?.currentValueInfo; | ||||
|             if (!currentValueInfo || currentValueInfo.monitoringResult === 'IN_LIMITS') { | ||||
|   | ||||
| @@ -96,17 +96,19 @@ export default { | ||||
|     computed: { | ||||
|         filteredFaultsList() { | ||||
|             const filterName = FILTER_ITEMS[this.filterIndex]; | ||||
|             let list = this.faultsList.filter(fault => !fault.shelved); | ||||
|             let list = this.faultsList; | ||||
|  | ||||
|             // Exclude shelved alarms from all views except the Shelved view | ||||
|             if (filterName !== 'Shelved') { | ||||
|                 list = list.filter(fault => fault.shelved !== true); | ||||
|             } | ||||
|  | ||||
|             if (filterName === 'Acknowledged') { | ||||
|                 list = this.faultsList.filter(fault => fault.acknowledged); | ||||
|             } | ||||
|  | ||||
|             if (filterName === 'Unacknowledged') { | ||||
|                 list = this.faultsList.filter(fault => !fault.acknowledged); | ||||
|             } | ||||
|  | ||||
|             if (filterName === 'Shelved') { | ||||
|                 list = this.faultsList.filter(fault => fault.shelved); | ||||
|                 list = list.filter(fault => fault.acknowledged); | ||||
|             } else if (filterName === 'Unacknowledged') { | ||||
|                 list = list.filter(fault => !fault.acknowledged); | ||||
|             } else if (filterName === 'Shelved') { | ||||
|                 list = list.filter(fault => fault.shelved); | ||||
|             } | ||||
|  | ||||
|             if (this.searchTerm.length > 0) { | ||||
|   | ||||
| @@ -169,6 +169,7 @@ | ||||
|             </g> | ||||
|             <g class="c-dial__text"> | ||||
|                 <text | ||||
|                     v-if="displayUnits" | ||||
|                     x="50%" | ||||
|                     y="70%" | ||||
|                     text-anchor="middle" | ||||
|   | ||||
| @@ -40,7 +40,7 @@ | ||||
|             <div class="c-form__row"> | ||||
|                 <span class="req-indicator req"> | ||||
|                 </span> | ||||
|                 <label>Range minimum value</label> | ||||
|                 <label>Minimum value</label> | ||||
|                 <input | ||||
|                     ref="min" | ||||
|                     v-model.number="min" | ||||
| @@ -53,7 +53,7 @@ | ||||
|             <div class="c-form__row"> | ||||
|                 <span class="req-indicator"> | ||||
|                 </span> | ||||
|                 <label>Range low limit</label> | ||||
|                 <label>Low limit</label> | ||||
|                 <input | ||||
|                     ref="limitLow" | ||||
|                     v-model.number="limitLow" | ||||
| @@ -64,26 +64,26 @@ | ||||
|             </div> | ||||
|  | ||||
|             <div class="c-form__row"> | ||||
|                 <span class="req-indicator req"> | ||||
|                 <span class="req-indicator"> | ||||
|                 </span> | ||||
|                 <label>Range maximum value</label> | ||||
|                 <label>High limit</label> | ||||
|                 <input | ||||
|                     ref="max" | ||||
|                     v-model.number="max" | ||||
|                     data-field-name="max" | ||||
|                     ref="limitHigh" | ||||
|                     v-model.number="limitHigh" | ||||
|                     data-field-name="limitHigh" | ||||
|                     type="number" | ||||
|                     @input="onChange" | ||||
|                 > | ||||
|             </div> | ||||
|  | ||||
|             <div class="c-form__row"> | ||||
|                 <span class="req-indicator"> | ||||
|                 <span class="req-indicator req"> | ||||
|                 </span> | ||||
|                 <label>Range high limit</label> | ||||
|                 <label>Maximum value</label> | ||||
|                 <input | ||||
|                     ref="limitHigh" | ||||
|                     v-model.number="limitHigh" | ||||
|                     data-field-name="limitHigh" | ||||
|                     ref="max" | ||||
|                     v-model.number="max" | ||||
|                     data-field-name="max" | ||||
|                     type="number" | ||||
|                     @input="onChange" | ||||
|                 > | ||||
|   | ||||
| @@ -210,9 +210,10 @@ | ||||
|         border-radius: $controlCr; | ||||
|         display: flex; | ||||
|         align-items: flex-start; | ||||
|         flex-direction: row; | ||||
|         justify-content: space-between; | ||||
|         padding: $interiorMargin; | ||||
|         width: min-content; | ||||
|         width: max-content; | ||||
|  | ||||
|         > * + * { | ||||
|             margin-left: $interiorMargin; | ||||
| @@ -338,7 +339,6 @@ | ||||
|     &__input { | ||||
|         display: flex; | ||||
|         align-items: center; | ||||
|         width: 100%; | ||||
|  | ||||
|         &:before { | ||||
|             color: rgba($colorMenuFg, 0.5); | ||||
| @@ -353,13 +353,16 @@ | ||||
|  | ||||
|     &--filters { | ||||
|         // Styles specific to the brightness and contrast controls | ||||
|  | ||||
|         .c-image-controls { | ||||
|             &__controls { | ||||
|                 width: 80px; // About the minimum this element can be; cannot size based on % due to markup structure | ||||
|             } | ||||
|  | ||||
|             &__sliders { | ||||
|                 display: flex; | ||||
|                 flex: 1 1 auto; | ||||
|                 flex-direction: column; | ||||
|                 min-width: 80px; | ||||
|                 width: 100%; | ||||
|  | ||||
|                 > * + * { | ||||
|                     margin-top: 11px; | ||||
|   | ||||
| @@ -76,7 +76,10 @@ export default { | ||||
|         dataRemoved(dataToRemove) { | ||||
|             this.imageHistory = this.imageHistory.filter(existingDatum => { | ||||
|                 const shouldKeep = dataToRemove.some(datumToRemove => { | ||||
|                     return (existingDatum.utc !== datumToRemove.utc); | ||||
|                     const existingDatumTimestamp = this.parseTime(existingDatum); | ||||
|                     const datumToRemoveTimestamp = this.parseTime(datumToRemove); | ||||
|  | ||||
|                     return (existingDatumTimestamp !== datumToRemoveTimestamp); | ||||
|                 }); | ||||
|  | ||||
|                 return shouldKeep; | ||||
|   | ||||
| @@ -49,6 +49,7 @@ describe("the plugin", () => { | ||||
|         let parentObject; | ||||
|         let parentObjectPath; | ||||
|         let changedParentObject; | ||||
|         let unobserve; | ||||
|         beforeEach((done) => { | ||||
|             parentObject = { | ||||
|                 name: 'mock folder', | ||||
| @@ -73,7 +74,7 @@ describe("the plugin", () => { | ||||
|                 }); | ||||
|             }); | ||||
|  | ||||
|             openmct.objects.observe(parentObject, '*', (newObject) => { | ||||
|             unobserve = openmct.objects.observe(parentObject, '*', (newObject) => { | ||||
|                 changedParentObject = newObject; | ||||
|  | ||||
|                 done(); | ||||
| @@ -81,6 +82,9 @@ describe("the plugin", () => { | ||||
|  | ||||
|             newFolderAction.invoke(parentObjectPath); | ||||
|         }); | ||||
|         afterEach(() => { | ||||
|             unobserve(); | ||||
|         }); | ||||
|  | ||||
|         it('creates a new folder object', () => { | ||||
|             expect(openmct.objects.save).toHaveBeenCalled(); | ||||
|   | ||||
| @@ -296,12 +296,17 @@ export default { | ||||
|         window.addEventListener('orientationchange', this.formatSidebar); | ||||
|         window.addEventListener('hashchange', this.setSectionAndPageFromUrl); | ||||
|         this.filterAndSortEntries(); | ||||
|         this.unlistenToEntryChanges = this.openmct.objects.observe(this.domainObject, "configuration.entries", () => this.filterAndSortEntries()); | ||||
|     }, | ||||
|     beforeDestroy() { | ||||
|         if (this.unlisten) { | ||||
|             this.unlisten(); | ||||
|         } | ||||
|  | ||||
|         if (this.unlistenToEntryChanges) { | ||||
|             this.unlistenToEntryChanges(); | ||||
|         } | ||||
|  | ||||
|         window.removeEventListener('orientationchange', this.formatSidebar); | ||||
|         window.removeEventListener('hashchange', this.setSectionAndPageFromUrl); | ||||
|     }, | ||||
|   | ||||
| @@ -233,6 +233,13 @@ export default { | ||||
|     }, | ||||
|     mounted() { | ||||
|         this.dropOnEntry = this.dropOnEntry.bind(this); | ||||
|         this.$on('tags-updated', async () => { | ||||
|             const user = await this.openmct.user.getCurrentUser(); | ||||
|             this.entry.modified = Date.now(); | ||||
|             this.entry.modifiedBy = user.getId(); | ||||
|  | ||||
|             this.$emit('updateEntry', this.entry); | ||||
|         }); | ||||
|     }, | ||||
|     methods: { | ||||
|         async addNewEmbed(objectPath) { | ||||
|   | ||||
| @@ -80,7 +80,7 @@ | ||||
|     &__content { | ||||
|         $m: $interiorMargin; | ||||
|         display: grid; | ||||
|         grid-template-columns: min-content 1fr; | ||||
|         grid-template-columns: max-content 1fr; | ||||
|         grid-column-gap: $m; | ||||
|         grid-row-gap: $m; | ||||
|  | ||||
|   | ||||
| @@ -34,7 +34,7 @@ | ||||
|  | ||||
|     <div class="c-status-poll__section c-status-poll-panel__content c-spq"> | ||||
|         <!-- Grid layout --> | ||||
|         <div class="c-spq__label">Current:</div> | ||||
|         <div class="c-spq__label">Current poll:</div> | ||||
|         <div class="c-spq__value c-status-poll-panel__poll-question">{{ currentPollQuestion }}</div> | ||||
|  | ||||
|         <template v-if="statusCountViewModel.length > 0"> | ||||
| @@ -43,6 +43,7 @@ | ||||
|                 <div | ||||
|                     v-for="entry in statusCountViewModel" | ||||
|                     :key="entry.status.key" | ||||
|                     :title="entry.status.label" | ||||
|                     class="c-status-poll-report__count" | ||||
|                     :style="[{ | ||||
|                         background: entry.status.statusBgColor, | ||||
| @@ -69,6 +70,7 @@ | ||||
|             > | ||||
|             <button | ||||
|                 class="c-button" | ||||
|                 title="Publish a new poll question and reset previous responses" | ||||
|                 @click="updatePollQuestion" | ||||
|             >Update</button> | ||||
|         </div> | ||||
|   | ||||
| @@ -215,6 +215,8 @@ class CouchObjectProvider { | ||||
|             // Network error, CouchDB unreachable. | ||||
|             if (response === null) { | ||||
|                 this.indicator.setIndicatorToState(DISCONNECTED); | ||||
|                 console.error(error.message); | ||||
|                 throw new Error(`CouchDB Error - No response"`); | ||||
|             } | ||||
|  | ||||
|             console.error(error.message); | ||||
| @@ -377,15 +379,7 @@ class CouchObjectProvider { | ||||
|         }; | ||||
|  | ||||
|         return this.request(ALL_DOCS, 'POST', query, signal).then((response) => { | ||||
|             if (!response) { | ||||
|                 //There was no response - no error code, no rows, nothing - this is bad and should not happen | ||||
|                 // Network error, CouchDB unreachable. | ||||
|                 if (response === null) { | ||||
|                     this.indicator.setIndicatorToState(DISCONNECTED); | ||||
|                 } | ||||
|  | ||||
|                 console.error('Failed to retrieve response: #bulkGet'); | ||||
|             } else if (response.rows !== undefined) { | ||||
|             if (response && response.rows !== undefined) { | ||||
|                 return response.rows.reduce((map, row) => { | ||||
|                     //row.doc === null if the document does not exist. | ||||
|                     //row.doc === undefined if the document is not found. | ||||
|   | ||||
| @@ -25,7 +25,7 @@ const exportPNG = { | ||||
|     name: 'Export as PNG', | ||||
|     key: 'export-as-png', | ||||
|     description: 'Export This View\'s Data as PNG', | ||||
|     cssClass: 'c-icon-button icon-download', | ||||
|     cssClass: 'icon-download', | ||||
|     group: 'view', | ||||
|     invoke(objectPath, view) { | ||||
|         view.getViewContext().exportPNG(); | ||||
| @@ -36,7 +36,7 @@ const exportJPG = { | ||||
|     name: 'Export as JPG', | ||||
|     key: 'export-as-jpg', | ||||
|     description: 'Export This View\'s Data as JPG', | ||||
|     cssClass: 'c-icon-button icon-download', | ||||
|     cssClass: 'icon-download', | ||||
|     group: 'view', | ||||
|     invoke(objectPath, view) { | ||||
|         view.getViewContext().exportJPG(); | ||||
|   | ||||
| @@ -27,7 +27,7 @@ import OverlayPlotCompositionPolicy from './overlayPlot/OverlayPlotCompositionPo | ||||
| import StackedPlotCompositionPolicy from './stackedPlot/StackedPlotCompositionPolicy'; | ||||
| import PlotViewActions from "./actions/ViewActions"; | ||||
| import StackedPlotsInspectorViewProvider from "./inspector/StackedPlotsInspectorViewProvider"; | ||||
| import plotInterceptor from "./plotInterceptor"; | ||||
| import stackedPlotConfigurationInterceptor from "./stackedPlot/stackedPlotConfigurationInterceptor"; | ||||
|  | ||||
| export default function () { | ||||
|     return function install(openmct) { | ||||
| @@ -65,7 +65,7 @@ export default function () { | ||||
|             priority: 890 | ||||
|         }); | ||||
|  | ||||
|         plotInterceptor(openmct); | ||||
|         stackedPlotConfigurationInterceptor(openmct); | ||||
|  | ||||
|         openmct.objectViews.addProvider(new StackedPlotViewProvider(openmct)); | ||||
|         openmct.objectViews.addProvider(new OverlayPlotViewProvider(openmct)); | ||||
|   | ||||
| @@ -20,7 +20,7 @@ | ||||
|  * at runtime from the About dialog for additional information. | ||||
|  *****************************************************************************/ | ||||
| 
 | ||||
| export default function plotInterceptor(openmct) { | ||||
| export default function stackedPlotConfigurationInterceptor(openmct) { | ||||
| 
 | ||||
|     openmct.objects.addGetInterceptor({ | ||||
|         appliesTo: (identifier, domainObject) => { | ||||
| @@ -71,7 +71,7 @@ describe("the RemoteClock plugin", () => { | ||||
|             parse: (datum) => datum.key | ||||
|         }; | ||||
|  | ||||
|         beforeEach((done) => { | ||||
|         beforeEach(async () => { | ||||
|             openmct.install(openmct.plugins.RemoteClock(TIME_TELEMETRY_ID)); | ||||
|  | ||||
|             let clocks = openmct.time.getAllClocks(); | ||||
| @@ -113,9 +113,7 @@ describe("the RemoteClock plugin", () => { | ||||
|                 end: OFFSET_END | ||||
|             }); | ||||
|  | ||||
|             Promise.all([objectPromiseResolve, requestPromise]) | ||||
|                 .then(done) | ||||
|                 .catch(done); | ||||
|             await Promise.all([objectPromiseResolve, requestPromise]); | ||||
|         }); | ||||
|  | ||||
|         it('is available and sets up initial values and listeners', () => { | ||||
|   | ||||
| @@ -78,13 +78,15 @@ describe("the plugin", () => { | ||||
|  | ||||
|     describe('when invoked', () => { | ||||
|  | ||||
|         beforeEach((done) => { | ||||
|         beforeEach(() => { | ||||
|             openmct.overlays.overlay = function (options) {}; | ||||
|  | ||||
|             spyOn(openmct.overlays, 'overlay'); | ||||
|  | ||||
|             viewDatumAction.invoke(mockObjectPath, mockView); | ||||
|         }); | ||||
|  | ||||
|         it('creates an overlay', () => { | ||||
|             expect(openmct.overlays.overlay).toHaveBeenCalled(); | ||||
|         }); | ||||
|     }); | ||||
|   | ||||
| @@ -2,7 +2,7 @@ | ||||
|     // <a> tag and draggable element that holds type icon and name. | ||||
|     // Used mostly in trees and lists | ||||
|     display: flex; | ||||
|     align-items: baseline; // Provides better vertical alignment than center | ||||
|     align-items: center; | ||||
|     flex: 0 1 auto; | ||||
|     overflow: hidden; | ||||
|     white-space: nowrap; | ||||
|   | ||||
| @@ -133,8 +133,11 @@ export default { | ||||
|             this.addedTags.push(newTagValue); | ||||
|             this.userAddingTag = true; | ||||
|         }, | ||||
|         tagRemoved(tagToRemove) { | ||||
|             return this.openmct.annotation.removeAnnotationTag(this.annotation, tagToRemove); | ||||
|         async tagRemoved(tagToRemove) { | ||||
|             const result = await this.openmct.annotation.removeAnnotationTag(this.annotation, tagToRemove); | ||||
|             this.$emit('tags-updated'); | ||||
|  | ||||
|             return result; | ||||
|         }, | ||||
|         async tagAdded(newTag) { | ||||
|             const annotationWasCreated = this.annotation === null || this.annotation === undefined; | ||||
| @@ -146,6 +149,7 @@ export default { | ||||
|  | ||||
|             this.tagsChanged(this.annotation.tags); | ||||
|             this.userAddingTag = false; | ||||
|             this.$emit('tags-updated'); | ||||
|         } | ||||
|     } | ||||
| }; | ||||
|   | ||||
| @@ -107,7 +107,12 @@ export default { | ||||
|                 this.preview(); | ||||
|             } else { | ||||
|                 const objectPath = this.result.originalPath; | ||||
|                 const resultUrl = objectPathToUrl(this.openmct, objectPath); | ||||
|                 let resultUrl = objectPathToUrl(this.openmct, objectPath); | ||||
|                 // get rid of ROOT if extant | ||||
|                 if (resultUrl.includes('/ROOT')) { | ||||
|                     resultUrl = resultUrl.split('/ROOT').join(''); | ||||
|                 } | ||||
|  | ||||
|                 this.openmct.router.navigate(resultUrl); | ||||
|             } | ||||
|         }, | ||||
|   | ||||
| @@ -50,6 +50,10 @@ class ApplicationRouter extends EventEmitter { | ||||
|         this.started = false; | ||||
|  | ||||
|         this.setHash = _.debounce(this.setHash.bind(this), 300); | ||||
|  | ||||
|         openmct.once('destroy', () => { | ||||
|             this.destroy(); | ||||
|         }); | ||||
|     } | ||||
|  | ||||
|     // Public Methods | ||||
|   | ||||
| @@ -2,10 +2,12 @@ | ||||
| // instrumentation using babel-plugin-istanbul (see babel.coverage.js) | ||||
|  | ||||
| const config = require('./webpack.dev'); | ||||
|  | ||||
| const path = require('path'); | ||||
|  | ||||
| const vueLoaderRule = config.module.rules.find(r => r.use === 'vue-loader'); | ||||
| // eslint-disable-next-line no-undef | ||||
| const CI = process.env.CI === 'true'; | ||||
|  | ||||
| config.devtool = CI ? false : undefined; | ||||
|  | ||||
| vueLoaderRule.use = { | ||||
|     loader: 'vue-loader' | ||||
|   | ||||
| @@ -6,6 +6,17 @@ const webpack = require('webpack'); | ||||
|  | ||||
| module.exports = merge(common, { | ||||
|     mode: 'development', | ||||
|     watchOptions: { | ||||
|         // Since we use require.context, webpack is watching the entire directory. | ||||
|         // We need to exclude any files we don't want webpack to watch. | ||||
|         // See: https://webpack.js.org/configuration/watch/#watchoptions-exclude | ||||
|         ignored: [ | ||||
|             '**/{node_modules,dist,docs,e2e}', // All files in node_modules, dist, docs, e2e, | ||||
|             '**/{*.yml,Procfile,webpack*.js,babel*.js,package*.json,tsconfig.json,jsdoc.json}', // Config files | ||||
|             '**/*.{sh,md,png,ttf,woff,svg}', // Non source files | ||||
|             '**/.*' // dotfiles and dotfolders | ||||
|         ] | ||||
|     }, | ||||
|     resolve: { | ||||
|         alias: { | ||||
|             "vue": path.join(__dirname, "node_modules/vue/dist/vue.js") | ||||
|   | ||||