diff --git a/LOCALIZATION.md b/LOCALIZATION.md new file mode 100644 index 000000000..ddc23bdaf --- /dev/null +++ b/LOCALIZATION.md @@ -0,0 +1,106 @@ +## Localization with react-i18next + +Cryostat-web uses [i18next](https://www.i18next.com/) as an internationalization-framework. The react-i18next package is used to integrate i18next with React. To add new user strings to the project, they must be handled in the manner below. + +### Adding a new translation + +The current list of language locales supported in Cryostat can be found in `src/i18n/config.ts`. The translations themselves can be found in `locales/{LOCALE_CODE}` + +To add a new language, add a new entry to the `i18nResources` object in `src/i18n.ts`. The key should be the language locale, and the value should be the translation object containing the corresponding namespace json files in `locales`. + +To add a new localization key for a user-facing string in `cryostat-web`, use the `t` function from `react-i18next`: + +```tsx +import { useTranslation } from 'react-i18next'; +... +export const SomeFC = (props) => { + const { t } = useTranslation(); + + return ( +
+ {t('somekey')} +
+ ); +} +``` +After saving the file, and running `yarn localize`, this will generate a new key in the `en` locale namespace json file in `/locales/en/common.json` *(having multiple locales will add a key to each locale json file!)*: +```bash +$ yarn localize # uses i18next-parser to generate based on files in src/ +``` +`locales/en/common.json` +```json +{ + ... + "somekey": "cryostat_tmp", + ... +} +``` + +The value of the key will be the string `cryostat_tmp` by default (we set this in `i18next-parser.config.js`). This is a placeholder value that should be replaced with the actual translation by going to the corresponding locale json file and manually replacing the value with the translation. + +`locales/en/common.json` +```json +{ + ... + "somekey": "This is a translation", + ... +} +``` + + + +The React i18next API docs can be found [here](https://react.i18next.com/latest/using-with-hooks). + +### Cryostat locale namespaces + +Currently the two namespaces are `common` and `public`. + +If you want to add a new key to a specific namespace, you can specify the namespace as the first argument to the `t` function: + +```tsx +
+ {t('SOME_COMMON_KEY', { ns: 'common' })} +
+``` + +In `cryostat-web`, we use `common` for common user-facing strings that you may see all the time: e.g. `Home`, `Help`, `Cancel`, etc. + + +```tsx +
+ {t('Cancel')} +
+``` + +`locales/en/common.json` +```json +{ + "CANCEL": "Cancel", +} +``` +These keys should be capitalized, and should be unique within the namespace. + +If we want to localize specific user-facing strings that are only used in a specific component, we can use the `public` namespace. We don't actually need to specify the namespace in this case for the `t` function, since we set this as the default namespace in `src/i18n/config.ts`: + +```tsx +
+ {t(`AboutDescription.VERSION`)} +
+``` +`locales/en/public.json` +```json + ... + "AboutDescription": { + "BUGS": "Bugs", + "FILE_A_REPORT": "File a Report", + "VERSION": "some version!" + }, + ... +``` + +To run unit tests using Jest that use a translation, but we want to test the value, use the `testT` function from `src/test/Common.tsx`: + +e.g. +```tsx +expect(screen.getByText(testT('CRYOSTAT_TRADEMARK', { ns: 'common' }))).toBeInTheDocument(); +``` diff --git a/README.md b/README.md index f3940232b..e882d1c4b 100644 --- a/README.md +++ b/README.md @@ -72,27 +72,52 @@ In this case, API requests are intercepted and handled by [Mirage JS](https://mi ## TEST ### Run the unit tests -``` +```bash $ yarn test ``` +### Run the integration tests +```bash +$ yarn itest:preview +``` + Refer to [TESTING.md](TESTING.md) for more details about tests. ### Run the linter +[ESLint](https://eslint.org/) is a linter that checks for code quality and style. Configuration can be found in `.eslintrc`. +The `ESLint` job runs on every pull request, and will fail if there are any ESLint errors. Warnings will not fail the job. + +To fix this, run: ```bash $ yarn eslint:apply ``` +You can also run `yarn eslint:check` to see if there are any ESLint issues without applying the fixes. + +To run a development server with ESLint enabled in hot-reload mode, run: +```bash +$ yarn start:dev:lint +``` + +With this command, ESLint will run on every file change, and will show ESLint errors/warnings in the terminal. ### Run the code formatter +Prettier is a code formatter that makes sure that all code is formatted the same way. Configuration can be found in `.prettierrc`. There is a `prettierignore` file that tells Prettier to ignore certain files. + +The license header checking job makes sure that all files have the correct license header. The npm package can be found [here](https://www.npmjs.com/package/license-check-and-add). The license header can be found in `LICENSE`. The `license-check-and-add` configuration can be found in `license-config.json`. + +The `Format` job runs on every pull request, and will fail if the code is not formatted correctly, or if some licenses have not been added to some files. + +To fix this, format the code: ```bash $ yarn format:apply -``` +``` +You can also run `yarn format:check` to see if there are any formatting issues without applying the formatting. ### Inspect the bundle size -``` +```bash $ yarn bundle-profile:analyze ``` @@ -100,11 +125,13 @@ $ yarn bundle-profile:analyze To generate translation entries for texts in the app, run: -``` -yarn localize +```bash +$ yarn localize ``` The extraction tool is [`i18next-parser`](https://www.npmjs.com/package/i18next-parser), which statically finds and exports translation entries, meaning `i18next-parser` does not run code and requires explicit values. See more [details](https://github.com/i18next/i18next-parser#caveats ). To workaround this, specify static values in `i18n.ts` file under any top-level directory below `src/app`. For example, `src/app/Settings/i18n.ts`. + +Refer to [LOCALIZATION.md](LOCALIZATION.md) for more details about our localization framework. diff --git a/TESTING.md b/TESTING.md index 6fc56263b..0c6533d3c 100644 --- a/TESTING.md +++ b/TESTING.md @@ -97,3 +97,9 @@ You will also need a WebDriver implementation for the specific browser you want * [Edge WebDriver (for Microsoft Edge)](https://developer.microsoft.com/en-us/microsoft-edge/tools/webdriver/) Then, finally we can run `yarn itest` to run the integration tests, which will open up a fresh browser window and run the tests. + +Alternatively, you can start the integration tests immediately without the need for an already up and running dev server, by running +```bash +$ yarn itest:preview +``` +This will automatically start a Mirage dev server, run the integration tests on that server, and tear down the server on completion. diff --git a/license-config.json b/license-config.json index 328b5f344..92f9764ba 100644 --- a/license-config.json +++ b/license-config.json @@ -17,8 +17,7 @@ "stories", "__mocks__", "**/.*", - "**/README.md", - "TESTING.md", + "**/*.md", "**/*.js", "yarn.lock", ".yarn/**/*", diff --git a/package.json b/package.json index fca912a6f..95cb8f10f 100644 --- a/package.json +++ b/package.json @@ -19,7 +19,7 @@ "eslint:check": "eslint --cache --ext .ts,.tsx,.js ./src/", "eslint:apply": "eslint --cache --ext .ts,.tsx,.js --fix ./src/", "itest:preview": "HEADLESS_BROWSER=true concurrently -c 'auto' 'yarn:start:dev:preview --no-open' 'yarn:itest' --kill-others --success first", - "itest": "wait-on -lt 60000 http://localhost:9091 && jest --testMatch='**/itest/**/*.test.(ts|tsx)'", + "itest": "wait-on -l --httpTimeout 120000 -d 5000 http://localhost:9091 && jest --testMatch='**/itest/**/*.test.(ts|tsx)'", "license:check": "license-check-and-add check -f license-config.json", "license:apply": "license-check-and-add add -f license-config.json", "lint": "concurrently -c 'auto' 'yarn:format' 'yarn:eslint' 'yarn:type-check'", diff --git a/src/itest/Dashboard.test.ts b/src/itest/Dashboard.test.ts index dbf7c3ad4..f662f70e4 100644 --- a/src/itest/Dashboard.test.ts +++ b/src/itest/Dashboard.test.ts @@ -14,34 +14,26 @@ * limitations under the License. */ import assert from 'assert'; -import { By, WebDriver, until } from 'selenium-webdriver'; -import { - getElementByCSS, - getElementById, - getElementByLinkText, - getElementByXPath, - selectFakeTarget, - setupBuilder, -} from './util'; +import { WebDriver } from 'selenium-webdriver'; +import { CardType, Cryostat, Dashboard, setupDriver } from './util'; describe('Dashboard route functionalities', function () { let driver: WebDriver; - jest.setTimeout(30000); + let dashboard: Dashboard; + let cryostat: Cryostat; + jest.setTimeout(60000); beforeAll(async function () { - driver = await setupBuilder().build(); - await driver.get('http://localhost:9091'); + driver = await setupDriver(); + cryostat = Cryostat.getInstance(driver); + dashboard = await cryostat.navigateToDashboard(); - const skipButton = await driver - .wait(until.elementLocated(By.css('button[data-action="skip"]')), 1000) - .catch(() => null); - if (skipButton) await skipButton.click(); - - await selectFakeTarget(driver); + await cryostat.skipTour(); + await cryostat.selectFakeTarget(); }); afterAll(async function () { - await driver.quit(); + await driver.close(); }); it('shows correct route', async function () { @@ -51,78 +43,22 @@ describe('Dashboard route functionalities', function () { }); it('adds a new layout', async function () { - const layoutSelector = await getElementById(driver, 'dashboard-layout-dropdown-toggle'); - await layoutSelector.click(); - - const newLayoutButton = await getElementByXPath(driver, '//button[contains(.,"New Layout")]'); - await newLayoutButton.click(); - - const emptyState = await getElementByCSS(driver, `.pf-c-empty-state__content`); - expect(emptyState).toBeTruthy(); + await dashboard.addLayout(); + const layoutName = await dashboard.getLayoutName(); + assert.equal(layoutName, 'Custom1'); }); - it('adds three different cards', async function () { - let finishButton; - const addCardButton = await getElementByCSS(driver, `[aria-label="Add card"]`); - await addCardButton.click(); - - // click TargetJVMDetails card - const detailsCard = await getElementById(driver, `JvmDetailsCard.CARD_TITLE`); - await detailsCard.click(); - - finishButton = await getElementByCSS(driver, 'button.pf-c-button.pf-m-primary[type="submit"]'); - await finishButton.click(); - await addCardButton.click(); - - // click AutomatedAnalysis card - const aaCard = await driver.findElement(By.id(`AutomatedAnalysisCard.CARD_TITLE`)); - await aaCard.click(); - - finishButton = await getElementByCSS(driver, 'button.pf-c-button.pf-m-primary[type="submit"]'); - await finishButton.click(); // next - await finishButton.click(); // finish - - await addCardButton.click(); - - // click MBeanMetrics card - const mbeanCard = await driver.findElement(By.id(`CHART_CARD.MBEAN_METRICS_CARD_TITLE`)); - await mbeanCard.click(); - - finishButton = await getElementByCSS(driver, 'button.pf-c-button.pf-m-primary[type="submit"]'); - await finishButton.click(); // next - await finishButton.click(); // finish - }); - - it('removes all cards', async function () { - let firstCard = await driver.findElement( - By.xpath(`//div[contains(@class, 'pf-l-grid__item')][@style='--pf-l-grid--item--Order: 0;']`) - ); - let actionsButton = await firstCard.findElement(By.css('button[aria-label="Actions"]')); - await actionsButton.click(); - - let removeButton = await getElementByLinkText(driver, 'Remove'); - await removeButton.click(); - - firstCard = await driver.findElement( - By.xpath(`//div[contains(@class, 'pf-l-grid__item')][@style='--pf-l-grid--item--Order: 0;']`) - ); - actionsButton = await firstCard.findElement(By.css('button[aria-label="Actions"]')); - await actionsButton.click(); - - removeButton = await getElementByLinkText(driver, 'Remove'); - await removeButton.click(); + it('adds three different cards and removes them', async function () { + await dashboard.addCard(CardType.TARGET_JVM_DETAILS); + await dashboard.addCard(CardType.AUTOMATED_ANALYSIS); + await dashboard.addCard(CardType.MBEAN_METRICS_CHART); - firstCard = await driver.findElement( - By.xpath(`//div[contains(@class, 'pf-l-grid__item')][@style='--pf-l-grid--item--Order: 0;']`) - ); - actionsButton = await firstCard.findElement(By.css('button[aria-label="Actions"]')); - await actionsButton.click(); + assert.equal((await dashboard.getCards()).length, 3); - removeButton = await getElementByLinkText(driver, 'Remove'); - await removeButton.click(); + while ((await dashboard.getCards()).length > 0) { + await dashboard.removeCard(); + } - // check all cards are removed - const emptyState = await getElementByCSS(driver, `.pf-c-empty-state__content`); - expect(emptyState).toBeTruthy(); + assert.ok(await dashboard.isEmpty()); }); }); diff --git a/src/itest/RecordingWorkflow.test.ts b/src/itest/RecordingWorkflow.test.ts new file mode 100644 index 000000000..be33400a7 --- /dev/null +++ b/src/itest/RecordingWorkflow.test.ts @@ -0,0 +1,87 @@ +/* + * Copyright The Cryostat Authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +import assert from 'assert'; +import { RecordingState } from '@app/Shared/Services/Api.service'; +import { WebDriver } from 'selenium-webdriver'; +import { Cryostat, Recordings, setupDriver, sleep } from './util'; + +describe('Recording workflow steps', function () { + let driver: WebDriver; + let recordings: Recordings; + let cryostat: Cryostat; + jest.setTimeout(60000); + + beforeAll(async function () { + driver = await setupDriver(); + cryostat = Cryostat.getInstance(driver); + recordings = await cryostat.navigateToRecordings(); + + await cryostat.skipTour(); + await cryostat.selectFakeTarget(); + }); + + afterAll(async function () { + await driver.close(); + }); + + it('shows correct route', async function () { + const url = await driver.getCurrentUrl(); + const route = url.split('/').pop(); + assert.equal('recordings', route); + }); + + it('creates a new recording', async function () { + assert.equal((await recordings.getRecordings()).length, 0); + await recordings.createRecording('helloWorld'); + const active = await recordings.getRecordings(); + assert.equal(active.length, 1); + + const state = await recordings.getRecordingState(active[0]); + assert.equal(state, RecordingState.RUNNING); + }); + + it('stops a recording', async function () { + const active = await recordings.getRecordings(); + assert.equal(active.length, 1); + + await recordings.stopRecording(active[0]); + + const state = await recordings.getRecordingState(active[0]); + assert.equal(state, RecordingState.STOPPED); + }); + + it('archives a new recording', async function () { + const active = await recordings.getRecordings(); + assert.equal(active.length, 1); + + await recordings.archiveRecording(active[0]); + const notif = await cryostat.getLatestNotification(); + + assert.equal(notif.title, 'Recording Saved'); + assert.ok(notif.description.includes('helloWorld')); + }); + + it('deletes a recording', async function () { + const active = await recordings.getRecordings(); + assert.equal(active.length, 1); + + await recordings.deleteRecording(active[0]); + await sleep(10000); + assert.equal((await recordings.getRecordings()).length, 0); + }); + + // TODO: checking UI for download, report generation, label editing +}); diff --git a/src/itest/util.ts b/src/itest/util.ts index 42dbb820d..31f0ef753 100644 --- a/src/itest/util.ts +++ b/src/itest/util.ts @@ -13,43 +13,265 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -import { Builder, By, WebDriver } from 'selenium-webdriver'; +import { Builder, By, WebDriver, WebElement, WebElementPromise, until } from 'selenium-webdriver'; import firefox from 'selenium-webdriver/firefox'; -export async function selectFakeTarget(driver: WebDriver) { - const targetName = 'Fake Target'; - const targetSelect = await driver.findElement(By.css(`[aria-label="Options menu"]`)); - await targetSelect.click(); - const targetOption = await driver.findElement(By.xpath(`//*[contains(text(), '${targetName}')]`)); - await targetOption.click(); +const DEFAULT_FIND_ELEMENT_TIMEOUT = 5000; + +export function getElementByXPath(driver: WebDriver, xpath: string) { + return driver.wait(until.elementLocated(By.xpath(xpath))); } -export async function getElementByXPath(driver: WebDriver, xpath: string) { - const element = await driver.findElement(By.xpath(xpath)); - return element; +export function getElementByCSS(driver: WebDriver, cssSelector: string) { + return driver.wait(until.elementLocated(By.css(cssSelector))); } -export async function getElementByCSS(driver: WebDriver, cssSelector: string) { - const element = await driver.findElement(By.css(cssSelector)); - return element; +export function getElementById(driver: WebDriver, id: string): WebElementPromise { + return driver.wait(until.elementLocated(By.id(id))); } -export async function getElementById(driver: WebDriver, id: string) { - const element = await driver.findElement(By.id(id)); - return element; +export function getElementByLinkText(driver: WebDriver, linkText: string) { + return driver.wait(until.elementLocated(By.linkText(linkText))); } -export async function getElementByLinkText(driver: WebDriver, linkText: string) { - const element = await driver.findElement(By.linkText(linkText)); - return element; +export function getElementByAttribute(driver: WebDriver, attribute: string, value: string) { + return driver.wait(until.elementLocated(By.xpath(`//*[@${attribute}='${value}']`))); } -export function setupBuilder(): Builder { +export async function setupDriver(): Promise { const headless = process.env.HEADLESS_BROWSER === 'true'; const options = new firefox.Options(); if (headless) { options.headless(); } options.setAcceptInsecureCerts(true); - return new Builder().forBrowser('firefox').setFirefoxOptions(options); + options.addArguments('--width=1920', '--height=1080'); + const driver = new Builder().forBrowser('firefox').setFirefoxOptions(options).build(); + await driver.manage().setTimeouts({ + implicit: DEFAULT_FIND_ELEMENT_TIMEOUT, + }); + return driver; +} + +export class Cryostat { + private driver: WebDriver; + private static instance: Cryostat; + + private constructor(driver: WebDriver) { + this.driver = driver; + } + + public static getInstance(driver: WebDriver): Cryostat { + if (!Cryostat.instance) { + Cryostat.instance = new Cryostat(driver); + } + return Cryostat.instance; + } + + async navigateToDashboard(): Promise { + await this.driver.get('http://localhost:9091'); + return new Dashboard(this.driver); + } + + async navigateToRecordings(): Promise { + await this.driver.get('http://localhost:9091/recordings'); + return new Recordings(this.driver); + } + + async selectFakeTarget() { + const targetName = 'Fake Target'; + const targetSelect = await this.driver.wait(until.elementLocated(By.css(`[aria-label="Options menu"]`))); + await targetSelect.click(); + const targetOption = await this.driver.wait( + until.elementLocated(By.xpath(`//*[contains(text(), '${targetName}')]`)) + ); + await targetOption.click(); + } + + async skipTour() { + const skipButton = await this.driver + .wait(until.elementLocated(By.css('button[data-action="skip"]'))) + .catch(() => null); + if (skipButton) await skipButton.click(); + } + + async getLatestNotification(): Promise { + const latestNotification = await this.driver.wait( + until.elementLocated(By.className('pf-c-alert-group pf-m-toast')) + ); + return { + title: await getDirectTextContent( + this.driver, + await latestNotification.findElement(By.css('li:last-of-type .pf-c-alert__title')) + ), + description: await latestNotification.findElement(By.css('li:last-of-type .pf-c-alert__description')).getText(), + }; + } +} + +// from here: https://stackoverflow.com/a/19040341/22316240 +async function getDirectTextContent(driver: WebDriver, el: WebElement): Promise { + return driver.executeScript( + ` + const parent = arguments[0]; + let child = parent.firstChild; + let ret = ""; + while (child) { + if (child.nodeType === Node.TEXT_NODE) { + ret += child.textContent; + } + child = child.nextSibling; + } + return ret; + `, + el + ); +} + +interface ITestNotification { + title: string; + description: string; +} + +export class Dashboard { + private driver: WebDriver; + + constructor(driver: WebDriver) { + this.driver = driver; + } + + getLayoutName(): Promise { + return getElementById(this.driver, 'dashboard-layout-dropdown-toggle').getText(); + } + + async addLayout() { + const layoutSelector = await getElementById(this.driver, 'dashboard-layout-dropdown-toggle'); + await layoutSelector.click(); + + const newLayoutButton = await getElementByXPath(this.driver, '//button[contains(.,"New Layout")]'); + await newLayoutButton.click(); + } + + async isEmpty(): Promise { + return (await this.getCards()).length == 0; + } + + async getCards(): Promise { + return await this.driver.findElements(By.className('dashboard-card')); + } + + async addCard(cardType: CardType) { + const addCardButton = await getElementByCSS(this.driver, `[aria-label="Add card"]`); + await addCardButton.click(); + const twoPartCards = [CardType.AUTOMATED_ANALYSIS, CardType.JFR_METRICS_CHART, CardType.MBEAN_METRICS_CHART]; + + switch (cardType) { + case CardType.AUTOMATED_ANALYSIS: { + const aaCard = await getElementById(this.driver, `AutomatedAnalysisCard.CARD_TITLE`); + await aaCard.click(); + break; + } + case CardType.JFR_METRICS_CHART: + break; + case CardType.TARGET_JVM_DETAILS: { + const detailsCard = await getElementById(this.driver, `JvmDetailsCard.CARD_TITLE`); + await detailsCard.click(); + break; + } + case CardType.MBEAN_METRICS_CHART: { + const mbeanCard = await getElementById(this.driver, `CHART_CARD.MBEAN_METRICS_CARD_TITLE`); + await mbeanCard.click(); + break; + } + } + const finishButton = await getElementByCSS(this.driver, 'button.pf-c-button.pf-m-primary[type="submit"]'); + await finishButton.click(); + if (twoPartCards.includes(cardType)) { + await finishButton.click(); + } + } + + async removeCard() { + const el: WebElement[] = await this.getCards(); + let firstCard; + if (el.length > 0) { + firstCard = el[0]; + await firstCard.click(); + } else { + return; + } + + const actionsButton = await getElementByCSS(this.driver, 'button[aria-label="Actions"]'); + await actionsButton.click(); + + const removeButton = await getElementByLinkText(this.driver, 'Remove'); + await removeButton.click(); + } +} + +export class Recordings { + private driver: WebDriver; + + constructor(driver: WebDriver) { + this.driver = driver; + } + + async createRecording(name: string) { + const createButton = await getElementByAttribute(this.driver, 'data-quickstart-id', 'recordings-create-btn'); + await createButton.click(); + + // Enter recording name + const recordingNameInput = await getElementById(this.driver, 'recording-name'); + await recordingNameInput.sendKeys(name); + + // Select template + await getElementById(this.driver, 'recording-template').sendKeys('Demo Template'); + + const submitButton = await getElementByAttribute(this.driver, 'data-quickstart-id', 'crf-create-btn'); + await submitButton.click(); + } + + async getRecordings(): Promise { + const tableXPath = "//div[@class='recording-table--inner-container pf-c-scroll-inner-wrapper']"; + return this.driver.findElements(By.xpath(`${tableXPath}//tbody`)); + } + + async getRecordingState(recording: WebElement): Promise { + return recording.findElement(By.xpath(`.//td[@data-label='State']`)).getText(); + } + + async stopRecording(recording: WebElement) { + await recording.findElement(By.xpath(`.//input[@data-quickstart-id='active-recordings-checkbox']`)).click(); + await getElementByAttribute(this.driver, 'data-quickstart-id', 'recordings-stop-btn').click(); + } + + async archiveRecording(recording: WebElement) { + await recording.findElement(By.xpath(`.//input[@data-quickstart-id='active-recordings-checkbox']`)).click(); + await getElementByAttribute(this.driver, 'data-quickstart-id', 'recordings-archive-btn').click(); + } + + async deleteRecording(recording: WebElement) { + await recording.findElement(By.xpath(`.//input[@data-quickstart-id='active-recordings-checkbox']`)).click(); + await getElementByAttribute(this.driver, 'data-quickstart-id', 'recordings-delete-btn').click(); + // confirm prompt + await getElementByXPath(this.driver, `//div[@id='portal-root']//button[contains(text(),'Delete')]`).click(); + } + + // async addLabel(recording: WebElement, k: string, v: string) { + // await recording.findElement(By.xpath(`.//input[@data-quickstart-id='active-recordings-checkbox']`)).click(); + // } + + // async removeAllLabels(recording: WebElement) { + // await recording.findElement(By.xpath(`.//input[@data-quickstart-id='active-recordings-checkbox']`)).click(); + // } +} + +// utility function for integration test debugging +export const sleep = (ms = 0) => new Promise((resolve) => setTimeout(resolve, ms)); + +export enum CardType { + TARGET_JVM_DETAILS, + AUTOMATED_ANALYSIS, + JFR_METRICS_CHART, + MBEAN_METRICS_CHART, }