mirror of
https://github.com/chanzuckerberg/cellxgene.git
synced 2026-10-08 07:48:11 +08:00
Add frontend smoke tests for annotations (#1154)
* Add smoke test for annotations features
* Do not save during annotations tests
* Fix botched rebase in dev guidelines
* Revert "Do not save during annotations tests"
This reverts commit f0bd970bb2.
* Respond to feedback from @bkmartinjr
This commit is contained in:
+5
-2
@@ -23,6 +23,9 @@ jobs:
|
|||||||
install: skip
|
install: skip
|
||||||
python: "3.6"
|
python: "3.6"
|
||||||
script: docker build .
|
script: docker build .
|
||||||
- name: "Smoke Tests"
|
- name: "Smoke Tests (with Annotations)"
|
||||||
python: "3.6"
|
python: "3.6"
|
||||||
script: make smoke-test
|
script: cd client && make smoke-test
|
||||||
|
- name: "Smoke Tests (without Annotations)"
|
||||||
|
python: "3.6"
|
||||||
|
script: cd client && make smoke-test-annotations
|
||||||
|
|||||||
+23
-4
@@ -1,9 +1,10 @@
|
|||||||
include ../common.mk
|
include ../common.mk
|
||||||
|
|
||||||
DATASET := $(if $(DATASET),$(DATASET),../example-dataset/pbmc3k.h5ad)
|
DATASET := $(if $(DATASET),$(DATASET),../example-dataset/pbmc3k.h5ad)
|
||||||
|
ANNOTATIONS := $(if $(ANNOTATIONS),$(ANNOTATIONS),../example-dataset/pbmc3k-annotations.csv)
|
||||||
|
ANNOTATIONS_FILENAME := $(shell basename $(ANNOTATIONS))
|
||||||
|
|
||||||
# Packaging
|
# Packaging
|
||||||
|
|
||||||
.PHONY: clean
|
.PHONY: clean
|
||||||
clean:
|
clean:
|
||||||
rm -rf node_modules
|
rm -rf node_modules
|
||||||
@@ -52,6 +53,10 @@ backend-dev-anno-ontology: server-requirements
|
|||||||
CXG_OPTIONS='--experimental-annotations --experimental-annotations-ontology' \
|
CXG_OPTIONS='--experimental-annotations --experimental-annotations-ontology' \
|
||||||
$(MAKE) backend-dev
|
$(MAKE) backend-dev
|
||||||
|
|
||||||
|
.PHONY: test
|
||||||
|
test:
|
||||||
|
node node_modules/jest/bin/jest.js
|
||||||
|
|
||||||
.PHONY: e2e
|
.PHONY: e2e
|
||||||
e2e:
|
e2e:
|
||||||
node node_modules/jest/bin/jest.js \
|
node node_modules/jest/bin/jest.js \
|
||||||
@@ -59,14 +64,28 @@ e2e:
|
|||||||
--config __tests__/e2e/e2eJestConfig.json \
|
--config __tests__/e2e/e2eJestConfig.json \
|
||||||
e2e/e2e.test.js
|
e2e/e2e.test.js
|
||||||
|
|
||||||
.PHONY: test
|
.PHONY: e2e-annotations
|
||||||
test:
|
e2e-annotations:
|
||||||
node node_modules/jest/bin/jest.js
|
node node_modules/jest/bin/jest.js \
|
||||||
|
--verbose false \
|
||||||
|
--config __tests__/e2e/e2eJestConfig.json \
|
||||||
|
e2e/e2eAnnotations.test.js
|
||||||
|
|
||||||
.PHONY: smoke-test
|
.PHONY: smoke-test
|
||||||
smoke-test:
|
smoke-test:
|
||||||
start_server_and_test '$(MAKE) start-server' $(CXG_SERVER_PORT) '$(MAKE) e2e'
|
start_server_and_test '$(MAKE) start-server' $(CXG_SERVER_PORT) '$(MAKE) e2e'
|
||||||
|
|
||||||
|
|
||||||
|
.PHONY: smoke-test-annotations
|
||||||
|
smoke-test-annotations:
|
||||||
|
$(eval TMP_DIR := $(shell mktemp -d /tmp/cellxgene_XXXXXX))
|
||||||
|
cp $(ANNOTATIONS) $(TMP_DIR)/ && \
|
||||||
|
start_server_and_test \
|
||||||
|
'CXG_OPTIONS="--experimental-annotations --experimental-annotations-file $(TMP_DIR)/$(ANNOTATIONS_FILENAME)" $(MAKE) start-server' \
|
||||||
|
$(CXG_SERVER_PORT) \
|
||||||
|
'$(MAKE) e2e-annotations'
|
||||||
|
rm -rf $(TMP_DIR)
|
||||||
|
|
||||||
.PHONY: unit-test
|
.PHONY: unit-test
|
||||||
unit-test:
|
unit-test:
|
||||||
node node_modules/jest/bin/jest.js --testPathIgnorePatterns e2e
|
node node_modules/jest/bin/jest.js --testPathIgnorePatterns e2e
|
||||||
|
|||||||
@@ -4,3 +4,6 @@ export const appUrlBase = `http://localhost:${appPort}`;
|
|||||||
export const DEV = jest_env === "dev";
|
export const DEV = jest_env === "dev";
|
||||||
export const DEBUG = jest_env === "debug";
|
export const DEBUG = jest_env === "debug";
|
||||||
export const DATASET = "pbmc3k";
|
export const DATASET = "pbmc3k";
|
||||||
|
|
||||||
|
if (DEBUG) jest.setTimeout(100000);
|
||||||
|
if (DEV) jest.setTimeout(10000);
|
||||||
|
|||||||
@@ -115,6 +115,18 @@ export const datasets = {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
categoryLabel: {
|
||||||
|
lasso: {
|
||||||
|
"coordinates-as-percent": { x1: 0.3, y1: 0.3, x2: 0.5, y2: 0.5 },
|
||||||
|
count: "38"
|
||||||
|
},
|
||||||
|
newCount: {
|
||||||
|
bySubsetConfig: {
|
||||||
|
false: "199",
|
||||||
|
true: "193"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
clip: {
|
clip: {
|
||||||
min: "30",
|
min: "30",
|
||||||
max: "70",
|
max: "70",
|
||||||
|
|||||||
@@ -3,45 +3,16 @@ 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
|
Tests included in this file are expected to be relatively stable and test core features
|
||||||
*/
|
*/
|
||||||
import puppeteer from "puppeteer";
|
import { appUrlBase, DEBUG, DATASET } from "./config";
|
||||||
import { appUrlBase, DEBUG, DEV, DATASET } from "./config";
|
import { setupTestBrowser } from "./puppeteerUtils";
|
||||||
import { puppeteerUtils, cellxgeneActions } from "./puppeteerUtils";
|
|
||||||
import { datasets } from "./data";
|
import { datasets } from "./data";
|
||||||
|
|
||||||
let browser, page, utils, cxgActions, spy;
|
let browser, page, utils, cxgActions, spy;
|
||||||
const browserViewport = { width: 1280, height: 960 };
|
const browserViewport = { width: 1280, height: 960 };
|
||||||
let data = datasets[DATASET];
|
let data = datasets[DATASET];
|
||||||
|
|
||||||
if (DEBUG) jest.setTimeout(100000);
|
|
||||||
if (DEV) jest.setTimeout(10000);
|
|
||||||
|
|
||||||
beforeAll(async () => {
|
beforeAll(async () => {
|
||||||
const browserParams = DEV
|
[browser, page, utils, cxgActions] = await setupTestBrowser(browserViewport);
|
||||||
? { 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", async msg => {
|
|
||||||
// If there is a console.error but an error is not thrown, this will ensure the test fails
|
|
||||||
if (msg.type() === "error") {
|
|
||||||
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}`);
|
|
||||||
}
|
|
||||||
console.log(`PAGE LOG: ${msg.text()}`);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
page.on("pageerror", err => {
|
|
||||||
throw new Error(`Console error: ${err}`);
|
|
||||||
});
|
|
||||||
utils = puppeteerUtils(page);
|
|
||||||
cxgActions = cellxgeneActions(page);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
beforeEach(async () => {
|
beforeEach(async () => {
|
||||||
@@ -69,7 +40,7 @@ describe("metadata loads", () => {
|
|||||||
`[data-testid="category-${label}"]`
|
`[data-testid="category-${label}"]`
|
||||||
);
|
);
|
||||||
expect(categoryName).toMatch(label);
|
expect(categoryName).toMatch(label);
|
||||||
await utils.clickOn(`category-expand-${label}`);
|
await utils.clickOn(`${label}:category-expand`);
|
||||||
const categories = await cxgActions.getAllCategoriesAndCounts(label);
|
const categories = await cxgActions.getAllCategoriesAndCounts(label);
|
||||||
expect(Object.keys(categories)).toMatchObject(
|
expect(Object.keys(categories)).toMatchObject(
|
||||||
Object.keys(data.categorical[label])
|
Object.keys(data.categorical[label])
|
||||||
@@ -112,8 +83,8 @@ describe("cell selection", () => {
|
|||||||
|
|
||||||
test("selects cells via categorical", async () => {
|
test("selects cells via categorical", async () => {
|
||||||
for (const cellset of data.cellsets.categorical) {
|
for (const cellset of data.cellsets.categorical) {
|
||||||
await utils.clickOn(`category-expand-${cellset.metadata}`);
|
await utils.clickOn(`${cellset.metadata}:category-expand`);
|
||||||
await utils.clickOn(`category-select-${cellset.metadata}`);
|
await utils.clickOn(`${cellset.metadata}:category-select`);
|
||||||
for (const val of cellset.values) {
|
for (const val of cellset.values) {
|
||||||
await utils.clickOn(
|
await utils.clickOn(
|
||||||
`categorical-value-select-${cellset.metadata}-${val}`
|
`categorical-value-select-${cellset.metadata}-${val}`
|
||||||
|
|||||||
@@ -0,0 +1,140 @@
|
|||||||
|
/*
|
||||||
|
Tests included in this file are specific to annotation features
|
||||||
|
*/
|
||||||
|
import {appUrlBase, DEBUG, DEV, DATASET} from "./config";
|
||||||
|
import {setupTestBrowser} from "./puppeteerUtils";
|
||||||
|
import {datasets} from "./data";
|
||||||
|
|
||||||
|
let browser, page, utils, cxgActions;
|
||||||
|
const browserViewport = {width: 1280, height: 960};
|
||||||
|
const data = datasets[DATASET];
|
||||||
|
|
||||||
|
beforeAll(async () => {
|
||||||
|
[browser, page, utils, cxgActions] = await setupTestBrowser(browserViewport);
|
||||||
|
});
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
await page.goto(appUrlBase);
|
||||||
|
});
|
||||||
|
|
||||||
|
afterAll(() => {
|
||||||
|
if (!DEBUG) browser.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("did launch", () => {
|
||||||
|
test("page launched", async () => {
|
||||||
|
let el = await utils.getOneElementInnerHTML("[data-testid='header']");
|
||||||
|
expect(el).toBe(data.title);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
|
||||||
|
describe.each([
|
||||||
|
{withSubset: true},
|
||||||
|
{withSubset: false}
|
||||||
|
])("annotations", (config) => {
|
||||||
|
|
||||||
|
async function subset() {
|
||||||
|
const lassoSelection = await cxgActions.calcDragCoordinates(
|
||||||
|
"layout-graph",
|
||||||
|
{ x1: 0.10, y1: 0.10, x2: 0.80, y2: 0.80 }
|
||||||
|
);
|
||||||
|
await cxgActions.drag(
|
||||||
|
"layout-graph",
|
||||||
|
lassoSelection.start,
|
||||||
|
lassoSelection.end,
|
||||||
|
true
|
||||||
|
);
|
||||||
|
await utils.clickOn("subset-button");
|
||||||
|
const coordinate = await cxgActions.calcCoordinate("layout-graph", 0.9, 0.9);
|
||||||
|
await cxgActions.clickOnCoordinate("layout-graph", coordinate);
|
||||||
|
}
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
if (config.withSubset) await subset();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("create a category", async () => {
|
||||||
|
await utils.clickOn("open-annotation-dialog");
|
||||||
|
await utils.typeInto("new-category-name", "test-category-name");
|
||||||
|
await utils.clickOn("submit-category");
|
||||||
|
const result = await utils.waitByID("test-category-name:category-expand");
|
||||||
|
expect(await result.evaluate(node => node.innerText)).toBe("test-category-name");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("delete a category", async () => {
|
||||||
|
const previous = await utils.waitByID("cluster-test:category-expand");
|
||||||
|
expect(await previous.evaluate(node => node.innerText)).toBe("cluster-test");
|
||||||
|
await utils.hoverOn("cluster-test:see-actions");
|
||||||
|
await utils.clickOn("cluster-test:delete-category");
|
||||||
|
const result = await page.$("[data-testid='cluster-test:category-expand']");
|
||||||
|
expect(result).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("rename a category", async () => {
|
||||||
|
const previous = await utils.waitByID("cluster-test:category-expand");
|
||||||
|
expect(await previous.evaluate(node => node.innerText)).toBe("cluster-test");
|
||||||
|
await utils.hoverOn("cluster-test:see-actions");
|
||||||
|
await utils.clickOn("cluster-test:edit-category-mode");
|
||||||
|
await utils.typeInto("cluster-test:edit-category-name-text", "-renamed");
|
||||||
|
await utils.clickOn("cluster-test:submit-category-edit");
|
||||||
|
const result = await utils.waitByID("cluster-test-renamed:category-expand");
|
||||||
|
expect(await result.evaluate(node => node.innerText)).toBe("cluster-test-renamed");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("create a label", async () => {
|
||||||
|
await utils.hoverOn("cluster-test:see-actions");
|
||||||
|
await utils.clickOn("cluster-test:add-new-label-to-category");
|
||||||
|
await utils.typeInto("cluster-test:new-label-name", "test-label-name");
|
||||||
|
await utils.clickOn("cluster-test:submit-label");
|
||||||
|
await cxgActions.expandCategory("cluster-test");
|
||||||
|
const result = await utils.waitByID("categorical-value-cluster-test-test-label-name");
|
||||||
|
expect(await result.evaluate(node => node.innerText)).toBe("test-label-name");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("delete a label", async () => {
|
||||||
|
await cxgActions.expandCategory("cluster-test");
|
||||||
|
const previous = await utils.waitByID("categorical-value-cluster-test-three");
|
||||||
|
expect(await previous.evaluate(node => node.innerText)).toBe("three");
|
||||||
|
await utils.hoverOn("cluster-test:three:see-actions");
|
||||||
|
await utils.clickOn("cluster-test:three:delete-label");
|
||||||
|
const result = await page.$("[data-testid='categorical-value-cluster-test-three']");
|
||||||
|
expect(result).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("rename a label", async () => {
|
||||||
|
await cxgActions.expandCategory("cluster-test");
|
||||||
|
const previous = await utils.waitByID("categorical-value-cluster-test-four");
|
||||||
|
expect(await previous.evaluate(node => node.innerText)).toBe("four");
|
||||||
|
await utils.hoverOn("cluster-test:four:see-actions");
|
||||||
|
await utils.clickOn("cluster-test:four:edit-label");
|
||||||
|
await utils.typeInto("cluster-test:four:edit-label-name", ".");
|
||||||
|
await utils.clickOn("cluster-test:four:submit-label-edit");
|
||||||
|
const result = await utils.waitByID("categorical-value-cluster-test-four.");
|
||||||
|
expect(await result.evaluate(node => node.innerText)).toBe("four.");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("assign cells to a label", async () => {
|
||||||
|
await cxgActions.expandCategory("cluster-test");
|
||||||
|
|
||||||
|
const lassoSelection = await cxgActions.calcDragCoordinates(
|
||||||
|
"layout-graph",
|
||||||
|
data.categoryLabel.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.categoryLabel.lasso.count);
|
||||||
|
await utils.hoverOn("cluster-test:one:see-actions");
|
||||||
|
await utils.clickOn("cluster-test:one:add-current-selection-to-this-label");
|
||||||
|
const result = await utils.waitByID("categorical-value-count-cluster-test-one");
|
||||||
|
expect(await result.evaluate(node => node.innerText)).toBe(
|
||||||
|
data.categoryLabel.newCount.bySubsetConfig[config.withSubset]
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,3 +1,6 @@
|
|||||||
|
import {DEBUG, DEV} from "./config";
|
||||||
|
import puppeteer from "puppeteer";
|
||||||
|
|
||||||
export const puppeteerUtils = puppeteerPage => ({
|
export const puppeteerUtils = puppeteerPage => ({
|
||||||
async waitByID(testid, props = {}) {
|
async waitByID(testid, props = {}) {
|
||||||
return await puppeteerPage.waitForSelector(
|
return await puppeteerPage.waitForSelector(
|
||||||
@@ -63,6 +66,12 @@ export const puppeteerUtils = puppeteerPage => ({
|
|||||||
await puppeteerPage.waitFor(50);
|
await puppeteerPage.waitFor(50);
|
||||||
},
|
},
|
||||||
|
|
||||||
|
async hoverOn(testid) {
|
||||||
|
await this.waitByID(testid);
|
||||||
|
await puppeteerPage.hover(`[data-testid='${testid}']`);
|
||||||
|
await puppeteerPage.waitFor(50);
|
||||||
|
},
|
||||||
|
|
||||||
async getOneElementInnerHTML(selector) {
|
async getOneElementInnerHTML(selector) {
|
||||||
await puppeteerPage.waitForSelector(selector);
|
await puppeteerPage.waitForSelector(selector);
|
||||||
let text = await puppeteerPage.$eval(selector, el => el.innerHTML);
|
let text = await puppeteerPage.$eval(selector, el => el.innerHTML);
|
||||||
@@ -97,6 +106,14 @@ export const cellxgeneActions = puppeteerPage => ({
|
|||||||
await puppeteerPage.mouse.up();
|
await puppeteerPage.mouse.up();
|
||||||
},
|
},
|
||||||
|
|
||||||
|
async clickOnCoordinate(testid, coord) {
|
||||||
|
const layout = await puppeteerUtils(puppeteerPage).waitByID(testid);
|
||||||
|
const elBox = await layout.boxModel();
|
||||||
|
const x = elBox.content[0].x + coord.x;
|
||||||
|
const y = elBox.content[0].y + coord.y;
|
||||||
|
await puppeteerPage.mouse.click(x, y);
|
||||||
|
},
|
||||||
|
|
||||||
async getAllHistograms(testclass, testids) {
|
async getAllHistograms(testclass, testids) {
|
||||||
const histTestIds = testids.map(tid => `histogram-${tid}`);
|
const histTestIds = testids.map(tid => `histogram-${tid}`);
|
||||||
// these load asynchronously, so we need to wait for each histogram individually
|
// these load asynchronously, so we need to wait for each histogram individually
|
||||||
@@ -137,7 +154,7 @@ export const cellxgeneActions = puppeteerPage => ({
|
|||||||
},
|
},
|
||||||
|
|
||||||
async resetCategory(category) {
|
async resetCategory(category) {
|
||||||
const checkboxId = `category-select-${category}`;
|
const checkboxId = `${category}:category-select`;
|
||||||
await puppeteerUtils(puppeteerPage).waitByID(checkboxId);
|
await puppeteerUtils(puppeteerPage).waitByID(checkboxId);
|
||||||
const checkedPseudoclass = await puppeteerPage.$eval(
|
const checkedPseudoclass = await puppeteerPage.$eval(
|
||||||
`[data-testid='${checkboxId}']`,
|
`[data-testid='${checkboxId}']`,
|
||||||
@@ -150,39 +167,40 @@ export const cellxgeneActions = puppeteerPage => ({
|
|||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
const categoryRow = await puppeteerUtils(puppeteerPage).waitByID(
|
const categoryRow = await puppeteerUtils(puppeteerPage).waitByID(
|
||||||
`category-expand-${category}`
|
`${category}:category-expand`
|
||||||
);
|
);
|
||||||
const isExpanded = await categoryRow.$(
|
const isExpanded = await categoryRow.$(
|
||||||
"[data-testclass='category-expand-is-expanded']"
|
"[data-testclass='category-expand-is-expanded']"
|
||||||
);
|
);
|
||||||
if (isExpanded) {
|
if (isExpanded) {
|
||||||
await puppeteerUtils(puppeteerPage).clickOn(
|
await puppeteerUtils(puppeteerPage).clickOn(
|
||||||
`category-expand-${category}`
|
`${category}:category-expand`
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
} catch {}
|
} catch {}
|
||||||
},
|
},
|
||||||
|
|
||||||
async calcDragCoordinates(testid, coordinateAsPercent) {
|
async calcCoordinate(testid, xAsPercent, yAsPercent) {
|
||||||
const el = await puppeteerUtils(puppeteerPage).waitByID(testid);
|
const el = await puppeteerUtils(puppeteerPage).waitByID(testid);
|
||||||
const size = await el.boxModel();
|
const size = await el.boxModel();
|
||||||
|
return {
|
||||||
|
x: Math.floor(size.width * xAsPercent),
|
||||||
|
y: Math.floor(size.height * yAsPercent)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
async calcDragCoordinates(testid, coordinateAsPercent) {
|
||||||
const coords = {
|
const coords = {
|
||||||
start: {
|
start: await this.calcCoordinate(testid, coordinateAsPercent.x1, coordinateAsPercent.y1),
|
||||||
x: Math.floor(size.width * coordinateAsPercent.x1),
|
end: await this.calcCoordinate(testid, coordinateAsPercent.x2, coordinateAsPercent.y2)
|
||||||
y: Math.floor(size.height * coordinateAsPercent.y1)
|
|
||||||
},
|
|
||||||
end: {
|
|
||||||
x: Math.floor(size.width * coordinateAsPercent.x2),
|
|
||||||
y: Math.floor(size.height * coordinateAsPercent.y2)
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
return coords;
|
return coords;
|
||||||
},
|
},
|
||||||
|
|
||||||
async selectCategory(category, values, reset = true) {
|
async selectCategory(category, values, reset = true) {
|
||||||
if (reset) await this.resetCategory(category);
|
if (reset) await this.resetCategory(category);
|
||||||
await puppeteerUtils(puppeteerPage).clickOn(`category-expand-${category}`);
|
await puppeteerUtils(puppeteerPage).clickOn(`${category}:category-expand`);
|
||||||
await puppeteerUtils(puppeteerPage).clickOn(`category-select-${category}`);
|
await puppeteerUtils(puppeteerPage).clickOn(`${category}:category-select`);
|
||||||
for (const val of values) {
|
for (const val of values) {
|
||||||
await puppeteerUtils(puppeteerPage).clickOn(
|
await puppeteerUtils(puppeteerPage).clickOn(
|
||||||
`categorical-value-select-${category}-${val}`
|
`categorical-value-select-${category}-${val}`
|
||||||
@@ -190,6 +208,12 @@ export const cellxgeneActions = puppeteerPage => ({
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
|
async expandCategory(category) {
|
||||||
|
const expand = await puppeteerUtils(puppeteerPage).waitByID(`${category}:category-expand`);
|
||||||
|
const expandArrow = await expand.$("[data-testclass='category-expand-is-not-expanded']");
|
||||||
|
await expandArrow.click();
|
||||||
|
},
|
||||||
|
|
||||||
async reset() {
|
async reset() {
|
||||||
await puppeteerUtils(puppeteerPage).clickOn("reset");
|
await puppeteerUtils(puppeteerPage).clickOn("reset");
|
||||||
// loading state never actually happens, reset is too fast
|
// loading state never actually happens, reset is too fast
|
||||||
@@ -209,3 +233,33 @@ export const cellxgeneActions = puppeteerPage => ({
|
|||||||
await puppeteerUtils(puppeteerPage).clickOn("clip-commit");
|
await puppeteerUtils(puppeteerPage).clickOn("clip-commit");
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
export async function setupTestBrowser(browserViewport) {
|
||||||
|
const browserParams = DEV
|
||||||
|
? { headless: false, slowMo: 5 }
|
||||||
|
: DEBUG
|
||||||
|
? { headless: false, slowMo: 100, devtools: true }
|
||||||
|
: {};
|
||||||
|
const browser = await puppeteer.launch(browserParams);
|
||||||
|
const page = await browser.newPage();
|
||||||
|
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
|
||||||
|
if (msg.type() === "error") {
|
||||||
|
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}`);
|
||||||
|
}
|
||||||
|
console.log(`PAGE LOG: ${msg.text()}`);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
page.on("pageerror", err => {
|
||||||
|
throw new Error(`Console error: ${err}`);
|
||||||
|
});
|
||||||
|
const utils = puppeteerUtils(page);
|
||||||
|
const cxgActions = cellxgeneActions(page);
|
||||||
|
return [browser, page, utils, cxgActions];
|
||||||
|
}
|
||||||
|
|||||||
@@ -32,7 +32,8 @@ class AnnoDialog extends React.Component {
|
|||||||
handleSubmit,
|
handleSubmit,
|
||||||
primaryButtonText,
|
primaryButtonText,
|
||||||
secondaryButtonText,
|
secondaryButtonText,
|
||||||
handleSecondaryButtonSubmit
|
handleSecondaryButtonSubmit,
|
||||||
|
primaryButtonProps
|
||||||
} = this.props;
|
} = this.props;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -75,6 +76,7 @@ class AnnoDialog extends React.Component {
|
|||||||
</Button>
|
</Button>
|
||||||
) : null}
|
) : null}
|
||||||
<Button
|
<Button
|
||||||
|
{...primaryButtonProps}
|
||||||
onClick={handleSubmit}
|
onClick={handleSubmit}
|
||||||
disabled={!text || validationError}
|
disabled={!text || validationError}
|
||||||
intent="primary"
|
intent="primary"
|
||||||
|
|||||||
@@ -101,6 +101,8 @@ class Category extends React.Component {
|
|||||||
annotations.isAddingNewLabel &&
|
annotations.isAddingNewLabel &&
|
||||||
annotations.categoryAddingNewLabel === metadataField
|
annotations.categoryAddingNewLabel === metadataField
|
||||||
}
|
}
|
||||||
|
inputProps={{ "data-testid": `${metadataField}:create-label-dialog` }}
|
||||||
|
primaryButtonProps={{ "data-testid": `${metadataField}:submit-label` }}
|
||||||
title="Add new label to category"
|
title="Add new label to category"
|
||||||
instruction="New, unique label name:"
|
instruction="New, unique label name:"
|
||||||
cancelTooltipContent="Close this dialog without adding a label."
|
cancelTooltipContent="Close this dialog without adding a label."
|
||||||
@@ -116,6 +118,7 @@ class Category extends React.Component {
|
|||||||
<AnnoInputs
|
<AnnoInputs
|
||||||
useSuggest={ontologyEnabled}
|
useSuggest={ontologyEnabled}
|
||||||
text={newLabelText}
|
text={newLabelText}
|
||||||
|
inputProps={{ "data-testid": `${metadataField}:new-label-name` }}
|
||||||
handleCreateArbitraryLabel={this.handleCreateArbitraryLabel}
|
handleCreateArbitraryLabel={this.handleCreateArbitraryLabel}
|
||||||
handleItemChange={this.handleSuggestActiveItemChange}
|
handleItemChange={this.handleSuggestActiveItemChange}
|
||||||
handleChoice={this.handleChoice}
|
handleChoice={this.handleChoice}
|
||||||
|
|||||||
@@ -121,6 +121,8 @@ class AnnoDialogEditCategoryName extends React.Component {
|
|||||||
annotations.isEditingCategoryName &&
|
annotations.isEditingCategoryName &&
|
||||||
annotations.categoryBeingEdited === metadataField
|
annotations.categoryBeingEdited === metadataField
|
||||||
}
|
}
|
||||||
|
inputProps={{ "data-testid": `${metadataField}:edit-category-name-dialog` }}
|
||||||
|
primaryButtonProps={{ "data-testid": `${metadataField}:submit-category-edit` }}
|
||||||
title="Edit category name"
|
title="Edit category name"
|
||||||
instruction="New, unique category name:"
|
instruction="New, unique category name:"
|
||||||
cancelTooltipContent="Close this dialog without editing this category."
|
cancelTooltipContent="Close this dialog without editing this category."
|
||||||
@@ -132,6 +134,7 @@ class AnnoDialogEditCategoryName extends React.Component {
|
|||||||
handleCancel={this.disableEditCategoryMode}
|
handleCancel={this.disableEditCategoryMode}
|
||||||
annoInput={
|
annoInput={
|
||||||
<AnnoInputs
|
<AnnoInputs
|
||||||
|
inputProps={{ "data-testid": `${metadataField}:edit-category-name-text`}}
|
||||||
useSuggest={false}
|
useSuggest={false}
|
||||||
text={newCategoryText}
|
text={newCategoryText}
|
||||||
handleTextChange={this.handleCategoryEditTextChange}
|
handleTextChange={this.handleCategoryEditTextChange}
|
||||||
|
|||||||
@@ -3,9 +3,10 @@ import { connect } from "react-redux";
|
|||||||
import { InputGroup } from "@blueprintjs/core";
|
import { InputGroup } from "@blueprintjs/core";
|
||||||
|
|
||||||
const VanillaInput = props => {
|
const VanillaInput = props => {
|
||||||
const { text, handleTextChange } = props;
|
const { text, handleTextChange, inputProps } = props;
|
||||||
return (
|
return (
|
||||||
<InputGroup
|
<InputGroup
|
||||||
|
{...inputProps}
|
||||||
autoFocus
|
autoFocus
|
||||||
value={text}
|
value={text}
|
||||||
intent="none"
|
intent="none"
|
||||||
@@ -31,10 +32,14 @@ class AnnoInputs extends React.Component {
|
|||||||
}
|
}
|
||||||
|
|
||||||
render() {
|
render() {
|
||||||
const { handleTextChange, text } = this.props;
|
const { handleTextChange, text, ...restProps } = this.props;
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<VanillaInput text={text} handleTextChange={handleTextChange} />
|
<VanillaInput
|
||||||
|
{...restProps}
|
||||||
|
text={text}
|
||||||
|
handleTextChange={handleTextChange}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -76,7 +76,7 @@ class AnnoMenuCategory extends React.Component {
|
|||||||
<MenuItem
|
<MenuItem
|
||||||
icon="tag"
|
icon="tag"
|
||||||
data-testclass="handleAddNewLabelToCategory"
|
data-testclass="handleAddNewLabelToCategory"
|
||||||
data-testid={`handleAddNewLabelToCategory-${metadataField}`}
|
data-testid={`${metadataField}:add-new-label-to-category`}
|
||||||
onClick={this.activateAddNewLabelMode}
|
onClick={this.activateAddNewLabelMode}
|
||||||
text={createText}
|
text={createText}
|
||||||
/>
|
/>
|
||||||
@@ -84,7 +84,7 @@ class AnnoMenuCategory extends React.Component {
|
|||||||
<MenuItem
|
<MenuItem
|
||||||
icon="book"
|
icon="book"
|
||||||
data-testclass="activateAddNewOntologyLabelMode"
|
data-testclass="activateAddNewOntologyLabelMode"
|
||||||
data-testid={`activateAddNewOntologyLabelMode-${metadataField}`}
|
data-testid={`${metadataField}:add-new-ontology-label-mode`}
|
||||||
onClick={this.activateAddNewOntologyLabelMode}
|
onClick={this.activateAddNewOntologyLabelMode}
|
||||||
text={createFromOntologyText}
|
text={createFromOntologyText}
|
||||||
/>
|
/>
|
||||||
@@ -93,7 +93,7 @@ class AnnoMenuCategory extends React.Component {
|
|||||||
icon="edit"
|
icon="edit"
|
||||||
disabled={annotations.isEditingCategoryName}
|
disabled={annotations.isEditingCategoryName}
|
||||||
data-testclass="activateEditCategoryMode"
|
data-testclass="activateEditCategoryMode"
|
||||||
data-testid={`activateEditCategoryMode-${metadataField}`}
|
data-testid={`${metadataField}:edit-category-mode`}
|
||||||
onClick={this.activateEditCategoryMode}
|
onClick={this.activateEditCategoryMode}
|
||||||
text={editText}
|
text={editText}
|
||||||
/>
|
/>
|
||||||
@@ -101,7 +101,7 @@ class AnnoMenuCategory extends React.Component {
|
|||||||
icon="delete"
|
icon="delete"
|
||||||
intent="danger"
|
intent="danger"
|
||||||
data-testclass="handleDeleteCategory"
|
data-testclass="handleDeleteCategory"
|
||||||
data-testid={`handleDeleteCategory-${metadataField}`}
|
data-testid={`${metadataField}:delete-category`}
|
||||||
onClick={this.handleDeleteCategory}
|
onClick={this.handleDeleteCategory}
|
||||||
text={deleteText}
|
text={deleteText}
|
||||||
/>
|
/>
|
||||||
@@ -111,7 +111,7 @@ class AnnoMenuCategory extends React.Component {
|
|||||||
<Button
|
<Button
|
||||||
style={{ marginLeft: 0 }}
|
style={{ marginLeft: 0 }}
|
||||||
data-testclass="seeActions"
|
data-testclass="seeActions"
|
||||||
data-testid={`seeActions-${metadataField}`}
|
data-testid={`${metadataField}:see-actions`}
|
||||||
icon="more"
|
icon="more"
|
||||||
minimal
|
minimal
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -145,6 +145,7 @@ class Categories extends React.Component {
|
|||||||
instruction="New, unique category name:"
|
instruction="New, unique category name:"
|
||||||
cancelTooltipContent="Close this dialog without creating a category."
|
cancelTooltipContent="Close this dialog without creating a category."
|
||||||
primaryButtonText="Create new category"
|
primaryButtonText="Create new category"
|
||||||
|
primaryButtonProps={{ "data-testid": "submit-category" }}
|
||||||
text={newCategoryText}
|
text={newCategoryText}
|
||||||
validationError={this.categoryNameError(newCategoryText)}
|
validationError={this.categoryNameError(newCategoryText)}
|
||||||
errorMessage={this.categoryNameErrorMessage(newCategoryText)}
|
errorMessage={this.categoryNameErrorMessage(newCategoryText)}
|
||||||
@@ -153,6 +154,7 @@ class Categories extends React.Component {
|
|||||||
annoInput={
|
annoInput={
|
||||||
<AnnoInputs
|
<AnnoInputs
|
||||||
text={newCategoryText}
|
text={newCategoryText}
|
||||||
|
inputProps={{ "data-testid": "new-category-name" }}
|
||||||
handleItemChange={this.handleSuggestActiveItemChange}
|
handleItemChange={this.handleSuggestActiveItemChange}
|
||||||
handleChoice={this.handleChoice}
|
handleChoice={this.handleChoice}
|
||||||
handleTextChange={this.handleNewCategoryText}
|
handleTextChange={this.handleNewCategoryText}
|
||||||
@@ -194,7 +196,11 @@ class Categories extends React.Component {
|
|||||||
)}
|
)}
|
||||||
{writableCategoriesEnabled ? (
|
{writableCategoriesEnabled ? (
|
||||||
<div>
|
<div>
|
||||||
<Button onClick={this.handleEnableAnnoMode} intent="primary">
|
<Button
|
||||||
|
data-testid="open-annotation-dialog"
|
||||||
|
onClick={this.handleEnableAnnoMode}
|
||||||
|
intent="primary"
|
||||||
|
>
|
||||||
Create new category
|
Create new category
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -175,7 +175,7 @@ class Category extends React.Component {
|
|||||||
<label className="bp3-control bp3-checkbox">
|
<label className="bp3-control bp3-checkbox">
|
||||||
<input
|
<input
|
||||||
data-testclass="category-select"
|
data-testclass="category-select"
|
||||||
data-testid={`category-select-${metadataField}`}
|
data-testid={`${metadataField}:category-select`}
|
||||||
onChange={this.handleToggleAllClick.bind(this)}
|
onChange={this.handleToggleAllClick.bind(this)}
|
||||||
ref={el => {
|
ref={el => {
|
||||||
this.checkbox = el;
|
this.checkbox = el;
|
||||||
@@ -187,7 +187,7 @@ class Category extends React.Component {
|
|||||||
<span className="bp3-control-indicator" />
|
<span className="bp3-control-indicator" />
|
||||||
</label>
|
</label>
|
||||||
<span
|
<span
|
||||||
data-testid={`category-expand-${metadataField}`}
|
data-testid={`${metadataField}:category-expand`}
|
||||||
style={{
|
style={{
|
||||||
cursor: "pointer",
|
cursor: "pointer",
|
||||||
display: "inline-block"
|
display: "inline-block"
|
||||||
|
|||||||
@@ -433,6 +433,8 @@ class CategoryValue extends React.Component {
|
|||||||
<div>
|
<div>
|
||||||
<AnnoDialog
|
<AnnoDialog
|
||||||
isActive={editModeActive}
|
isActive={editModeActive}
|
||||||
|
inputProps={{ "data-testid": `${metadataField}:edit-label-name-dialog` }}
|
||||||
|
primaryButtonProps={{ "data-testid": `${metadataField}:${displayString}:submit-label-edit` }}
|
||||||
title="Edit label"
|
title="Edit label"
|
||||||
instruction={`New label text must be unique within category ${metadataField}:`}
|
instruction={`New label text must be unique within category ${metadataField}:`}
|
||||||
cancelTooltipContent="Close this dialog without editing label text."
|
cancelTooltipContent="Close this dialog without editing label text."
|
||||||
@@ -447,6 +449,7 @@ class CategoryValue extends React.Component {
|
|||||||
<AnnoInputs
|
<AnnoInputs
|
||||||
useSuggest={ontologyEnabled}
|
useSuggest={ontologyEnabled}
|
||||||
text={editedLabelText}
|
text={editedLabelText}
|
||||||
|
inputProps={{ "data-testid": `${metadataField}:${displayString}:edit-label-name` }}
|
||||||
handleCreateArbitraryLabel={
|
handleCreateArbitraryLabel={
|
||||||
this.handleCreateArbitraryLabel
|
this.handleCreateArbitraryLabel
|
||||||
}
|
}
|
||||||
@@ -513,7 +516,7 @@ class CategoryValue extends React.Component {
|
|||||||
<MenuItem
|
<MenuItem
|
||||||
icon="plus"
|
icon="plus"
|
||||||
data-testclass="handleAddCurrentSelectionToThisLabel"
|
data-testclass="handleAddCurrentSelectionToThisLabel"
|
||||||
data-testid={`handleAddCurrentSelectionToThisLabel-${metadataField}`}
|
data-testid={`${metadataField}:${displayString}:add-current-selection-to-this-label`}
|
||||||
onClick={this.handleAddCurrentSelectionToThisLabel}
|
onClick={this.handleAddCurrentSelectionToThisLabel}
|
||||||
text={
|
text={
|
||||||
<span>
|
<span>
|
||||||
@@ -541,7 +544,7 @@ class CategoryValue extends React.Component {
|
|||||||
icon="edit"
|
icon="edit"
|
||||||
text="Edit this label's name"
|
text="Edit this label's name"
|
||||||
data-testclass="handleEditValue"
|
data-testclass="handleEditValue"
|
||||||
data-testid={`handleEditValue-${metadataField}`}
|
data-testid={`${metadataField}:${displayString}:edit-label`}
|
||||||
onClick={this.activateEditLabelMode}
|
onClick={this.activateEditLabelMode}
|
||||||
disabled={annotations.isEditingLabelName}
|
disabled={annotations.isEditingLabelName}
|
||||||
/>
|
/>
|
||||||
@@ -551,7 +554,7 @@ class CategoryValue extends React.Component {
|
|||||||
icon="delete"
|
icon="delete"
|
||||||
intent="danger"
|
intent="danger"
|
||||||
data-testclass="handleDeleteValue"
|
data-testclass="handleDeleteValue"
|
||||||
data-testid={`handleDeleteValue-${metadataField}`}
|
data-testid={`${metadataField}:${displayString}:delete-label`}
|
||||||
onClick={this.handleDeleteValue}
|
onClick={this.handleDeleteValue}
|
||||||
text={`Delete this label, and reassign all cells to type '${globals.unassignedCategoryLabel}'`}
|
text={`Delete this label, and reassign all cells to type '${globals.unassignedCategoryLabel}'`}
|
||||||
/>
|
/>
|
||||||
@@ -567,7 +570,7 @@ class CategoryValue extends React.Component {
|
|||||||
minHeight: 16
|
minHeight: 16
|
||||||
}}
|
}}
|
||||||
data-testclass="seeActions"
|
data-testclass="seeActions"
|
||||||
data-testid={`seeActions-${metadataField}`}
|
data-testid={`${metadataField}:${displayString}:see-actions`}
|
||||||
icon="more"
|
icon="more"
|
||||||
small
|
small
|
||||||
minimal
|
minimal
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
/*
|
/*
|
||||||
Helper functions for user-editable nnotations state management.
|
Helper functions for user-editable annotations state management.
|
||||||
See also reducers/annotations.js
|
See also reducers/annotations.js
|
||||||
*/
|
*/
|
||||||
import { unassignedCategoryLabel } from "../../globals";
|
import { unassignedCategoryLabel } from "../../globals";
|
||||||
|
|||||||
@@ -134,4 +134,3 @@ You can also use `CXG_OPTIONS` to pass options to the `cellxgene launch` command
|
|||||||
**About** Runs backend tests without starting the server. You will need to start the rest api separately with the pbmc3k.h5ad file. Note you can use the `JEST_ENV` environment variable to change how JEST runs in the browser.
|
**About** Runs backend tests without starting the server. You will need to start the rest api separately with the pbmc3k.h5ad file. Note you can use the `JEST_ENV` environment variable to change how JEST runs in the browser.
|
||||||
|
|
||||||
**Usage** `make e2e`
|
**Usage** `make e2e`
|
||||||
|
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -14,6 +14,8 @@ START_SERVER_SCRIPT="$1"
|
|||||||
PORT="$2"
|
PORT="$2"
|
||||||
TEST_SCRIPT="$3"
|
TEST_SCRIPT="$3"
|
||||||
|
|
||||||
|
await_port --await-free "$PORT"
|
||||||
|
|
||||||
eval "$START_SERVER_SCRIPT &"
|
eval "$START_SERVER_SCRIPT &"
|
||||||
SERVER_PID=$!
|
SERVER_PID=$!
|
||||||
|
|
||||||
|
|||||||
@@ -38,7 +38,6 @@ class WritableAnnotationTest(unittest.TestCase):
|
|||||||
|
|
||||||
def test_error_checks(self):
|
def test_error_checks(self):
|
||||||
# verify that the expected errors are generated
|
# verify that the expected errors are generated
|
||||||
|
|
||||||
n_rows = self.data.data.obs.shape[0]
|
n_rows = self.data.data.obs.shape[0]
|
||||||
fbs_bad = self.make_fbs({"louvain": pd.Series(["undefined" for l in range(0, n_rows)], dtype="category")})
|
fbs_bad = self.make_fbs({"louvain": pd.Series(["undefined" for l in range(0, n_rows)], dtype="category")})
|
||||||
|
|
||||||
@@ -64,7 +63,7 @@ class WritableAnnotationTest(unittest.TestCase):
|
|||||||
self.assertTrue(path.exists(self.annotations_file))
|
self.assertTrue(path.exists(self.annotations_file))
|
||||||
df = pd.read_csv(self.annotations_file, index_col=0, header=0, comment="#")
|
df = pd.read_csv(self.annotations_file, index_col=0, header=0, comment="#")
|
||||||
self.assertEqual(df.shape, (n_rows, 2))
|
self.assertEqual(df.shape, (n_rows, 2))
|
||||||
self.assertEqual(set(df.columns), set(["cat_A", "cat_B"]))
|
self.assertEqual(set(df.columns), {"cat_A", "cat_B"})
|
||||||
self.assertTrue(self.data.original_obs_index.equals(df.index))
|
self.assertTrue(self.data.original_obs_index.equals(df.index))
|
||||||
self.assertTrue(np.all(df["cat_A"] == ["label_A" for l in range(0, n_rows)]))
|
self.assertTrue(np.all(df["cat_A"] == ["label_A" for l in range(0, n_rows)]))
|
||||||
self.assertTrue(np.all(df["cat_B"] == ["label_B" for l in range(0, n_rows)]))
|
self.assertTrue(np.all(df["cat_B"] == ["label_B" for l in range(0, n_rows)]))
|
||||||
@@ -80,7 +79,7 @@ class WritableAnnotationTest(unittest.TestCase):
|
|||||||
self.assertEqual(res, json.dumps({"status": "OK"}))
|
self.assertEqual(res, json.dumps({"status": "OK"}))
|
||||||
self.assertTrue(path.exists(self.annotations_file))
|
self.assertTrue(path.exists(self.annotations_file))
|
||||||
df = pd.read_csv(self.annotations_file, index_col=0, header=0, comment="#")
|
df = pd.read_csv(self.annotations_file, index_col=0, header=0, comment="#")
|
||||||
self.assertEqual(set(df.columns), set(["cat_A", "cat_C"]))
|
self.assertEqual(set(df.columns), {"cat_A", "cat_C"})
|
||||||
self.assertTrue(np.all(df["cat_A"] == ["label_A1" for l in range(0, n_rows)]))
|
self.assertTrue(np.all(df["cat_A"] == ["label_A1" for l in range(0, n_rows)]))
|
||||||
self.assertTrue(np.all(df["cat_C"] == ["label_C" for l in range(0, n_rows)]))
|
self.assertTrue(np.all(df["cat_C"] == ["label_C" for l in range(0, n_rows)]))
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user