Compare commits

...

6 Commits

16 changed files with 289 additions and 17 deletions

View File

@ -55,7 +55,7 @@ const config = {
alias: { alias: {
'@': path.join(projectRootDir, 'src'), '@': path.join(projectRootDir, 'src'),
legacyRegistry: path.join(projectRootDir, 'src/legacyRegistry'), legacyRegistry: path.join(projectRootDir, 'src/legacyRegistry'),
saveAs: 'file-saver/src/FileSaver.js', //saveAs: 'file-saver/src/FileSaver.js',
csv: 'comma-separated-values', csv: 'comma-separated-values',
EventEmitter: 'eventemitter3', EventEmitter: 'eventemitter3',
bourbon: 'bourbon.scss', bourbon: 'bourbon.scss',
@ -124,6 +124,7 @@ const config = {
test: /\.vue$/, test: /\.vue$/,
loader: 'vue-loader', loader: 'vue-loader',
options: { options: {
hotReload: false,
compilerOptions: { compilerOptions: {
whitespace: 'preserve', whitespace: 'preserve',
compatConfig: { compatConfig: {

View File

@ -13,6 +13,7 @@ const common = require('./webpack.common');
const projectRootDir = path.resolve(__dirname, '..'); const projectRootDir = path.resolve(__dirname, '..');
module.exports = merge(common, { module.exports = merge(common, {
cache: false,
mode: 'development', mode: 'development',
watchOptions: { watchOptions: {
// Since we use require.context, webpack is watching the entire directory. // Since we use require.context, webpack is watching the entire directory.
@ -32,6 +33,7 @@ module.exports = merge(common, {
], ],
devtool: 'eval-source-map', devtool: 'eval-source-map',
devServer: { devServer: {
hot: false,
devMiddleware: { devMiddleware: {
writeToDisk: (filePathString) => { writeToDisk: (filePathString) => {
const filePath = path.parse(filePathString); const filePath = path.parse(filePathString);

View File

@ -23,7 +23,10 @@ const config = {
ignoreHTTPSErrors: true, ignoreHTTPSErrors: true,
screenshot: 'off', screenshot: 'off',
trace: 'on-first-retry', trace: 'on-first-retry',
video: 'off' video: 'off',
launchOptions: {
args: ['--js-flags=--expose-gc']
}
}, },
projects: [ projects: [
{ {

File diff suppressed because one or more lines are too long

View File

@ -0,0 +1,251 @@
/*****************************************************************************
* Open MCT, Copyright (c) 2014-2023, United States Government
* as represented by the Administrator of the National Aeronautics and Space
* Administration. All rights reserved.
*
* Open MCT is licensed under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
* http://www.apache.org/licenses/LICENSE-2.0.
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
* WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
* License for the specific language governing permissions and limitations
* under the License.
*
* Open MCT includes source code licensed under additional open source
* licenses. See the Open Source Licenses file (LICENSES.md) included with
* this source code distribution or the Licensing information page available
* at runtime from the About dialog for additional information.
*****************************************************************************/
const { test, expect } = require('@playwright/test');
const filePath = 'e2e/test-data/memory-leak-detection.json';
/**
* Executes tests to verify that views are not leaking memory on navigation away. This sort of
* memory leak is generally caused by a failure to clean up registered listeners.
*
* These tests are executed on a set of pre-built displays loaded from ../test-data/memory-leak-detection.json.
*
* In order to modify the test data set:
* 1. Run Open MCT locally (npm start)
* 2. Right click on a folder in the tree, and select "Import From JSON"
* 3. In the subsequent dialog, select the file ../test-data/memory-leak-detection.json
* 4. Click "OK"
* 5. Modify test objects as desired
* 6. Right click on the "Memory Leak Detection" folder, and select "Export to JSON"
* 7. Copy the exported file to ../test-data/memory-leak-detection.json
*
*/
test.describe('Navigation memory leak is not detected in', () => {
test.beforeEach(async ({ page, browser }, testInfo) => {
// Go to baseURL
await page.goto('./', { waitUntil: 'networkidle' });
// Click a:has-text("My Items")
await page.locator('a:has-text("My Items")').click({
button: 'right'
});
// Click text=Import from JSON
await page.locator('text=Import from JSON').click();
// Upload memory-leak-detection.json
await page.setInputFiles('#fileElem', filePath);
// Click text=OK
await page.locator('text=OK').click();
await expect(page.locator('a:has-text("Memory Leak Detection")')).toBeVisible();
});
test('plot view', async ({ page }) => {
const result = await navigateToObjectAndDetectMemoryLeak(page, 'overlay-plot-single-1hz-swg');
// If we got here without timing out, then the root view object was garbage collected and no memory leak was detected.
expect(result).toBe(true);
});
test('stacked plot view', async ({ page }) => {
const result = await navigateToObjectAndDetectMemoryLeak(page, 'stacked-plot-single-1hz-swg');
// If we got here without timing out, then the root view object was garbage collected and no memory leak was detected.
expect(result).toBe(true);
});
test.only('LAD table view', async ({ page }) => {
const result = await navigateToObjectAndDetectMemoryLeak(page, 'lad-table-single-1hz-swg');
// If we got here without timing out, then the root view object was garbage collected and no memory leak was detected.
expect(result).toBe(true);
});
test('LAD table set', async ({ page }) => {
const result = await navigateToObjectAndDetectMemoryLeak(page, 'lad-table-set-single-1hz-swg');
// If we got here without timing out, then the root view object was garbage collected and no memory leak was detected.
expect(result).toBe(true);
});
test('telemetry table view', async ({ page }) => {
const result = await navigateToObjectAndDetectMemoryLeak(
page,
'telemetry-table-single-1hz-swg'
);
// If we got here without timing out, then the root view object was garbage collected and no memory leak was detected.
expect(result).toBe(true);
});
test('notebook view', async ({ page }) => {
const result = await navigateToObjectAndDetectMemoryLeak(
page,
'notebook-memory-leak-detection-test'
);
// If we got here without timing out, then the root view object was garbage collected and no memory leak was detected.
expect(result).toBe(true);
});
test('display layout of a single SWG alphanumeric', async ({ page }) => {
const result = await navigateToObjectAndDetectMemoryLeak(page, 'display-layout-single-1hz-swg');
// If we got here without timing out, then the root view object was garbage collected and no memory leak was detected.
expect(result).toBe(true);
});
test('display layout of a single SWG plot', async ({ page }) => {
const result = await navigateToObjectAndDetectMemoryLeak(
page,
'display-layout-single-overlay-plot'
);
// If we got here without timing out, then the root view object was garbage collected and no memory leak was detected.
expect(result).toBe(true);
});
test.skip('example imagery view', async ({ page }) => {
const result = await navigateToObjectAndDetectMemoryLeak(
page,
'example-imagery-memory-leak-test'
);
// If we got here without timing out, then the root view object was garbage collected and no memory leak was detected.
expect(result).toBe(true);
});
test.skip('display layout of example imagery views', async ({ page }) => {
const result = await navigateToObjectAndDetectMemoryLeak(
page,
'display-layout-images-memory-leak-test'
);
// If we got here without timing out, then the root view object was garbage collected and no memory leak was detected.
expect(result).toBe(true);
});
test.skip('display layout with plots of swgs, alphanumerics, and condition sets, ', async ({
page
}) => {
const result = await navigateToObjectAndDetectMemoryLeak(
page,
'display-layout-simple-telemetry'
);
// If we got here without timing out, then the root view object was garbage collected and no memory leak was detected.
expect(result).toBe(true);
});
test.skip('flexible layout with plots of swgs', async ({ page }) => {
const result = await navigateToObjectAndDetectMemoryLeak(
page,
'flexible-layout-plots-memory-leak-test'
);
// If we got here without timing out, then the root view object was garbage collected and no memory leak was detected.
expect(result).toBe(true);
});
test.skip('flexible layout of example imagery views', async ({ page }) => {
const result = await navigateToObjectAndDetectMemoryLeak(
page,
'flexible-layout-images-memory-leak-test'
);
// If we got here without timing out, then the root view object was garbage collected and no memory leak was detected.
expect(result).toBe(true);
});
test.skip('tabbed view of display layouts and time strips', async ({ page }) => {
const result = await navigateToObjectAndDetectMemoryLeak(
page,
'tab-view-simple-memory-leak-test'
);
// If we got here without timing out, then the root view object was garbage collected and no memory leak was detected.
expect(result).toBe(true);
});
test.skip('time strip view of telemetry', async ({ page }) => {
const result = await navigateToObjectAndDetectMemoryLeak(
page,
'time-strip-telemetry-memory-leak-test'
);
// If we got here without timing out, then the root view object was garbage collected and no memory leak was detected.
expect(result).toBe(true);
});
async function navigateToObjectAndDetectMemoryLeak(page, objectName) {
await page.locator('[aria-label="OpenMCT Search"] input[type="search"]').click();
// Fill Search input
await page.locator('[aria-label="OpenMCT Search"] input[type="search"]').fill(objectName);
//Search Result Appears and is clicked
await Promise.all([
page.locator(`div.c-gsearch-result__title:has-text("${objectName}")`).first().click(),
page.waitForNavigation()
]);
// Register a finalization listener on the root node for the view. This tends to be the last thing to be
// garbage collected since it has either direct or indirect references to all resources used by the view. Therefore it's a pretty good proxy
// for detecting memory leaks.
await page.evaluate(() => {
window.gcPromise = new Promise((resolve) => {
// eslint-disable-next-line no-undef
window.fr = new FinalizationRegistry(resolve);
window.fr.register(
window.openmct.layout.$refs.browseObject.$refs.objectViewWrapper.firstChild.__vnode.ctx.ctx,
'navigatedObject',
window.openmct.layout.$refs.browseObject.$refs.objectViewWrapper.firstChild.__vnode.ctx.ctx
);
});
});
// Nav back to folder
await page.goto('./#/browse/mine', { waitUntil: 'networkidle' });
await page.waitForNavigation();
// This next code block blocks until the finalization listener is called and the gcPromise resolved. This means that the root node for the view has been garbage collected.
// In the event that the root node is not garbage collected, the gcPromise will never resolve and the test will time out.
await page.evaluate(() => {
const gcPromise = window.gcPromise;
window.gcPromise = null;
// Manually invoke the garbage collector once all references are removed.
window.gc();
return gcPromise;
});
// Clean up the finalization registry since we don't need it any more.
await page.evaluate(() => {
window.fr = null;
});
// If we get here without timing out, it means the garbage collection promise resolved and the test passed.
return true;
}
});

View File

@ -392,7 +392,7 @@ define([
const component = appLayout.mount(domElement); const component = appLayout.mount(domElement);
component.$nextTick(() => { component.$nextTick(() => {
this.layout = component.$refs.layout; this.layout = component.$refs.layout;
this.app = appLayout; // this.app = appLayout;
Browse(this); Browse(this);
window.addEventListener('beforeunload', this.destroy); window.addEventListener('beforeunload', this.destroy);
this.router.start(); this.router.start();

View File

@ -22,13 +22,14 @@
import EventEmitter from 'EventEmitter'; import EventEmitter from 'EventEmitter';
import ActionCollection from './ActionCollection'; import ActionCollection from './ActionCollection';
import _ from 'lodash'; import _ from 'lodash';
import { markRaw } from 'vue';
class ActionsAPI extends EventEmitter { class ActionsAPI extends EventEmitter {
constructor(openmct) { constructor(openmct) {
super(); super();
this._allActions = {}; this._allActions = {};
this._actionCollections = new WeakMap(); this._actionCollections = markRaw(new WeakMap());
this._openmct = openmct; this._openmct = openmct;
this._groupOrder = ['windowing', 'undefined', 'view', 'action', 'export', 'import']; this._groupOrder = ['windowing', 'undefined', 'view', 'action', 'export', 'import'];

View File

@ -21,7 +21,7 @@
*****************************************************************************/ *****************************************************************************/
import CSV from 'comma-separated-values'; import CSV from 'comma-separated-values';
import { saveAs } from 'saveAs'; //import { saveAs } from 'saveAs';
class CSVExporter { class CSVExporter {
export(rows, options) { export(rows, options) {

View File

@ -31,7 +31,7 @@ function replaceDotsWithUnderscores(filename) {
return filename.replace(regex, '_'); return filename.replace(regex, '_');
} }
import { saveAs } from 'saveAs'; //import { saveAs } from 'saveAs';
import html2canvas from 'html2canvas'; import html2canvas from 'html2canvas';
import { v4 as uuid } from 'uuid'; import { v4 as uuid } from 'uuid';

View File

@ -20,7 +20,7 @@
* at runtime from the About dialog for additional information. * at runtime from the About dialog for additional information.
*****************************************************************************/ *****************************************************************************/
import { saveAs } from 'saveAs'; //import { saveAs } from 'saveAs';
class JSONExporter { class JSONExporter {
export(obj, options) { export(obj, options) {

View File

@ -23,6 +23,7 @@
import LadTable from './components/LADTable.vue'; import LadTable from './components/LADTable.vue';
import LADTableConfiguration from './LADTableConfiguration'; import LADTableConfiguration from './LADTableConfiguration';
import mount from 'utils/mount'; import mount from 'utils/mount';
import { markRaw } from 'vue';
export default class LADTableView { export default class LADTableView {
constructor(openmct, domainObject, objectPath) { constructor(openmct, domainObject, objectPath) {
@ -31,26 +32,29 @@ export default class LADTableView {
this.objectPath = objectPath; this.objectPath = objectPath;
this.component = null; this.component = null;
this._destroy = null; this._destroy = null;
markRaw(this);
} }
show(element) { show(element) {
let ladTableConfiguration = new LADTableConfiguration(this.domainObject, this.openmct); let ladTableConfiguration = new LADTableConfiguration(this.domainObject, this.openmct);
const domainObject = this.domainObject;
const objectPath = this.objectPath;
const { vNode, destroy } = mount( const { vNode, destroy } = mount(
{ {
el: element, el: element,
components: { components: {
LadTable LadTable: Object.create(LadTable)
}, },
provide: { provide: {
openmct: this.openmct, openmct: this.openmct,
currentView: this, //currentView: markRaw(this),
ladTableConfiguration ladTableConfiguration: markRaw(ladTableConfiguration)
}, },
data: () => { data: () => {
return { return {
domainObject: this.domainObject, domainObject,
objectPath: this.objectPath objectPath
}; };
}, },
template: template:
@ -77,5 +81,7 @@ export default class LADTableView {
if (this._destroy) { if (this._destroy) {
this._destroy(); this._destroy();
} }
//this.openmct.app._context.optionsCache.delete(LadTable);
//this.openmct.app._context.optionsCache = new WeakMap();
} }
} }

View File

@ -49,7 +49,7 @@
</template> </template>
<script> <script>
import Vue, { toRaw } from 'vue'; import Vue, { toRaw, markRaw } from 'vue';
import LadRow from './LADRow.vue'; import LadRow from './LADRow.vue';
import StalenessUtils from '@/utils/staleness'; import StalenessUtils from '@/utils/staleness';
@ -139,8 +139,9 @@ export default {
); );
this.initializeViewActions(); this.initializeViewActions();
}, },
unmounted() { beforeUnmounted() {
this.ladTableConfiguration.off('change', this.handleConfigurationChange); this.ladTableConfiguration.off('change', this.handleConfigurationChange);
this.ladTableConfiguration.destroy();
this.composition.off('add', this.addItem); this.composition.off('add', this.addItem);
this.composition.off('remove', this.removeItem); this.composition.off('remove', this.removeItem);
@ -150,6 +151,8 @@ export default {
stalenessSubscription.unsubscribe(); stalenessSubscription.unsubscribe();
stalenessSubscription.stalenessUtils.destroy(); stalenessSubscription.stalenessUtils.destroy();
}); });
this.viewActionsCollection.destroy();
delete this.viewActionsCollection;
}, },
methods: { methods: {
addItem(domainObject) { addItem(domainObject) {

View File

@ -1,4 +1,4 @@
import { saveAs } from 'saveAs'; //import { saveAs } from 'saveAs';
import Moment from 'moment'; import Moment from 'moment';
import { NOTEBOOK_TYPE, RESTRICTED_NOTEBOOK_TYPE } from '../notebook-constants'; import { NOTEBOOK_TYPE, RESTRICTED_NOTEBOOK_TYPE } from '../notebook-constants';
const UNKNOWN_USER = 'Unknown'; const UNKNOWN_USER = 'Unknown';

View File

@ -26,7 +26,7 @@ export default {
const rawOldObject = toRaw(oldObject); const rawOldObject = toRaw(oldObject);
Object.assign(rawOldObject, rawNewObject); Object.assign(rawOldObject, rawNewObject);
} }
this.unobservers = [];
this.objectPath.forEach((object) => { this.objectPath.forEach((object) => {
if (object) { if (object) {
const unobserve = this.openmct.objects.observe( const unobserve = this.openmct.objects.observe(
@ -34,12 +34,13 @@ export default {
'*', '*',
updateObject.bind(this, object) updateObject.bind(this, object)
); );
this.$once('hook:unmounted', unobserve); this.unobservers.push(unobserve);
} }
}); });
}, },
beforeUnmount() { beforeUnmount() {
this.$refs.root.removeEventListener('contextMenu', this.showContextMenu); this.$refs.root.removeEventListener('contextMenu', this.showContextMenu);
this.unobservers.forEach((unobserve) => unobserve());
}, },
methods: { methods: {
showContextMenu(event) { showContextMenu(event) {

View File

@ -20,6 +20,9 @@ export default function mount(component, { props, children, element, app } = {})
} }
el = null; el = null;
vNode = null; vNode = null;
// if (app && app._context) {
// app._context.optionsCache.delete(component);
// }
}; };
return { vNode, destroy, el }; return { vNode, destroy, el };