Compare commits
14 Commits
composable
...
memlab-poc
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
45f2796f43 | ||
|
|
5d201e7705 | ||
|
|
8503f91e1a | ||
|
|
47edc1604c | ||
|
|
9b647ca628 | ||
|
|
a5700b82e3 | ||
|
|
270b4279c4 | ||
|
|
91050ad697 | ||
|
|
83df1cc712 | ||
|
|
d39e8a69d4 | ||
|
|
25b8129b93 | ||
|
|
4c1fdd8095 | ||
|
|
87014319ce | ||
|
|
23ed6e15c1 |
4
.gitignore
vendored
4
.gitignore
vendored
@@ -47,3 +47,7 @@ codecov
|
||||
|
||||
# :(
|
||||
package-lock.json
|
||||
|
||||
e2e/test-data/heapsnapshots/**
|
||||
!e2e/test-data/heapsnapshots/data/cur/snap-seq.json
|
||||
!e2e/test-data/heapsnapshots/data/cur/run-meta.json
|
||||
|
||||
@@ -33,12 +33,16 @@
|
||||
* existing ones.
|
||||
*/
|
||||
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
import AxeBuilder from '@axe-core/playwright';
|
||||
import { BrowserInteractionResultReader, findLeaks } from '@memlab/api';
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
|
||||
import { expect, test } from './pluginFixtures.js';
|
||||
|
||||
const snapshotsPath = fileURLToPath(new URL('../../../test-data/snapshots', import.meta.url));
|
||||
// Constants for repeated values
|
||||
const TEST_RESULTS_DIR = './test-results';
|
||||
|
||||
@@ -56,8 +60,8 @@ const TEST_RESULTS_DIR = './test-results';
|
||||
* @returns {Promise<object|null>} Returns the accessibility scan results if violations are found,
|
||||
* otherwise returns null.
|
||||
*/
|
||||
/* eslint-disable no-undef */
|
||||
export async function scanForA11yViolations(page, testCaseName, options = {}) {
|
||||
|
||||
async function scanForA11yViolations(page, testCaseName, options = {}) {
|
||||
const builder = new AxeBuilder({ page });
|
||||
builder.withTags(['wcag2aa']);
|
||||
// https://github.com/dequelabs/axe-core/blob/develop/doc/rule-descriptions.md
|
||||
@@ -94,4 +98,138 @@ export async function scanForA11yViolations(page, testCaseName, options = {}) {
|
||||
}
|
||||
}
|
||||
|
||||
export { expect, test };
|
||||
/**
|
||||
* Gets the used JS heap size from the page.
|
||||
*
|
||||
* @param {import('@playwright/test').Page} page - The page from which to get the heap size.
|
||||
* @returns {Promise<number|null>} The used JS heap size in bytes, or null if not available.
|
||||
* @note Can only be executed when running the 'chrome-memory' Playwright config at e2e/playwright-performance-prod.config.js.
|
||||
*/
|
||||
function getHeapSize(page) {
|
||||
return page.evaluate(() => {
|
||||
if (window.performance && window.performance.memory) {
|
||||
return window.performance.memory.usedJSHeapSize;
|
||||
}
|
||||
return null;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Forces garbage collection. Useful for getting accurate memory usage in 'getHeapSize'.
|
||||
*
|
||||
* @param {import('@playwright/test').Page} page - The page on which to force garbage collection.
|
||||
* @param {number} [repeat=6] - Number of times to repeat the garbage collection process.
|
||||
* @note Can only be executed when running the 'chrome-memory' Playwright config at e2e/playwright-performance-prod.config.js.
|
||||
*/
|
||||
async function forceGC(page, repeat = 6) {
|
||||
for (let i = 0; i < repeat; i++) {
|
||||
await client.send('HeapProfiler.collectGarbage');
|
||||
// wait for a while and let GC do the job
|
||||
await page.waitForTimeout(200);
|
||||
}
|
||||
await page.waitForTimeout(1400);
|
||||
}
|
||||
|
||||
/**
|
||||
* Captures a heap snapshot and saves it to a specified path by attaching to the CDP session.
|
||||
*
|
||||
* @param {import('@playwright/test').Page} page - The page from which to capture the heap snapshot.
|
||||
* @param {string} outputPath - The file path where the heap snapshot will be saved.
|
||||
* @note Can only be executed when running the 'chrome-memory' Playwright config at e2e/playwright-performance-prod.config.js.
|
||||
*/
|
||||
async function captureHeapSnapshot(page, outputPath) {
|
||||
const client = await page.context().newCDPSession(page);
|
||||
const dir = path.dirname(outputPath);
|
||||
|
||||
try {
|
||||
await fs.mkdir(dir, { recursive: true });
|
||||
} catch (error) {
|
||||
if (error.code !== 'EEXIST') {
|
||||
throw error; // Throw the error if it is not because the directory already exists
|
||||
}
|
||||
}
|
||||
const chunks = [];
|
||||
|
||||
function dataHandler(data) {
|
||||
chunks.push(data.chunk);
|
||||
}
|
||||
|
||||
try {
|
||||
client.on('HeapProfiler.addHeapSnapshotChunk', dataHandler);
|
||||
console.debug(`🚮 Running garbage collection...`);
|
||||
await forceGC(page);
|
||||
await client.send('HeapProfiler.enable');
|
||||
console.debug(`📸 Capturing heap snapshot to ${outputPath}`);
|
||||
await client.send('HeapProfiler.takeHeapSnapshot');
|
||||
client.removeListener('HeapProfiler.addHeapSnapshotChunk', dataHandler);
|
||||
const fullSnapshot = chunks.join('');
|
||||
fs.writeFile(outputPath, fullSnapshot, { encoding: 'UTF-8' });
|
||||
} catch (error) {
|
||||
console.error('🛑 Error while capturing heap snapshot:', error);
|
||||
} finally {
|
||||
await client.detach(); // Ensure that the client is detached after the operation
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Navigates to an object in the application and checks for memory leaks by forcing garbage collection.
|
||||
*
|
||||
* @param {import('@playwright/test').Page} page - The page on which the navigation and memory leak detection will be performed.
|
||||
* @param {string} objectName - The name of the object to navigate to.
|
||||
* @returns {Promise<boolean>} Returns true if no memory leak is detected.
|
||||
* @note Can only be executed when running the 'chrome-memory' Playwright config at e2e/playwright-performance-prod.config.js.
|
||||
*/
|
||||
async function navigateToObjectAndDetectMemoryLeak(page, objectName) {
|
||||
await page.getByRole('searchbox', { name: 'Search Input' }).click();
|
||||
// Fill Search input
|
||||
await page.getByRole('searchbox', { name: 'Search Input' }).fill(objectName);
|
||||
|
||||
// Search Result Appears and is clicked
|
||||
await page.getByText(objectName, { exact: true }).click();
|
||||
|
||||
// Register a finalization listener on the root node for the view.
|
||||
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,
|
||||
'navigatedObject',
|
||||
window.openmct.layout.$refs.browseObject.$refs.objectViewWrapper.firstChild
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
// Nav back to folder
|
||||
await page.goto('./#/browse/mine');
|
||||
|
||||
// Invoke garbage collection using forceGC
|
||||
await forceGC(page);
|
||||
|
||||
// Wait for the garbage collection promise to resolve.
|
||||
const gcCompleted = await page.evaluate(() => {
|
||||
const gcPromise = window.gcPromise;
|
||||
window.gcPromise = null;
|
||||
|
||||
return gcPromise;
|
||||
});
|
||||
|
||||
// Clean up the finalization registry
|
||||
await page.evaluate(() => {
|
||||
window.fr = null;
|
||||
});
|
||||
|
||||
return gcCompleted;
|
||||
}
|
||||
|
||||
export {
|
||||
BrowserInteractionResultReader,
|
||||
captureHeapSnapshot,
|
||||
expect,
|
||||
findLeaks,
|
||||
forceGC,
|
||||
getHeapSize,
|
||||
navigateToObjectAndDetectMemoryLeak,
|
||||
scanForA11yViolations,
|
||||
test
|
||||
};
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
|
||||
/** @type {import('@playwright/test').PlaywrightTestConfig} */
|
||||
const config = {
|
||||
retries: 1, //Only for debugging purposes for trace: 'on-first-retry'
|
||||
retries: 0, //Only for debugging purposes for trace: 'on-first-retry'
|
||||
testDir: 'tests/performance/',
|
||||
testMatch: '*.contract.perf.spec.js', //Run everything except contract tests which require marks in dev mode
|
||||
timeout: 60 * 1000,
|
||||
@@ -28,7 +28,18 @@ const config = {
|
||||
name: 'chrome',
|
||||
testIgnore: '*.memory.perf.spec.js', //Do not run memory tests without proper flags. Shouldn't get here
|
||||
use: {
|
||||
browserName: 'chromium'
|
||||
browserName: 'chromium',
|
||||
launchOptions: {
|
||||
args: [
|
||||
'--no-sandbox',
|
||||
'--disable-notifications',
|
||||
'--use-fake-ui-for-media-stream',
|
||||
'--use-fake-device-for-media-stream',
|
||||
'--js-flags=--no-move-object-start',
|
||||
'--enable-precise-memory-info',
|
||||
'--display=:100'
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
],
|
||||
|
||||
23
e2e/snap-seq.json
Normal file
23
e2e/snap-seq.json
Normal file
@@ -0,0 +1,23 @@
|
||||
[
|
||||
{
|
||||
"name": "page-load",
|
||||
"snapshot": true,
|
||||
"type": "baseline",
|
||||
"idx": 1,
|
||||
"JSHeapUsedSize": 88941668
|
||||
},
|
||||
{
|
||||
"name": "action-on-page",
|
||||
"snapshot": true,
|
||||
"type": "target",
|
||||
"idx": 2,
|
||||
"JSHeapUsedSize": 92784609
|
||||
},
|
||||
{
|
||||
"name": "revert",
|
||||
"snapshot": true,
|
||||
"type": "final",
|
||||
"idx": 3,
|
||||
"JSHeapUsedSize": 93053124
|
||||
}
|
||||
]
|
||||
6
e2e/test-data/heapsnapshots/data/cur/console-log.txt
Normal file
6
e2e/test-data/heapsnapshots/data/cur/console-log.txt
Normal file
@@ -0,0 +1,6 @@
|
||||
snapshot meta data invalid or missing
|
||||
Use `memlab help` or `memlab <COMMAND> -h` to get helper text
|
||||
snapshot meta data invalid or missing
|
||||
Use `memlab help` or `memlab <COMMAND> -h` to get helper text
|
||||
snapshot meta data invalid or missing
|
||||
Use `memlab help` or `memlab <COMMAND> -h` to get helper text
|
||||
137
e2e/tests/performance/memory/imagery.memory.perf.spec.js
Normal file
137
e2e/tests/performance/memory/imagery.memory.perf.spec.js
Normal file
@@ -0,0 +1,137 @@
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const { forceGC, getHeapSize, test, expect } = require('../../../avpFixtures.js');
|
||||
const { createDomainObjectWithDefaults } = require('../../../appActions.js');
|
||||
const filePath = 'e2e/test-data/PerformanceDisplayLayout.json';
|
||||
const snapshotPath = 'e2e/test-data/heapsnapshots/data/cur/';
|
||||
|
||||
//Constants for URL handling in the Performance Tests
|
||||
// We want 5 seconds in the past and 1 second in the future
|
||||
const startDelta = 5 * 1000;
|
||||
const endDelta = 1 * 1000;
|
||||
|
||||
// saturation period that all telemetry has been loaded into memory
|
||||
const saturationPeriod = startDelta + endDelta;
|
||||
|
||||
const timeBetweenSnapshots = 2 * 1000;
|
||||
|
||||
// test.describe('Memory Performance tests', () => {
|
||||
// test.beforeEach(async ({ page }) => {
|
||||
// await page.goto(
|
||||
// './#/browse/mine?tc.mode=local&tc.timeSystem=utc&view=grid&tc.startDelta=1800000&tc.endDelta=30000',
|
||||
// { waitUntil: 'domcontentloaded' }
|
||||
// );
|
||||
// await page.waitForTimeout(3 * 1000);
|
||||
// await forceGC(page);
|
||||
// await captureHeapSnapshot(page, snapshotPath + 's1.heapsnapshot');
|
||||
|
||||
// // Get and compare JSHeapUsedSize at different points in the test
|
||||
// let heapSize = await getHeapSize(page);
|
||||
// console.log(`Initial JSHeapUsedSize: ${heapSize}`);
|
||||
|
||||
// await page.locator('a:has-text("My Items")').click({ button: 'right' });
|
||||
// await page.locator('text=Import from JSON').click();
|
||||
// await page.setInputFiles('#fileElem', filePath);
|
||||
// await page.locator('text=OK').click();
|
||||
// await expect(
|
||||
// page.locator('a:has-text("Performance Display Layout Display Layout")')
|
||||
// ).toBeVisible();
|
||||
// });
|
||||
|
||||
// test('Embedded View Large for Imagery is performant in Fixed Time', async ({ page }) => {
|
||||
// await page.locator('[aria-label="OpenMCT Search"] input[type="search"]').click();
|
||||
// await page
|
||||
// .locator('[aria-label="OpenMCT Search"] input[type="search"]')
|
||||
// .fill('Performance Display Layout');
|
||||
// await Promise.all([
|
||||
// page.waitForNavigation(),
|
||||
// page.locator('a:has-text("Performance Display Layout")').first().click()
|
||||
// ]);
|
||||
|
||||
// await page.waitForSelector('.c-imagery__main-image__bg', { state: 'visible' });
|
||||
// await page.waitForSelector('.c-imagery__main-image__background-image', { state: 'visible' });
|
||||
// await page.waitForTimeout(3 * 1000);
|
||||
// await forceGC(page);
|
||||
// await captureHeapSnapshot(page, snapshotPath + 's2.heapsnapshot');
|
||||
// // Get and compare JSHeapUsedSize at different points in the test
|
||||
// let heapSize = await getHeapSize(page);
|
||||
// console.log(`second JSHeapUsedSize: ${heapSize}`);
|
||||
// });
|
||||
|
||||
// test.afterEach(async ({ page }) => {
|
||||
// await page.goto(
|
||||
// './#/browse/mine?tc.mode=local&tc.timeSystem=utc&view=grid&tc.startDelta=1800000&tc.endDelta=30000',
|
||||
// { waitUntil: 'domcontentloaded' }
|
||||
// );
|
||||
// await page.waitForTimeout(3 * 1000);
|
||||
// await forceGC(page);
|
||||
// await captureHeapSnapshot(page, snapshotPath + 's3.heapsnapshot');
|
||||
|
||||
// // Get and compare JSHeapUsedSize at different points in the test
|
||||
// let heapSize = await getHeapSize(page);
|
||||
// console.log(`Final JSHeapUsedSize: ${heapSize}`);
|
||||
|
||||
// const reader = BrowserInteractionResultReader.from(snapshotPath);
|
||||
// const leaks = await findLeaks(reader);
|
||||
// console.log('Leaks:', leaks);
|
||||
// });
|
||||
// });
|
||||
|
||||
test.describe.only('Performance - Telemetry Memory Tests', () => {
|
||||
let overlayPlot;
|
||||
let alphaSineWave;
|
||||
let baseHeapSize;
|
||||
let heapSizes;
|
||||
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await page.goto('./', { waitUntil: 'domcontentloaded' });
|
||||
|
||||
overlayPlot = await createDomainObjectWithDefaults(page, {
|
||||
type: 'Overlay Plot'
|
||||
});
|
||||
|
||||
alphaSineWave = await createDomainObjectWithDefaults(page, {
|
||||
type: 'Sine Wave Generator',
|
||||
name: 'Alpha Sine Wave',
|
||||
parent: overlayPlot.uuid
|
||||
});
|
||||
|
||||
await page.goto(overlayPlot.url);
|
||||
|
||||
await page.waitForTimeout(3 * 1000);
|
||||
await forceGC(page);
|
||||
// Get and compare JSHeapUsedSize at different points in the test
|
||||
const initialHeapSize = await getHeapSize(page);
|
||||
console.log(`Initial JSHeapUsedSize: ${initialHeapSize}`);
|
||||
});
|
||||
|
||||
test('Plots memory does not grow unbounded', async ({ page }) => {
|
||||
await page.goto(
|
||||
`${overlayPlot.url}?tc.mode=local&tc.startDelta=${startDelta}&tc.endDelta=${endDelta}&tc.timeSystem=utc&view=grid`,
|
||||
{ waitUntil: 'domcontentloaded' }
|
||||
);
|
||||
|
||||
await page.waitForTimeout(saturationPeriod);
|
||||
|
||||
await forceGC(page);
|
||||
baseHeapSize = await getHeapSize(page);
|
||||
console.log(`Initial JSHeapUsedSize: ${baseHeapSize}`);
|
||||
|
||||
//Temporarily monitor how long it takes memory to grow
|
||||
heapSizes = [];
|
||||
for (let i = 0; i < 10; i++) {
|
||||
await page.waitForTimeout(timeBetweenSnapshots);
|
||||
await forceGC(page);
|
||||
const heapSize = await getHeapSize(page);
|
||||
console.log(`Heap Size at iteration ${i}: ${heapSize}`);
|
||||
heapSizes.push(heapSize);
|
||||
}
|
||||
});
|
||||
|
||||
test.afterEach(async ({ page }) => {
|
||||
heapSizes.forEach(heapSize => {
|
||||
expect(heapSize).toBeLessThanOrEqual(baseHeapSize);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -20,12 +20,16 @@
|
||||
* at runtime from the About dialog for additional information.
|
||||
*****************************************************************************/
|
||||
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
import { expect, test } from '@playwright/test';
|
||||
import { fileURLToPath } from 'url';
|
||||
|
||||
import { navigateToObjectAndDetectMemoryLeak } from '../../../avpFixtures.js';
|
||||
|
||||
const memoryLeakFilePath = fileURLToPath(
|
||||
new URL('../../../../e2e/test-data/memory-leak-detection.json', import.meta.url)
|
||||
);
|
||||
|
||||
/**
|
||||
* 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.
|
||||
@@ -279,57 +283,4 @@ test.describe('Navigation memory leak is not detected in', () => {
|
||||
// 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);
|
||||
});
|
||||
|
||||
/**
|
||||
*
|
||||
* @param {import('@playwright/test').Page} page
|
||||
* @param {*} objectName
|
||||
* @returns
|
||||
*/
|
||||
async function navigateToObjectAndDetectMemoryLeak(page, objectName) {
|
||||
await page.getByRole('searchbox', { name: 'Search Input' }).click();
|
||||
// Fill Search input
|
||||
await page.getByRole('searchbox', { name: 'Search Input' }).fill(objectName);
|
||||
|
||||
//Search Result Appears and is clicked
|
||||
await page.getByText(objectName, { exact: true }).click();
|
||||
|
||||
// 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,
|
||||
'navigatedObject',
|
||||
window.openmct.layout.$refs.browseObject.$refs.objectViewWrapper.firstChild
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
// Nav back to folder
|
||||
await page.goto('./#/browse/mine');
|
||||
|
||||
// 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;
|
||||
}
|
||||
});
|
||||
|
||||
@@ -9,6 +9,7 @@
|
||||
"@babel/eslint-parser": "7.23.3",
|
||||
"@braintree/sanitize-url": "6.0.4",
|
||||
"@deploysentinel/playwright": "0.3.4",
|
||||
"@memlab/api": "1.0.25",
|
||||
"@percy/cli": "1.27.4",
|
||||
"@percy/playwright": "1.0.4",
|
||||
"@playwright/test": "1.39.0",
|
||||
@@ -116,6 +117,7 @@
|
||||
"test:e2e:visual:ci": "percy exec --config ./e2e/.percy.ci.yml --partial -- npx playwright test --config=e2e/playwright-visual-a11y.config.js --project=chrome --grep-invert @unstable",
|
||||
"test:e2e:visual:full": "percy exec --config ./e2e/.percy.nightly.yml -- npx playwright test --config=e2e/playwright-visual-a11y.config.js --grep-invert @unstable",
|
||||
"test:e2e:full": "npx playwright test --config=e2e/playwright-ci.config.js --grep-invert @couchdb",
|
||||
"test:memlab:findleaks": "npx memlab find-leaks --snapshot-dir e2e/test-data/heapsnapshots/",
|
||||
"test:e2e:watch": "npx playwright test --ui --config=e2e/playwright-watch.config.js",
|
||||
"test:perf:contract": "npx playwright test --config=e2e/playwright-performance-dev.config.js",
|
||||
"test:perf:localhost": "npx playwright test --config=e2e/playwright-performance-prod.config.js --project=chrome",
|
||||
|
||||
Reference in New Issue
Block a user