diff --git a/.circleci/config.yml b/.circleci/config.yml index 0afd63dba..7e03439b1 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -226,13 +226,20 @@ jobs: environment: NO_SANDBOX: "true" TESTS_REPORT_FILE: "test-results/memleaks/results.xml" + RUNNING_ON_CI: "true" steps: - checkout-with-deps - attach_workspace: at: ./ + # install the required libraries for chrome version used by memlab + - run: sudo apt-get update + - run: sudo apt-get install ca-certificates fonts-liberation libasound2 libatk-bridge2.0-0 libatk1.0-0 libc6 libcairo2 libcups2 libdbus-1-3 libexpat1 libfontconfig1 libgbm1 libgcc1 libglib2.0-0 libgtk-3-0 libnspr4 libnss3 libpango-1.0-0 libpangocairo-1.0-0 libstdc++6 libx11-6 libx11-xcb1 libxcb1 libxcomposite1 libxcursor1 libxdamage1 libxext6 libxfixes3 libxi6 libxrandr2 libxrender1 libxss1 libxtst6 lsb-release wget xdg-utils + - run: sleep 5 - run: scripts/run-memleaks-tests.sh - store_test_results: path: test-results/ + - store_artifacts: + path: tests/e2e/memleaks/.logs/ coverage: executor: node16-browsers-executor diff --git a/.eslintignore b/.eslintignore index 404a3388a..517b6298d 100644 --- a/.eslintignore +++ b/.eslintignore @@ -6,6 +6,8 @@ !/dist/typings.d.ts /lib/** +/src/typings/_resize-observer/index.d.ts + **/node_modules /website/docs/api/** diff --git a/.gitignore b/.gitignore index 69426ed6e..2e0382c58 100644 --- a/.gitignore +++ b/.gitignore @@ -34,3 +34,6 @@ debug.html # graphics tests out data /tests/e2e/graphics/.gendata/ tests/e2e/coverage/.gendata + +# memleak log output +tests/e2e/memleaks/.logs/ diff --git a/.size-limit.js b/.size-limit.js index 98e56ae6c..61da982ee 100644 --- a/.size-limit.js +++ b/.size-limit.js @@ -9,7 +9,7 @@ module.exports = [ { name: 'ESM', path: 'dist/lightweight-charts.production.mjs', - limit: '44.0 KB', + limit: '44.3 KB', }, { name: 'Standalone-ESM', @@ -19,6 +19,6 @@ module.exports = [ { name: 'Standalone', path: 'dist/lightweight-charts.standalone.production.js', - limit: '45.8 KB', + limit: '46.1 KB', }, ]; diff --git a/package.json b/package.json index 842a906d7..65a2838ef 100644 --- a/package.json +++ b/package.json @@ -58,6 +58,7 @@ "@rollup/plugin-replace": "~5.0.1", "@size-limit/file": "~8.1.0", "@types/chai": "~4.3.4", + "@types/glob": "~8.0.0", "@types/mocha": "~10.0.1", "@types/node": "~16.18.9", "@types/pixelmatch": "~5.2.4", @@ -85,6 +86,7 @@ "markdown-it": "~13.0.1", "markdown-it-anchor": "~8.6.5", "markdownlint-cli": "~0.32.2", + "memlab": "~1.1.36", "mocha": "~10.2.0", "npm-run-all": "~4.1.5", "pixelmatch": "~5.3.0", diff --git a/scripts/run-memleaks-tests.sh b/scripts/run-memleaks-tests.sh index b3106e438..d7ad47f07 100755 --- a/scripts/run-memleaks-tests.sh +++ b/scripts/run-memleaks-tests.sh @@ -2,7 +2,14 @@ set -e echo "Preparing" -npm run build +# npm run build echo "Memleaks tests" -node ./tests/e2e/memleaks/runner.js ./dist/lightweight-charts.standalone.development.js +if [[ ! -z "$RUNNING_ON_CI" ]]; then + echo "Running on CI, therefore logging mem leaks output to artifact file" + rm -rf tests/e2e/memleaks/.logs + mkdir tests/e2e/memleaks/.logs + node ./tests/e2e/memleaks/runner.js ./dist/lightweight-charts.standalone.development.js > ./tests/e2e/memleaks/.logs/memleaks.txt +else + node ./tests/e2e/memleaks/runner.js ./dist/lightweight-charts.standalone.development.js +fi diff --git a/src/api/options/chart-options-defaults.ts b/src/api/options/chart-options-defaults.ts index 4f80b36fb..9abf077e5 100644 --- a/src/api/options/chart-options-defaults.ts +++ b/src/api/options/chart-options-defaults.ts @@ -12,6 +12,7 @@ import { watermarkOptionsDefaults } from './watermark-options-defaults'; export const chartOptionsDefaults: ChartOptionsInternal = { width: 0, height: 0, + autoSize: false, layout: layoutOptionsDefaults, crosshair: crosshairOptionsDefaults, grid: gridOptionsDefaults, diff --git a/src/gui/chart-widget.ts b/src/gui/chart-widget.ts index b48142c7c..78e8baf89 100644 --- a/src/gui/chart-widget.ts +++ b/src/gui/chart-widget.ts @@ -5,6 +5,7 @@ import { isChromiumBased, isWindows } from '../helpers/browsers'; import { Delegate } from '../helpers/delegate'; import { IDestroyable } from '../helpers/idestroyable'; import { ISubscription } from '../helpers/isubscription'; +import { warn } from '../helpers/logger'; import { DeepPartial } from '../helpers/strict-type-checks'; import { ChartModel, ChartOptionsInternal } from '../model/chart-model'; @@ -22,10 +23,12 @@ import { SeriesPlotRow } from '../model/series-data'; import { OriginalTime, TimePointIndex } from '../model/time-data'; import { TouchMouseEventData } from '../model/touch-mouse-event-data'; -// import { PaneSeparator, SEPARATOR_HEIGHT } from './pane-separator'; +import { suggestChartSize, suggestPriceScaleWidth, suggestTimeScaleHeight } from './internal-layout-sizes-hints'; import { PaneWidget } from './pane-widget'; import { TimeAxisWidget } from './time-axis-widget'; +// import { PaneSeparator, SEPARATOR_HEIGHT } from './pane-separator'; + export interface MouseEventParamsImpl { time?: OriginalTime; index?: TimePointIndex; @@ -58,8 +61,12 @@ export class ChartWidget implements IDestroyable { private _clicked: Delegate = new Delegate(); private _crosshairMoved: Delegate = new Delegate(); private _onWheelBound: (event: WheelEvent) => void; + private _observer: ResizeObserver | null = null; + + private _container: HTMLElement; public constructor(container: HTMLElement, options: ChartOptionsInternal) { + this._container = container; this._options = options; this._element = document.createElement('div'); @@ -86,29 +93,25 @@ export class ChartWidget implements IDestroyable { this._timeAxisWidget = new TimeAxisWidget(this); this._tableElement.appendChild(this._timeAxisWidget.getElement()); + const usedObserver = options.autoSize && this._installObserver(); + + // observer could not fire event immediately for some cases + // so we have to set initial size manually let width = this._options.width; let height = this._options.height; - - if (width === 0 || height === 0) { + // ignore width/height options if observer has actually been used + // however respect options if installing resize observer failed + if (usedObserver || width === 0 || height === 0) { const containerRect = container.getBoundingClientRect(); - // TODO: Fix it better - // on Hi-DPI CSS size * Device Pixel Ratio should be integer to avoid smoothing - // For chart widget we decreases because we must be inside container. - // For time axis this is not important, since it just affects space for pane widgets - if (width === 0) { - width = Math.floor(containerRect.width); - width -= width % 2; - } - - if (height === 0) { - height = Math.floor(containerRect.height); - height -= height % 2; - } + width = width || containerRect.width; + height = height || containerRect.height; } // BEWARE: resize must be called BEFORE _syncGuiWithModel (in constructor only) // or after but with adjustSize to properly update time scale - this.resize(width, height); + if (!usedObserver) { + this.resize(width, height); + } this._syncGuiWithModel(); @@ -165,6 +168,8 @@ export class ChartWidget implements IDestroyable { this._crosshairMoved.destroy(); this._clicked.destroy(); + + this._uninstallObserver(); } public resize(width: number, height: number, forceRepaint: boolean = false): void { @@ -172,11 +177,13 @@ export class ChartWidget implements IDestroyable { return; } - this._height = height; - this._width = width; + const sizeHint = suggestChartSize(size({ width, height })); - const heightStr = height + 'px'; - const widthStr = width + 'px'; + this._height = sizeHint.height; + this._width = sizeHint.width; + + const heightStr = this._height + 'px'; + const widthStr = this._width + 'px'; ensureNotNull(this._element).style.height = heightStr; ensureNotNull(this._element).style.width = widthStr; @@ -220,10 +227,7 @@ export class ChartWidget implements IDestroyable { this._updateTimeAxisVisibility(); - const width = options.width || this._width; - const height = options.height || this._height; - - this.resize(width, height); + this._applyAutoSizeOptions(options); } public clicked(): ISubscription { @@ -273,6 +277,26 @@ export class ChartWidget implements IDestroyable { return ensureNotNull(priceAxisWidget).getWidth(); } + // eslint-disable-next-line complexity + private _applyAutoSizeOptions(options: DeepPartial): void { + if (options.autoSize === undefined && this._observer && (options.width !== undefined || options.height !== undefined)) { + warn(`You should turn autoSize off explicitly before specifying sizes; try adding options.autoSize: false to new options`); + return; + } + if (options.autoSize && !this._observer) { + // installing observer will override resize if successful + this._installObserver(); + } + + if (options.autoSize === false && this._observer !== null) { + this._uninstallObserver(); + } + + if (!options.autoSize && (options.width !== undefined || options.height !== undefined)) { + this.resize(options.width || this._width, options.height || this._height); + } + } + /** * Traverses the widget's layout (pane and axis child widgets), * draws the screenshot (if rendering context is passed) and returns the screenshot bitmap size @@ -385,10 +409,12 @@ export class ChartWidget implements IDestroyable { if (this._isRightAxisVisible()) { rightPriceAxisWidth = Math.max(rightPriceAxisWidth, ensureNotNull(paneWidget.rightPriceAxisWidget()).optimalWidth()); } - totalStretch += paneWidget.stretchFactor(); } + leftPriceAxisWidth = suggestPriceScaleWidth(leftPriceAxisWidth); + rightPriceAxisWidth = suggestPriceScaleWidth(rightPriceAxisWidth); + const width = this._width; const height = this._height; @@ -399,11 +425,8 @@ export class ChartWidget implements IDestroyable { const separatorsHeight = 0; // separatorHeight * separatorCount; const timeAxisVisible = this._options.timeScale.visible; let timeAxisHeight = timeAxisVisible ? this._timeAxisWidget.optimalHeight() : 0; - // TODO: Fix it better - // on Hi-DPI CSS size * Device Pixel Ratio should be integer to avoid smoothing - if (timeAxisHeight % 2) { - timeAxisHeight += 1; - } + timeAxisHeight = suggestTimeScaleHeight(timeAxisHeight); + const otherWidgetHeight = separatorsHeight + timeAxisHeight; const totalPaneHeight = height < otherWidgetHeight ? 0 : height - otherWidgetHeight; const stretchPixels = totalPaneHeight / totalStretch; @@ -753,6 +776,30 @@ export class ChartWidget implements IDestroyable { private _isRightAxisVisible(): boolean { return this._paneWidgets[0].state().rightPriceScale().options().visible; } + + private _installObserver(): boolean { + // eslint-disable-next-line no-restricted-syntax + if (!('ResizeObserver' in window)) { + warn('Options contains "autoSize" flag, but the browser does not support ResizeObserver feature. Please provide polyfill.'); + return false; + } else { + this._observer = new ResizeObserver((entries: ResizeObserverEntry[]) => { + const containerEntry = entries.find((entry: ResizeObserverEntry) => entry.target === this._container); + if (!containerEntry) { + return; + } + this.resize(containerEntry.contentRect.width, containerEntry.contentRect.height); + }); + this._observer.observe(this._container, { box: 'border-box' }); + return true; + } + } + + private _uninstallObserver(): void { + if (this._observer !== null) { + this._observer.disconnect(); + } + } } function disableSelection(element: HTMLElement): void { diff --git a/src/gui/internal-layout-sizes-hints.ts b/src/gui/internal-layout-sizes-hints.ts new file mode 100644 index 000000000..53e5f1776 --- /dev/null +++ b/src/gui/internal-layout-sizes-hints.ts @@ -0,0 +1,20 @@ +import { Size, size } from 'fancy-canvas'; + +// on Hi-DPI CSS size * Device Pixel Ratio should be integer to avoid smoothing +// For chart widget we decrease the size because we must be inside container. +// For time axis this is not important, since it just affects space for pane widgets +export function suggestChartSize(originalSize: Size): Size { + const integerWidth = Math.floor(originalSize.width); + const integerHeight = Math.floor(originalSize.height); + const width = integerWidth - (integerWidth % 2); + const height = integerHeight - (integerHeight % 2); + return size({ width, height }); +} + +export function suggestTimeScaleHeight(originalHeight: number): number { + return originalHeight + (originalHeight % 2); +} + +export function suggestPriceScaleWidth(originalWidth: number): number { + return originalWidth + (originalWidth % 2); +} diff --git a/src/gui/price-axis-widget.ts b/src/gui/price-axis-widget.ts index 85a4270ee..4fb247386 100644 --- a/src/gui/price-axis-widget.ts +++ b/src/gui/price-axis-widget.ts @@ -28,6 +28,7 @@ import { PriceAxisRendererOptionsProvider } from '../renderers/price-axis-render import { IPriceAxisView } from '../views/price-axis/iprice-axis-view'; import { createBoundCanvas } from './canvas-utils'; +import { suggestPriceScaleWidth } from './internal-layout-sizes-hints'; import { MouseEventHandler, MouseEventHandlers, TouchMouseEvent } from './mouse-event-handler'; import { PaneWidget } from './pane-widget'; @@ -206,7 +207,7 @@ export class PriceAxisWidget implements IDestroyable { ctx.restore(); const resultTickMarksMaxWidth = tickMarkMaxWidth || Constants.DefaultOptimalWidth; - let res = Math.ceil( + const res = Math.ceil( rendererOptions.borderSize + rendererOptions.tickLength + rendererOptions.paddingInner + @@ -216,8 +217,7 @@ export class PriceAxisWidget implements IDestroyable { ); // make it even, remove this after migration to perfect fancy canvas - res += res % 2; - return res; + return suggestPriceScaleWidth(res); } public setSize(newSize: Size): void { diff --git a/src/model/chart-model.ts b/src/model/chart-model.ts index 92da58ccf..1ff8344ab 100644 --- a/src/model/chart-model.ts +++ b/src/model/chart-model.ts @@ -247,6 +247,24 @@ export interface ChartOptions { */ height: number; + /** + * Setting this flag to `true` will make the chart watch the chart container's size and automatically resize the chart to fit its container whenever the size changes. + * + * This feature requires [`ResizeObserver`](https://developer.mozilla.org/en-US/docs/Web/API/ResizeObserver) class to be available in the global scope. + * Note that calling code is responsible for providing a polyfill if required. If the global scope does not have `ResizeObserver`, a warning will appear and the flag will be ignored. + * + * Please pay attention that `autoSize` option and explicit sizes options `width` and `height` don't conflict with one another. + * If you specify `autoSize` flag, then `width` and `height` options will be ignored unless `ResizeObserver` has failed. If it fails then the values will be used as fallback. + * + * The flag `autoSize` could also be set with and unset with `applyOptions` function. + * ```js + * const chart = LightweightCharts.createChart(document.body, { + * autoSize: true, + * }); + * ``` + */ + autoSize: boolean; + /** * Watermark options. * diff --git a/tests/e2e/coverage/test-cases/chart/auto-size.js b/tests/e2e/coverage/test-cases/chart/auto-size.js new file mode 100644 index 000000000..ad91d0067 --- /dev/null +++ b/tests/e2e/coverage/test-cases/chart/auto-size.js @@ -0,0 +1,27 @@ +function interactionsToPerform() { + return []; +} + +function beforeInteractions(container) { + const chart = LightweightCharts.createChart(container); + + const mainSeries = chart.addAreaSeries(); + + mainSeries.setData(generateLineData()); + + chart.applyOptions({ + autoSize: true, + }); + + return new Promise(resolve => { + requestAnimationFrame(() => { + container.style.height = '200px'; + container.style.width = '250px'; + requestAnimationFrame(resolve); + }); + }); +} + +function afterInteractions() { + return Promise.resolve(); +} diff --git a/tests/e2e/graphics/test-cases/initial-options/use-observer.js b/tests/e2e/graphics/test-cases/initial-options/use-observer.js new file mode 100644 index 000000000..2fc258782 --- /dev/null +++ b/tests/e2e/graphics/test-cases/initial-options/use-observer.js @@ -0,0 +1,48 @@ +window.ignoreMouseMove = true; +function generateData() { + const res = []; + const time = new Date(Date.UTC(2018, 0, 1, 0, 0, 0, 0)); + for (let i = 0; i < 500; ++i) { + res.push({ + time: time.getTime() / 1000, + value: i, + }); + + time.setUTCDate(time.getUTCDate() + 1); + } + + return res; +} + +function runTestCase(container) { + const configs = [{}, { width: 500 }, { height: 100 }, { width: 500, height: 100 }]; + + const boxes = configs.map((config, i) => { + const box = document.createElement('div'); + + box.style.position = 'absolute'; + box.style.top = `${i * 25}%`; + box.style.left = 0; + box.style.right = 0; + box.style.height = '25%'; + + container.appendChild(box); + + const chart = LightweightCharts.createChart(box, { autoSize: true, ...config }); + const mainSeries = chart.addAreaSeries(); + + mainSeries.setData(generateData()); + + return box; + }); + + boxes.forEach(box => { + box.style.right = 0; + box.style.height = '20%'; + box.style.width = '400'; + }); + + return new Promise(resolve => { + setTimeout(resolve, 300); + }); +} diff --git a/tests/e2e/memleaks/helpers/get-all-class-names.ts b/tests/e2e/memleaks/helpers/get-all-class-names.ts new file mode 100644 index 000000000..bc50b2bd2 --- /dev/null +++ b/tests/e2e/memleaks/helpers/get-all-class-names.ts @@ -0,0 +1,31 @@ +/// + +import * as fs from 'fs'; +import * as path from 'path'; + +import glob from 'glob'; +import { promisify } from 'util'; + +const globPromise = promisify(glob); + +const srcDir = path.join(__dirname, '..', '..', '..', '..', 'src'); + +const classNameRegex = /class\s+([a-zA-Z_][^\W<{]*)/gm; + +/** + * This will get all the class names within the source code. + * This is used within the mem leaks to ensure that no instances + * of these classes exist in the memory heap. + */ +export async function getClassNames(): Promise> { + const sourceFiles = await globPromise(`${srcDir}/**/*.ts`); + const classNames: Set = new Set(); + sourceFiles.forEach((sourceFilePath: string) => { + const content = fs.readFileSync(sourceFilePath, { encoding: 'utf-8' }); + const matches = content.matchAll(classNameRegex); + for (const match of matches) { + classNames.add(match[1]); + } + }); + return classNames; +} diff --git a/tests/e2e/memleaks/helpers/get-test-cases.ts b/tests/e2e/memleaks/helpers/get-test-cases.ts index 93468d19c..ad5ae3d4d 100644 --- a/tests/e2e/memleaks/helpers/get-test-cases.ts +++ b/tests/e2e/memleaks/helpers/get-test-cases.ts @@ -5,7 +5,7 @@ import * as path from 'path'; export interface TestCase { name: string; - caseContent: string; + path: string; } const testCasesDir = path.join(__dirname, '..', 'test-cases'); @@ -24,6 +24,6 @@ export function getTestCases(): TestCase[] { .filter(isTestCaseFile) .map((testCaseFile: string) => ({ name: extractTestCaseName(testCaseFile) as string, - caseContent: fs.readFileSync(path.join(testCasesDir, testCaseFile), { encoding: 'utf-8' }), + path: path.join(testCasesDir, testCaseFile), })); } diff --git a/tests/e2e/memleaks/helpers/test-page-dummy.html b/tests/e2e/memleaks/helpers/test-page.html similarity index 55% rename from tests/e2e/memleaks/helpers/test-page-dummy.html rename to tests/e2e/memleaks/helpers/test-page.html index 1fc7a2db0..d60acdf1f 100644 --- a/tests/e2e/memleaks/helpers/test-page-dummy.html +++ b/tests/e2e/memleaks/helpers/test-page.html @@ -5,18 +5,9 @@ Test case page -
- - - - - - + + diff --git a/tests/e2e/memleaks/memleaks-test-cases.ts b/tests/e2e/memleaks/memleaks-test-cases.ts index 5221fa9f2..ceefb8504 100644 --- a/tests/e2e/memleaks/memleaks-test-cases.ts +++ b/tests/e2e/memleaks/memleaks-test-cases.ts @@ -1,191 +1,98 @@ -/// - -import * as fs from 'fs'; -import * as path from 'path'; - +import { findLeaks, takeSnapshots } from '@memlab/api'; +import type { IHeapNode, IScenario } from '@memlab/core'; import { expect } from 'chai'; import { describe, it } from 'mocha'; -import puppeteer, { type Browser, type HTTPResponse, type JSHandle, type Page, type PuppeteerLaunchOptions } from 'puppeteer'; +import { getClassNames } from './helpers/get-all-class-names'; import { getTestCases } from './helpers/get-test-cases'; -const dummyContent = fs.readFileSync(path.join(__dirname, 'helpers', 'test-page-dummy.html'), { encoding: 'utf-8' }); +const serverAddressVarName = 'SERVER_ADDRESS'; +const serverURL: string = process.env[serverAddressVarName] || ''; -function generatePageContent(standaloneBundlePath: string, testCaseCode: string): string { - return dummyContent - .replace('PATH_TO_STANDALONE_MODULE', standaloneBundlePath) - .replace('TEST_CASE_SCRIPT', testCaseCode) - ; -} - -const testStandalonePathEnvKey = 'TEST_STANDALONE_PATH'; - -const testStandalonePath: string = process.env[testStandalonePathEnvKey] || ''; - -async function getReferencesCount(page: Page, prototypeReference: JSHandle): Promise { - const activeRefsHandle = await page.queryObjects(prototypeReference); - const activeRefsCount = await (await activeRefsHandle?.getProperty('length'))?.jsonValue(); - - await activeRefsHandle.dispose(); - - return activeRefsCount; -} - -function promisleep(ms: number): Promise { - return new Promise((resolve: () => void) => { - setTimeout(resolve, ms); - }); -} - -/** - * Request garbage collection on the page. - * **Note:** This is only a request and the page will still decide - * when best to perform this action. - */ -async function requestGarbageCollection(page: Page): Promise { - const client = await page.target().createCDPSession(); - await client.send('HeapProfiler.enable'); - await client.send('HeapProfiler.collectGarbage'); - await client.send('HeapProfiler.disable'); - return page.evaluate(() => { - // exposed when '--js-flags="expose-gc"' argument is used with chrome - if (window.gc) { - window.gc(); - } - }); -} - -// Poll the references count on the page until the condition -// is satisfied for a specific prototype. -async function pollReferencesCount( - page: Page, - prototype: JSHandle, - condition: (currentCount: number) => boolean, - timeout: number, - actionName?: string, - tryCallGarbageCollection?: boolean -): Promise { - const start = performance.now(); - let referencesCount = 0; - let done = false; - do { - const duration = performance.now() - start; - if (duration > timeout) { - throw new Error(`${actionName ? `${actionName}: ` : ''}Timeout exceeded waiting for references count to meet desired condition.`); - } - referencesCount = await getReferencesCount(page, prototype); - done = condition(referencesCount); - if (!done) { - await promisleep(50); - if (tryCallGarbageCollection) { - await requestGarbageCollection(page); - } - } - } while (!done); - return referencesCount; +interface ITestScenario extends IScenario { + /** + * Set to true if the expected behavior of the test is to fail + */ + expectFail?: boolean; + /** + * List of class names which are allowed to leak in this + * test. + * For example: a cache while the chart is still present + */ + allowedLeaks?: string[]; } describe('Memleaks tests', function(): void { // this tests are unstable sometimes. - this.retries(5); - - const puppeteerOptions: PuppeteerLaunchOptions = {}; - puppeteerOptions.args = ['--js-flags="expose-gc"']; - if (process.env.NO_SANDBOX) { - puppeteerOptions.args.push('--no-sandbox', '--disable-setuid-sandbox'); - } - - let browser: Browser; - - before(async function(): Promise { - this.timeout(40000); // puppeteer may take a while to launch for the first time. - expect(testStandalonePath, `path to test standalone module must be passed via ${testStandalonePathEnvKey} env var`) - .to.have.length.greaterThan(0); - browser = await puppeteer.launch(puppeteerOptions); - return Promise.resolve(); - }); + this.retries(0); const testCases = getTestCases(); it('number of test cases', () => { // we need to have at least 1 test to check it - expect(testCases.length).to.be.greaterThan(0, 'there should be at least 1 test case'); + expect(testCases.length).to.be.greaterThan( + 0, + 'there should be at least 1 test case' + ); }); + const classNames: Set = new Set(); + for (const testCase of testCases) { - // eslint-disable-next-line @typescript-eslint/no-loop-func it(testCase.name, async () => { - const pageContent = generatePageContent(testStandalonePath, testCase.caseContent); - - const page = await browser.newPage(); - await page.setViewport({ width: 600, height: 600 }); - - // set empty page as a content to get initial number - // of references - await page.setContent('', { waitUntil: 'load' }); - - const errors: string[] = []; - page.on('pageerror', (error: Error) => { - errors.push(error.message); - }); - - page.on('response', (response: HTTPResponse) => { - if (!response.ok()) { - errors.push(`Network error: ${response.url()} status=${response.status()}`); + console.log(`\n\tRunning test: ${testCase.name}`); + if (classNames.size < 1) { + // async function that we will only call if we don't already have values + const names = await getClassNames(); + for (const name of names) { + classNames.add(name); } - }); - - const getCanvasPrototype = () => { - return Promise.resolve(CanvasRenderingContext2D.prototype); - }; - - const prototype = await page.evaluateHandle(getCanvasPrototype); - - const referencesCountBefore = await getReferencesCount(page, prototype); - - await page.setContent(pageContent, { waitUntil: 'load' }); - - if (errors.length !== 0) { - throw new Error(`Page has errors:\n${errors.join('\n')}`); } - - // Wait until at least one canvas element has been created. - await pollReferencesCount( - page, - prototype, - (count: number) => count > referencesCountBefore, - 2500, - 'Creation' + expect(classNames.size).to.be.greaterThan( + 0, + 'Class name list should contain items' ); - // now remove chart + const test = await import(testCase.path); - await page.evaluate(() => { - // eslint-disable-next-line @typescript-eslint/no-explicit-any,@typescript-eslint/no-unsafe-member-access,@typescript-eslint/no-unsafe-call - (window as any).chart.remove(); + // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access + const scenario = test.scenario as ITestScenario; + const expectToFail = scenario.expectFail === true; + const allowedLeaks = scenario.allowedLeaks ?? []; + if (expectToFail) { + console.log(`\t!! This test is expected to fail.`); + } + console.log(''); - // eslint-disable-next-line @typescript-eslint/no-explicit-any,@typescript-eslint/no-unsafe-member-access - delete (window as any).chart; + const result = await takeSnapshots({ + scenario: { + ...scenario, + url: () => serverURL, + leakFilter: (node: IHeapNode) => { + if ( + (classNames.has(node.name) && + !allowedLeaks.includes(node.name)) || + node.retainedSize > 1_000_000 + ) { + if (!expectToFail) { + console.log(`LEAK FOUND! Name of constructor: ${node.name} Retained Size: ${node.retainedSize}`); + } + return true; // This is considered to be a leak. + } + return false; + }, + }, }); + const leaks = await findLeaks(result); - await requestGarbageCollection(page); - - // Wait until all the created canvas elements have been garbage collected. - // Browser could keep references to DOM elements several milliseconds after its actual removing - // So we have to wait to be sure all is clear - const referencesCountAfter = await pollReferencesCount( - page, - prototype, - (count: number) => count <= referencesCountBefore, - 10000, - 'Garbage Collection', - true - ); - - expect(referencesCountAfter).to.be.equal(referencesCountBefore, 'There should not be extra references after removing a chart'); + if (expectToFail) { + expect(leaks.length).to.be.greaterThan( + 0, + 'no memory leak detected, but was expected in this case' + ); + } else { + expect(leaks.length).to.equal(0, 'memory leak detected'); + } }); } - after(async () => { - await browser.close(); - }); }); diff --git a/tests/e2e/memleaks/runner.js b/tests/e2e/memleaks/runner.js index 76a817d48..1ce8ad4ef 100755 --- a/tests/e2e/memleaks/runner.js +++ b/tests/e2e/memleaks/runner.js @@ -28,22 +28,29 @@ let testStandalonePath = process.argv[2]; const hostname = 'localhost'; const port = 34567; const httpServerPrefix = `http://${hostname}:${port}/`; +let serverAddress = `${httpServerPrefix}index.html`; const filesToServe = new Map(); if (fs.existsSync(testStandalonePath)) { - const fileNameToServe = 'test.js'; + const fileNameToServe = 'index.html'; + filesToServe.set(fileNameToServe, path.join(__dirname, 'helpers', 'test-page.html')); + serverAddress = `${httpServerPrefix}${fileNameToServe}`; +} + +if (fs.existsSync(testStandalonePath)) { + const fileNameToServe = 'library.js'; filesToServe.set(fileNameToServe, path.resolve(testStandalonePath)); testStandalonePath = `${httpServerPrefix}${fileNameToServe}`; } -process.env.TEST_STANDALONE_PATH = testStandalonePath; +process.env.SERVER_ADDRESS = serverAddress; function runMocha(closeServer) { console.log('Running tests...'); const mocha = new Mocha({ - timeout: 20000, - slow: 10000, + timeout: 120000, + slow: 60000, reporter: mochaConfig.reporter, reporterOptions: mochaConfig._reporterOptions, }); diff --git a/tests/e2e/memleaks/test-cases/control-test-case.js b/tests/e2e/memleaks/test-cases/control-test-case.js new file mode 100644 index 000000000..f9a76141f --- /dev/null +++ b/tests/e2e/memleaks/test-cases/control-test-case.js @@ -0,0 +1,36 @@ +/** + * This test verifies that memlab doesn't detect instances which + * are still in use. + * + * We are creating a chart before the `action` and don't make any + * changes during the `action` and `back` actions therefore the chart will + * still be present. + */ + +/** @type {import('@memlab/core/dist/lib/Types').IScenario} */ +const scenario = { + setup: async function(page) { + await page.addScriptTag({ + url: 'library.js', + }); + await page.evaluate(() => { + window.chart = LightweightCharts.createChart( + document.getElementById('container') + ); + const mainSeries = window.chart.addLineSeries(); + mainSeries.setData([ + { time: 0, value: 1 }, + { time: 1, value: 2 }, + ]); + }); + }, + action: async function(page) { + await page.evaluate(() => {}); + }, + back: async function(page) { + await page.evaluate(() => {}); + }, +}; + +// eslint-disable-next-line no-undef +exports.scenario = scenario; diff --git a/tests/e2e/memleaks/test-cases/expect-fail.js b/tests/e2e/memleaks/test-cases/expect-fail.js new file mode 100644 index 000000000..2a0a4bea8 --- /dev/null +++ b/tests/e2e/memleaks/test-cases/expect-fail.js @@ -0,0 +1,54 @@ +/** + * This test is expected to cause a memory leak. By setting + * `expectFail` to `true` we are letting the test runner know + * that we are testing that the test does fail. + */ + +/** @type {import('@memlab/core/dist/lib/Types').IScenario} */ +const scenario = { + expectFail: true, + setup: async function(page) { + await page.addScriptTag({ + url: 'library.js', + }); + }, + action: async function(page) { + await page.evaluate(() => { + function generateData() { + const res = []; + const time = new Date(Date.UTC(2018, 0, 1, 0, 0, 0, 0)); + for (let i = 0; i < 500; ++i) { + res.push({ + time: time.getTime() / 1000, + value: i, + }); + time.setUTCDate(time.getUTCDate() + 1); + } + return res; + } + window.chart = LightweightCharts.createChart( + document.getElementById('container') + ); + const mainSeries = window.chart.addLineSeries({ + priceFormat: { + minMove: 1, + precision: 0, + }, + }); + mainSeries.setData(generateData()); + }); + }, + back: async function(page) { + /** + * We are not removing the chart here because we + * want to 'cause' a leak to test if it is detected correctly. + */ + await page.evaluate(() => { + const container = document.querySelector('#container'); + container.parentElement.removeChild(container); + }); + }, +}; + +// eslint-disable-next-line no-undef +exports.scenario = scenario; diff --git a/tests/e2e/memleaks/test-cases/series.js b/tests/e2e/memleaks/test-cases/series.js new file mode 100644 index 000000000..a5efd4ba2 --- /dev/null +++ b/tests/e2e/memleaks/test-cases/series.js @@ -0,0 +1,97 @@ +/** + * This test takes an existing chart and adds a variety of series + * and then removes these series. Tests if there is any memory leak + * resulting from the creation and removal of series. + */ + +/** @type {import('@memlab/core/dist/lib/Types').IScenario} */ +const scenario = { + allowedLeaks: [ + 'FormattedLabelsCache', + 'CrosshairPriceAxisView', // <- We should check and maybe fix this? + 'PriceAxisViewRenderer', // <- (part of the same leak above) + ], + setup: async function(page) { + await page.addScriptTag({ + url: 'library.js', + }); + await page.evaluate(() => { + window.chart = LightweightCharts.createChart( + document.getElementById('container') + ); + }); + }, + action: async function(page) { + await page.evaluate(() => { + function generateData() { + const res = []; + const time = new Date(Date.UTC(2018, 0, 1, 0, 0, 0, 0)); + for (let i = 0; i < 500; ++i) { + res.push({ + time: time.getTime() / 1000, + value: i, + }); + time.setUTCDate(time.getUTCDate() + 1); + } + return res; + } + function generateBars(count = 500, startDay = 15) { + const res = []; + const time = new Date(Date.UTC(2018, 0, startDay, 0, 0, 0, 0)); + for (let i = 0; i < count; ++i) { + const step = (i % 20) / 5000; + const base = i / 5; + + res.push({ + time: time.getTime() / 1000, + open: base * (1 - step), + high: base * (1 + 2 * step), + low: base * (1 - 2 * step), + close: base * (1 + step), + }); + + time.setUTCDate(time.getUTCDate() + 1); + } + + return res; + } + if (window.chart) { + window.lineSeries = window.chart.addLineSeries(); + window.lineSeries.setData(generateData()); + window.areaSeries = window.chart.addAreaSeries(); + window.areaSeries.setData(generateData()); + window.baselineSeries = window.chart.addBaselineSeries(); + window.baselineSeries.setData(generateData()); + window.histogramSeries = window.chart.addHistogramSeries(); + window.histogramSeries.setData(generateData()); + + window.barSeries = window.chart.addBarSeries(); + window.barSeries.setData(generateBars()); + window.candlestickSeries = window.chart.addCandlestickSeries(); + window.candlestickSeries.setData(generateBars()); + } + }); + }, + back: async function(page) { + await page.evaluate(() => { + if (window.chart) { + window.chart.removeSeries(window.lineSeries); + delete window.lineSeries; + window.chart.removeSeries(window.areaSeries); + delete window.areaSeries; + window.chart.removeSeries(window.baselineSeries); + delete window.baselineSeries; + window.chart.removeSeries(window.histogramSeries); + delete window.histogramSeries; + + window.chart.removeSeries(window.barSeries); + delete window.barSeries; + window.chart.removeSeries(window.candlestickSeries); + delete window.candlestickSeries; + } + }); + }, +}; + +// eslint-disable-next-line no-undef +exports.scenario = scenario; diff --git a/tests/e2e/memleaks/test-cases/simple.js b/tests/e2e/memleaks/test-cases/simple.js index 6c5673fa3..340311e78 100644 --- a/tests/e2e/memleaks/test-cases/simple.js +++ b/tests/e2e/memleaks/test-cases/simple.js @@ -1,43 +1,46 @@ -function nextBusinessDay(time) { - const d = new Date(); - d.setUTCFullYear(time.year); - d.setUTCMonth(time.month - 1); - d.setUTCDate(time.day + 1); - d.setUTCHours(0, 0, 0, 0); - return { - year: d.getUTCFullYear(), - month: d.getUTCMonth() + 1, - day: d.getUTCDate(), - }; -} - -function businessDayToTimestamp(time) { - const d = new Date(); - d.setUTCFullYear(time.year); - d.setUTCMonth(time.month - 1); - d.setUTCDate(time.day); - d.setUTCHours(0, 0, 0, 0); - return d.getTime() / 1000; -} - -function generateData() { - const res = []; - let time = nextBusinessDay({ day: 1, month: 1, year: 2018 }); - for (let i = 0; i < 500; ++i) { - time = nextBusinessDay(time); - res.push({ - time: businessDayToTimestamp(time), - value: i, +/** @type {import('@memlab/core/dist/lib/Types').IScenario} */ +const scenario = { + setup: async function(page) { + await page.addScriptTag({ + url: 'library.js', }); - } - return res; -} + }, + action: async function(page) { + await page.evaluate(() => { + function generateData() { + const res = []; + const time = new Date(Date.UTC(2018, 0, 1, 0, 0, 0, 0)); + for (let i = 0; i < 500; ++i) { + res.push({ + time: time.getTime() / 1000, + value: i, + }); + time.setUTCDate(time.getUTCDate() + 1); + } + return res; + } + window.chart = LightweightCharts.createChart( + document.getElementById('container') + ); + const mainSeries = window.chart.addLineSeries({ + priceFormat: { + minMove: 1, + precision: 0, + }, + }); + mainSeries.setData(generateData()); + }); + }, + back: async function(page) { + await page.evaluate(() => { + if (window.chart) { + window.chart.remove(); + delete window.chart; + delete window.LightweightCharts; + } + }); + }, +}; -function runTestCase(container) { - const chart = LightweightCharts.createChart(container); - - const mainSeries = chart.addAreaSeries(); - - mainSeries.setData(generateData()); - return chart; -} +// eslint-disable-next-line no-undef +exports.scenario = scenario; diff --git a/tests/e2e/tsconfig.composite.json b/tests/e2e/tsconfig.composite.json index 6a9fb5865..6f8b47901 100644 --- a/tests/e2e/tsconfig.composite.json +++ b/tests/e2e/tsconfig.composite.json @@ -1,6 +1,7 @@ { "extends": "../../tsconfig.composite.base.json", "compilerOptions": { + "skipLibCheck": true, "esModuleInterop": true, "lib": [ "dom", diff --git a/website/docusaurus.config.js b/website/docusaurus.config.js index c4d2b8e8d..3b2566c0f 100644 --- a/website/docusaurus.config.js +++ b/website/docusaurus.config.js @@ -48,7 +48,13 @@ function httpGetJson(url) { }); } -function downloadFile(urlString, filePath) { +function delay(duration) { + return new Promise(resolve => { + setTimeout(resolve, duration); + }); +} + +function downloadFile(urlString, filePath, retriesRemaining = 0, attempt = 1) { return new Promise((resolve, reject) => { let file; @@ -58,11 +64,18 @@ function downloadFile(urlString, filePath) { if (response.statusCode && response.statusCode >= 300 && response.statusCode < 400 && response.headers.location !== undefined) { // handling redirect url.pathname = response.headers.location; - downloadFile(url.toString(), filePath).then(resolve, reject); + downloadFile(url.toString(), filePath, retriesRemaining).then(resolve, reject); return; } if (response.statusCode && (response.statusCode < 100 || response.statusCode > 299)) { + if (retriesRemaining > 0) { + logger.info(`Failed to download from ${urlString}, attempting again (${retriesRemaining - 1} retries remaining).`); + delay(Math.pow(2, attempt) * 200).then(() => { + downloadFile(url.toString(), filePath, retriesRemaining - 1, attempt + 1).then(resolve, reject); + }); + return; + } reject(new Error(`Cannot download file "${urlString}", error code=${response.statusCode}`)); return; } @@ -88,7 +101,8 @@ function downloadFile(urlString, filePath) { function downloadTypingsToFile(typingsFilePath, version) { return downloadFile( `https://unpkg.com/lightweight-charts@${version}/dist/typings.d.ts`, - typingsFilePath + typingsFilePath, + 2 ); } diff --git a/website/plugins/enhanced-codeblock/theme/CodeBlock/index.jsx b/website/plugins/enhanced-codeblock/theme/CodeBlock/index.jsx index a1a61b11d..ed4752c17 100644 --- a/website/plugins/enhanced-codeblock/theme/CodeBlock/index.jsx +++ b/website/plugins/enhanced-codeblock/theme/CodeBlock/index.jsx @@ -24,6 +24,10 @@ export function replaceThemeConstantStrings(originalString, isDarkTheme) { return result; } +export function removeUnwantedLines(originalString) { + return originalString.replace(new RegExp(/\/\/ delete-start[\w\W]*?\/\/ delete-end/, 'gm'), ''); +} + const EnhancedCodeBlock = props => { const { chart, replaceThemeConstants, hideableCode, ...rest } = props; let { children } = props; @@ -34,6 +38,7 @@ const EnhancedCodeBlock = props => { if (replaceThemeConstants && typeof children === 'string') { children = replaceThemeConstantStrings(children, isDarkTheme); } + children = removeUnwantedLines(children); if (chart || hideableCode) { return ( diff --git a/website/src/components/tutorials/advanced-react-example.jsx b/website/src/components/tutorials/advanced-react-example.jsx index 1705934dc..d3010f6b3 100644 --- a/website/src/components/tutorials/advanced-react-example.jsx +++ b/website/src/components/tutorials/advanced-react-example.jsx @@ -1,3 +1,9 @@ +// delete-start +/* Note: this file shouldn't be used directly because it has some constants which are set by +the docusaurus site to ensure that the chart looks great in both dark and light color themes. +If you want to use this example then please copy the code presented on the documentation site. +[link](https://tradingview.github.io/lightweight-charts/tutorials/react/advanced) */ +// delete-end import { createChart } from 'lightweight-charts'; import React, { createContext, @@ -29,8 +35,9 @@ export const App = props => { backgroundColor = CHART_BACKGROUND_COLOR, lineColor = LINE_LINE_COLOR, textColor = CHART_TEXT_COLOR, - }, + } = {}, } = props; + const [chartLayoutOptions, setChartLayoutOptions] = useState({}); // The following variables illustrate how a series could be updated. const series1 = useRef(null); diff --git a/website/src/components/tutorials/simple-react-example.jsx b/website/src/components/tutorials/simple-react-example.jsx index d0b3eb5c0..eded6309c 100644 --- a/website/src/components/tutorials/simple-react-example.jsx +++ b/website/src/components/tutorials/simple-react-example.jsx @@ -1,3 +1,9 @@ +// delete-start +/* Note: this file shouldn't be used directly because it has some constants which are set by +the docusaurus site to ensure that the chart looks great in both dark and light color themes. +If you want to use this example then please copy the code presented on the documentation site. +[link](https://tradingview.github.io/lightweight-charts/tutorials/react/simple) */ +// delete-end import { createChart, ColorType } from 'lightweight-charts'; import React, { useEffect, useRef } from 'react'; @@ -10,8 +16,9 @@ export const ChartComponent = props => { textColor = CHART_TEXT_COLOR, areaTopColor = AREA_TOP_COLOR, areaBottomColor = AREA_BOTTOM_COLOR, - }, + } = {}, } = props; + const chartContainerRef = useRef(); useEffect(