diff --git a/.github/workflows/push_tests.yml b/.github/workflows/push_tests.yml index 0e84a43b..0ccbde78 100644 --- a/.github/workflows/push_tests.yml +++ b/.github/workflows/push_tests.yml @@ -4,7 +4,7 @@ on: push: branches: master pull_request: - branches: '*' + branches: "*" env: JEST_ENV: prod @@ -32,7 +32,7 @@ jobs: run: | pip install flake8 cd client - npm i "eslint" "eslint-config-airbnb" "eslint-config-prettier" "eslint-loader" "eslint-plugin-filenames" "eslint-plugin-import" "eslint-plugin-jest" "eslint-plugin-jsx-a11y" "eslint-plugin-react" "eslint-plugin-react-hooks" "eslint-plugin-prettier" + npm install - name: Lint with flake8 run: | make lint-server @@ -41,7 +41,6 @@ jobs: run: | make lint - unit-test: runs-on: ubuntu-latest steps: diff --git a/.gitignore b/.gitignore index 4f93ac98..cb29f839 100644 --- a/.gitignore +++ b/.gitignore @@ -19,11 +19,7 @@ venv/ cellxgene/ # client build -server/common/web/static/css/ -server/common/web/static/img/ -server/common/web/static/media/ -server/common/web/static/fonts/ -server/common/web/static/js/ +server/common/web/static/* server/common/web/templates/ server/common/web/csp-hashes.json @@ -53,3 +49,6 @@ docs/_site/ docs/Gemfile.lock client/.eslintcache + +# E2E Testing +ignoreE2E* diff --git a/client/Makefile b/client/Makefile index 3a9acb5b..5033053f 100644 --- a/client/Makefile +++ b/client/Makefile @@ -7,6 +7,7 @@ ANNOTATIONS_FILENAME := $(shell basename $(ANNOTATIONS)) .PHONY: clean clean: rm -rf node_modules + rm -f __tests__/screenshots/*.png .PHONY: ci ci: @@ -21,39 +22,20 @@ WEBPACK_CONFIG ?= configuration/webpack/webpack.config.prod.js build: npm run build $(WEBPACK_CONFIG) -# Formatting code - -.PHONY: lint -lint: - npx eslint ./src/ - # Development convenience methods .PHONY: start-frontend start-frontend: node server/development.js -.PHONY: e2e -e2e: - node node_modules/jest/bin/jest.js \ - --verbose false \ - --config __tests__/e2e/e2eJestConfig.json \ - e2e/e2e.test.js - -.PHONY: e2e-annotations -e2e-annotations: - node node_modules/jest/bin/jest.js \ - --verbose false \ - --config __tests__/e2e/e2eJestConfig.json \ - e2e/e2eAnnotations.test.js - +# start an instance of cellxgene and run the end-to-end tests .PHONY: smoke-test smoke-test: start_server_and_test \ 'CXG_OPTIONS="--disable-annotations" $(MAKE) start-server' \ $(CXG_SERVER_PORT) \ - '$(MAKE) e2e' - + 'CXG_URL_BASE="http://localhost:$(CXG_SERVER_PORT)" npm run e2e -- --verbose false' +# start an instance of cellxgene and run the end-to-end annotations tests .PHONY: smoke-test-annotations smoke-test-annotations: $(eval TMP_DIR := $(shell mktemp -d /tmp/cellxgene_XXXXXX)) @@ -61,13 +43,12 @@ smoke-test-annotations: start_server_and_test \ 'CXG_OPTIONS="--annotations-file $(TMP_DIR)/$(ANNOTATIONS_FILENAME)" $(MAKE) start-server' \ $(CXG_SERVER_PORT) \ - '$(MAKE) e2e-annotations' + 'CXG_URL_BASE="http://localhost:$(CXG_SERVER_PORT)" npm run e2e-annotations -- --verbose false' rm -rf $(TMP_DIR) .PHONY: unit-test unit-test: - node node_modules/jest/bin/jest.js \ - --testPathIgnorePatterns e2e + node node_modules/jest/bin/jest.js --testPathIgnorePatterns e2e # pass remaining commands through to npm run %: diff --git a/client/__tests__/e2e/__snapshots__/e2e.test.js.snap b/client/__tests__/e2e/__snapshots__/e2e.test.js.snap index 6c411b0f..a81872c6 100644 --- a/client/__tests__/e2e/__snapshots__/e2e.test.js.snap +++ b/client/__tests__/e2e/__snapshots__/e2e.test.js.snap @@ -2,4 +2,4 @@ exports[`did launch page launched 1`] = `"pbmc3kc3k"`; -exports[`metadata loads categories and values from dataset appear 1`] = `"
louvainvain
tint
"`; +exports[`metadata loads categories and values from dataset appear 1`] = `"
louvainvain
tint
"`; diff --git a/client/__tests__/e2e/__snapshots__/e2eAnnotations.test.js.snap b/client/__tests__/e2e/__snapshots__/e2eAnnotations.test.js.snap new file mode 100644 index 00000000..a16e2dcb --- /dev/null +++ b/client/__tests__/e2e/__snapshots__/e2eAnnotations.test.js.snap @@ -0,0 +1,15 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`annotations stacked bar graph renders 1`] = ` +Array [ + "
TEST-LABELLABEL
0
", + "
unassignedigned
2132
", +] +`; + +exports[`annotations stacked bar graph renders 2`] = ` +Array [ + "
TEST-LABELLABEL
0
", + "
unassignedigned
2638
", +] +`; diff --git a/client/__tests__/e2e/cellxgeneActions.js b/client/__tests__/e2e/cellxgeneActions.js index 09f80138..eee4714c 100644 --- a/client/__tests__/e2e/cellxgeneActions.js +++ b/client/__tests__/e2e/cellxgeneActions.js @@ -1,228 +1,315 @@ +/* eslint-disable no-await-in-loop -- await in loop is needed to emulate sequential user actions */ import { strict as assert } from "assert"; +import { + clearInputAndTypeInto, + clickOn, + getAllByClass, + getOneElementInnerText, + typeInto, + waitByID, + waitByClass, + waitForAllByIds, + clickOnUntil, + getTestClass, + getTestId, + isElementPresent, +} from "./puppeteerUtils"; -const cellxgeneActions = (page, utils) => ({ - async drag(testId, start, end, lasso = false) { - const layout = await utils.waitByID(testId); - const elBox = await layout.boxModel(); - const x1 = elBox.content[0].x + start.x; - const x2 = elBox.content[0].x + end.x; - const y1 = elBox.content[0].y + start.y; - const y2 = elBox.content[0].y + end.y; +export async function drag(testId, start, end, lasso = false) { + const layout = await waitByID(testId); + const elBox = await layout.boxModel(); + const x1 = elBox.content[0].x + start.x; + const x2 = elBox.content[0].x + end.x; + const y1 = elBox.content[0].y + start.y; + const y2 = elBox.content[0].y + end.y; + await page.mouse.move(x1, y1); + await page.mouse.down(); + if (lasso) { + await page.mouse.move(x2, y1); + await page.mouse.move(x2, y2); + await page.mouse.move(x1, y2); await page.mouse.move(x1, y1); - await page.mouse.down(); - if (lasso) { - await page.mouse.move(x2, y1); - await page.mouse.move(x2, y2); - await page.mouse.move(x1, y2); - await page.mouse.move(x1, y1); - } else { - await page.mouse.move(x2, y2); - } - await page.mouse.up(); - }, + } else { + await page.mouse.move(x2, y2); + } + await page.mouse.up(); +} - async clickOnCoordinate(testId, coord) { - const layout = await utils.waitByID(testId); - const elBox = await layout.boxModel(); - const x = elBox.content[0].x + coord.x; - const y = elBox.content[0].y + coord.y; - await page.mouse.click(x, y); - }, +export async function clickOnCoordinate(testId, coord) { + const layout = await expect(page).toMatchElement(getTestId(testId)); + const elBox = await layout.boxModel(); - async getAllHistograms(testclass, testIds) { - const histTestIds = testIds.map((tid) => `histogram-${tid}`); - // these load asynchronously, so we need to wait for each histogram individually, - // and they may be quite slow in some cases. - await utils.waitForAllByIds(histTestIds, { timeout: 240000 }); - const allHistograms = await utils.getAllByClass(testclass); - return allHistograms.map((hist) => hist.replace(/^histogram-/, "")); - }, + if (!elBox) { + throw Error("Layout's boxModel is not available!"); + } - async getAllCategoriesAndCounts(category) { - // these load asynchronously, so we have to wait for the specific category. - await utils.waitByID(`category-${category}`); - return page.$$eval( - `[data-testid="category-${category}"] [data-testclass='categorical-row']`, - (rows) => - Object.fromEntries( - rows.map((row) => { - const cat = row - .querySelector("[data-testclass='categorical-value']") - .getAttribute("aria-label"); + const x = elBox.content[0].x + coord.x; + const y = elBox.content[0].y + coord.y; + await page.mouse.click(x, y); +} - const count = row.querySelector( - "[data-testclass='categorical-value-count']" - ).innerText; - return [cat, count]; - }) - ) +export async function getAllHistograms(testclass, testIds) { + const histTestIds = testIds.map((tid) => `histogram-${tid}`); + + // these load asynchronously, so we need to wait for each histogram individually, + // and they may be quite slow in some cases. + await waitForAllByIds(histTestIds, { timeout: 4 * 60 * 1000 }); + + const allHistograms = await getAllByClass(testclass); + + const testIDs = await Promise.all( + allHistograms.map((hist) => { + return page.evaluate((elem) => { + return elem.dataset.testid; + }, hist); + }) + ); + + return testIDs.map((id) => id.replace(/^histogram-/, "")); +} + +export async function getAllCategoriesAndCounts(category) { + // these load asynchronously, so we have to wait for the specific category. + await waitByID(`category-${category}`); + + return page.$$eval( + `[data-testid="category-${category}"] [data-testclass='categorical-row']`, + (rows) => + Object.fromEntries( + rows.map((row) => { + const cat = row + .querySelector("[data-testclass='categorical-value']") + .getAttribute("aria-label"); + + const count = row.querySelector( + "[data-testclass='categorical-value-count']" + ).innerText; + + return [cat, count]; + }) + ) + ); +} + +export async function getCellSetCount(num) { + await clickOn(`cellset-button-${num}`); + return getOneElementInnerText(`[data-testid='cellset-count-${num}']`); +} + +export async function resetCategory(category) { + const checkboxId = `${category}:category-select`; + await waitByID(checkboxId); + const checkedPseudoclass = await page.$eval( + `[data-testid='${checkboxId}']`, + (el) => el.matches(":checked") + ); + if (!checkedPseudoclass) await clickOn(checkboxId); + + const categoryRow = await waitByID(`${category}:category-expand`); + + const isExpanded = await categoryRow.$( + "[data-testclass='category-expand-is-expanded']" + ); + + if (isExpanded) await clickOn(`${category}:category-expand`); +} + +export async function calcCoordinate(testId, xAsPercent, yAsPercent) { + const el = await waitByID(testId); + const size = await el.boxModel(); + return { + x: Math.floor(size.width * xAsPercent), + y: Math.floor(size.height * yAsPercent), + }; +} + +export async function calcDragCoordinates(testId, coordinateAsPercent) { + return { + start: await calcCoordinate( + testId, + coordinateAsPercent.x1, + coordinateAsPercent.y1 + ), + end: await calcCoordinate( + testId, + coordinateAsPercent.x2, + coordinateAsPercent.y2 + ), + }; +} + +export async function selectCategory(category, values, reset = true) { + if (reset) await resetCategory(category); + + await clickOn(`${category}:category-expand`); + await clickOn(`${category}:category-select`); + + for (const value of values) { + await clickOn(`categorical-value-select-${category}-${value}`); + } +} + +export async function expandCategory(category) { + const expand = await waitByID(`${category}:category-expand`); + const notExpanded = await expand.$( + "[data-testclass='category-expand-is-not-expanded']" + ); + if (notExpanded) await clickOn(`${category}:category-expand`); +} + +export async function clip(min = 0, max = 100) { + await clickOn("visualization-settings"); + await clearInputAndTypeInto("clip-min-input", min); + await clearInputAndTypeInto("clip-max-input", max); + await clickOn("clip-commit"); +} + +export async function createCategory(categoryName) { + await clickOnUntil("open-annotation-dialog", async () => { + await expect(page).toMatchElement(getTestId("new-category-name")); + }); + + await typeInto("new-category-name", categoryName); + await clickOn("submit-category"); +} + +export async function duplicateCategory(categoryName) { + await clickOn("open-annotation-dialog"); + + await typeInto("new-category-name", categoryName); + + const dropdownOptionClass = "duplicate-category-dropdown-option"; + + await clickOnUntil("duplicate-category-dropdown", async () => { + await expect(page).toMatchElement(getTestClass(dropdownOptionClass)); + }); + + const option = await expect(page).toMatchElement( + getTestClass(dropdownOptionClass) + ); + + await option.click(); + + await clickOnUntil("submit-category", async () => { + await expect(page).toMatchElement( + getTestId(`${categoryName}:category-expand`) ); - }, + }); - async cellSet(num) { - await utils.clickOn(`cellset-button-${num}`); - return utils.getOneElementInnerText(`[data-testid='cellset-count-${num}']`); - }, + await waitByClass("autosave-complete"); +} - async resetCategory(category) { - const checkboxId = `${category}:category-select`; - await utils.waitByID(checkboxId); - const checkedPseudoclass = await page.$eval( - `[data-testid='${checkboxId}']`, - (el) => el.matches(":checked") - ); - if (!checkedPseudoclass) await utils.clickOn(checkboxId); - try { - const categoryRow = await utils.waitByID(`${category}:category-expand`); - const isExpanded = await categoryRow.$( - "[data-testclass='category-expand-is-expanded']" - ); - if (isExpanded) await utils.clickOn(`${category}:category-expand`); - } catch {} - }, +export async function renameCategory(oldCategoryName, newCategoryName) { + await clickOn(`${oldCategoryName}:see-actions`); + await clickOn(`${oldCategoryName}:edit-category-mode`); + await clearInputAndTypeInto( + `${oldCategoryName}:edit-category-name-text`, + newCategoryName + ); + await clickOn(`${oldCategoryName}:submit-category-edit`); +} - async calcCoordinate(testId, xAsPercent, yAsPercent) { - const el = await utils.waitByID(testId); - const size = await el.boxModel(); - return { - x: Math.floor(size.width * xAsPercent), - y: Math.floor(size.height * yAsPercent), - }; - }, +export async function deleteCategory(categoryName) { + const targetId = `${categoryName}:delete-category`; - async calcDragCoordinates(testId, coordinateAsPercent) { - return { - start: await this.calcCoordinate( - testId, - coordinateAsPercent.x1, - coordinateAsPercent.y1 - ), - end: await this.calcCoordinate( - testId, - coordinateAsPercent.x2, - coordinateAsPercent.y2 - ), - }; - }, + await clickOnUntil(`${categoryName}:see-actions`, async () => { + await expect(page).toMatchElement(getTestId(targetId)); + }); - async selectCategory(category, values, reset = true) { - if (reset) await this.resetCategory(category); - await utils.clickOn(`${category}:category-expand`); - await utils.clickOn(`${category}:category-select`); - for (const val of values) { - await utils.clickOn(`categorical-value-select-${category}-${val}`); - } - }, + await clickOn(targetId); - async expandCategory(category) { - const expand = await utils.waitByID(`${category}:category-expand`); - const notExpanded = await expand.$( - "[data-testclass='category-expand-is-not-expanded']" - ); - if (notExpanded) await utils.clickOn(`${category}:category-expand`); - }, + await assertCategoryDoesNotExist(); +} - async clip(min = 0, max = 100) { - await utils.clickOn("visualization-settings"); - await utils.clearInputAndTypeInto("clip-min-input", min); - await utils.clearInputAndTypeInto("clip-max-input", max); - await utils.clickOn("clip-commit"); - }, +export async function createLabel(categoryName, labelName) { + /** + * (thuang): This explicit wait is needed, since currently showing + * the modal again quickly after the previous action dismissing the + * modal will persist the input value from the previous action. + * + * To reproduce: + * 1. Click on the plus sign to show the modal to add a new label to the category + * 2. Type `123` in the input box + * 3. Hover over your mouse over the plus sign and double click to quickly dismiss and + * invoke the modal again + * 4. You will see `123` is persisted in the input box + * 5. Expected behavior is to get an empty input box + */ + await page.waitFor(500); - async createCategory(categoryName) { - await utils.clickOn("open-annotation-dialog"); - await utils.typeInto("new-category-name", categoryName); - await utils.clickOn("submit-category"); - }, + await clickOn(`${categoryName}:see-actions`); - async renameCategory(oldCatgoryName, newCategoryName) { - await utils.clickOn(`${oldCatgoryName}:see-actions`); - await utils.clickOn(`${oldCatgoryName}:edit-category-mode`); - await utils.clearInputAndTypeInto( - `${oldCatgoryName}:edit-category-name-text`, - newCategoryName - ); - await utils.clickOn(`${oldCatgoryName}:submit-category-edit`); - }, + await clickOn(`${categoryName}:add-new-label-to-category`); - async deleteCategory(categoryName) { - await utils.clickOn(`${categoryName}:see-actions`); - await utils.clickOn(`${categoryName}:delete-category`); - }, + await typeInto(`${categoryName}:new-label-name`, labelName); - async createLabel(categoryName, labelName) { - await utils.clickOn(`${categoryName}:see-actions`); - await utils.clickOn(`${categoryName}:add-new-label-to-category`); - await utils.typeInto(`${categoryName}:new-label-name`, labelName); - await utils.clickOn(`${categoryName}:submit-label`); - }, + await clickOn(`${categoryName}:submit-label`); +} - async deleteLabel(categoryName, labelName) { - await this.expandCategory(categoryName); - await utils.clickOn(`${categoryName}:${labelName}:see-actions`); - await utils.clickOn(`${categoryName}:${labelName}:delete-label`); - }, +export async function deleteLabel(categoryName, labelName) { + await expandCategory(categoryName); + await clickOn(`${categoryName}:${labelName}:see-actions`); + await clickOn(`${categoryName}:${labelName}:delete-label`); +} - async renameLabel(categoryName, oldLabelName, newLabelName) { - await this.expandCategory(categoryName); - await utils.clickOn(`${categoryName}:${oldLabelName}:see-actions`); - await utils.clickOn(`${categoryName}:${oldLabelName}:edit-label`); - await utils.clearInputAndTypeInto( - `${categoryName}:${oldLabelName}:edit-label-name`, - newLabelName - ); - await utils.clickOn(`${categoryName}:${oldLabelName}:submit-label-edit`); - }, +export async function renameLabel(categoryName, oldLabelName, newLabelName) { + await expandCategory(categoryName); + await clickOn(`${categoryName}:${oldLabelName}:see-actions`); + await clickOn(`${categoryName}:${oldLabelName}:edit-label`); + await clearInputAndTypeInto( + `${categoryName}:${oldLabelName}:edit-label-name`, + newLabelName + ); + await clickOn(`${categoryName}:${oldLabelName}:submit-label-edit`); +} - async addGeneToSearch(geneName) { - await utils.typeInto("gene-search", geneName); - await page.keyboard.press("Enter"); - await page.waitForSelector(`[data-testid='histogram-${geneName}']`); - }, +export async function addGeneToSearch(geneName) { + await typeInto("gene-search", geneName); + await page.keyboard.press("Enter"); + await page.waitForSelector(`[data-testid='histogram-${geneName}']`); +} - async subset(coordinatesAsPercent) { - // In order to deselect the selection after the subset, make sure we have some clear part - // of the scatterplot we can click on - assert(coordinatesAsPercent.x2 < 0.99 || coordinatesAsPercent.y2 < 0.99); - const lassoSelection = await this.calcDragCoordinates( - "layout-graph", - coordinatesAsPercent - ); - await this.drag( - "layout-graph", - lassoSelection.start, - lassoSelection.end, - true - ); - await utils.clickOn("subset-button"); - const clearCoordinate = await this.calcCoordinate( - "layout-graph", - 0.5, - 0.99 - ); - await this.clickOnCoordinate("layout-graph", clearCoordinate); - }, +export async function subset(coordinatesAsPercent) { + // In order to deselect the selection after the subset, make sure we have some clear part + // of the scatterplot we can click on + assert(coordinatesAsPercent.x2 < 0.99 || coordinatesAsPercent.y2 < 0.99); + const lassoSelection = await calcDragCoordinates( + "layout-graph", + coordinatesAsPercent + ); + await drag("layout-graph", lassoSelection.start, lassoSelection.end, true); + await clickOn("subset-button"); + const clearCoordinate = await calcCoordinate("layout-graph", 0.5, 0.99); + await clickOnCoordinate("layout-graph", clearCoordinate); +} - async setSellSet(cellSet, cellSetNum) { - for (const selection of cellSet.filter( - (sel) => sel.kind === "categorical" - )) { - await this.selectCategory(selection.metadata, selection.values, true); - } - await this.cellSet(cellSetNum); - }, +export async function setSellSet(cellSet, cellSetNum) { + const selections = cellSet.filter((sel) => sel.kind === "categorical"); - async runDiffExp(cellSet1, cellSet2) { - await this.setSellSet(cellSet1, 1); - await this.setSellSet(cellSet2, 2); - await utils.clickOn("diffexp-button"); - }, + for (const selection of selections) { + await selectCategory(selection.metadata, selection.values, true); + } - async bulkAddGenes(geneNames) { - await utils.clickOn("section-bulk-add"); - await utils.typeInto("input-bulk-add", geneNames.join(",")); - await page.keyboard.press("Enter"); - }, -}); + await getCellSetCount(cellSetNum); +} -export default cellxgeneActions; +export async function runDiffExp(cellSet1, cellSet2) { + await setSellSet(cellSet1, 1); + await setSellSet(cellSet2, 2); + await clickOn("diffexp-button"); +} + +export async function bulkAddGenes(geneNames) { + await clickOn("section-bulk-add"); + await typeInto("input-bulk-add", geneNames.join(",")); + await page.keyboard.press("Enter"); +} + +export async function assertCategoryDoesNotExist(categoryName) { + const result = await isElementPresent( + getTestId(`${categoryName}:category-label`) + ); + + await expect(result).toBe(false); +} +/* eslint-enable no-await-in-loop -- await in loop is needed to emulate sequential user actions */ diff --git a/client/__tests__/e2e/config.js b/client/__tests__/e2e/config.js index dc7f47e5..4222a103 100644 --- a/client/__tests__/e2e/config.js +++ b/client/__tests__/e2e/config.js @@ -1,11 +1,8 @@ -export const jestEnv = process.env.JEST_ENV; -export const appPort = process.env.CXG_SERVER_PORT; -export const appUrlBase = - process.env.CXG_URL_BASE || `http://localhost:${appPort}`; -export const DEV = jestEnv === "dev"; -export const DEBUG = jestEnv === "debug"; -export const DATASET = "pbmc3k"; +import * as ENV_DEFAULT from "../../../environment.default.json"; -if (DEBUG) jest.setTimeout(2 * 60 * 1000); -if (DEV) jest.setTimeout(30 * 1000); -if (!DEBUG && !DEV) jest.setTimeout(10 * 1000); +export const jestEnv = process.env.JEST_ENV || ENV_DEFAULT.JEST_ENV; +export const appUrlBase = + process.env.CXG_URL_BASE || `http://localhost:${ENV_DEFAULT.CXG_CLIENT_PORT}`; +export const DATASET = "pbmc3k"; +export const isDev = jestEnv === ENV_DEFAULT.DEV; +export const isDebug = jestEnv === ENV_DEFAULT.DEBUG; diff --git a/client/__tests__/e2e/data.js b/client/__tests__/e2e/data.js index e0fda7a8..b7f8db77 100644 --- a/client/__tests__/e2e/data.js +++ b/client/__tests__/e2e/data.js @@ -27,7 +27,7 @@ export const datasets = { lasso: [ { "coordinates-as-percent": { x1: 0.1, y1: 0.25, x2: 0.7, y2: 0.75 }, - count: "1173" + count: "1173", }, ], categorical: [ @@ -111,7 +111,7 @@ export const datasets = { panzoom: { lasso: { "coordinates-as-percent": { x1: 0.3, y1: 0.3, x2: 0.5, y2: 0.5 }, - count: "24", + count: "38", }, }, }, diff --git a/client/__tests__/e2e/e2e.test.js b/client/__tests__/e2e/e2e.test.js index bf5bba59..5627a105 100644 --- a/client/__tests__/e2e/e2e.test.js +++ b/client/__tests__/e2e/e2e.test.js @@ -1,62 +1,69 @@ -/* -Smoke test suite that will be run in Travis CI - -Tests included in this file are expected to be relatively stable and test core features +/** + * Smoke test suite that will be run in Travis CI + * Tests included in this file are expected to be relatively stable and test core features */ -import { appUrlBase, DATASET, DEBUG } from "./config"; -import setupTestBrowser from "./testBrowser"; + +/* eslint-disable no-await-in-loop -- await in loop is needed to emulate sequential user actions */ +import { appUrlBase, DATASET } from "./config"; + import { datasets } from "./data"; -let browser; -let page; -let utils; -let cxgActions; +import { + clickOn, + getAllByClass, + getElementCoordinates, + getOneElementInnerHTML, + getTestId, + goToPage, + typeInto, + waitByID, +} from "./puppeteerUtils"; + +import { + addGeneToSearch, + bulkAddGenes, + calcDragCoordinates, + clip, + drag, + getAllCategoriesAndCounts, + getAllHistograms, + getCellSetCount, + runDiffExp, + selectCategory, + subset, +} from "./cellxgeneActions"; + const data = datasets[DATASET]; -beforeAll(async () => { - [browser, page, utils, cxgActions] = await setupTestBrowser(); -}); - -beforeEach(async () => { - await page.goto(appUrlBase); -}); - -afterAll(() => { - if (!DEBUG && browser !== undefined) browser.close(); -}); - describe("did launch", () => { test("page launched", async () => { - const element = await utils.getOneElementInnerHTML( - "[data-testid='header']" - ); - expect(element).toMatchSnapshot(); - }); + await goToPage(appUrlBase); - test("terms of service, if they are there", async () => { - try { - await utils.clickOn("tos-cookies-accept", { timeout: 3000 }); - } catch { - console.warn("No terms of service footer detected."); - } - page.waitFor(50); // give the footer a chance to disappear - const result = await page.$("[data-testid='tos-cookies-accept']"); - expect(result).toBeNull(); + const element = await getOneElementInnerHTML(getTestId("header")); + + expect(element).toMatchSnapshot(); }); }); describe("metadata loads", () => { test("categories and values from dataset appear", async () => { - for (const label in data.categorical) { - const elem = await utils.getOneElementInnerHTML( - `[data-testid="category-${label}"]` + await goToPage(appUrlBase); + + for (const label of Object.keys(data.categorical)) { + const element = await getOneElementInnerHTML( + getTestId(`category-${label}`) ); - expect(elem).toMatchSnapshot(); - await utils.clickOn(`${label}:category-expand`); - const categories = await cxgActions.getAllCategoriesAndCounts(label); + + expect(element).toMatchSnapshot(); + + await clickOn(`${label}:category-expand`); + + const categories = await getAllCategoriesAndCounts(label); + expect(Object.keys(categories)).toMatchObject( Object.keys(data.categorical[label]) ); + expect(Object.values(categories)).toMatchObject( Object.values(data.categorical[label]) ); @@ -64,74 +71,98 @@ describe("metadata loads", () => { }); test("continuous data appears", async () => { - for (const label in data.continuous) { - await utils.waitByID(`histogram-${label}`); + await goToPage(appUrlBase); + + for (const label of Object.keys(data.continuous)) { + await waitByID(`histogram-${label}`); } }); }); describe("cell selection", () => { test("selects all cells cellset 1", async () => { - const cellCount = await cxgActions.cellSet(1); + await goToPage(appUrlBase); + + const cellCount = await getCellSetCount(1); expect(cellCount).toBe(data.dataframe.nObs); }); test("selects all cells cellset 2", async () => { - const cellCount = await cxgActions.cellSet(2); + await goToPage(appUrlBase); + + const cellCount = await getCellSetCount(2); expect(cellCount).toBe(data.dataframe.nObs); }); test("selects cells via lasso", async () => { + await goToPage(appUrlBase); + for (const cellset of data.cellsets.lasso) { - const cellset1 = await cxgActions.calcDragCoordinates( + const cellset1 = await calcDragCoordinates( "layout-graph", cellset["coordinates-as-percent"] ); - await cxgActions.drag("layout-graph", cellset1.start, cellset1.end, true); - const cellCount = await cxgActions.cellSet(1); + + await drag("layout-graph", cellset1.start, cellset1.end, true); + const cellCount = await getCellSetCount(1); expect(cellCount).toBe(cellset.count); } }); test("selects cells via categorical", async () => { + await goToPage(appUrlBase); + for (const cellset of data.cellsets.categorical) { - await utils.clickOn(`${cellset.metadata}:category-expand`); - await utils.clickOn(`${cellset.metadata}:category-select`); - for (const val of cellset.values) { - await utils.clickOn( - `categorical-value-select-${cellset.metadata}-${val}` - ); + await clickOn(`${cellset.metadata}:category-expand`); + await clickOn(`${cellset.metadata}:category-select`); + + for (const value of cellset.values) { + await clickOn(`categorical-value-select-${cellset.metadata}-${value}`); } - const cellCount = await cxgActions.cellSet(1); + + const cellCount = await getCellSetCount(1); + expect(cellCount).toBe(cellset.count); } }); test("selects cells via continuous", async () => { + await goToPage(appUrlBase); + for (const cellset of data.cellsets.continuous) { const histBrushableAreaId = `histogram-${cellset.metadata}-plot-brushable-area`; - const coords = await cxgActions.calcDragCoordinates( + + const coords = await calcDragCoordinates( histBrushableAreaId, cellset["coordinates-as-percent"] ); - await cxgActions.drag(histBrushableAreaId, coords.start, coords.end); - const cellCount = await cxgActions.cellSet(1); + + await drag(histBrushableAreaId, coords.start, coords.end); + + const cellCount = await getCellSetCount(1); + expect(cellCount).toBe(cellset.count); } }); }); describe("gene entry", () => { - test("search for single gene", async () => - cxgActions.addGeneToSearch(data.genes.search)); + test("search for single gene", async () => { + await goToPage(appUrlBase); + addGeneToSearch(data.genes.search); + }); test("bulk add genes", async () => { + await goToPage(appUrlBase); + const testGenes = data.genes.bulkadd; - await cxgActions.bulkAddGenes(testGenes); - const allHistograms = await cxgActions.getAllHistograms( + + await bulkAddGenes(testGenes); + const allHistograms = await getAllHistograms( "histogram-user-gene", testGenes ); + expect(allHistograms).toEqual(expect.arrayContaining(testGenes)); expect(allHistograms).toHaveLength(testGenes.length); }); @@ -139,31 +170,42 @@ describe("gene entry", () => { describe("differential expression", () => { test("selects cells, saves them and performs diffexp", async () => { - await cxgActions.runDiffExp(data.diffexp.cellset1, data.diffexp.cellset2); - const allHistograms = await cxgActions.getAllHistograms( + await goToPage(appUrlBase); + + await runDiffExp(data.diffexp.cellset1, data.diffexp.cellset2); + + const allHistograms = await getAllHistograms( "histogram-diffexp", data.diffexp["gene-results"] ); + expect(allHistograms).toEqual( expect.arrayContaining(data.diffexp["gene-results"]) ); + expect(allHistograms).toHaveLength(data.diffexp["gene-results"].length); }); }); describe("subset", () => { test("subset - cell count matches", async () => { + await goToPage(appUrlBase); + for (const select of data.subset.cellset1) { if (select.kind === "categorical") { - await cxgActions.selectCategory(select.metadata, select.values, true); + await selectCategory(select.metadata, select.values, true); } } - await utils.clickOn("subset-button"); - for (const label in data.subset.categorical) { - const categories = await cxgActions.getAllCategoriesAndCounts(label); + + await clickOn("subset-button"); + + for (const label of Object.keys(data.subset.categorical)) { + const categories = await getAllCategoriesAndCounts(label); + expect(Object.keys(categories)).toMatchObject( Object.keys(data.subset.categorical[label]) ); + expect(Object.values(categories)).toMatchObject( Object.values(data.subset.categorical[label]) ); @@ -171,79 +213,99 @@ describe("subset", () => { }); test("lasso after subset", async () => { + await goToPage(appUrlBase); + for (const select of data.subset.cellset1) { if (select.kind === "categorical") { - await cxgActions.selectCategory(select.metadata, select.values, true); + await selectCategory(select.metadata, select.values, true); } } - await utils.clickOn("subset-button"); - const lassoSelection = await cxgActions.calcDragCoordinates( + + await clickOn("subset-button"); + + const lassoSelection = await calcDragCoordinates( "layout-graph", data.subset.lasso["coordinates-as-percent"] ); - await cxgActions.drag( - "layout-graph", - lassoSelection.start, - lassoSelection.end, - true - ); - const cellCount = await cxgActions.cellSet(1); + + await drag("layout-graph", lassoSelection.start, lassoSelection.end, true); + + const cellCount = await getCellSetCount(1); expect(cellCount).toBe(data.subset.lasso.count); }); test("undo selection appends the top diff exp genes to user defined genes", async () => { + await goToPage(appUrlBase); + const userDefinedGenes = data.genes.bulkadd; const diffExpGenes = data.diffexp["gene-results"]; - await cxgActions.bulkAddGenes(userDefinedGenes); - const userDefinedHistograms = await cxgActions.getAllHistograms( + + await bulkAddGenes(userDefinedGenes); + const userDefinedHistograms = await getAllHistograms( "histogram-user-gene", userDefinedGenes ); + expect(userDefinedHistograms).toEqual( expect.arrayContaining(userDefinedGenes) ); - await cxgActions.subset({ x1: 0.15, y1: 0.1, x2: 0.98, y2: 0.98 }); - await cxgActions.runDiffExp(data.diffexp.cellset1, data.diffexp.cellset2); - const diffExpHistograms = await cxgActions.getAllHistograms( + + await subset({ x1: 0.15, y1: 0.1, x2: 0.98, y2: 0.98 }); + await runDiffExp(data.diffexp.cellset1, data.diffexp.cellset2); + + const diffExpHistograms = await getAllHistograms( "histogram-diffexp", diffExpGenes ); + expect(diffExpHistograms).toEqual(expect.arrayContaining(diffExpGenes)); - await utils.clickOn("reset-subset-button"); + + await clickOn("reset-subset-button"); const expected = [].concat(userDefinedGenes, diffExpGenes); - const userDefinedHistogramsAfterSubset = await cxgActions.getAllHistograms( + const userDefinedHistogramsAfterSubset = await getAllHistograms( "histogram-user-gene", expected ); + expect(userDefinedHistogramsAfterSubset).toEqual( expect.arrayContaining(expected) ); }); test("subset selection appends the top diff exp genes to user defined genes", async () => { + await goToPage(appUrlBase); + const userDefinedGenes = data.genes.bulkadd; const diffExpGenes = data.diffexp["gene-results"]; - await cxgActions.bulkAddGenes(userDefinedGenes); - const userDefinedHistograms = await cxgActions.getAllHistograms( + + await bulkAddGenes(userDefinedGenes); + const userDefinedHistograms = await getAllHistograms( "histogram-user-gene", userDefinedGenes ); + expect(userDefinedHistograms).toEqual( expect.arrayContaining(userDefinedGenes) ); - await cxgActions.subset({ x1: 0.15, y1: 0.1, x2: 0.98, y2: 0.98 }); - await cxgActions.runDiffExp(data.diffexp.cellset1, data.diffexp.cellset2); - const diffExpHistograms = await cxgActions.getAllHistograms( + + await subset({ x1: 0.15, y1: 0.1, x2: 0.98, y2: 0.98 }); + await runDiffExp(data.diffexp.cellset1, data.diffexp.cellset2); + + const diffExpHistograms = await getAllHistograms( "histogram-diffexp", diffExpGenes ); + expect(diffExpHistograms).toEqual(expect.arrayContaining(diffExpGenes)); - await cxgActions.subset({ x1: 0.16, y1: 0.11, x2: 0.97, y2: 0.97 }); + + await subset({ x1: 0.16, y1: 0.11, x2: 0.97, y2: 0.97 }); + const expected = [].concat(userDefinedGenes, diffExpGenes); - const userDefinedHistogramsAfterSubset = await cxgActions.getAllHistograms( + const userDefinedHistogramsAfterSubset = await getAllHistograms( "histogram-user-gene", expected ); + expect(userDefinedHistogramsAfterSubset).toEqual( expect.arrayContaining(expected) ); @@ -252,38 +314,51 @@ describe("subset", () => { describe("scatter plot", () => { test("scatter plot appears", async () => { - await cxgActions.bulkAddGenes(Object.values(data.scatter.genes)); - await utils.clickOn(`plot-x-${data.scatter.genes.x}`); - await utils.clickOn(`plot-y-${data.scatter.genes.y}`); - await utils.waitByID("scatterplot"); + await goToPage(appUrlBase); + + await bulkAddGenes(Object.values(data.scatter.genes)); + await clickOn(`plot-x-${data.scatter.genes.x}`); + await clickOn(`plot-y-${data.scatter.genes.y}`); + await waitByID("scatterplot"); }); }); describe("clipping", () => { test("clip continuous", async () => { - await cxgActions.clip(data.clip.min, data.clip.max); + await goToPage(appUrlBase); + + await clip(data.clip.min, data.clip.max); const histBrushableAreaId = `histogram-${data.clip.metadata}-plot-brushable-area`; - const coords = await cxgActions.calcDragCoordinates( + const coords = await calcDragCoordinates( histBrushableAreaId, data.clip["coordinates-as-percent"] ); - await cxgActions.drag(histBrushableAreaId, coords.start, coords.end); - const cellCount = await cxgActions.cellSet(1); + await drag(histBrushableAreaId, coords.start, coords.end); + const cellCount = await getCellSetCount(1); expect(cellCount).toBe(data.clip.count); }); test("clip gene", async () => { - await utils.typeInto("gene-search", data.clip.gene); + await goToPage(appUrlBase); + + await typeInto("gene-search", data.clip.gene); await page.keyboard.press("Enter"); + await page.waitForSelector(`[data-testid='histogram-${data.clip.gene}']`); - await cxgActions.clip(data.clip.min, data.clip.max); + + await clip(data.clip.min, data.clip.max); + const histBrushableAreaId = `histogram-${data.clip.gene}-plot-brushable-area`; - const coords = await cxgActions.calcDragCoordinates( + + const coords = await calcDragCoordinates( histBrushableAreaId, data.clip["coordinates-as-percent"] ); - await cxgActions.drag(histBrushableAreaId, coords.start, coords.end); - const cellCount = await cxgActions.cellSet(1); + + await drag(histBrushableAreaId, coords.start, coords.end); + + const cellCount = await getCellSetCount(1); + expect(cellCount).toBe(data.clip["gene-cell-count"]); }); }); @@ -291,85 +366,92 @@ describe("clipping", () => { // interact with UI elements just that they do not break describe("ui elements don't error", () => { test("color by", async () => { - for (const label in data.categorical) { - await utils.clickOn(`colorby-${label}`); - } - for (const label in data.continuous) { - await utils.clickOn(`colorby-${label}`); + await goToPage(appUrlBase); + + const allLabels = [ + ...Object.keys(data.categorical), + ...Object.keys(data.continuous), + ]; + + for (const label of allLabels) { + await clickOn(`colorby-${label}`); } }); test("color by for gene", async () => { - await utils.typeInto("gene-search", data.genes.search); + await goToPage(appUrlBase); + + await typeInto("gene-search", data.genes.search); await page.keyboard.press("Enter"); await page.waitForSelector( `[data-testid='histogram-${data.genes.search}']` ); - await utils.clickOn(`colorby-${data.genes.search}`); + await clickOn(`colorby-${data.genes.search}`); }); test("pan and zoom", async () => { - await utils.clickOn("mode-pan-zoom"); - const panCoords = await cxgActions.calcDragCoordinates( + await goToPage(appUrlBase); + + await clickOn("mode-pan-zoom"); + const panCoords = await calcDragCoordinates( "layout-graph", data.pan["coordinates-as-percent"] ); - await cxgActions.drag( - "layout-graph", - panCoords.start, - panCoords.end, - false - ); + + await drag("layout-graph", panCoords.start, panCoords.end, false); + await page.evaluate("window.scrollBy(0, 1000);"); }); }); describe("centroid labels", () => { test("labels are created", async () => { + await goToPage(appUrlBase); + const labels = Object.keys(data.categorical); - await utils.clickOn(`colorby-${labels[0]}`); - await utils.clickOn("centroid-label-toggle"); - /* eslint-disable no-await-in-loop */ + await clickOn(`colorby-${labels[0]}`); + await clickOn("centroid-label-toggle"); + // Toggle colorby for each category and check to see if labels are generated for (let i = 0, { length } = labels; i < length; i += 1) { const label = labels[i]; // first label is already enabled - if (i !== 0) await utils.clickOn(`colorby-${label}`); - const generatedLabels = await utils.getAllByClass("centroid-label"); + if (i !== 0) await clickOn(`colorby-${label}`); + const generatedLabels = await getAllByClass("centroid-label"); // Number of labels generated should be equal to size of the object expect(generatedLabels).toHaveLength( Object.keys(data.categorical[label]).length ); } - /* eslint-enable no-await-in-loop */ }); }); describe("graph overlay", () => { test("transform centroids correctly", async () => { + await goToPage(appUrlBase); + const category = Object.keys(data.categorical)[0]; - await utils.clickOn(`colorby-${category}`); - await utils.clickOn("centroid-label-toggle"); - await utils.clickOn("mode-pan-zoom"); - const panCoords = await cxgActions.calcDragCoordinates( + + await clickOn(`colorby-${category}`); + await clickOn("centroid-label-toggle"); + await clickOn("mode-pan-zoom"); + + const panCoords = await calcDragCoordinates( "layout-graph", data.pan["coordinates-as-percent"] ); const categoryValue = Object.keys(data.categorical[category])[0]; - const initialCoordinates = await utils.getElementCoordinates( + const initialCoordinates = await getElementCoordinates( `${categoryValue}-centroid-label` ); - await cxgActions.drag( - "layout-graph", - panCoords.start, - panCoords.end, - false - ); - const terminalCoordinates = await utils.getElementCoordinates( + + await drag("layout-graph", panCoords.start, panCoords.end, false); + const terminalCoordinates = await getElementCoordinates( `${categoryValue}-centroid-label` ); + expect(terminalCoordinates[0] - initialCoordinates[0]).toBeCloseTo( panCoords.end.x - panCoords.start.x ); @@ -378,3 +460,62 @@ describe("graph overlay", () => { ); }); }); + +test("pan zoom mode resets lasso selection", async () => { + await goToPage(appUrlBase); + + const panzoomLasso = data.features.panzoom.lasso; + + const lassoSelection = await calcDragCoordinates( + "layout-graph", + panzoomLasso["coordinates-as-percent"] + ); + + await drag("layout-graph", lassoSelection.start, lassoSelection.end, true); + await waitByID("lasso-element", { visible: true }); + + const initialCount = await getCellSetCount(1); + + expect(initialCount).toBe(panzoomLasso.count); + + await clickOn("mode-pan-zoom"); + await clickOn("mode-lasso"); + + const modeSwitchCount = await getCellSetCount(1); + + expect(modeSwitchCount).toBe(initialCount); +}); + +test("lasso moves after pan", async () => { + await goToPage(appUrlBase); + + const panzoomLasso = data.features.panzoom.lasso; + const coordinatesAsPercent = panzoomLasso["coordinates-as-percent"]; + + const lassoSelection = await calcDragCoordinates( + "layout-graph", + coordinatesAsPercent + ); + + await drag("layout-graph", lassoSelection.start, lassoSelection.end, true); + await waitByID("lasso-element", { visible: true }); + + const initialCount = await getCellSetCount(1); + + expect(initialCount).toBe(panzoomLasso.count); + + await clickOn("mode-pan-zoom"); + + const panCoords = await calcDragCoordinates( + "layout-graph", + coordinatesAsPercent + ); + + await drag("layout-graph", panCoords.start, panCoords.end, false); + await clickOn("mode-lasso"); + + const panCount = await getCellSetCount(2); + + expect(panCount).toBe(initialCount); +}); +/* eslint-enable no-await-in-loop -- await in loop is needed to emulate sequential user actions */ diff --git a/client/__tests__/e2e/e2eAnnotations.test.js b/client/__tests__/e2e/e2eAnnotations.test.js index fbfe9561..3e930237 100644 --- a/client/__tests__/e2e/e2eAnnotations.test.js +++ b/client/__tests__/e2e/e2eAnnotations.test.js @@ -1,208 +1,304 @@ /* Tests included in this file are specific to annotation features */ -import { appUrlBase, DATASET, DEBUG } from "./config"; -import setupTestBrowser from "./testBrowser"; +import { appUrlBase, DATASET } from "./config"; import { datasets } from "./data"; -let browser; -let page; -let utils; -let actions; +import { + clickOn, + goToPage, + waitByClass, + waitByID, + getTestId, + getTestClass, + getAllByClass, +} from "./puppeteerUtils"; + +import { + assertCategoryDoesNotExist, + calcDragCoordinates, + createCategory, + createLabel, + deleteCategory, + deleteLabel, + drag, + expandCategory, + renameCategory, + renameLabel, + subset, + duplicateCategory, +} from "./cellxgeneActions"; + const data = datasets[DATASET]; -beforeAll(async () => { - [browser, page, utils, actions] = await setupTestBrowser(); -}); +const perTestCategoryName = "TEST-CATEGORY"; +const perTestLabelName = "TEST-LABEL"; -afterAll(async () => { - if (!DEBUG && browser !== undefined) await browser.close(); -}); +async function setup(config) { + await goToPage(appUrlBase); + + // setup the test fixtures + await createCategory(perTestCategoryName); + await createLabel(perTestCategoryName, perTestLabelName); + + if (config.withSubset) { + await subset({ x1: 0.1, y1: 0.1, x2: 0.8, y2: 0.8 }); + } + + await waitByClass("autosave-complete"); +} describe.each([ { withSubset: true, tag: "subset" }, { withSubset: false, tag: "whole" }, ])("annotations", (config) => { - const perTestCategoryName = "per-test-category"; - const perTestLabelName = "per-test-label"; - - beforeEach(async () => { - await page.goto(appUrlBase); - - // wait for the page to load - await utils.waitByClass("autosave-complete"); - // setup the test fixtures - await actions.createCategory(perTestCategoryName); - await actions.createLabel(perTestCategoryName, perTestLabelName); - if (config.withSubset) - await actions.subset({ x1: 0.1, y1: 0.1, x2: 0.8, y2: 0.8 }); - await utils.waitByClass("autosave-complete"); - }); - - afterEach(async () => { - await deleteCategoryIfExists(perTestCategoryName); - await utils.waitByClass("autosave-complete"); - }); - test("create a category", async () => { + await setup(config); + const categoryName = `category-created-${config.tag}`; + await assertCategoryDoesNotExist(categoryName); - await actions.createCategory(categoryName); + + await createCategory(categoryName); + await assertCategoryExists(categoryName); }); test("delete a category", async () => { - await actions.deleteCategory(perTestCategoryName); + await setup(config); + + await deleteCategory(perTestCategoryName); await assertCategoryDoesNotExist(perTestCategoryName); }); test("rename a category", async () => { - const newCategoryName = `cluster-for-real-${config.tag}`; - await actions.renameCategory(perTestCategoryName, newCategoryName); + await setup(config); + + const newCategoryName = `NEW-${config.tag}`; + + await renameCategory(perTestCategoryName, newCategoryName); await assertCategoryDoesNotExist(perTestCategoryName); await assertCategoryExists(newCategoryName); }); test("create a label", async () => { + await setup(config); + const labelName = `new-label-${config.tag}`; + await assertLabelDoesNotExist(perTestCategoryName, labelName); - await actions.createLabel(perTestCategoryName, labelName); + + await createLabel(perTestCategoryName, labelName); + await assertLabelExists(perTestCategoryName, labelName); }); test("delete a label", async () => { - await actions.deleteLabel(perTestCategoryName, perTestLabelName); + await setup(config); + + await deleteLabel(perTestCategoryName, perTestLabelName); await assertLabelDoesNotExist(perTestCategoryName, perTestLabelName); }); test("rename a label", async () => { + await setup(config); + const newLabelName = "my-cool-new-label"; + await assertLabelDoesNotExist(perTestCategoryName, newLabelName); - await actions.renameLabel( - perTestCategoryName, - perTestLabelName, - newLabelName - ); + await renameLabel(perTestCategoryName, perTestLabelName, newLabelName); await assertLabelDoesNotExist(perTestCategoryName, perTestLabelName); await assertLabelExists(perTestCategoryName, newLabelName); }); test("check cell count for a label loaded from file", async () => { - const categoryName = "cluster-test"; - const labelName = "four"; - await actions.expandCategory(categoryName); - const result = await utils.waitByID( - `categorical-value-count-${categoryName}-${labelName}` + await setup(config); + + const duplicateCategoryName = "duplicate"; + await duplicateCategory(duplicateCategoryName); + + await page.reload({ waitUntil: ["networkidle0", "domcontentloaded"] }); + + const firstCategoryExpandIcon = await expect(page).toMatchElement( + getTestClass("category-expand") ); - expect(await result.evaluate((node) => node.innerText)).toBe( - data.annotationsFromFile.count.bySubsetConfig[config.withSubset] + + await firstCategoryExpandIcon.click(); + + const expectedCategoryRow = await expect(page).toMatchElement( + getTestClass("categorical-row") ); + const expectedLabelName = await getInnerText( + expectedCategoryRow, + "categorical-value" + ); + const expectedLabelCount = await getInnerText( + expectedCategoryRow, + "categorical-value-count" + ); + + await expandCategory(duplicateCategoryName); + + const expectedCategory = await expect(page).toMatchElement( + getTestClass("category") + ); + + const actualCategoryRow = await expect(expectedCategory).toMatchElement( + getTestClass("categorical-row") + ); + const actualLabelName = await getInnerText( + actualCategoryRow, + "categorical-value" + ); + const actualLabelCount = await getInnerText( + actualCategoryRow, + "categorical-value-count" + ); + + expect(actualLabelName).toBe(expectedLabelName); + expect(actualLabelCount).toBe(expectedLabelCount); + + async function getInnerText(element, className) { + return element.$eval(getTestClass(className), (node) => node?.innerText); + } }); test("assign cells to a label", async () => { - await actions.expandCategory(perTestCategoryName); - const lassoSelection = await actions.calcDragCoordinates( + await setup(config); + + await expandCategory(perTestCategoryName); + + const lassoSelection = await calcDragCoordinates( "layout-graph", data.categoryLabel.lasso["coordinates-as-percent"] ); - await actions.drag( - "layout-graph", - lassoSelection.start, - lassoSelection.end, - true - ); - await utils.waitByID("lasso-element", { visible: true }); - await utils.clickOn( - `${perTestCategoryName}:${perTestLabelName}:see-actions` - ); - await utils.clickOn( + + await drag("layout-graph", lassoSelection.start, lassoSelection.end, true); + await waitByID("lasso-element", { visible: true }); + await clickOn(`${perTestCategoryName}:${perTestLabelName}:see-actions`); + await clickOn( `${perTestCategoryName}:${perTestLabelName}:add-current-selection-to-this-label` ); - const result = await utils.waitByID( + + const result = await waitByID( `categorical-value-count-${perTestCategoryName}-${perTestLabelName}` ); + expect(await result.evaluate((node) => node.innerText)).toBe( data.categoryLabel.newCount.bySubsetConfig[config.withSubset] ); }); test("undo/redo category creation", async () => { + await setup(config); + const categoryName = `category-created-undo-${config.tag}`; + await assertCategoryDoesNotExist(categoryName); - await actions.createCategory(categoryName); + await createCategory(categoryName); await assertCategoryExists(categoryName); - await utils.clickOn("undo"); + await clickOn("undo"); await assertCategoryDoesNotExist(categoryName); - await utils.clickOn("redo"); + await clickOn("redo"); await assertCategoryExists(categoryName); }); test("undo/redo category deletion", async () => { + await setup(config); + const categoryName = `category-deleted-undo-${config.tag}`; - await actions.createCategory(categoryName); + + await createCategory(categoryName); await assertCategoryExists(categoryName); - await actions.deleteCategory(categoryName); + await deleteCategory(categoryName); await assertCategoryDoesNotExist(categoryName); - await utils.clickOn("undo"); + await clickOn("undo"); await assertCategoryExists(categoryName); - await utils.clickOn("redo"); + await clickOn("redo"); await assertCategoryDoesNotExist(categoryName); }); test("undo/redo category rename", async () => { + await setup(config); + const newCategoryName = `category-renamed-undo-${config.tag}`; + await assertCategoryDoesNotExist(newCategoryName); - await actions.renameCategory(perTestCategoryName, newCategoryName); + await renameCategory(perTestCategoryName, newCategoryName); await assertCategoryExists(newCategoryName); await assertCategoryDoesNotExist(perTestCategoryName); - await utils.clickOn("undo"); + await clickOn("undo"); await assertCategoryExists(perTestCategoryName); await assertCategoryDoesNotExist(newCategoryName); - await utils.clickOn("redo"); + await clickOn("redo"); await assertCategoryExists(newCategoryName); await assertCategoryDoesNotExist(perTestCategoryName); }); test("undo/redo label creation", async () => { + await setup(config); + const labelName = `label-created-undo-${config.tag}`; + await assertLabelDoesNotExist(perTestCategoryName, labelName); - await actions.createLabel(perTestCategoryName, labelName); + await createLabel(perTestCategoryName, labelName); await assertLabelExists(perTestCategoryName, labelName); - await utils.clickOn("undo"); + await clickOn("undo"); await assertLabelDoesNotExist(perTestCategoryName); - await utils.clickOn("redo"); + await clickOn("redo"); await assertLabelExists(perTestCategoryName, labelName); }); test("undo/redo label deletion", async () => { - await actions.deleteLabel(perTestCategoryName, perTestLabelName); + await setup(config); + + await deleteLabel(perTestCategoryName, perTestLabelName); await assertLabelDoesNotExist(perTestCategoryName); - await utils.clickOn("undo"); + await clickOn("undo"); await assertLabelExists(perTestCategoryName, perTestLabelName); - await utils.clickOn("redo"); + await clickOn("redo"); await assertLabelDoesNotExist(perTestCategoryName); }); test("undo/redo label rename", async () => { + await setup(config); + const newLabelName = `label-renamed-undo-${config.tag}`; + await assertLabelDoesNotExist(perTestCategoryName, newLabelName); - await actions.renameLabel( - perTestCategoryName, - perTestLabelName, - newLabelName - ); + await renameLabel(perTestCategoryName, perTestLabelName, newLabelName); await assertLabelExists(perTestCategoryName, newLabelName); await assertLabelDoesNotExist(perTestCategoryName, perTestLabelName); - await utils.clickOn("undo"); + await clickOn("undo"); await assertLabelExists(perTestCategoryName, perTestLabelName); await assertLabelDoesNotExist(perTestCategoryName, newLabelName); - await utils.clickOn("redo"); + await clickOn("redo"); await assertLabelExists(perTestCategoryName, newLabelName); await assertLabelDoesNotExist(perTestCategoryName, perTestLabelName); }); + test("stacked bar graph renders", async () => { + await setup(config); + + await expandCategory(perTestCategoryName); + + await clickOn(`colorby-louvain`); + + const labels = await getAllByClass("categorical-row"); + + const result = await Promise.all( + labels.map((label) => { + return page.evaluate((element) => { + return element.outerHTML; + }, label); + }) + ); + + expect(result).toMatchSnapshot(); + }); + async function assertCategoryExists(categoryName) { - const handle = await utils.waitByID(`${categoryName}:category-label`); + const handle = await waitByID(`${categoryName}:category-label`); const result = await handle.evaluate((node) => node.getAttribute("aria-label") @@ -211,37 +307,27 @@ describe.each([ expect(result).toBe(categoryName); } - async function assertCategoryDoesNotExist(categoryName) { - const result = await page.$( - `[data-testid='${categoryName}:category-label']` - ); - expect(result).toBeNull(); - } - async function assertLabelExists(categoryName, labelName) { - const category = await utils.waitByID(`${categoryName}:category-expand`); - expect(category).not.toBeNull(); - await actions.expandCategory(categoryName); - const previous = await utils.waitByID( + await expect(page).toMatchElement( + getTestId(`${categoryName}:category-expand`) + ); + + await expandCategory(categoryName); + + const previous = await waitByID( `categorical-value-${categoryName}-${labelName}` ); + expect( await previous.evaluate((node) => node.getAttribute("aria-label")) ).toBe(labelName); } async function assertLabelDoesNotExist(categoryName, labelName) { - await actions.expandCategory(categoryName); + await expandCategory(categoryName); const result = await page.$( `[data-testid='categorical-value-${categoryName}-${labelName}']` ); expect(result).toBeNull(); } - - async function deleteCategoryIfExists(categoryName) { - const handle = await page.$( - `[data-testid='${categoryName}:category-expand']` - ); - if (handle) await actions.deleteCategory(categoryName); - } }); diff --git a/client/__tests__/e2e/e2eJestConfig.json b/client/__tests__/e2e/e2eJestConfig.json index 585d59f0..95035bf3 100644 --- a/client/__tests__/e2e/e2eJestConfig.json +++ b/client/__tests__/e2e/e2eJestConfig.json @@ -1,5 +1,10 @@ { + "testRunner": "jest-circus/runner", "preset": "jest-puppeteer", "testMatch": ["**/__tests__/**/?(*.)(spec|test).js?(x)"], - "setupFiles": ["../setupMissingGlobals.js"] + "setupFiles": ["../setupMissingGlobals.js"], + "setupFilesAfterEnv": ["expect-puppeteer", "./puppeteer.setup.js"], + "globalSetup": "jest-environment-puppeteer/setup", + "globalTeardown": "jest-environment-puppeteer/teardown", + "testEnvironment": "./screenshot_env.js" } diff --git a/client/__tests__/e2e/feature.test.js b/client/__tests__/e2e/feature.test.js deleted file mode 100644 index 9e7adc11..00000000 --- a/client/__tests__/e2e/feature.test.js +++ /dev/null @@ -1,122 +0,0 @@ -/* -NOT run in Travis CI - -UX tests using puppeteer to be run locally. - -To run locally, ensure you are running the client is running on port 3000. -Then run jest --verbose false --config __tests__/e2e/e2eJestConfig.json feature. - */ - -import puppeteer from "puppeteer"; -import { appUrlBase, DEBUG, DEV, DATASET } from "./config"; -import { puppeteerUtils, cellxgeneActions } from "./puppeteerUtils"; -import { datasets } from "./data"; - -let browser; -let page; -let utils; -let cxgActions; -let spy; -const browserViewport = { width: 1280, height: 960 }; -const data = datasets[DATASET].features; - -if (DEBUG) jest.setTimeout(100000); -if (DEV) jest.setTimeout(10000); - -beforeAll(async () => { - const browserParams = DEV - ? { headless: false, slowMo: 5 } - : DEBUG - ? { headless: false, slowMo: 100, devtools: true } - : {}; - browser = await puppeteer.launch(browserParams); - page = await browser.newPage(); - await page.setViewport(browserViewport); - if (DEV || DEBUG) { - page.on("console", (msg) => console.log(`PAGE LOG: ${msg.text()}`)); - } - page.on("pageerror", (err) => { - throw new Error(`Console error: ${err}`); - }); - utils = puppeteerUtils(page); - cxgActions = cellxgeneActions(page); -}); - -beforeEach(async () => { - await page.goto(appUrlBase); -}); - -afterAll(() => { - if (!DEBUG) { - browser.close(); - } -}); - -describe("zoom interaction", async () => { - // Skip this test since UI is to hide lasso path when switching modes - test.skip("lasso visible after switching modes to pan/zoom", async () => { - const lassoSelection = await cxgActions.calcDragCoordinates( - "layout-graph", - data.panzoom.lasso["coordinates-as-percent"] - ); - await cxgActions.drag( - "layout-graph", - lassoSelection.start, - lassoSelection.end, - true - ); - await utils.waitByID("lasso-element", { visible: true }); - await utils.clickOn("mode-pan-zoom"); - await utils.waitByID("lasso-element", { visible: true }); - }); - - test("pan zoom mode resets lasso selection", async () => { - const lassoSelection = await cxgActions.calcDragCoordinates( - "layout-graph", - data.panzoom.lasso["coordinates-as-percent"] - ); - await cxgActions.drag( - "layout-graph", - lassoSelection.start, - lassoSelection.end, - true - ); - await utils.waitByID("lasso-element", { visible: true }); - const initialCount = await cxgActions.cellSet(1); - expect(initialCount).toBe(data.panzoom.lasso.count); - await utils.clickOn("mode-pan-zoom"); - await utils.clickOn("mode-lasso"); - const modeSwitchCount = await cxgActions.cellSet(1); - expect(modeSwitchCount).toBe(initialCount); - }); - - test("lasso moves after pan", async () => { - const lassoSelection = await cxgActions.calcDragCoordinates( - "layout-graph", - data.panzoom.lasso["coordinates-as-percent"] - ); - await cxgActions.drag( - "layout-graph", - lassoSelection.start, - lassoSelection.end, - true - ); - await utils.waitByID("lasso-element", { visible: true }); - const initialCount = await cxgActions.cellSet(1); - expect(initialCount).toBe(data.panzoom.lasso.count); - await utils.clickOn("mode-pan-zoom"); - const panCoords = await cxgActions.calcDragCoordinates( - "layout-graph", - data.panzoom.lasso["coordinates-as-percent"] - ); - await cxgActions.drag( - "layout-graph", - panCoords.start, - panCoords.end, - false - ); - await utils.clickOn("mode-lasso"); - const panCount = await cxgActions.cellSet(2); - expect(panCount).toBe(initialCount); - }); -}); diff --git a/client/__tests__/e2e/puppeteer.setup.js b/client/__tests__/e2e/puppeteer.setup.js new file mode 100644 index 00000000..cddb5efe --- /dev/null +++ b/client/__tests__/e2e/puppeteer.setup.js @@ -0,0 +1,60 @@ +/** + * `client/jest-puppeteer.config.js` is for configuring Puppeteer's launch config options + * `client/__tests__/e2e/puppeteer.setup.js` is for configuring `jest`, `browser`, + * and `page` objects + */ + +import { setDefaultOptions } from "expect-puppeteer"; +import { isDebug, isDev } from "./config"; +import * as ENV_DEFAULT from "../../../environment.default.json"; + +// (thuang): This is the max time a test can take to run. +// Since when debugging, we run slowMo and !headless, this means +// a test can take more time to finish, so we don't want +// jest to shut off the test too soon +jest.setTimeout(2 * 60 * 1000); +setDefaultOptions({ timeout: 20 * 1000 }); + +jest.retryTimes(ENV_DEFAULT.RETRY_ATTEMPTS); + +beforeEach(async () => { + await jestPuppeteer.resetBrowser(); + + const userAgent = await browser.userAgent(); + await page.setUserAgent(`${userAgent}bot`); + + await page._client.send("Animation.setPlaybackRate", { playbackRate: 12 }); + + page.on("console", async (msg) => { + page.on("pageerror", (err) => { + console.log(`PAGE LOG: ${msg.text()}`); + throw new Error(`Console error: ${err}`); + }); + + page.on("error", (err) => { + console.log(`PAGE LOG: ${msg.text()}`); + throw new Error(`Console error: ${err}`); + }); + + if (isDev || isDebug) { + // If there is a console.error but an error is not thrown, this will ensure the test fails + console.log(`PAGE LOG: ${msg.text()}`); + if (msg.type() === "error") { + // TODO: chromium does not currently support the CSP directive on the + // line below, so we swallow this error. Remove this when the test + // suite uses a browser version that supports this directive. + if ( + msg.text() === + "Unrecognized Content-Security-Policy directive 'require-trusted-types-for'.\n" + ) { + return; + } + const errorMsgText = await Promise.all( + // TODO can we do this without internal properties? + msg.args().map((arg) => arg._remoteObject.description) + ); + throw new Error(`Console error: ${errorMsgText}`); + } + } + }); +}); diff --git a/client/__tests__/e2e/puppeteerUtils.js b/client/__tests__/e2e/puppeteerUtils.js index 6eeb384b..df915f1f 100644 --- a/client/__tests__/e2e/puppeteerUtils.js +++ b/client/__tests__/e2e/puppeteerUtils.js @@ -1,73 +1,131 @@ -const puppeteerUtils = (page) => ({ - waitByID(testId, props = {}) { - return page.waitForSelector(`[data-testid='${testId}']`, props); - }, +/* eslint-disable no-await-in-loop -- await in loop is needed to emulate sequential user actions */ +export function getTestId(id) { + return `[data-testid='${id}']`; +} - waitByClass(testClass, props = {}) { - return page.waitForSelector(`[data-testclass='${testClass}']`, props); - }, +export function getTestClass(className) { + return `[data-testclass='${className}']`; +} - async waitForAllByIds(testIds, props = {}) { - return Promise.all( - testIds.map((testId) => - page.waitForSelector(`[data-testid='${testId}']`, props) - ) - ); - }, +export async function waitByID(testId, props = {}) { + return page.waitForSelector(getTestId(testId), props); +} - async getAllByClass(testClass) { - return page.$$eval(`[data-testclass=${testClass}]`, (eles) => - eles.map((ele) => ele.dataset.testid) - ); - }, +export async function waitByClass(testClass, props = {}) { + await page.waitForSelector(`[data-testclass='${testClass}']`, props); +} - async typeInto(testId, text) { - // blueprint's typeahead is treating typing weird, clicking & waiting first solves this - // only works for text without special characters - await this.waitByID(testId); - const selector = `[data-testid='${testId}']`; - // type ahead can be annoying if you don't pause before you type - await page.click(selector); - await page.waitFor(200); - await page.type(selector, text); - }, +export async function waitForAllByIds(testIds) { + await Promise.all( + testIds.map((testId) => page.waitForSelector(getTestId(testId))) + ); +} - async clearInputAndTypeInto(testId, text) { - await this.waitByID(testId); - const selector = `[data-testid='${testId}']`; - // only works for text without special characters - // type ahead can be annoying if you don't pause before you type - await page.click(selector); - await page.waitFor(200); - // select all - await page.click(selector, { clickCount: 3 }); - await page.keyboard.press("Backspace"); - await page.type(selector, text); - }, +export async function getAllByClass(testClass) { + return page.$$(`[data-testclass=${testClass}]`); +} - async clickOn(testid, options = {}) { - await this.waitByID(testid, options); - const click = await page.click(`[data-testid='${testid}']`); - await page.waitFor(50); - return click; - }, +export async function typeInto(testId, text) { + // blueprint's typeahead is treating typing weird, clicking & waiting first solves this + // only works for text without special characters + await waitByID(testId); + const selector = getTestId(testId); + // type ahead can be annoying if you don't pause before you type + await page.click(selector); + await page.waitFor(200); + await page.type(selector, text); +} - async getOneElementInnerHTML(selector, options = {}) { - await page.waitForSelector(selector, options); - return page.$eval(selector, (el) => el.innerHTML); - }, +export async function clearInputAndTypeInto(testId, text) { + await waitByID(testId); + const selector = getTestId(testId); + // only works for text without special characters + // type ahead can be annoying if you don't pause before you type + await page.click(selector); + await page.waitFor(200); + // select all + await page.click(selector, { clickCount: 3 }); + await page.keyboard.press("Backspace"); + await page.type(selector, text); +} - async getOneElementInnerText(selector) { - await page.waitForSelector(selector); - return page.$eval(selector, (el) => el.innerText); - }, +export async function clickOn(testId, options = {}) { + await expect(page).toClick(getTestId(testId), options); +} - async getElementCoordinates(testid) { - return page.$eval(`[data-testid='${testid}']`, (elem) => { - const { left, top } = elem.getBoundingClientRect(); - return [left, top]; - }); - }, -}); +/** + * (thuang): There are times when Puppeteer clicks on a button and the page doesn't respond. + * So I added clickOnUntil() to retry clicking until a given condition is met. + */ +export async function clickOnUntil(testId, assert) { + const MAX_RETRY = 10; + const WAIT_FOR_MS = 200; -export default puppeteerUtils; + let retry = 0; + + while (retry < MAX_RETRY) { + try { + await clickOn(testId); + await assert(); + + break; + } catch (error) { + retry += 1; + + await page.waitFor(WAIT_FOR_MS); + } + } + + if (retry === MAX_RETRY) { + throw Error("clickOnUntil() assertion failed!"); + } +} + +export async function getOneElementInnerHTML(selector, options = {}) { + await page.waitForSelector(selector, options); + + return page.$eval(selector, (el) => el.innerHTML); +} + +export async function getOneElementInnerText(selector) { + expect(page).toMatchElement(selector); + + return page.$eval(selector, (el) => el.innerText); +} + +export async function getElementCoordinates(testId) { + return page.$eval(getTestId(testId), (elem) => { + const { left, top } = elem.getBoundingClientRect(); + return [left, top]; + }); +} + +async function clickTermsOfService() { + if (!(await isElementPresent(getTestId("tos-cookies-accept")))) return; + + await clickOn("tos-cookies-accept"); +} + +async function nameNewAnnotation() { + if (await isElementPresent(getTestId("annotation-dialog"))) { + await typeInto("new-annotation-name", "ignoreE2E"); + await clickOn("submit-annotation"); + + // wait for the page to load + await waitByClass("autosave-complete"); + } +} + +export async function goToPage(url) { + await page.goto(url, { + waitUntil: "networkidle0", + }); + + await nameNewAnnotation(); + await clickTermsOfService(); +} + +export async function isElementPresent(selector, options) { + return Boolean(await page.$(selector, options)); +} +/* eslint-enable no-await-in-loop -- await in loop is needed to emulate sequential user actions */ diff --git a/client/__tests__/e2e/screenshot_env.js b/client/__tests__/e2e/screenshot_env.js new file mode 100644 index 00000000..32dc4c1a --- /dev/null +++ b/client/__tests__/e2e/screenshot_env.js @@ -0,0 +1,28 @@ +const PuppeteerEnvironment = require("jest-environment-puppeteer"); +require("jest-circus"); +const ENV_DEFAULT = require("../../../environment.default.json"); + +const takeScreenshot = require("./takeScreenshot"); + +class ScreenshotEnvironment extends PuppeteerEnvironment { + async handleTestEvent(event, state) { + if (event.name === "error") { + console.log("error", JSON.stringify(event)); + } + + if (event.name === "test_fn_failure" || event.name === "hook_failure") { + // (thuang): We only want to take screenshot on the last try + if ( + state.currentlyRunningTest.invocations <= ENV_DEFAULT.RETRY_ATTEMPTS + ) { + return; + } + + console.log("===> Failure event\n", new Date(), event); + + await takeScreenshot(state.currentlyRunningTest.name, this.global.page); + } + } +} + +module.exports = ScreenshotEnvironment; diff --git a/client/__tests__/e2e/takeScreenshot.js b/client/__tests__/e2e/takeScreenshot.js new file mode 100644 index 00000000..9800efab --- /dev/null +++ b/client/__tests__/e2e/takeScreenshot.js @@ -0,0 +1,17 @@ +function toFilename(name) { + return name.replace(/[^a-z0-9.-]+/gi, "-"); +} + +async function takeScreenshot(currentTestName, page) { + const testName = toFilename(currentTestName); + + // Take a screenshot at the point of failure + const date = new Date().toISOString(); + const screenshotName = `${date}-${testName}.png`; + + await page.screenshot({ + path: `./__tests__/screenshots/ignoreE2E-screenshot-${screenshotName}`, + }); +} + +module.exports = takeScreenshot; diff --git a/client/__tests__/e2e/testBrowser.js b/client/__tests__/e2e/testBrowser.js deleted file mode 100644 index 58306de4..00000000 --- a/client/__tests__/e2e/testBrowser.js +++ /dev/null @@ -1,67 +0,0 @@ -import puppeteer from "puppeteer"; -import { DEBUG, DEV } from "./config"; -import puppeteerUtils from "./puppeteerUtils"; -import cellxgeneActions from "./cellxgeneActions"; - -export default async function setupTestBrowser() { - const browserViewport = { width: 1280, height: 960 }; - const browserParams = DEV - ? { - headless: false, - slowMo: 5, - args: [ - `--window-size=${browserViewport.width},${browserViewport.height}`, - ], - } - : DEBUG - ? { - headless: false, - slowMo: 100, - devtools: true, - args: [ - `--window-size=${browserViewport.width + 560},${ - browserViewport.height - }`, - ], - } - : { - args: [ - `--window-size=${browserViewport.width},${browserViewport.height}`, - ], - }; - const browser = await puppeteer.launch(browserParams); - const page = await browser.pages().then((pages) => pages[0]); - await page.setViewport(browserViewport); - if (DEV || DEBUG) { - page.on("console", async (msg) => { - // If there is a console.error but an error is not thrown, this will ensure the test fails - console.log(`PAGE LOG: ${msg.text()}`); - if (msg.type() === "error") { - // TODO: chromium does not currently support the CSP directive on the - // line below, so we swallow this error. Remove this when the test - // suite uses a browser version that supports this directive. - if ( - msg.text() === - "Unrecognized Content-Security-Policy directive 'require-trusted-types-for'.\n" - ) - return; - const errorMsgText = await Promise.all( - // TODO can we do this without internal properties? - msg.args().map((arg) => arg._remoteObject.description) - ); - throw new Error(`Console error: ${errorMsgText}`); - } - }); - } - page.on("pageerror", (err) => { - console.log(`PAGE LOG: ${msg.text()}`); - throw new Error(`Console error: ${err}`); - }); - page.on("error", (err) => { - console.log(`PAGE LOG: ${msg.text()}`); - throw new Error(`Console error: ${err}`); - }); - const utils = puppeteerUtils(page); - const cxgActions = cellxgeneActions(page, utils); - return [browser, page, utils, cxgActions]; -} diff --git a/client/__tests__/screenshots/.gitignore b/client/__tests__/screenshots/.gitignore new file mode 100644 index 00000000..5e7d2734 --- /dev/null +++ b/client/__tests__/screenshots/.gitignore @@ -0,0 +1,4 @@ +# Ignore everything in this directory +* +# Except this file +!.gitignore diff --git a/client/__tests__/util/centroid.test.js b/client/__tests__/util/centroid.test.js index 99aca8db..6b7dcc6e 100644 --- a/client/__tests__/util/centroid.test.js +++ b/client/__tests__/util/centroid.test.js @@ -7,11 +7,10 @@ import * as Universe from "../../src/util/stateManager/universe"; import { matrixFBSToDataframe } from "../../src/util/stateManager/matrix"; import * as World from "../../src/util/stateManager/world"; import * as REST from "./stateManager/sampleResponses"; -import { ControlsHelpers as CH } from "../../src/util/stateManager"; describe("centroid", () => { let world; - let categoricalSelection; + beforeAll(() => { // Create world + universe let universe = Universe.createUniverseFromResponse( @@ -32,19 +31,10 @@ describe("centroid", () => { ...Universe.addObsLayout(universe, matrixFBSToDataframe(REST.layoutObs)), }; world = World.createWorldFromEntireUniverse(universe); - - // Create categorical selection from world - categoricalSelection = CH.createCategoricalSelection( - CH.selectableCategoryNames(world.schema) - ); }); test("field4 (categorical obsAnnotation)", () => { - const centroidResult = calcCentroid( - world, - "field4", - ["umap_0", "umap_1"] - ); + const centroidResult = calcCentroid(world, "field4", ["umap_0", "umap_1"]); // Check to see that a centroid has been calculated for every categorical value const keysAsArray = Array.from(centroidResult.keys()); @@ -64,11 +54,7 @@ describe("centroid", () => { }); test("field3 (boolean obsAnnotation)", () => { - const centroidResult = calcCentroid( - world, - "field3", - ["umap_0", "umap_1"] - ); + const centroidResult = calcCentroid(world, "field3", ["umap_0", "umap_1"]); // Check to see that a centroid has been calculated for every categorical value const keysAsArray = Array.from(centroidResult.keys()); diff --git a/client/__tests__/util/dataframe/dataframe.test.js b/client/__tests__/util/dataframe/dataframe.test.js index b15df2e1..d509d88a 100644 --- a/client/__tests__/util/dataframe/dataframe.test.js +++ b/client/__tests__/util/dataframe/dataframe.test.js @@ -918,43 +918,59 @@ describe("dataframe col", () => { }); describe("label indexing", () => { - test("IdentityInt32Index", () => { const idx = new Dataframe.IdentityInt32Index(12); // [0, 12) expect(Dataframe.isLabelIndex(idx)).toBeTruthy(); - expect(idx.labels()).toEqual(new Int32Array([0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11])); + expect(idx.labels()).toEqual( + new Int32Array([0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11]) + ); expect(idx.getLabel(1)).toEqual(1); expect(idx.getOffset(1)).toEqual(1); - expect(idx.getOffsets([1,3])).toEqual([1,3]) - expect(idx.getLabels([1, 3])).toEqual([1,3]) + expect(idx.getOffsets([1, 3])).toEqual([1, 3]); + expect(idx.getLabels([1, 3])).toEqual([1, 3]); expect(idx.size()).toEqual(12); expect(idx.subset([2]).labels()).toEqual([2]); expect(idx.subset([2, 3, 4]).labels()).toEqual(new Int32Array([2, 3, 4])); - expect(idx.subset([0, 1, 2, 3]).labels()).toEqual(new Int32Array([0, 1, 2, 3])); + expect(idx.subset([0, 1, 2, 3]).labels()).toEqual( + new Int32Array([0, 1, 2, 3]) + ); expect(idx.isubset([2]).labels()).toEqual([2]); expect(idx.isubset([2, 3, 4]).labels()).toEqual(new Int32Array([2, 3, 4])); - expect(idx.isubset([0, 1, 2, 3]).labels()).toEqual(new Int32Array([0, 1, 2, 3])); + expect(idx.isubset([0, 1, 2, 3]).labels()).toEqual( + new Int32Array([0, 1, 2, 3]) + ); - expect(idx.subset([0, 1, 2, 3, 4])).toBeInstanceOf(Dataframe.IdentityInt32Index); + expect(idx.subset([0, 1, 2, 3, 4])).toBeInstanceOf( + Dataframe.IdentityInt32Index + ); expect(idx.subset([2, 1, 0])).toBeInstanceOf(Dataframe.IdentityInt32Index); expect(idx.subset([1, 2, 3, 4])).toBeInstanceOf(Dataframe.DenseInt32Index); expect(idx.subset([0, 1, 3, 4])).toBeInstanceOf(Dataframe.DenseInt32Index); - expect(idx.subset([0, 1, 2, 3, 10])).toBeInstanceOf(Dataframe.DenseInt32Index); + expect(idx.subset([0, 1, 2, 3, 10])).toBeInstanceOf( + Dataframe.DenseInt32Index + ); expect(idx.subset([4, 3, 2, 1])).toBeInstanceOf(Dataframe.DenseInt32Index); expect(idx.subset([4])).toBeInstanceOf(Dataframe.KeyIndex); - expect(idx.withLabel(99).labels()).toEqual(new Int32Array([0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 99])); - expect(idx.dropLabel(0).labels()).toEqual(new Int32Array([1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11])); - expect(idx.dropLabel(11).labels()).toEqual(new Int32Array([0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10])); - expect(idx.dropLabel(5).labels()).toEqual(new Int32Array([0, 1, 2, 3, 4, 6, 7, 8, 9, 10, 11])); + expect(idx.withLabel(99).labels()).toEqual( + new Int32Array([0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 99]) + ); + expect(idx.dropLabel(0).labels()).toEqual( + new Int32Array([1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11]) + ); + expect(idx.dropLabel(11).labels()).toEqual( + new Int32Array([0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10]) + ); + expect(idx.dropLabel(5).labels()).toEqual( + new Int32Array([0, 1, 2, 3, 4, 6, 7, 8, 9, 10, 11]) + ); }); test("DenseInt32Index", () => { - const idx = new Dataframe.DenseInt32Index([99, 1002, 48, 0, 22]); expect(Dataframe.isLabelIndex(idx)).toBeTruthy(); @@ -964,17 +980,29 @@ describe("label indexing", () => { expect(idx.getOffset(1002)).toEqual(1); expect(idx.getOffset(0)).toEqual(3); expect(idx.getLabel(0)).toEqual(99); - expect(idx.getLabels(new Int32Array([2, 4]))).toEqual(new Int32Array([48, 22])); + expect(idx.getLabels(new Int32Array([2, 4]))).toEqual( + new Int32Array([48, 22]) + ); expect(idx.getLabels([2, 4])).toEqual([48, 22]); expect(idx.getOffsets([0, 48])).toEqual([3, 2]); - expect(idx.subset([1002, 0, 99]).labels()).toEqual(new Int32Array([1002, 0, 99])) - expect(idx.getOffsets(idx.subset([1002, 0, 99]).labels())).toEqual(new Int32Array([1, 3, 0])); - expect(idx.isubset([4, 1, 2]).labels()).toEqual(new Int32Array([22, 1002, 48])); + expect(idx.subset([1002, 0, 99]).labels()).toEqual( + new Int32Array([1002, 0, 99]) + ); + expect(idx.getOffsets(idx.subset([1002, 0, 99]).labels())).toEqual( + new Int32Array([1, 3, 0]) + ); + expect(idx.isubset([4, 1, 2]).labels()).toEqual( + new Int32Array([22, 1002, 48]) + ); - expect(idx.withLabel(88).labels()).toEqual(new Int32Array([99, 1002, 48, 0, 22, 88])); + expect(idx.withLabel(88).labels()).toEqual( + new Int32Array([99, 1002, 48, 0, 22, 88]) + ); expect(idx.withLabel(88).getOffset(88)).toEqual(5); - expect(idx.dropLabel(48).labels()).toEqual(new Int32Array([99, 1002, 0, 22])); + expect(idx.dropLabel(48).labels()).toEqual( + new Int32Array([99, 1002, 0, 22]) + ); }); test("KeyIndex", () => { @@ -991,9 +1019,13 @@ describe("label indexing", () => { expect(idx.subset(["green", "red"]).labels()).toEqual(["green", "red"]); expect(idx.isubset([2, 1, 0]).labels()).toEqual(["blue", "green", "red"]); - expect(idx.withLabel("yo").labels()).toEqual(["red", "green", "blue", "yo"]); + expect(idx.withLabel("yo").labels()).toEqual([ + "red", + "green", + "blue", + "yo", + ]); expect(idx.withLabel("yo").getOffset("yo")).toEqual(3); expect(idx.dropLabel("blue").labels()).toEqual(["red", "green"]); + }); }); - -}) \ No newline at end of file diff --git a/client/__tests__/util/promiseLimit.test.js b/client/__tests__/util/promiseLimit.test.js index 659e2f23..58424601 100644 --- a/client/__tests__/util/promiseLimit.test.js +++ b/client/__tests__/util/promiseLimit.test.js @@ -1,7 +1,7 @@ import PromiseLimit from "../../src/util/promiseLimit"; import { range } from "../../src/util/range"; -const delay = (t) => new Promise((resolve, reject) => setTimeout(resolve, t)); +const delay = (t) => new Promise((resolve) => setTimeout(resolve, t)); describe("PromiseLimit", () => { test("simple evaluation, concurrency 1", async () => { @@ -28,13 +28,14 @@ describe("PromiseLimit", () => { test("eval in order of insertion", async () => { const plimit = new PromiseLimit(100); - let counter = 0; + const result = await Promise.all([ - plimit.add(() => Promise.resolve((counter += 1))), - plimit.add(() => Promise.resolve((counter += 1))), - plimit.add(() => Promise.resolve((counter += 1))), - plimit.add(() => Promise.resolve((counter += 1))), + plimit.add(() => Promise.resolve(1)), + plimit.add(() => Promise.resolve(2)), + plimit.add(() => Promise.resolve(3)), + plimit.add(() => Promise.resolve(4)), ]); + expect(result).toEqual([1, 2, 3, 4]); }); @@ -43,16 +44,15 @@ describe("PromiseLimit", () => { let running = 0; let maxRunning = 0; - const cbfn = async (i) => { - running = running + 1; + const callback = async () => { + running += 1; maxRunning = running > maxRunning ? running : maxRunning; await delay(100); - running = running - 1; + running -= 1; }; - const result = await Promise.all( - range(10).map((i) => plimit.add(() => cbfn(i))) - ); + await Promise.all(range(10).map((i) => plimit.add(() => callback(i)))); + expect(maxRunning).toEqual(2); }); @@ -60,6 +60,7 @@ describe("PromiseLimit", () => { const plimit = new PromiseLimit(2); const result = await Promise.all([ plimit.add(() => Promise.resolve("OK")), + // eslint-disable-next-line prefer-promise-reject-errors -- unit test plimit.add(() => Promise.reject("not OK")).catch((e) => e), plimit.add(() => Promise.resolve("OK")), plimit diff --git a/client/__tests__/util/stateManager/controlsHelpers.test.js b/client/__tests__/util/stateManager/controlsHelpers.test.js index 923d7f5a..7e003843 100644 --- a/client/__tests__/util/stateManager/controlsHelpers.test.js +++ b/client/__tests__/util/stateManager/controlsHelpers.test.js @@ -4,7 +4,6 @@ test controls helpers import { subsetAndResetGeneLists } from "../../../src/util/stateManager/controlsHelpers"; import * as globals from "../../../src/globals"; - describe("controls helpers", () => { test("subsetAndResetGeneLists", () => { const geneList = []; @@ -28,7 +27,7 @@ describe("controls helpers", () => { ); const expectedNewUserDefinedGenes = [ ...geneList.slice(0, 20), - ...geneList.slice(21) + ...geneList.slice(21), ].slice(0, globals.maxGenes); expect(globals.maxUserDefinedGenes).toBeLessThan(globals.maxGenes); expect(geneList.length).toBeGreaterThan(globals.maxGenes); diff --git a/client/__tests__/util/stateManager/sampleResponses.js b/client/__tests__/util/stateManager/sampleResponses.js index 9e2041d0..ce086056 100644 --- a/client/__tests__/util/stateManager/sampleResponses.js +++ b/client/__tests__/util/stateManager/sampleResponses.js @@ -1,4 +1,3 @@ -/* eslint no-bitwise: "off" */ import _ from "lodash"; import { flatbuffers } from "flatbuffers"; import { NetEncoding } from "../../../src/util/stateManager/matrix_generated"; @@ -79,6 +78,7 @@ const anAnnotationsObsJSONResponse = { `obs${idx}`, 2 * idx, idx + 0.0133, + // eslint-disable-next-line no-bitwise -- idx & 1 to check for odd numbers !!(idx & 1), field4Categories[idx % field4Categories.length], ]) @@ -93,6 +93,7 @@ const anAnnotationsVarJSONResponse = { idx, 10 * idx, idx + 2.90143, + // eslint-disable-next-line no-bitwise -- idx & 1 to check for odd numbers !!(idx & 1), fieldDCategories[idx % fieldDCategories.length], `var${idx}`, @@ -112,7 +113,7 @@ function encodeTypedArray(builder, uType, uData) { function encodeMatrix(columns, colIndex = undefined) { /* IMPORTANT: this is not a general purpose encoder. in particular, - it doesn't correctly handle all column index types, nor does it + it doesn't correctly handle all column index types, nor does it handle all column typedarray types. encodeMatrixFBS in matrix.py is more general. This is used only diff --git a/client/__tests__/util/typedCrossfilter/sort.test.js b/client/__tests__/util/typedCrossfilter/sort.test.js index ea155c29..f7650f4f 100644 --- a/client/__tests__/util/typedCrossfilter/sort.test.js +++ b/client/__tests__/util/typedCrossfilter/sort.test.js @@ -2,9 +2,6 @@ import { sortArray, sortIndex, lowerBound, - upperBound, - lowerBoundIndirect, - upperBoundIndirect, } from "../../../src/util/typedCrossfilter/sort"; /* @@ -68,7 +65,7 @@ describe("sortArray", () => { }); describe("non-finite numbers", () => { - test("inifinity", () => { + test("infinity", () => { expect(sortArray(new Float32Array([pInf, nInf, 0, 1, 2]))).toMatchObject( new Float32Array([nInf, 0, 1, 2, pInf]) ); diff --git a/client/configuration/eslint/eslint.js b/client/configuration/eslint/eslint.js index 73e0f511..a1189581 100644 --- a/client/configuration/eslint/eslint.js +++ b/client/configuration/eslint/eslint.js @@ -8,7 +8,16 @@ module.exports = { "prettier/react", ], env: { browser: true, commonjs: true, es6: true }, - globals: { expect: true }, + globals: { + expect: true, + jest: true, + jestPuppeteer: true, + it: true, + page: true, + browser: true, + context: true, + beforeEach: true, + }, parserOptions: { ecmaVersion: 2017, sourceType: "module", @@ -36,6 +45,13 @@ module.exports = { "space-before-function-paren": "off", "function-paren-newline": "off", "prefer-destructuring": ["error", { object: true, array: false }], + "import/prefer-default-export": "off", + "no-restricted-syntax": [ + "error", + "ForInStatement", + "LabeledStatement", + "WithStatement", + ], }, overrides: [ { diff --git a/client/configuration/lint-staged/lint-staged.config.js b/client/configuration/lint-staged/lint-staged.config.js index 8d9c56b8..e8a2415d 100644 --- a/client/configuration/lint-staged/lint-staged.config.js +++ b/client/configuration/lint-staged/lint-staged.config.js @@ -1,3 +1,3 @@ module.exports = { - "./src/**/*.js": "eslint --fix", + "*.js": "eslint --fix", }; diff --git a/client/jest-puppeteer.config.js b/client/jest-puppeteer.config.js new file mode 100644 index 00000000..9e733cad --- /dev/null +++ b/client/jest-puppeteer.config.js @@ -0,0 +1,47 @@ +/** + * `client/jest-puppeteer.config.js` is for configuring Puppeteer's launch config options + * `client/__tests__/e2e/puppeteer.setup.js` is for configuring `jest`, `browser`, + * and `page` objects + */ + +const ENV_DEFAULT = require("../environment.default.json"); + +const jestEnv = process.env.JEST_ENV || ENV_DEFAULT.JEST_ENV; +const isHeadful = + process.env.HEADFUL === "true" || process.env.HEADLESS === "false"; + +const DEFAULT_LAUNCH_CONFIG = { + headless: !isHeadful, + args: ["--ignore-certificate-errors", "--ignore-ssl-errors"], + dumpio: true, + ignoreHTTPSErrors: true, + defaultViewport: { + width: 1280, + height: 960, + }, +}; + +const LAUNCH_CONFIG_BY_ENV = { + [ENV_DEFAULT.DEBUG]: { + ...DEFAULT_LAUNCH_CONFIG, + headless: false, + slowMo: 100, + devtools: true, + defaultViewport: { + width: DEFAULT_LAUNCH_CONFIG.defaultViewport.width, + height: DEFAULT_LAUNCH_CONFIG.defaultViewport.height + 560, + }, + }, + [ENV_DEFAULT.DEV]: { + ...DEFAULT_LAUNCH_CONFIG, + headless: false, + slowMo: 5, + }, +}; + +const launchConfig = LAUNCH_CONFIG_BY_ENV[jestEnv] || DEFAULT_LAUNCH_CONFIG; + +module.exports = { + browserContext: "incognito", + launch: launchConfig, +}; diff --git a/client/package-lock.json b/client/package-lock.json index 4a04d4fc..34c22613 100644 --- a/client/package-lock.json +++ b/client/package-lock.json @@ -12541,6 +12541,32 @@ } } }, + "jest-circus": { + "version": "26.0.1", + "resolved": "https://registry.npmjs.org/jest-circus/-/jest-circus-26.0.1.tgz", + "integrity": "sha512-dp20V0Pi1N92Y7+ULPa3tNR9KCG0Sy19NiopyPmo5rNoQ4OGWmuzp1P0q1je2HV3fRD0BYE7wqh8aReGGENfUA==", + "dev": true, + "requires": { + "@babel/traverse": "^7.1.0", + "@jest/environment": "^26.0.1", + "@jest/test-result": "^26.0.1", + "@jest/types": "^26.0.1", + "chalk": "^4.0.0", + "co": "^4.6.0", + "dedent": "^0.7.0", + "expect": "^26.0.1", + "is-generator-fn": "^2.0.0", + "jest-each": "^26.0.1", + "jest-matcher-utils": "^26.0.1", + "jest-message-util": "^26.0.1", + "jest-runtime": "^26.0.1", + "jest-snapshot": "^26.0.1", + "jest-util": "^26.0.1", + "pretty-format": "^26.0.1", + "stack-utils": "^2.0.2", + "throat": "^5.0.0" + } + }, "jest-config": { "version": "26.0.1", "resolved": "https://registry.npmjs.org/jest-config/-/jest-config-26.0.1.tgz", diff --git a/client/package.json b/client/package.json index 1b56cc94..f907f55a 100644 --- a/client/package.json +++ b/client/package.json @@ -5,12 +5,16 @@ "description": "cellxgene is a web application for the interactive exploration of single cell sequence data.", "repository": "https://github.com/chanzuckerberg/cellxgene", "scripts": { - "clean": "rimraf build", "build": "npm run clean && webpack --config", - "prod": "npm run build -- configuration/webpack/webpack.config.prod.js", + "clean": "rimraf build", "dev": "npm run build -- configuration/webpack/webpack.config.dev.js", - "fmt": "eslint --fix src", - "lint": "eslint src" + "e2e": "jest --config __tests__/e2e/e2eJestConfig.json e2e/e2e.test.js", + "e2e-annotations": "jest --config __tests__/e2e/e2eJestConfig.json e2e/e2eAnnotations.test.js", + "e2e-prod": "CXG_URL_BASE='https://cellxgene.cziscience.com/d/pbmc3k.cxg/' jest --config __tests__/e2e/e2eJestConfig.json e2e/e2e.test.js", + "fmt": "eslint --fix src __tests__", + "lint": "eslint --fix src __tests__", + "prod": "npm run build -- configuration/webpack/webpack.config.prod.js", + "test": "jest --testPathIgnorePatterns e2e" }, "engineStrict": true, "engines": { @@ -88,6 +92,7 @@ "eslint-plugin-prettier": "^3.1.3", "eslint-plugin-react": "^7.20.0", "eslint-plugin-react-hooks": "^4.0.4", + "expect-puppeteer": "^4.4.0", "express": "^4.17.1", "favicons-webpack-plugin": "^3.0.1", "file-loader": "^6.0.0", @@ -95,6 +100,8 @@ "html-webpack-plugin": "^4.3.0", "husky": "^4.2.5", "jest": "^26.0.1", + "jest-circus": "^26.0.1", + "jest-environment-puppeteer": "^4.4.0", "jest-puppeteer": "^4.4.0", "json-loader": "^0.5.7", "lint-staged": "^10.2.9", diff --git a/client/src/components/autosave/filenameDialog.js b/client/src/components/autosave/filenameDialog.js index c2f02e64..715a978c 100644 --- a/client/src/components/autosave/filenameDialog.js +++ b/client/src/components/autosave/filenameDialog.js @@ -113,7 +113,7 @@ class FilenameDialog extends React.Component { this.handleCreateFilename(); }} > -
+

Name your annotations collection:

Create annotations collection diff --git a/client/src/components/categorical/annoSelect.js b/client/src/components/categorical/annoSelect.js index a78a5331..f2d3cd84 100644 --- a/client/src/components/categorical/annoSelect.js +++ b/client/src/components/categorical/annoSelect.js @@ -27,7 +27,14 @@ class DuplicateCategorySelect extends React.PureComponent { } filterable={false} itemRenderer={(d, { handleClick }) => { - return ; + return ( + + ); }} noResults={} onItemSelect={(d) => { @@ -36,6 +43,7 @@ class DuplicateCategorySelect extends React.PureComponent { > {/* children become the popover target; render value here */}