Merge branch 'master' into cjs-and-ssr

This commit is contained in:
Mark Silverwood 2023-02-01 23:25:16 +00:00 committed by GitHub
commit b832193cf2
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
27 changed files with 604 additions and 262 deletions

View File

@ -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

View File

@ -6,6 +6,8 @@
!/dist/typings.d.ts
/lib/**
/src/typings/_resize-observer/index.d.ts
**/node_modules
/website/docs/api/**

3
.gitignore vendored
View File

@ -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/

View File

@ -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',
},
];

View File

@ -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",

View File

@ -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

View File

@ -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,

View File

@ -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<MouseEventParamsImplSupplier> = new Delegate();
private _crosshairMoved: Delegate<MouseEventParamsImplSupplier> = 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<MouseEventParamsImplSupplier> {
@ -273,6 +277,26 @@ export class ChartWidget implements IDestroyable {
return ensureNotNull(priceAxisWidget).getWidth();
}
// eslint-disable-next-line complexity
private _applyAutoSizeOptions(options: DeepPartial<ChartOptionsInternal>): 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 {

View File

@ -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);
}

View File

@ -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 {

View File

@ -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.
*

View File

@ -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();
}

View File

@ -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);
});
}

View File

@ -0,0 +1,31 @@
/// <reference types="node" />
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<Set<string>> {
const sourceFiles = await globPromise(`${srcDir}/**/*.ts`);
const classNames: Set<string> = 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;
}

View File

@ -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<TestCase>((testCaseFile: string) => ({
name: extractTestCaseName(testCaseFile) as string,
caseContent: fs.readFileSync(path.join(testCasesDir, testCaseFile), { encoding: 'utf-8' }),
path: path.join(testCasesDir, testCaseFile),
}));
}

View File

@ -5,18 +5,9 @@
<meta name="viewport" content="width=device-width,initial-scale=1.0,maximum-scale=1.0,minimum-scale=1.0">
<title>Test case page</title>
</head>
<body style="padding: 0; margin: 0;">
<div id="container" style="position: absolute; width: 100%; height: 100%;"></div>
<script type="text/javascript" src="PATH_TO_STANDALONE_MODULE"></script>
<script type="text/javascript">
TEST_CASE_SCRIPT
</script>
<script type="text/javascript">
window.chart = runTestCase(document.getElementById('container'));
</script>
<!-- Tests will inject their own code -->
<!-- We don't add library.js here because we may want to test the esm version instead -->
</body>
</html>

View File

@ -1,191 +1,98 @@
/// <reference types="node" />
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<number> {
const activeRefsHandle = await page.queryObjects(prototypeReference);
const activeRefsCount = await (await activeRefsHandle?.getProperty('length'))?.jsonValue();
await activeRefsHandle.dispose();
return activeRefsCount;
}
function promisleep(ms: number): Promise<void> {
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<void> {
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<number> {
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<void> {
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<string> = 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('<html><body></body></html>', { 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();
});
});

View File

@ -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,
});

View File

@ -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;

View File

@ -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;

View File

@ -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;

View File

@ -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;

View File

@ -1,6 +1,7 @@
{
"extends": "../../tsconfig.composite.base.json",
"compilerOptions": {
"skipLibCheck": true,
"esModuleInterop": true,
"lib": [
"dom",

View File

@ -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
);
}

View File

@ -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 (

View File

@ -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);

View File

@ -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(