From 05ea046da1fadc4ca713a1c5835b2391d8be7c73 Mon Sep 17 00:00:00 2001 From: Mark Silverwood Date: Wed, 14 Sep 2022 16:13:05 +0100 Subject: [PATCH] added interactions e2e tests Added new e2e tests for chart interactions such as mouse and touch interactions. --- .circleci/config.yml | 17 ++ .vscode/launch.json | 10 + scripts/run-interactions-test.sh | 8 + tests/README.md | 18 ++ tests/e2e/coverage/coverage.spec.ts | 181 +----------------- tests/e2e/helpers/mouse-drag-actions.ts | 67 +++++++ tests/e2e/helpers/mouse-scroll-actions.ts | 48 +++++ tests/e2e/helpers/page-timeout.ts | 11 ++ tests/e2e/helpers/touch-actions.ts | 89 +++++++++ .../interactions/helpers/get-test-cases.ts | 60 ++++++ .../interactions/helpers/test-page-dummy.html | 23 +++ .../interactions/interactions-test-cases.ts | 171 +++++++++++++++++ tests/e2e/interactions/runner.js | 71 +++++++ .../e2e/interactions/test-cases/.eslintrc.js | 14 ++ .../mouse-wheel/default-scroll-handler.js | 47 +++++ .../mouse-wheel/disabled-scroll-handler.js | 54 ++++++ .../mouse-wheel/enabled-scroll-handler.js | 54 ++++++ .../mouse-wheel/reenabled-scroll-handler.js | 62 ++++++ tests/e2e/tsconfig.composite.json | 4 +- 19 files changed, 831 insertions(+), 178 deletions(-) create mode 100644 scripts/run-interactions-test.sh create mode 100644 tests/e2e/helpers/mouse-drag-actions.ts create mode 100644 tests/e2e/helpers/mouse-scroll-actions.ts create mode 100644 tests/e2e/helpers/page-timeout.ts create mode 100644 tests/e2e/helpers/touch-actions.ts create mode 100644 tests/e2e/interactions/helpers/get-test-cases.ts create mode 100644 tests/e2e/interactions/helpers/test-page-dummy.html create mode 100644 tests/e2e/interactions/interactions-test-cases.ts create mode 100644 tests/e2e/interactions/runner.js create mode 100644 tests/e2e/interactions/test-cases/.eslintrc.js create mode 100644 tests/e2e/interactions/test-cases/mouse-wheel/default-scroll-handler.js create mode 100644 tests/e2e/interactions/test-cases/mouse-wheel/disabled-scroll-handler.js create mode 100644 tests/e2e/interactions/test-cases/mouse-wheel/enabled-scroll-handler.js create mode 100644 tests/e2e/interactions/test-cases/mouse-wheel/reenabled-scroll-handler.js diff --git a/.circleci/config.yml b/.circleci/config.yml index 35f676f8f3..947e675644 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -220,6 +220,19 @@ jobs: - store_test_results: path: test-results/ + interactions: + executor: node16-browsers-executor + environment: + NO_SANDBOX: "true" + TESTS_REPORT_FILE: "test-results/interactions/results.xml" + steps: + - checkout-with-deps + - attach_workspace: + at: ./ + - run: scripts/run-interactions-tests.sh + - store_test_results: + path: test-results/ + size-limit: executor: node16-executor steps: @@ -336,6 +349,10 @@ workflows: filters: *default-filters requires: - build + - interactions: + filters: *default-filters + requires: + - build - lint-dts: filters: *default-filters requires: diff --git a/.vscode/launch.json b/.vscode/launch.json index 522a265c74..54bf3dc70d 100644 --- a/.vscode/launch.json +++ b/.vscode/launch.json @@ -41,6 +41,16 @@ "${input:testStandalonePath}" ], "internalConsoleOptions": "openOnSessionStart" + }, + { + "type": "node", + "request": "launch", + "name": "Interaction tests", + "program": "${workspaceFolder}/tests/e2e/interactions/runner.js", + "args": [ + "${input:testStandalonePath}" + ], + "internalConsoleOptions": "openOnSessionStart" } ], "inputs": [ diff --git a/scripts/run-interactions-test.sh b/scripts/run-interactions-test.sh new file mode 100644 index 0000000000..94485e7147 --- /dev/null +++ b/scripts/run-interactions-test.sh @@ -0,0 +1,8 @@ +#!/bin/bash +set -e +echo "Preparing" + +npm run build + +echo "Interactions tests" +node ./tests/e2e/interactions/runner.js ./dist/lightweight-charts.standalone.development.js diff --git a/tests/README.md b/tests/README.md index 1c2a9799d9..05bac4185b 100644 --- a/tests/README.md +++ b/tests/README.md @@ -107,3 +107,21 @@ Alternatively, you can run the test on a specific file like this: ```bash node ./tests/e2e/memleaks/runner.js ./dist/lightweight-charts.standalone.development.js ``` + +### Interactions + +The interactions tests check whether the library is correctly handling user interaction on the chart. Interactions include: mouse scrolling, mouse dragging, and touches. + +#### Running the Interactions tests + +You can run the interactions tests with the following command: + +```bash +./scripts/run-interactions-tests.sh +``` + +Alternatively, you can run the tests on a specific file like this: + +```bash +node ./tests/e2e/interactions/runner.js ./dist/lightweight-charts.standalone.development.js +``` diff --git a/tests/e2e/coverage/coverage.spec.ts b/tests/e2e/coverage/coverage.spec.ts index 66155da68c..2fecb0f911 100644 --- a/tests/e2e/coverage/coverage.spec.ts +++ b/tests/e2e/coverage/coverage.spec.ts @@ -13,9 +13,12 @@ import puppeteer, { HTTPResponse, launch as launchPuppeteer, Page, - type CDPSession, } from 'puppeteer'; +import { doHorizontalDrag, doKineticAnimation, doVerticalDrag } from '../helpers/mouse-drag-actions'; +import { doMouseScrolls } from '../helpers/mouse-scroll-actions'; +import { doLongTouch, doPinchZoomTouch, doSwipeTouch } from '../helpers/touch-actions'; + import { expectedCoverage, threshold } from './coverage-config'; const coverageScript = fs.readFileSync(path.join(__dirname, 'coverage-script.js'), { encoding: 'utf-8' }); @@ -24,31 +27,6 @@ const testStandalonePathEnvKey = 'TEST_STANDALONE_PATH'; const testStandalonePath: string = process.env[testStandalonePathEnvKey] || ''; -async function doMouseScrolls(page: Page, element: ElementHandle): Promise { - const boundingBox = await element.boundingBox(); - if (!boundingBox) { - throw new Error('Unable to get boundingBox for element.'); - } - - // move mouse to center of element - await page.mouse.move( - boundingBox.x + boundingBox.width / 2, - boundingBox.y + boundingBox.height / 2 - ); - - await page.mouse.wheel({ deltaX: 10.0 }); - - await page.mouse.wheel({ deltaY: 10.0 }); - - await page.mouse.wheel({ deltaX: -10.0 }); - - await page.mouse.wheel({ deltaY: -10.0 }); - - await page.mouse.wheel({ deltaX: 10.0, deltaY: 10.0 }); - - await page.mouse.wheel({ deltaX: -10.0, deltaY: -10.0 }); -} - async function doZoomInZoomOut(page: Page): Promise { // eslint-disable-next-line @typescript-eslint/no-non-null-assertion const prevViewport = page.viewport()!; @@ -60,157 +38,6 @@ async function doZoomInZoomOut(page: Page): Promise { 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' }); -} - -// await a setTimeout delay evaluated within page context -async function pageTimeout(page: Page, delay: number): Promise { - return page.evaluate( - (ms: number) => new Promise( - (resolve: () => void) => setTimeout(resolve, ms) - ), - delay - ); -} - -async function doKineticAnimation(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 pageTimeout(page, 50); - await page.mouse.move(elMiddleX - 40, elMiddleY); - await page.mouse.move(elMiddleX - 55, elMiddleY); - await page.mouse.move(elMiddleX - 105, elMiddleY); - await page.mouse.move(elMiddleX - 155, elMiddleY); - await page.mouse.move(elMiddleX - 205, elMiddleY); - await page.mouse.move(elMiddleX - 255, elMiddleY); - await page.mouse.up({ button: 'left' }); - - await pageTimeout(page, 200); - // stop animation - await page.mouse.down({ button: 'left' }); - await page.mouse.up({ button: 'left' }); -} - -// Simulate a long touch action in a single position -async function doLongTouch(page: Page, element: ElementHandle, duration: number): Promise { - const elBox = (await element.boundingBox()) as BoundingBox; - - const elCenterX = elBox.x + elBox.width / 2; - const elCenterY = elBox.y + elBox.height / 2; - - const client = await page.target().createCDPSession(); - - await client.send('Input.dispatchTouchEvent', { - type: 'touchStart', - touchPoints: [ - { x: elCenterX, y: elCenterY }, - ], - }); - await pageTimeout(page, duration); - return client.send('Input.dispatchTouchEvent', { - type: 'touchEnd', - touchPoints: [ - { x: elCenterX, y: elCenterY }, - ], - }); -} - -// Simulate a touch swipe gesture -async function doSwipeTouch( - devToolsSession: CDPSession, - element: ElementHandle, - { - horizontal = false, - vertical = false, - }: { horizontal?: boolean; vertical?: boolean } -): Promise { - const elBox = (await element.boundingBox()) as BoundingBox; - - const elCenterX = elBox.x + elBox.width / 2; - const elCenterY = elBox.y + elBox.height / 2; - const xStep = horizontal ? elBox.width / 8 : 0; - const yStep = vertical ? elBox.height / 8 : 0; - - for (let i = 2; i > 0; i--) { - const type = i === 2 ? 'touchStart' : 'touchMove'; - await devToolsSession.send('Input.dispatchTouchEvent', { - type, - touchPoints: [{ x: elCenterX - i * xStep, y: elCenterY - i * yStep }], - }); - } - return devToolsSession.send('Input.dispatchTouchEvent', { - type: 'touchEnd', - touchPoints: [{ x: elCenterX - xStep, y: elCenterY - yStep }], - }); -} - -// Perform a pinch or zoom touch gesture within the specified element. -async function doPinchZoomTouch( - devToolsSession: CDPSession, - element: ElementHandle, - zoom?: boolean -): Promise { - const elBox = (await element.boundingBox()) as BoundingBox; - - const sign = zoom ? -1 : 1; - const elCenterX = elBox.x + elBox.width / 2; - const elCenterY = elBox.y + elBox.height / 2; - const xStep = (sign * elBox.width) / 8; - const yStep = (sign * elBox.height) / 8; - - for (let i = 2; i > 0; i--) { - const type = i === 2 ? 'touchStart' : 'touchMove'; - await devToolsSession.send('Input.dispatchTouchEvent', { - type, - touchPoints: [ - { x: elCenterX - i * xStep, y: elCenterY - i * yStep }, - { x: elCenterX + i * xStep, y: elCenterY + i * xStep }, - ], - }); - } - return devToolsSession.send('Input.dispatchTouchEvent', { - type: 'touchEnd', - touchPoints: [ - { x: elCenterX - xStep, y: elCenterY - yStep }, - { x: elCenterX + xStep, y: elCenterY + xStep }, - ], - }); -} - async function doUserInteractions(page: Page): Promise { const chartContainer = await page.$('#container') as ElementHandle; const chartBox = await chartContainer.boundingBox() as BoundingBox; diff --git a/tests/e2e/helpers/mouse-drag-actions.ts b/tests/e2e/helpers/mouse-drag-actions.ts new file mode 100644 index 0000000000..e462effa03 --- /dev/null +++ b/tests/e2e/helpers/mouse-drag-actions.ts @@ -0,0 +1,67 @@ +import { BoundingBox, ElementHandle, Page } from 'puppeteer'; + +import { pageTimeout } from './page-timeout'; + +export 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' }); +} + +export 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' }); +} + +export async function doKineticAnimation( + 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 pageTimeout(page, 50); + await page.mouse.move(elMiddleX - 40, elMiddleY); + await page.mouse.move(elMiddleX - 55, elMiddleY); + await page.mouse.move(elMiddleX - 105, elMiddleY); + await page.mouse.move(elMiddleX - 155, elMiddleY); + await page.mouse.move(elMiddleX - 205, elMiddleY); + await page.mouse.move(elMiddleX - 255, elMiddleY); + await page.mouse.up({ button: 'left' }); + + await pageTimeout(page, 200); + // stop animation + await page.mouse.down({ button: 'left' }); + await page.mouse.up({ button: 'left' }); +} diff --git a/tests/e2e/helpers/mouse-scroll-actions.ts b/tests/e2e/helpers/mouse-scroll-actions.ts new file mode 100644 index 0000000000..dda6c5a0f9 --- /dev/null +++ b/tests/e2e/helpers/mouse-scroll-actions.ts @@ -0,0 +1,48 @@ +import { ElementHandle, Page } from 'puppeteer'; + +export async function centerMouseOnElement( + page: Page, + element: ElementHandle +): Promise { + const boundingBox = await element.boundingBox(); + if (!boundingBox) { + throw new Error('Unable to get boundingBox for element.'); + } + + // move mouse to center of element + await page.mouse.move( + boundingBox.x + boundingBox.width / 2, + boundingBox.y + boundingBox.height / 2 + ); +} + +interface MouseScrollDelta { + x?: number; + y?: number; +} + +export async function doMouseScroll( + deltas: MouseScrollDelta, + page: Page +): Promise { + await page.mouse.wheel({ deltaX: deltas.x || 0, deltaY: deltas.y || 0 }); +} + +export async function doMouseScrolls( + page: Page, + element: ElementHandle +): Promise { + await centerMouseOnElement(page, element); + + await doMouseScroll({ x: 10.0 }, page); + + await doMouseScroll({ y: 10.0 }, page); + + await doMouseScroll({ x: -10.0 }, page); + + await doMouseScroll({ y: -10.0 }, page); + + await doMouseScroll({ x: 10.0, y: 10.0 }, page); + + await doMouseScroll({ x: -10.0, y: -10.0 }, page); +} diff --git a/tests/e2e/helpers/page-timeout.ts b/tests/e2e/helpers/page-timeout.ts new file mode 100644 index 0000000000..d57ef781bc --- /dev/null +++ b/tests/e2e/helpers/page-timeout.ts @@ -0,0 +1,11 @@ +import { Page } from 'puppeteer'; + +// await a setTimeout delay evaluated within page context +export async function pageTimeout(page: Page, delay: number): Promise { + return page.evaluate( + (ms: number) => new Promise( + (resolve: () => void) => setTimeout(resolve, ms) + ), + delay + ); +} diff --git a/tests/e2e/helpers/touch-actions.ts b/tests/e2e/helpers/touch-actions.ts new file mode 100644 index 0000000000..431d6d2b65 --- /dev/null +++ b/tests/e2e/helpers/touch-actions.ts @@ -0,0 +1,89 @@ +import { BoundingBox, ElementHandle, Page, type CDPSession } from 'puppeteer'; + +import { pageTimeout } from './page-timeout'; + +// Simulate a long touch action in a single position +export async function doLongTouch(page: Page, element: ElementHandle, duration: number): Promise { + const elBox = (await element.boundingBox()) as BoundingBox; + + const elCenterX = elBox.x + elBox.width / 2; + const elCenterY = elBox.y + elBox.height / 2; + + const client = await page.target().createCDPSession(); + + await client.send('Input.dispatchTouchEvent', { + type: 'touchStart', + touchPoints: [ + { x: elCenterX, y: elCenterY }, + ], + }); + await pageTimeout(page, duration); + return client.send('Input.dispatchTouchEvent', { + type: 'touchEnd', + touchPoints: [ + { x: elCenterX, y: elCenterY }, + ], + }); +} + +// Simulate a touch swipe gesture +export async function doSwipeTouch( + devToolsSession: CDPSession, + element: ElementHandle, + { + horizontal = false, + vertical = false, + }: { horizontal?: boolean; vertical?: boolean } +): Promise { + const elBox = (await element.boundingBox()) as BoundingBox; + + const elCenterX = elBox.x + elBox.width / 2; + const elCenterY = elBox.y + elBox.height / 2; + const xStep = horizontal ? elBox.width / 8 : 0; + const yStep = vertical ? elBox.height / 8 : 0; + + for (let i = 2; i > 0; i--) { + const type = i === 2 ? 'touchStart' : 'touchMove'; + await devToolsSession.send('Input.dispatchTouchEvent', { + type, + touchPoints: [{ x: elCenterX - i * xStep, y: elCenterY - i * yStep }], + }); + } + return devToolsSession.send('Input.dispatchTouchEvent', { + type: 'touchEnd', + touchPoints: [{ x: elCenterX - xStep, y: elCenterY - yStep }], + }); +} + +// Perform a pinch or zoom touch gesture within the specified element. +export async function doPinchZoomTouch( + devToolsSession: CDPSession, + element: ElementHandle, + zoom?: boolean +): Promise { + const elBox = (await element.boundingBox()) as BoundingBox; + + const sign = zoom ? -1 : 1; + const elCenterX = elBox.x + elBox.width / 2; + const elCenterY = elBox.y + elBox.height / 2; + const xStep = (sign * elBox.width) / 8; + const yStep = (sign * elBox.height) / 8; + + for (let i = 2; i > 0; i--) { + const type = i === 2 ? 'touchStart' : 'touchMove'; + await devToolsSession.send('Input.dispatchTouchEvent', { + type, + touchPoints: [ + { x: elCenterX - i * xStep, y: elCenterY - i * yStep }, + { x: elCenterX + i * xStep, y: elCenterY + i * xStep }, + ], + }); + } + return devToolsSession.send('Input.dispatchTouchEvent', { + type: 'touchEnd', + touchPoints: [ + { x: elCenterX - xStep, y: elCenterY - yStep }, + { x: elCenterX + xStep, y: elCenterY + xStep }, + ], + }); +} diff --git a/tests/e2e/interactions/helpers/get-test-cases.ts b/tests/e2e/interactions/helpers/get-test-cases.ts new file mode 100644 index 0000000000..d2dfa97e5d --- /dev/null +++ b/tests/e2e/interactions/helpers/get-test-cases.ts @@ -0,0 +1,60 @@ +/// + +import * as fs from 'fs'; +import * as path from 'path'; + +export interface TestCase { + name: string; + caseContent: string; +} + +const testCasesDir = path.join(__dirname, '..', 'test-cases'); + +function extractTestCaseName(fileName: string): string | null { + const match = /^([^.].+)\.js$/.exec(path.basename(fileName)); + return match && match[1]; +} + +function isTestCaseFile(filePath: string): boolean { + return fs.lstatSync(filePath).isFile() && extractTestCaseName(filePath) !== null; +} + +interface TestCasesGroupInfo { + name: string; + path: string; +} + +function getTestCaseGroups(): TestCasesGroupInfo[] { + return [ + { + name: '', + path: testCasesDir, + }, + ...fs.readdirSync(testCasesDir) + .filter((filePath: string) => fs.lstatSync(path.join(testCasesDir, filePath)).isDirectory()) + .map((filePath: string) => { + return { + name: filePath, + path: path.join(testCasesDir, filePath), + }; + }), + ]; +} + +export function getTestCases(): Record { + const result: Record = {}; + + for (const group of getTestCaseGroups()) { + result[group.name] = fs.readdirSync(group.path) + .map((filePath: string) => path.join(group.path, filePath)) + .filter(isTestCaseFile) + .map((testCaseFile: string) => { + return { + name: extractTestCaseName(testCaseFile) as string, + caseContent: fs.readFileSync(testCaseFile, { encoding: 'utf-8' }), + }; + }); + } + + return result; +} diff --git a/tests/e2e/interactions/helpers/test-page-dummy.html b/tests/e2e/interactions/helpers/test-page-dummy.html new file mode 100644 index 0000000000..f7b010d1e8 --- /dev/null +++ b/tests/e2e/interactions/helpers/test-page-dummy.html @@ -0,0 +1,23 @@ + + + + + + Test case page + + + +
+ + + + + + + + diff --git a/tests/e2e/interactions/interactions-test-cases.ts b/tests/e2e/interactions/interactions-test-cases.ts new file mode 100644 index 0000000000..9e3a745344 --- /dev/null +++ b/tests/e2e/interactions/interactions-test-cases.ts @@ -0,0 +1,171 @@ +/// + +import * as fs from 'fs'; +import * as path from 'path'; + +import { expect } from 'chai'; +import { describe, it } from 'mocha'; +import puppeteer, { + Browser, + HTTPResponse, + launch as launchPuppeteer, +} from 'puppeteer'; + +import { doMouseScroll } from '../helpers/mouse-scroll-actions'; + +import { getTestCases, TestCase } from './helpers/get-test-cases'; + +const dummyContent = fs.readFileSync( + path.join(__dirname, 'helpers', 'test-page-dummy.html'), + { encoding: 'utf-8' } +); + +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] || ''; + +type Interaction = 'scrollLeft' | 'scrollRight' | 'scrollUp' | 'scrollDown'; + +interface InternalWindow { + interactions: Interaction[]; + finishedSetup: Promise<() => void>; + afterInteractions: () => void; +} + +describe('Interactions tests', function(): void { + // this tests are unstable sometimes. + this.retries(5); + + const puppeteerOptions: Parameters[0] = {}; + 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); + + // note that we cannot use launchPuppeteer here as soon it wrong typing in puppeteer + // see https://github.com/puppeteer/puppeteer/issues/7529 + const browserPromise = puppeteer.launch(puppeteerOptions); + browser = await browserPromise; + }); + + let testCaseCount = 0; + + const runTestCase = (testCase: TestCase) => { + testCaseCount += 1; + it(testCase.name, async () => { + const pageContent = generatePageContent( + testStandalonePath, + testCase.caseContent + ); + + const page = await browser.newPage(); + await page.setViewport({ width: 600, height: 600 }); + + 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()}` + ); + } + }); + + await page.setContent(pageContent, { waitUntil: 'load' }); + + await page.evaluate(() => { + return (window as unknown as InternalWindow).finishedSetup; + }); + + const interactionsToPerform = await page.evaluate(() => { + return (window as unknown as InternalWindow).interactions; + }); + + for (const interactionName of interactionsToPerform) { + switch (interactionName) { + case 'scrollLeft': + await doMouseScroll({ x: -10.0 }, page); + break; + case 'scrollRight': + await doMouseScroll({ x: 10.0 }, page); + break; + case 'scrollDown': + await doMouseScroll({ y: 10.0 }, page); + break; + case 'scrollUp': + await doMouseScroll({ y: -10.0 }, page); + break; + default: + // eslint-disable-next-line no-case-declarations + const exhaustiveCheck: never = interactionName; + throw new Error(exhaustiveCheck); + } + } + + await page.evaluate(() => { + return new Promise((resolve: () => void) => { + (window as unknown as InternalWindow).afterInteractions(); + window.requestAnimationFrame(() => { + setTimeout(resolve, 50); + }); + }); + }); + + if (errors.length !== 0) { + throw new Error(`Page has errors:\n${errors.join('\n')}`); + } + + expect(errors.length).to.be.equal( + 0, + 'There should not be any errors thrown within the test page.' + ); + }); + }; + + const testCaseGroups = getTestCases(); + + for (const groupName of Object.keys(testCaseGroups)) { + if (groupName.length === 0) { + for (const testCase of testCaseGroups[groupName]) { + runTestCase(testCase); + } + } else { + describe(groupName, () => { + for (const testCase of testCaseGroups[groupName]) { + runTestCase(testCase); + } + }); + } + } + + it('number of test cases', () => { + // we need to have at least 1 test to check it + expect(testCaseCount).to.be.greaterThan( + 0, + 'there should be at least 1 test case' + ); + }); + + after(async () => { + await browser.close(); + }); +}); diff --git a/tests/e2e/interactions/runner.js b/tests/e2e/interactions/runner.js new file mode 100644 index 0000000000..a2c5289e97 --- /dev/null +++ b/tests/e2e/interactions/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.composite.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, './interactions-test-cases.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); diff --git a/tests/e2e/interactions/test-cases/.eslintrc.js b/tests/e2e/interactions/test-cases/.eslintrc.js new file mode 100644 index 0000000000..2459a86d2d --- /dev/null +++ b/tests/e2e/interactions/test-cases/.eslintrc.js @@ -0,0 +1,14 @@ +/* eslint-env node */ + +module.exports = { + env: { + browser: true, + node: false, + }, + rules: { + 'no-unused-vars': ['error', { varsIgnorePattern: '^(beforeInteractions|afterInteractions|interactionsToPerform)$', args: 'none' }], + }, + globals: { + LightweightCharts: false, + }, +}; diff --git a/tests/e2e/interactions/test-cases/mouse-wheel/default-scroll-handler.js b/tests/e2e/interactions/test-cases/mouse-wheel/default-scroll-handler.js new file mode 100644 index 0000000000..8f8184edc1 --- /dev/null +++ b/tests/e2e/interactions/test-cases/mouse-wheel/default-scroll-handler.js @@ -0,0 +1,47 @@ +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 interactionsToPerform() { + return ['scrollLeft', 'scrollDown']; +} + +let chart; +let startRange; + +function beforeInteractions(container) { + chart = LightweightCharts.createChart(container); + + const mainSeries = chart.addLineSeries(); + + mainSeries.setData(generateData()); + + return new Promise(resolve => { + requestAnimationFrame(() => { + startRange = chart.timeScale().getVisibleLogicalRange(); + resolve(); + }); + }); +} + +function afterInteractions() { + const endRange = chart.timeScale().getVisibleLogicalRange(); + + const pass = Boolean(startRange.from !== endRange.from && startRange.to !== endRange.to); + + if (!pass) { + throw new Error('Expected visible logical range to have changed.'); + } + + return Promise.resolve(); +} diff --git a/tests/e2e/interactions/test-cases/mouse-wheel/disabled-scroll-handler.js b/tests/e2e/interactions/test-cases/mouse-wheel/disabled-scroll-handler.js new file mode 100644 index 0000000000..313356fc1e --- /dev/null +++ b/tests/e2e/interactions/test-cases/mouse-wheel/disabled-scroll-handler.js @@ -0,0 +1,54 @@ +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 interactionsToPerform() { + return ['scrollLeft', 'scrollDown']; +} + +let chart; +let startRange; + +function beforeInteractions(container) { + chart = LightweightCharts.createChart(container, { + handleScroll: { + mouseWheel: false, + }, + handleScale: { + mouseWheel: false, + }, + }); + + const mainSeries = chart.addLineSeries(); + + mainSeries.setData(generateData()); + + return new Promise(resolve => { + requestAnimationFrame(() => { + startRange = chart.timeScale().getVisibleLogicalRange(); + resolve(); + }); + }); +} + +function afterInteractions() { + const endRange = chart.timeScale().getVisibleLogicalRange(); + + const pass = Boolean(startRange.from === endRange.from && startRange.to === endRange.to); + + if (!pass) { + throw new Error('Expected visible logical range to be unchanged.'); + } + + return Promise.resolve(); +} diff --git a/tests/e2e/interactions/test-cases/mouse-wheel/enabled-scroll-handler.js b/tests/e2e/interactions/test-cases/mouse-wheel/enabled-scroll-handler.js new file mode 100644 index 0000000000..d5617e1e93 --- /dev/null +++ b/tests/e2e/interactions/test-cases/mouse-wheel/enabled-scroll-handler.js @@ -0,0 +1,54 @@ +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 interactionsToPerform() { + return ['scrollLeft', 'scrollDown']; +} + +let chart; +let startRange; + +function beforeInteractions(container) { + chart = LightweightCharts.createChart(container, { + handleScroll: { + mouseWheel: true, + }, + handleScale: { + mouseWheel: true, + }, + }); + + const mainSeries = chart.addLineSeries(); + + mainSeries.setData(generateData()); + + return new Promise(resolve => { + requestAnimationFrame(() => { + startRange = chart.timeScale().getVisibleLogicalRange(); + resolve(); + }); + }); +} + +function afterInteractions() { + const endRange = chart.timeScale().getVisibleLogicalRange(); + + const pass = Boolean(startRange.from !== endRange.from && startRange.to !== endRange.to); + + if (!pass) { + throw new Error('Expected visible logical range to have changed.'); + } + + return Promise.resolve(); +} diff --git a/tests/e2e/interactions/test-cases/mouse-wheel/reenabled-scroll-handler.js b/tests/e2e/interactions/test-cases/mouse-wheel/reenabled-scroll-handler.js new file mode 100644 index 0000000000..edee3c2566 --- /dev/null +++ b/tests/e2e/interactions/test-cases/mouse-wheel/reenabled-scroll-handler.js @@ -0,0 +1,62 @@ +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 interactionsToPerform() { + return ['scrollLeft', 'scrollDown']; +} + +let chart; +let startRange; + +function beforeInteractions(container) { + chart = LightweightCharts.createChart(container, { + handleScroll: { + mouseWheel: false, + }, + handleScale: { + mouseWheel: false, + }, + }); + + const mainSeries = chart.addLineSeries(); + + mainSeries.setData(generateData()); + + return new Promise(resolve => { + requestAnimationFrame(() => { + chart.applyOptions({ + handleScroll: { + mouseWheel: true, + }, + handleScale: { + mouseWheel: true, + }, + }); + startRange = chart.timeScale().getVisibleLogicalRange(); + resolve(); + }); + }); +} + +function afterInteractions() { + const endRange = chart.timeScale().getVisibleLogicalRange(); + + const pass = Boolean(startRange.from !== endRange.from && startRange.to !== endRange.to); + + if (!pass) { + throw new Error('Expected visible logical range to have changed.'); + } + + return Promise.resolve(); +} diff --git a/tests/e2e/tsconfig.composite.json b/tests/e2e/tsconfig.composite.json index b4c16e15bf..6c70a50f22 100644 --- a/tests/e2e/tsconfig.composite.json +++ b/tests/e2e/tsconfig.composite.json @@ -13,6 +13,8 @@ "./coverage/**/*.ts", "./graphics/graphics-test-cases.ts", "./graphics/helpers/**/*.ts", - "./memleaks/**/*.ts" + "./memleaks/**/*.ts", + "./interactions/**/*.ts", + "./helpers/**/*.ts" ] }