From 885fcf090b672b4a4c49fab7f4aa0ad98e65ce63 Mon Sep 17 00:00:00 2001 From: Evgeniy Timokhov Date: Tue, 7 Jul 2020 17:36:50 +0300 Subject: [PATCH] Introduced coverage test Fixes #495 --- .circleci/config.yml | 19 +- .vscode/launch.json | 10 + scripts/run-coverage-tests.sh | 8 + tests/e2e/coverage/coverage-config.ts | 2 + tests/e2e/coverage/coverage-script.js | 245 ++++++++++++++++++++ tests/e2e/coverage/coverage.spec.ts | 308 ++++++++++++++++++++++++++ tests/e2e/coverage/runner.js | 71 ++++++ 7 files changed, 662 insertions(+), 1 deletion(-) create mode 100644 scripts/run-coverage-tests.sh create mode 100644 tests/e2e/coverage/coverage-config.ts create mode 100644 tests/e2e/coverage/coverage-script.js create mode 100644 tests/e2e/coverage/coverage.spec.ts create mode 100644 tests/e2e/coverage/runner.js diff --git a/.circleci/config.yml b/.circleci/config.yml index cbcfa788d5..d7612edd97 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -157,7 +157,6 @@ jobs: - run-graphics-tests: devicePixelRatio: "2.0" - memleaks-tests: executor: node-browsers-latest-executor environment: @@ -171,6 +170,19 @@ jobs: - store_test_results: path: test-results/ + coverage-tests: + executor: node-browsers-latest-executor + environment: + NO_SANDBOX: "true" + TESTS_REPORT_FILE: "test-results/coverage/results.xml" + steps: + - checkout-with-deps + - attach_workspace: + at: ./ + - run: scripts/run-coverage-tests.sh + - store_test_results: + path: test-results/ + size-limit: executor: node-latest-executor steps: @@ -182,6 +194,7 @@ jobs: command: node scripts/compare-size-with-merge-base.js - run: npm run size-limit + workflows: version: 2 @@ -239,3 +252,7 @@ workflows: filters: *default-filters requires: - build + - coverage-tests: + filters: *default-filters + requires: + - build diff --git a/.vscode/launch.json b/.vscode/launch.json index 28b645b93a..522a265c74 100644 --- a/.vscode/launch.json +++ b/.vscode/launch.json @@ -31,6 +31,16 @@ "${input:testStandalonePath}" ], "internalConsoleOptions": "openOnSessionStart" + }, + { + "type": "node", + "request": "launch", + "name": "Coverage tests", + "program": "${workspaceFolder}/tests/e2e/coverage/runner.js", + "args": [ + "${input:testStandalonePath}" + ], + "internalConsoleOptions": "openOnSessionStart" } ], "inputs": [ diff --git a/scripts/run-coverage-tests.sh b/scripts/run-coverage-tests.sh new file mode 100644 index 0000000000..824109dfd4 --- /dev/null +++ b/scripts/run-coverage-tests.sh @@ -0,0 +1,8 @@ +#!/bin/bash +set -e +echo "Preparing" + +npm run build + +echo "Coverage tests" +node ./tests/e2e/coverage/runner.js ./dist/lightweight-charts.standalone.development.js diff --git a/tests/e2e/coverage/coverage-config.ts b/tests/e2e/coverage/coverage-config.ts new file mode 100644 index 0000000000..d4e176539f --- /dev/null +++ b/tests/e2e/coverage/coverage-config.ts @@ -0,0 +1,2 @@ +export const expectedCoverage = 89; +export const threshold = 1; diff --git a/tests/e2e/coverage/coverage-script.js b/tests/e2e/coverage/coverage-script.js new file mode 100644 index 0000000000..5973250ee0 --- /dev/null +++ b/tests/e2e/coverage/coverage-script.js @@ -0,0 +1,245 @@ +/* global LightweightCharts */ + +// eslint-env browser + +function generateLineData() { + 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.toISOString().slice(0, 10), + 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; +} + +function generateHistogramData() { + const colors = [ + '#013370', + '#3a9656', + undefined, + ]; + + return generateLineData().map((item, index) => ({ + ...item, + color: colors[index % colors.length], + })); +} + +// eslint-disable-next-line no-unused-vars +function runTestCase(container) { + const chart = LightweightCharts.createChart(container, { + leftPriceScale: { + visible: true, + mode: LightweightCharts.PriceScaleMode.Logarithmic, + }, + rightPriceScale: { + visible: true, + mode: LightweightCharts.PriceScaleMode.Percentage, + }, + timeScale: { + timeVisible: true, + }, + watermark: { + visible: true, + color: 'red', + text: 'Watermark', + fontSize: 24, + fontFamily: 'Roboto', + fontStyle: 'italic', + }, + }); + + const data = generateLineData(); + const areaSeries = chart.addAreaSeries({ + priceFormat: { + type: 'custom', + minMove: 0.02, + formatter: price => '$' + price.toFixed(2), + }, + }); + areaSeries.setData(data); + + const seriesToRemove = chart.addAreaSeries({ + priceScaleId: 'overlay-id', + scaleMargins: { + top: 0.3, + bottom: 0.3, + }, + priceFormat: { + type: 'volume', + }, + }); + seriesToRemove.setData(generateLineData()); + + const candlestickSeries = chart.addCandlestickSeries({ priceScaleId: 'left' }); + candlestickSeries.setData(generateBars()); + + const barSeries = chart.addBarSeries({ + title: 'Initial title', + priceFormat: { + type: 'percent', + }, + }); + barSeries.setData(generateBars()); + barSeries.applyOptions({ priceScaleId: 'left' }); + + const histogramSeries = chart.addHistogramSeries({ + color: '#ff0000', + autoscaleInfoProvider: original => original(), + }); + + histogramSeries.setData(generateHistogramData()); + + const lineSeries = chart.addLineSeries({ + lineWidth: 1, + color: '#ff0000', + priceFormat: { + type: 'volume', + }, + }); + + lineSeries.setData(generateLineData()); + + areaSeries.createPriceLine({ + price: 10, + color: 'red', + lineWidth: 1, + lineStyle: LightweightCharts.LineStyle.Solid, + }); + + areaSeries.createPriceLine({ + price: 20, + color: '#00FF00', + lineWidth: 2, + lineStyle: LightweightCharts.LineStyle.Dotted, + }); + + areaSeries.createPriceLine({ + price: 30, + color: 'rgb(0,0,255)', + lineWidth: 3, + lineStyle: LightweightCharts.LineStyle.Dashed, + }); + + const priceLineToRemove = areaSeries.createPriceLine({ + price: 40, + color: 'rgba(255,0,0,0.5)', + lineWidth: 4, + lineStyle: LightweightCharts.LineStyle.LargeDashed, + }); + + const priceLine1 = areaSeries.createPriceLine({ + price: 50, + color: '#f0f', + lineWidth: 4, + lineStyle: LightweightCharts.LineStyle.SparseDotted, + }); + + areaSeries.setMarkers([ + { time: data[data.length - 7].time, position: 'belowBar', color: 'rgb(255, 0, 0)', shape: 'arrowUp', text: 'test' }, + { time: data[data.length - 5].time, position: 'aboveBar', color: 'rgba(255, 255, 0, 1)', shape: 'arrowDown', text: 'test' }, + { time: data[data.length - 3].time, position: 'inBar', color: '#f0f', shape: 'circle', text: 'test' }, + { time: data[data.length - 1].time, position: 'belowBar', color: '#fff00a', shape: 'square', text: 'test', size: 2 }, + ]); + + // apply overlay price scales while create series + // time formatter + + chart.timeScale().fitContent(); + + chart.timeScale().subscribeVisibleTimeRangeChange(console.log); + chart.timeScale().subscribeVisibleLogicalRangeChange(console.log); + chart.subscribeCrosshairMove(console.log); + chart.subscribeClick(console.log); + + return new Promise(resolve => { + setTimeout(() => { + chart.timeScale().scrollToRealTime(); + + chart.priceScale('overlay-id').applyOptions({}); + + chart.removeSeries(seriesToRemove); + areaSeries.removePriceLine(priceLineToRemove); + + chart.takeScreenshot(); + + chart.resize(700, 700); + + chart.applyOptions({ + leftPriceScale: { + mode: LightweightCharts.PriceScaleMode.IndexedTo100, + }, + rightPriceScale: { + mode: LightweightCharts.PriceScaleMode.Normal, + invertScale: true, + alignLabels: false, + }, + localization: { + dateFormat: 'yyyy MM dd', + }, + }); + + chart.priceScale('left').width(); + + // move series to left price scale + lineSeries.applyOptions({ priceScaleId: 'left' }); + + // set new series data + const newData = generateBars(520, 1); + barSeries.setData(newData); + barSeries.update({ + ...newData[newData.length - 1], + close: newData[newData.length - 1].close - 10, + }); + barSeries.update({ + ...newData[newData.length - 1], + time: newData[newData.length - 1].time + 3600, + }); + + chart.timeScale().getVisibleRange(); + chart.timeScale().setVisibleRange({ + from: newData[0].time, + to: newData[newData.length - 1].time, + }); + + barSeries.barsInLogicalRange(chart.timeScale().getVisibleLogicalRange()); + chart.timeScale().applyOptions({ fixLeftEdge: true }); + + priceLine1.applyOptions({}); + + setTimeout(() => { + chart.timeScale().unsubscribeVisibleTimeRangeChange(console.log); + chart.timeScale().unsubscribeVisibleLogicalRangeChange(console.log); + chart.unsubscribeCrosshairMove(console.log); + chart.unsubscribeClick(console.log); + + resolve(() => chart.remove()); + }, 500); + }, 500); + }); +} diff --git a/tests/e2e/coverage/coverage.spec.ts b/tests/e2e/coverage/coverage.spec.ts new file mode 100644 index 0000000000..f7ab75b221 --- /dev/null +++ b/tests/e2e/coverage/coverage.spec.ts @@ -0,0 +1,308 @@ +/// + +import * as fs from 'fs'; +import * as path from 'path'; + +import { expect } from 'chai'; +import { describe, it } from 'mocha'; +import { BoundingBox, + Browser, + ConsoleMessage, + ElementHandle, + launch as launchPuppeteer, + LaunchOptions, + Page, + Response, +} from 'puppeteer'; + +import { expectedCoverage, threshold } from './coverage-config'; + +const coverageScript = fs.readFileSync(path.join(__dirname, 'coverage-script.js'), { encoding: 'utf-8' }); + +const testStandalonePathEnvKey = 'TEST_STANDALONE_PATH'; + +const testStandalonePath: string = process.env[testStandalonePathEnvKey] || ''; + +interface MouseWheelEventOptions { + type: 'mouseWheel'; + x: number; + y: number; + deltaX: number; + deltaY: number; +} + +interface InternalPuppeteerClient { + // see https://github.com/ChromeDevTools/devtools-protocol/blob/20413fc82dea0d45a598715970293b4787296673/json/browser_protocol.json#L7822-L7898 + // see https://github.com/puppeteer/puppeteer/issues/4119 + send(event: 'Input.dispatchMouseEvent', options: MouseWheelEventOptions): Promise; +} + +async function doMouseScrolls(element: ElementHandle): Promise { + // eslint-disable-next-line @typescript-eslint/no-explicit-any,@typescript-eslint/no-unsafe-member-access + const client: InternalPuppeteerClient = (element as any)._client; + + await client.send('Input.dispatchMouseEvent', { + type: 'mouseWheel', + x: 0, + y: 0, + deltaX: 10.0, + deltaY: 0, + }); + + await client.send('Input.dispatchMouseEvent', { + type: 'mouseWheel', + x: 0, + y: 0, + deltaX: 0, + deltaY: 10.0, + }); + + await client.send('Input.dispatchMouseEvent', { + type: 'mouseWheel', + x: 0, + y: 0, + deltaX: -10.0, + deltaY: 0, + }); + + await client.send('Input.dispatchMouseEvent', { + type: 'mouseWheel', + x: 0, + y: 0, + deltaX: 0, + deltaY: -10.0, + }); + + await client.send('Input.dispatchMouseEvent', { + type: 'mouseWheel', + x: 0, + y: 0, + deltaX: 10.0, + deltaY: 10.0, + }); + + await client.send('Input.dispatchMouseEvent', { + type: 'mouseWheel', + x: 0, + y: 0, + deltaX: -10.0, + deltaY: -10.0, + }); +} + +async function doZoomInZoomOut(page: Page): Promise { + const prevViewport = page.viewport(); + await page.setViewport({ + ...prevViewport, + deviceScaleFactor: 2, + }); + + await page.setViewport(prevViewport); +} + +async function doVerticalDrag(page: Page, element: ElementHandle): Promise { + const elBox = await element.boundingBox() as BoundingBox; + + const elMiddleX = elBox.x + elBox.width / 2; + const elMiddleY = elBox.y + elBox.height / 2; + + // move mouse to the middle of element + await page.mouse.move(elMiddleX, elMiddleY); + + await page.mouse.down({ button: 'left' }); + await page.mouse.move(elMiddleX, elMiddleY - 20); + await page.mouse.move(elMiddleX, elMiddleY + 40); + await page.mouse.up({ button: 'left' }); +} + +async function doHorizontalDrag(page: Page, element: ElementHandle): Promise { + const elBox = await element.boundingBox() as BoundingBox; + + const elMiddleX = elBox.x + elBox.width / 2; + const elMiddleY = elBox.y + elBox.height / 2; + + // move mouse to the middle of element + await page.mouse.move(elMiddleX, elMiddleY); + + await page.mouse.down({ button: 'left' }); + await page.mouse.move(elMiddleX - 20, elMiddleY); + await page.mouse.move(elMiddleX + 40, elMiddleY); + await page.mouse.up({ button: 'left' }); +} + +async function doUserInteractions(page: Page): Promise { + const chartContainer = await page.$('#container') as ElementHandle; + const chartBox = await chartContainer.boundingBox() as BoundingBox; + + // move cursor to the middle of the chart + await page.mouse.move(chartBox.width / 2, chartBox.height / 2); + + const leftPriceAxis = (await chartContainer.$$('tr:nth-of-type(1) td:nth-of-type(1) div canvas'))[0]; + const paneWidget = (await chartContainer.$$('tr:nth-of-type(1) td:nth-of-type(2) div canvas'))[0]; + const rightPriceAxis = (await chartContainer.$$('tr:nth-of-type(1) td:nth-of-type(3) div canvas'))[0]; + const timeAxis = (await chartContainer.$$('tr:nth-of-type(2) td:nth-of-type(2) div canvas'))[0]; + + // mouse scroll + await doMouseScrolls(chartContainer); + + // outside click + await page.mouse.click(chartBox.x + chartBox.width + 20, chartBox.y + chartBox.height + 50, { button: 'left' }); + + // change viewport zoom + await doZoomInZoomOut(page); + + // drag price scale + await doVerticalDrag(page, leftPriceAxis); + await doVerticalDrag(page, rightPriceAxis); + + // drag time scale + await doHorizontalDrag(page, timeAxis); + + // drag pane + await doVerticalDrag(page, paneWidget); + await doVerticalDrag(page, paneWidget); + + // clicks on scales + await leftPriceAxis.click({ button: 'left' }); + await leftPriceAxis.click({ button: 'left', clickCount: 2 }); + + await rightPriceAxis.click({ button: 'left' }); + await rightPriceAxis.click({ button: 'left', clickCount: 2 }); + + await timeAxis.click({ button: 'left' }); + await timeAxis.click({ button: 'left', clickCount: 2 }); +} + +interface CoverageResult { + usedBytes: number; + totalBytes: number; +} + +interface InternalWindow { + finishTestCasePromise: Promise<() => void>; +} + +async function getCoverageResult(page: Page): Promise> { + const coverageEntries = await page.coverage.stopJSCoverage(); + + const result = new Map(); + + for (const entry of coverageEntries) { + let entryRes = result.get(entry.url); + if (entryRes === undefined) { + entryRes = { + totalBytes: 0, + usedBytes: 0, + }; + + result.set(entry.url, entryRes); + } + + entryRes.totalBytes += entry.text.length; + + for (const range of entry.ranges) { + entryRes.usedBytes += range.end - range.start; + } + + result.set(entry.url, entryRes); + } + + return result; +} + +describe('Coverage tests', () => { + const puppeteerOptions: LaunchOptions = {}; + if (process.env.NO_SANDBOX) { + puppeteerOptions.args = ['--no-sandbox', '--disable-setuid-sandbox']; + } + + let browser: Browser; + + before(async () => { + expect(testStandalonePath, `path to test standalone module must be passed via ${testStandalonePathEnvKey} env var`) + .to.have.length.greaterThan(0); + + const browserPromise = launchPuppeteer(puppeteerOptions); + browser = await browserPromise; + }); + + async function runTest(onError: (errorMsg: string) => void): Promise { + const page = await browser.newPage(); + await page.coverage.startJSCoverage(); + + page.on('pageerror', (error: Error) => { + onError(`Page error: ${error.message}`); + }); + + page.on('console', (message: ConsoleMessage) => { + const type = message.type(); + if (type === 'error' || type === 'assert') { + onError(`Console ${type}: ${message.text()}`); + } + }); + + page.on('response', (response: Response) => { + if (!response.ok()) { + onError(`Network error: ${response.url()} status=${response.status()}`); + } + }); + + await page.setContent(` + + + + + + Test case page + + + +
+ + + + + + + + `); + + // first, wait until test case is ready + await page.evaluate(() => { + return (window as unknown as InternalWindow).finishTestCasePromise; + }); + + // now let's do some user's interactions + await doUserInteractions(page); + + // finish test case + await page.evaluate(() => { + return (window as unknown as InternalWindow).finishTestCasePromise.then((finishTestCase: () => void) => finishTestCase()); + }); + + const result = await getCoverageResult(page); + const libraryRes = result.get(testStandalonePath) as CoverageResult; + expect(libraryRes).not.to.be.equal(undefined); + + const currentCoverage = parseFloat((libraryRes.usedBytes / libraryRes.totalBytes * 100).toFixed(1)); + expect(currentCoverage).to.be.closeTo(expectedCoverage, threshold, `Please either update config to pass the test or improve coverage`); + + console.log(`Current coverage is ${currentCoverage.toFixed(1)}% (${formatChange(currentCoverage - expectedCoverage)}%)`); + } + + it(`should have coverage around ${expectedCoverage.toFixed(1)}% (±${threshold.toFixed(1)}%)`, async () => { + return new Promise((resolve: () => void, reject: () => void) => { + runTest(reject).then(resolve).catch(reject); + }); + }); + + after(async () => { + await browser.close(); + }); +}); + +function formatChange(change: number): string { + return change < 0 ? change.toFixed(1) : `+${change.toFixed(1)}`; +} diff --git a/tests/e2e/coverage/runner.js b/tests/e2e/coverage/runner.js new file mode 100644 index 0000000000..d74c229b2d --- /dev/null +++ b/tests/e2e/coverage/runner.js @@ -0,0 +1,71 @@ +#!/usr/bin/env node + +const fs = require('fs'); +const path = require('path'); + +const Mocha = require('mocha'); + +const serveLocalFiles = require('../serve-local-files').serveLocalFiles; + +const mochaConfig = require('../../../.mocharc.js'); + +// override tsconfig +process.env.TS_NODE_PROJECT = path.resolve(__dirname, '../tsconfig.json'); + +mochaConfig.require.forEach(module => { + require(module); +}); + +if (process.argv.length !== 3) { + console.log('Usage: runner PATH_TO_TEST_STANDALONE_MODULE'); + process.exit(1); +} + +const startTime = Date.now(); + +let testStandalonePath = process.argv[2]; + +const hostname = 'localhost'; +const port = 34567; +const httpServerPrefix = `http://${hostname}:${port}/`; + +const filesToServe = new Map(); + +if (fs.existsSync(testStandalonePath)) { + const fileNameToServe = 'test.js'; + filesToServe.set(fileNameToServe, path.resolve(testStandalonePath)); + testStandalonePath = `${httpServerPrefix}${fileNameToServe}`; +} + +process.env.TEST_STANDALONE_PATH = testStandalonePath; + +function runMocha(closeServer) { + console.log('Running tests...'); + const mocha = new Mocha({ + timeout: 20000, + slow: 10000, + reporter: mochaConfig.reporter, + reporterOptions: mochaConfig._reporterOptions, + }); + + if (mochaConfig.checkLeaks) { + mocha.checkLeaks(); + } + + mocha.diff(mochaConfig.diff); + mocha.addFile(path.resolve(__dirname, './coverage.spec.ts')); + + mocha.run(failures => { + if (closeServer !== null) { + closeServer(); + } + + const timeInSecs = (Date.now() - startTime) / 1000; + console.log(`Done in ${timeInSecs.toFixed(2)}s with ${failures} error(s)`); + + process.exitCode = failures !== 0 ? 1 : 0; + }); +} + +serveLocalFiles(filesToServe, port, hostname) + .then(runMocha);