Compare commits
18 Commits
plots-fix-
...
persistenc
Author | SHA1 | Date | |
---|---|---|---|
5de5ff347c | |||
2bfe632e7e | |||
4ac39a3990 | |||
169d148c58 | |||
40d2f3295f | |||
0e707150e0 | |||
2540d96617 | |||
1c8784fec5 | |||
2943d2b6ec | |||
4246a597a9 | |||
0af7965021 | |||
e9c0909415 | |||
0f0a3dc48f | |||
4c82680b87 | |||
c4734b8ad6 | |||
9786ff5de4 | |||
437154a5c0 | |||
2bd38dab9f |
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
|
||||
|
31
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 === '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) {
|
||||
|
@ -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,13 @@ 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 +73,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 +84,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 +110,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 +134,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
|
||||
|
@ -172,7 +172,8 @@ test.describe('Example Imagery Object', () => {
|
||||
|
||||
});
|
||||
|
||||
test('Can use the reset button to reset the image', async ({ page }) => {
|
||||
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");
|
||||
// wait for zoom animation to finish
|
||||
await page.locator(backgroundImageSelector).hover({trial: true});
|
||||
|
||||
@ -191,16 +192,17 @@ test.describe('Example Imagery Object', () => {
|
||||
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});
|
||||
// FIXME: The zoom is flakey, sometimes not returning to original dimensions
|
||||
// https://github.com/nasa/openmct/issues/5491
|
||||
await expect.poll(async () => {
|
||||
await zoomResetBtn.click();
|
||||
const boundingBox = await page.locator(backgroundImageSelector).boundingBox();
|
||||
|
||||
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);
|
||||
return boundingBox;
|
||||
}, {
|
||||
timeout: 10 * 1000
|
||||
}).toEqual(initialBoundingBox);
|
||||
});
|
||||
|
||||
test('Using the zoom features does not pause telemetry', async ({ page }) => {
|
||||
|
@ -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);
|
||||
|
@ -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();
|
||||
|
@ -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",
|
||||
|
@ -88,8 +88,8 @@
|
||||
"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",
|
||||
@ -98,7 +98,7 @@
|
||||
"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'",
|
||||
|
272
src/MCT.js
@ -96,161 +96,167 @@ define([
|
||||
};
|
||||
|
||||
this.destroy = this.destroy.bind(this);
|
||||
/**
|
||||
* Tracks current selection state of the application.
|
||||
* @private
|
||||
*/
|
||||
this.selection = new Selection(this);
|
||||
[
|
||||
/**
|
||||
* Tracks current selection state of the application.
|
||||
* @private
|
||||
*/
|
||||
['selection', () => new Selection(this)],
|
||||
|
||||
/**
|
||||
* MCT's time conductor, which may be used to synchronize view contents
|
||||
* for telemetry- or time-based views.
|
||||
* @type {module:openmct.TimeConductor}
|
||||
* @memberof module:openmct.MCT#
|
||||
* @name conductor
|
||||
*/
|
||||
this.time = new api.TimeAPI(this);
|
||||
/**
|
||||
* MCT's time conductor, which may be used to synchronize view contents
|
||||
* for telemetry- or time-based views.
|
||||
* @type {module:openmct.TimeConductor}
|
||||
* @memberof module:openmct.MCT#
|
||||
* @name conductor
|
||||
*/
|
||||
['time', () => new api.TimeAPI(this)],
|
||||
|
||||
/**
|
||||
* An interface for interacting with the composition of domain objects.
|
||||
* The composition of a domain object is the list of other domain
|
||||
* objects it "contains" (for instance, that should be displayed
|
||||
* beneath it in the tree.)
|
||||
*
|
||||
* `composition` may be called as a function, in which case it acts
|
||||
* as [`composition.get`]{@link module:openmct.CompositionAPI#get}.
|
||||
*
|
||||
* @type {module:openmct.CompositionAPI}
|
||||
* @memberof module:openmct.MCT#
|
||||
* @name composition
|
||||
*/
|
||||
this.composition = new api.CompositionAPI(this);
|
||||
/**
|
||||
* An interface for interacting with the composition of domain objects.
|
||||
* The composition of a domain object is the list of other domain
|
||||
* objects it "contains" (for instance, that should be displayed
|
||||
* beneath it in the tree.)
|
||||
*
|
||||
* `composition` may be called as a function, in which case it acts
|
||||
* as [`composition.get`]{@link module:openmct.CompositionAPI#get}.
|
||||
*
|
||||
* @type {module:openmct.CompositionAPI}
|
||||
* @memberof module:openmct.MCT#
|
||||
* @name composition
|
||||
*/
|
||||
['composition', () => new api.CompositionAPI(this)],
|
||||
|
||||
/**
|
||||
* Registry for views of domain objects which should appear in the
|
||||
* main viewing area.
|
||||
*
|
||||
* @type {module:openmct.ViewRegistry}
|
||||
* @memberof module:openmct.MCT#
|
||||
* @name objectViews
|
||||
*/
|
||||
this.objectViews = new ViewRegistry();
|
||||
/**
|
||||
* Registry for views of domain objects which should appear in the
|
||||
* main viewing area.
|
||||
*
|
||||
* @type {module:openmct.ViewRegistry}
|
||||
* @memberof module:openmct.MCT#
|
||||
* @name objectViews
|
||||
*/
|
||||
['objectViews', () => new ViewRegistry()],
|
||||
|
||||
/**
|
||||
* Registry for views which should appear in the Inspector area.
|
||||
* These views will be chosen based on the selection state.
|
||||
*
|
||||
* @type {module:openmct.InspectorViewRegistry}
|
||||
* @memberof module:openmct.MCT#
|
||||
* @name inspectorViews
|
||||
*/
|
||||
this.inspectorViews = new InspectorViewRegistry();
|
||||
/**
|
||||
* Registry for views which should appear in the Inspector area.
|
||||
* These views will be chosen based on the selection state.
|
||||
*
|
||||
* @type {module:openmct.InspectorViewRegistry}
|
||||
* @memberof module:openmct.MCT#
|
||||
* @name inspectorViews
|
||||
*/
|
||||
['inspectorViews', () => new InspectorViewRegistry()],
|
||||
|
||||
/**
|
||||
* Registry for views which should appear in Edit Properties
|
||||
* dialogs, and similar user interface elements used for
|
||||
* modifying domain objects external to its regular views.
|
||||
*
|
||||
* @type {module:openmct.ViewRegistry}
|
||||
* @memberof module:openmct.MCT#
|
||||
* @name propertyEditors
|
||||
*/
|
||||
this.propertyEditors = new ViewRegistry();
|
||||
/**
|
||||
* Registry for views which should appear in Edit Properties
|
||||
* dialogs, and similar user interface elements used for
|
||||
* modifying domain objects external to its regular views.
|
||||
*
|
||||
* @type {module:openmct.ViewRegistry}
|
||||
* @memberof module:openmct.MCT#
|
||||
* @name propertyEditors
|
||||
*/
|
||||
['propertyEditors', () => new ViewRegistry()],
|
||||
|
||||
/**
|
||||
* Registry for views which should appear in the status indicator area.
|
||||
* @type {module:openmct.ViewRegistry}
|
||||
* @memberof module:openmct.MCT#
|
||||
* @name indicators
|
||||
*/
|
||||
this.indicators = new ViewRegistry();
|
||||
/**
|
||||
* Registry for views which should appear in the toolbar area while
|
||||
* editing. These views will be chosen based on the selection state.
|
||||
*
|
||||
* @type {module:openmct.ToolbarRegistry}
|
||||
* @memberof module:openmct.MCT#
|
||||
* @name toolbars
|
||||
*/
|
||||
['toolbars', () => new ToolbarRegistry()],
|
||||
|
||||
/**
|
||||
* Registry for views which should appear in the toolbar area while
|
||||
* editing. These views will be chosen based on the selection state.
|
||||
*
|
||||
* @type {module:openmct.ToolbarRegistry}
|
||||
* @memberof module:openmct.MCT#
|
||||
* @name toolbars
|
||||
*/
|
||||
this.toolbars = new ToolbarRegistry();
|
||||
/**
|
||||
* Registry for domain object types which may exist within this
|
||||
* instance of Open MCT.
|
||||
*
|
||||
* @type {module:openmct.TypeRegistry}
|
||||
* @memberof module:openmct.MCT#
|
||||
* @name types
|
||||
*/
|
||||
['types', () => new api.TypeRegistry()],
|
||||
|
||||
/**
|
||||
* Registry for domain object types which may exist within this
|
||||
* instance of Open MCT.
|
||||
*
|
||||
* @type {module:openmct.TypeRegistry}
|
||||
* @memberof module:openmct.MCT#
|
||||
* @name types
|
||||
*/
|
||||
this.types = new api.TypeRegistry();
|
||||
/**
|
||||
* An interface for interacting with domain objects and the domain
|
||||
* object hierarchy.
|
||||
*
|
||||
* @type {module:openmct.ObjectAPI}
|
||||
* @memberof module:openmct.MCT#
|
||||
* @name objects
|
||||
*/
|
||||
['objects', () => new api.ObjectAPI.default(this.types, this)],
|
||||
|
||||
/**
|
||||
* An interface for interacting with domain objects and the domain
|
||||
* object hierarchy.
|
||||
*
|
||||
* @type {module:openmct.ObjectAPI}
|
||||
* @memberof module:openmct.MCT#
|
||||
* @name objects
|
||||
*/
|
||||
this.objects = new api.ObjectAPI.default(this.types, this);
|
||||
/**
|
||||
* An interface for retrieving and interpreting telemetry data associated
|
||||
* with a domain object.
|
||||
*
|
||||
* @type {module:openmct.TelemetryAPI}
|
||||
* @memberof module:openmct.MCT#
|
||||
* @name telemetry
|
||||
*/
|
||||
['telemetry', () => new api.TelemetryAPI.default(this)],
|
||||
|
||||
/**
|
||||
* An interface for retrieving and interpreting telemetry data associated
|
||||
* with a domain object.
|
||||
*
|
||||
* @type {module:openmct.TelemetryAPI}
|
||||
* @memberof module:openmct.MCT#
|
||||
* @name telemetry
|
||||
*/
|
||||
this.telemetry = new api.TelemetryAPI.default(this);
|
||||
/**
|
||||
* An interface for creating new indicators and changing them dynamically.
|
||||
*
|
||||
* @type {module:openmct.IndicatorAPI}
|
||||
* @memberof module:openmct.MCT#
|
||||
* @name indicators
|
||||
*/
|
||||
['indicators', () => new api.IndicatorAPI(this)],
|
||||
|
||||
/**
|
||||
* An interface for creating new indicators and changing them dynamically.
|
||||
*
|
||||
* @type {module:openmct.IndicatorAPI}
|
||||
* @memberof module:openmct.MCT#
|
||||
* @name indicators
|
||||
*/
|
||||
this.indicators = new api.IndicatorAPI(this);
|
||||
/**
|
||||
* MCT's user awareness management, to enable user and
|
||||
* role specific functionality.
|
||||
* @type {module:openmct.UserAPI}
|
||||
* @memberof module:openmct.MCT#
|
||||
* @name user
|
||||
*/
|
||||
['user', () => new api.UserAPI(this)],
|
||||
|
||||
/**
|
||||
* MCT's user awareness management, to enable user and
|
||||
* role specific functionality.
|
||||
* @type {module:openmct.UserAPI}
|
||||
* @memberof module:openmct.MCT#
|
||||
* @name user
|
||||
*/
|
||||
this.user = new api.UserAPI(this);
|
||||
['notifications', () => new api.NotificationAPI()],
|
||||
|
||||
this.notifications = new api.NotificationAPI();
|
||||
['editor', () => new api.EditorAPI.default(this)],
|
||||
|
||||
this.editor = new api.EditorAPI.default(this);
|
||||
['overlays', () => new OverlayAPI.default()],
|
||||
|
||||
this.overlays = new OverlayAPI.default();
|
||||
['menus', () => new api.MenuAPI(this)],
|
||||
|
||||
this.menus = new api.MenuAPI(this);
|
||||
['actions', () => new api.ActionsAPI(this)],
|
||||
|
||||
this.actions = new api.ActionsAPI(this);
|
||||
['status', () => new api.StatusAPI(this)],
|
||||
|
||||
this.status = new api.StatusAPI(this);
|
||||
['priority', () => api.PriorityAPI],
|
||||
|
||||
this.priority = api.PriorityAPI;
|
||||
['router', () => new ApplicationRouter(this)],
|
||||
|
||||
this.router = new ApplicationRouter(this);
|
||||
this.faults = new api.FaultManagementAPI.default(this);
|
||||
this.forms = new api.FormsAPI.default(this);
|
||||
['faults', () => new api.FaultManagementAPI.default(this)],
|
||||
|
||||
this.branding = BrandingAPI.default;
|
||||
['forms', () => new api.FormsAPI.default(this)],
|
||||
|
||||
/**
|
||||
* MCT's annotation API that enables
|
||||
* human-created comments and categorization linked to data products
|
||||
* @type {module:openmct.AnnotationAPI}
|
||||
* @memberof module:openmct.MCT#
|
||||
* @name annotation
|
||||
*/
|
||||
this.annotation = new api.AnnotationAPI(this);
|
||||
['branding', () => BrandingAPI.default],
|
||||
|
||||
/**
|
||||
* MCT's annotation API that enables
|
||||
* human-created comments and categorization linked to data products
|
||||
* @type {module:openmct.AnnotationAPI}
|
||||
* @memberof module:openmct.MCT#
|
||||
* @name annotation
|
||||
*/
|
||||
['annotation', () => new api.AnnotationAPI(this)]
|
||||
].forEach(apiEntry => {
|
||||
const apiName = apiEntry[0];
|
||||
const apiObject = apiEntry[1]();
|
||||
|
||||
Object.defineProperty(this, apiName, {
|
||||
value: apiObject,
|
||||
enumerable: false,
|
||||
configurable: false,
|
||||
writable: true
|
||||
});
|
||||
});
|
||||
|
||||
// Plugins that are installed by default
|
||||
this.install(this.plugins.Plot());
|
||||
|
@ -230,10 +230,15 @@ export default class ObjectAPI {
|
||||
return result;
|
||||
}).catch((result) => {
|
||||
console.warn(`Failed to retrieve ${keystring}:`, result);
|
||||
this.openmct.notifications.error(`Failed to retrieve object ${keystring}`);
|
||||
|
||||
delete this.cache[keystring];
|
||||
|
||||
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;
|
||||
});
|
||||
@ -383,7 +388,13 @@ export default class ObjectAPI {
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
return result.catch((error) => {
|
||||
if (error instanceof this.errors.Conflict) {
|
||||
this.openmct.notifications.error(`Conflict detected while saving ${this.makeKeyString(domainObject.identifier)}`);
|
||||
}
|
||||
|
||||
throw error;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -61,7 +61,7 @@ export default class TelemetryAPI {
|
||||
* @returns {CustomStringFormatter}
|
||||
*/
|
||||
customStringFormatter(valueMetadata, format) {
|
||||
return new CustomStringFormatter.default(this.openmct, valueMetadata, format);
|
||||
return new CustomStringFormatter(this.openmct, valueMetadata, format);
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -170,7 +170,6 @@ export default class TelemetryCollection extends EventEmitter {
|
||||
* @private
|
||||
*/
|
||||
_processNewTelemetry(telemetryData) {
|
||||
performance.mark('tlm:process:start');
|
||||
if (telemetryData === undefined) {
|
||||
return;
|
||||
}
|
||||
@ -389,7 +388,6 @@ export default class TelemetryCollection extends EventEmitter {
|
||||
* @todo handle subscriptions more granually
|
||||
*/
|
||||
_reset() {
|
||||
performance.mark('tlm:reset');
|
||||
this.boundedTelemetry = [];
|
||||
this.futureBuffer = [];
|
||||
|
||||
|
@ -197,7 +197,7 @@ export default {
|
||||
}
|
||||
},
|
||||
setUnit() {
|
||||
this.unit = this.valueMetadata.unit || '';
|
||||
this.unit = this.valueMetadata ? this.valueMetadata.unit : '';
|
||||
},
|
||||
firstNonDomainAttribute(metadata) {
|
||||
return metadata
|
||||
|
@ -83,9 +83,12 @@ export default {
|
||||
for (let ladTable of ladTables) {
|
||||
for (let telemetryObject of ladTable) {
|
||||
let metadata = this.openmct.telemetry.getMetadata(telemetryObject.domainObject);
|
||||
for (let metadatum of metadata.valueMetadatas) {
|
||||
if (metadatum.unit) {
|
||||
return true;
|
||||
|
||||
if (metadata) {
|
||||
for (let metadatum of metadata.valueMetadatas) {
|
||||
if (metadatum.unit) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -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;
|
||||
|
@ -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>
|
||||
|
@ -45,8 +45,7 @@ export default function CouchDocument(id, model, rev, markDeleted) {
|
||||
"category": "domain object",
|
||||
"type": model.type,
|
||||
"owner": "admin",
|
||||
"name": model.name,
|
||||
"created": Date.now()
|
||||
"name": model.name
|
||||
},
|
||||
"model": model
|
||||
};
|
||||
|
@ -215,9 +215,13 @@ class CouchObjectProvider {
|
||||
// Network error, CouchDB unreachable.
|
||||
if (response === null) {
|
||||
this.indicator.setIndicatorToState(DISCONNECTED);
|
||||
}
|
||||
console.error(error.message);
|
||||
throw new Error(`CouchDB Error - No response"`);
|
||||
} else {
|
||||
console.error(error.message);
|
||||
|
||||
console.error(error.message);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -379,6 +383,8 @@ class CouchObjectProvider {
|
||||
return this.request(ALL_DOCS, 'POST', query, signal).then((response) => {
|
||||
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.
|
||||
if (row.doc !== undefined) {
|
||||
map[row.key] = this.#getModel(row.doc);
|
||||
}
|
||||
@ -647,8 +653,8 @@ class CouchObjectProvider {
|
||||
this.objectQueue[key].pending = true;
|
||||
const queued = this.objectQueue[key].dequeue();
|
||||
let document = new CouchDocument(key, queued.model);
|
||||
document.metadata.created = Date.now();
|
||||
this.request(key, "PUT", document).then((response) => {
|
||||
console.log('create check response', key);
|
||||
this.#checkResponse(response, queued.intermediateResponse, key);
|
||||
}).catch(error => {
|
||||
queued.intermediateResponse.reject(error);
|
||||
|
@ -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();
|
||||
|
@ -34,6 +34,12 @@ export default class Model extends EventEmitter {
|
||||
*/
|
||||
constructor(options) {
|
||||
super();
|
||||
Object.defineProperty(this, '_events', {
|
||||
value: this._events,
|
||||
enumerable: false,
|
||||
configurable: false,
|
||||
writable: true
|
||||
});
|
||||
|
||||
//need to do this as we're already extending EventEmitter
|
||||
eventHelpers.extend(this);
|
||||
|
@ -27,6 +27,7 @@ import OverlayPlotCompositionPolicy from './overlayPlot/OverlayPlotCompositionPo
|
||||
import StackedPlotCompositionPolicy from './stackedPlot/StackedPlotCompositionPolicy';
|
||||
import PlotViewActions from "./actions/ViewActions";
|
||||
import StackedPlotsInspectorViewProvider from "./inspector/StackedPlotsInspectorViewProvider";
|
||||
import stackedPlotConfigurationInterceptor from "./stackedPlot/stackedPlotConfigurationInterceptor";
|
||||
|
||||
export default function () {
|
||||
return function install(openmct) {
|
||||
@ -64,6 +65,8 @@ export default function () {
|
||||
priority: 890
|
||||
});
|
||||
|
||||
stackedPlotConfigurationInterceptor(openmct);
|
||||
|
||||
openmct.objectViews.addProvider(new StackedPlotViewProvider(openmct));
|
||||
openmct.objectViews.addProvider(new OverlayPlotViewProvider(openmct));
|
||||
openmct.objectViews.addProvider(new PlotViewProvider(openmct));
|
||||
|
@ -0,0 +1,38 @@
|
||||
/*****************************************************************************
|
||||
* 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.
|
||||
*****************************************************************************/
|
||||
|
||||
export default function stackedPlotConfigurationInterceptor(openmct) {
|
||||
|
||||
openmct.objects.addGetInterceptor({
|
||||
appliesTo: (identifier, domainObject) => {
|
||||
return domainObject && domainObject.type === 'telemetry.plot.stacked';
|
||||
},
|
||||
invoke: (identifier, object) => {
|
||||
|
||||
if (object && object.configuration && object.configuration.series === undefined) {
|
||||
object.configuration.series = [];
|
||||
}
|
||||
|
||||
return object;
|
||||
}
|
||||
});
|
||||
}
|
@ -35,8 +35,8 @@ function remoteClockRequestInterceptor(openmct, remoteClockIdentifier, waitForBo
|
||||
invoke: async (request) => {
|
||||
const { start, end } = await waitForBounds();
|
||||
remoteClockLoaded = true;
|
||||
request[1].start = start;
|
||||
request[1].end = end;
|
||||
request.start = start;
|
||||
request.end = end;
|
||||
|
||||
return request;
|
||||
}
|
||||
|
@ -225,9 +225,7 @@ define(
|
||||
sortBy(sortOptions) {
|
||||
if (arguments.length > 0) {
|
||||
this.sortOptions = sortOptions;
|
||||
performance.mark('table:row:sort:start');
|
||||
this.rows = _.orderBy(this.rows, (row) => row.getParsedValue(sortOptions.key), sortOptions.direction);
|
||||
performance.mark('table:row:sort:stop');
|
||||
this.emit('sort');
|
||||
}
|
||||
|
||||
|
@ -612,7 +612,6 @@ export default {
|
||||
this.calculateScrollbarWidth();
|
||||
},
|
||||
sortBy(columnKey) {
|
||||
performance.mark('table:sort');
|
||||
// If sorting by the same column, flip the sort direction.
|
||||
if (this.sortOptions.key === columnKey) {
|
||||
if (this.sortOptions.direction === 'asc') {
|
||||
@ -669,7 +668,6 @@ export default {
|
||||
this.setHeight();
|
||||
},
|
||||
rowsAdded(rows) {
|
||||
performance.mark('row:added');
|
||||
this.setHeight();
|
||||
|
||||
let sizingRow;
|
||||
@ -691,7 +689,6 @@ export default {
|
||||
this.updateVisibleRows();
|
||||
},
|
||||
rowsRemoved(rows) {
|
||||
performance.mark('row:removed');
|
||||
this.setHeight();
|
||||
this.updateVisibleRows();
|
||||
},
|
||||
|
@ -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;
|
||||
|
@ -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'
|
||||
|