diff --git a/integration-tests/alerts/onCallSchedule.test.ts b/integration-tests/alerts/onCallSchedule.test.ts index a8b13efd02..800faea66d 100644 --- a/integration-tests/alerts/onCallSchedule.test.ts +++ b/integration-tests/alerts/onCallSchedule.test.ts @@ -1,15 +1,10 @@ import { test } from '@playwright/test'; -import { configureOnCallPlugin } from '../utils/configurePlugin'; import { verifyThatAlertGroupIsTriggered } from '../utils/alertGroup'; import { createEscalationChain, EscalationStep } from '../utils/escalationChain'; import { generateRandomValue } from '../utils/forms'; import { createIntegrationAndSendDemoAlert } from '../utils/integrations'; import { createOnCallSchedule } from '../utils/schedule'; -test.beforeEach(async ({ page }) => { - await configureOnCallPlugin(page); -}); - test('we can create an oncall schedule + receive an alert', async ({ page }) => { const escalationChainName = generateRandomValue(); const integrationName = generateRandomValue(); diff --git a/integration-tests/alerts/sms.test.ts b/integration-tests/alerts/sms.test.ts index dac42bb80e..f4a1d8f7eb 100644 --- a/integration-tests/alerts/sms.test.ts +++ b/integration-tests/alerts/sms.test.ts @@ -1,5 +1,4 @@ import { test, expect } from '@playwright/test'; -import { configureOnCallPlugin } from '../utils/configurePlugin'; import { GRAFANA_USERNAME } from '../utils/constants'; import { createEscalationChain, EscalationStep } from '../utils/escalationChain'; import { generateRandomValue } from '../utils/forms'; @@ -7,10 +6,6 @@ import { createIntegrationAndSendDemoAlert } from '../utils/integrations'; import { waitForSms } from '../utils/phone'; import { configureUserNotificationSettings, verifyUserPhoneNumber } from '../utils/userSettings'; -test.beforeEach(async ({ page }) => { - await configureOnCallPlugin(page); -}); - // TODO: enable once we've signed up for a MailSlurp account to receieve SMSes test.skip('we can verify our phone number + receive an SMS alert', async ({ page }) => { const escalationChainName = generateRandomValue(); diff --git a/integration-tests/escalationChains/searching.test.ts b/integration-tests/escalationChains/searching.test.ts index 97d0edf835..5013f00f0e 100644 --- a/integration-tests/escalationChains/searching.test.ts +++ b/integration-tests/escalationChains/searching.test.ts @@ -1,12 +1,7 @@ import { test, expect, Page } from '@playwright/test'; -import { configureOnCallPlugin } from '../utils/configurePlugin'; import { generateRandomValue } from '../utils/forms'; import { createEscalationChain } from '../utils/escalationChain'; -test.beforeEach(async ({ page }) => { - await configureOnCallPlugin(page); -}); - const assertEscalationChainSearchWorks = async ( page: Page, searchTerm: string, @@ -20,18 +15,18 @@ const assertEscalationChainSearchWorks = async ( await expect(page.getByTestId('escalation-chains-list')).toHaveText(escalationChainFullName); }; -test('searching allows case-insensitive partial matches', async ({ page }) => { +// TODO: add tests for the new filtering. Commented out as this search doesn't exist anymore +test.skip('searching allows case-insensitive partial matches', async ({ page }) => { const escalationChainName = `${generateRandomValue()} ${generateRandomValue()}`; - // const [firstHalf, secondHalf] = escalationChainName.split(' '); + const [firstHalf, secondHalf] = escalationChainName.split(' '); await createEscalationChain(page, escalationChainName); - // Commented as this search doesn't exist anymore TODO: add tests for the new filtering - // await assertEscalationChainSearchWorks(page, firstHalf, escalationChainName); - // await assertEscalationChainSearchWorks(page, firstHalf.toUpperCase(), escalationChainName); - // await assertEscalationChainSearchWorks(page, firstHalf.toLowerCase(), escalationChainName); - // - // await assertEscalationChainSearchWorks(page, secondHalf, escalationChainName); - // await assertEscalationChainSearchWorks(page, secondHalf.toUpperCase(), escalationChainName); - // await assertEscalationChainSearchWorks(page, secondHalf.toLowerCase(), escalationChainName); + await assertEscalationChainSearchWorks(page, firstHalf, escalationChainName); + await assertEscalationChainSearchWorks(page, firstHalf.toUpperCase(), escalationChainName); + await assertEscalationChainSearchWorks(page, firstHalf.toLowerCase(), escalationChainName); + + await assertEscalationChainSearchWorks(page, secondHalf, escalationChainName); + await assertEscalationChainSearchWorks(page, secondHalf.toUpperCase(), escalationChainName); + await assertEscalationChainSearchWorks(page, secondHalf.toLowerCase(), escalationChainName); }); diff --git a/integration-tests/globalSetup.ts b/integration-tests/globalSetup.ts index 3fc05399da..244158d543 100644 --- a/integration-tests/globalSetup.ts +++ b/integration-tests/globalSetup.ts @@ -1,6 +1,39 @@ -import { chromium, FullConfig, expect } from '@playwright/test'; +import { chromium, FullConfig, expect, Page } from '@playwright/test'; -import { BASE_URL, GRAFANA_PASSWORD, GRAFANA_USERNAME } from './utils/constants'; +import { BASE_URL, GRAFANA_PASSWORD, GRAFANA_USERNAME, IS_OPEN_SOURCE, ONCALL_API_URL } from './utils/constants'; +import { clickButton, getInputByName } from './utils/forms'; +import { goToGrafanaPage } from './utils/navigation'; + +/** + * go to config page and wait for plugin icon to be available on left-hand navigation + */ +export const configureOnCallPlugin = async (page: Page): Promise<void> => { + // plugin configuration can safely be skipped for non open-source environments + if (!IS_OPEN_SOURCE) { + return; + } + + /** + * go to the oncall plugin configuration page and wait for the page to be loaded + */ + await goToGrafanaPage(page, '/plugins/grafana-oncall-app'); + await page.waitForSelector('text=Configure Grafana OnCall'); + + /** + * we may need to fill in the OnCall API URL if it is not set in the process.env + * of the frontend build + */ + const onCallApiUrlInput = getInputByName(page, 'onCallApiUrl'); + const pluginIsAutoConfigured = (await onCallApiUrlInput.count()) === 0; + + if (!pluginIsAutoConfigured) { + await onCallApiUrlInput.fill(ONCALL_API_URL); + await clickButton({ page, buttonText: 'Connect' }); + } + + // wait for the "Connected to OnCall" message to know that everything is properly configured + await expect(page.getByTestId('status-message-block')).toHaveText(/Connected to OnCall.*/); +}; /** * Borrowed from our friends on the Incident team @@ -20,6 +53,11 @@ const globalSetup = async (config: FullConfig): Promise<void> => { expect(res.ok()).toBeTruthy(); await browserContext.storageState({ path: './storageState.json' }); + + // make sure the plugin has been configured + const page = await browserContext.newPage(); + await configureOnCallPlugin(page); + await browserContext.close(); }; diff --git a/integration-tests/integrations/uniqueIntegrationNames.test.ts b/integration-tests/integrations/uniqueIntegrationNames.test.ts index 06fc92843b..e3c8df8012 100644 --- a/integration-tests/integrations/uniqueIntegrationNames.test.ts +++ b/integration-tests/integrations/uniqueIntegrationNames.test.ts @@ -1,11 +1,6 @@ import { test, expect } from '@playwright/test'; -import { configureOnCallPlugin } from '../utils/configurePlugin'; import { openCreateIntegrationModal } from '../utils/integrations'; -test.beforeEach(async ({ page }) => { - await configureOnCallPlugin(page); -}); - test('integrations have unique names', async ({ page }) => { await openCreateIntegrationModal(page); diff --git a/integration-tests/schedules/addOverride.test.ts b/integration-tests/schedules/addOverride.test.ts index 3104cc689c..f76b6ef9e2 100644 --- a/integration-tests/schedules/addOverride.test.ts +++ b/integration-tests/schedules/addOverride.test.ts @@ -1,13 +1,8 @@ import { test, expect } from '@playwright/test'; -import { configureOnCallPlugin } from '../utils/configurePlugin'; import { clickButton, generateRandomValue } from '../utils/forms'; import { createOnCallSchedule, getOverrideFormDateInputs } from '../utils/schedule'; import dayjs from 'dayjs'; -test.beforeEach(async ({ page }) => { - await configureOnCallPlugin(page); -}); - test('default dates in override creation modal are correct', async ({ page }) => { const onCallScheduleName = generateRandomValue(); await createOnCallSchedule(page, onCallScheduleName); diff --git a/integration-tests/schedules/quality.test.ts b/integration-tests/schedules/quality.test.ts index 952e22d835..94b8e2a07d 100644 --- a/integration-tests/schedules/quality.test.ts +++ b/integration-tests/schedules/quality.test.ts @@ -1,12 +1,7 @@ import { test, expect } from '@playwright/test'; -import { configureOnCallPlugin } from '../utils/configurePlugin'; import { generateRandomValue } from '../utils/forms'; import { createOnCallSchedule } from '../utils/schedule'; -test.beforeEach(async ({ page }) => { - await configureOnCallPlugin(page); -}); - test('check schedule quality for simple 1-user schedule', async ({ page }) => { const onCallScheduleName = generateRandomValue(); await createOnCallSchedule(page, onCallScheduleName); @@ -14,6 +9,10 @@ test('check schedule quality for simple 1-user schedule', async ({ page }) => { await expect(page.locator('div[class*="ScheduleQuality"]')).toHaveText('Quality: Great'); await page.hover('div[class*="ScheduleQuality"]'); - await expect(page.locator('div[class*="ScheduleQualityDetails"] >> span[class*="Text"] >> nth=2 ')).toHaveText('Schedule has no gaps'); - await expect(page.locator('div[class*="ScheduleQualityDetails"] >> span[class*="Text"] >> nth=3 ')).toHaveText('Schedule is perfectly balanced'); + await expect(page.locator('div[class*="ScheduleQualityDetails"] >> span[class*="Text"] >> nth=2 ')).toHaveText( + 'Schedule has no gaps' + ); + await expect(page.locator('div[class*="ScheduleQualityDetails"] >> span[class*="Text"] >> nth=3 ')).toHaveText( + 'Schedule is perfectly balanced' + ); }); diff --git a/integration-tests/utils/configurePlugin.ts b/integration-tests/utils/configurePlugin.ts deleted file mode 100644 index d838cf150e..0000000000 --- a/integration-tests/utils/configurePlugin.ts +++ /dev/null @@ -1,38 +0,0 @@ -import type { Page } from '@playwright/test'; -import { ONCALL_API_URL, IS_OPEN_SOURCE } from './constants'; -import { clickButton, getInputByName } from './forms'; -import { goToGrafanaPage } from './navigation'; - -/** - * go to config page and wait for plugin icon to be available on left-hand navigation - */ -export const configureOnCallPlugin = async (page: Page): Promise<void> => { - // plugin configuration can safely be skipped for non open-source environments - if (!IS_OPEN_SOURCE) { - return; - } - - /** - * go to the oncall plugin configuration page and wait for the page to be loaded - */ - await goToGrafanaPage(page, '/plugins/grafana-oncall-app'); - await page.waitForSelector('text=Configure Grafana OnCall'); - - /** - * we may need to fill in the OnCall API URL if it is not set in the process.env - * of the frontend build - */ - const onCallApiUrlInput = getInputByName(page, 'onCallApiUrl'); - const pluginIsAutoConfigured = (await onCallApiUrlInput.count()) === 0; - - if (!pluginIsAutoConfigured) { - await onCallApiUrlInput.fill(ONCALL_API_URL); - await clickButton({ page, buttonText: 'Connect' }); - } - - /** - * wait for the page to be refreshed and the icon to show up, this means the plugin - * has been successfully configured - */ - await page.waitForSelector('div.scrollbar-view img[src*="grafana-oncall-app/img/logo.svg"]'); -}; diff --git a/integration-tests/utils/escalationChain.ts b/integration-tests/utils/escalationChain.ts index a4fdf3ee09..a0f908e499 100644 --- a/integration-tests/utils/escalationChain.ts +++ b/integration-tests/utils/escalationChain.ts @@ -1,4 +1,4 @@ -import { Page } from '@playwright/test'; +import { expect, Page } from '@playwright/test'; import { clickButton, fillInInput, selectDropdownValue } from './forms'; import { goToOnCallPage } from './navigation'; @@ -22,6 +22,14 @@ export const createEscalationChain = async ( // go to the escalation chains page await goToOnCallPage(page, 'escalations'); + /** + * wait for Esclation Chains page to fully load. this is because this can change which "New Escalation Chain" + * button is present + * ie. the one on the left hand side in the list vs the one in the center when no escalation chains exist + */ + await page.getByTestId('page-title').locator('text=Escalation Chains').waitFor({ state: 'visible' }); + await page.locator('text=Loading...').waitFor({ state: 'detached' }); + // open the create escalation chain modal (await page.waitForSelector('text=New Escalation Chain')).click(); @@ -30,7 +38,7 @@ export const createEscalationChain = async ( // submit the form and wait for it to be created await clickButton({ page, buttonText: 'Create' }); - await page.waitForSelector(`text=${escalationChainName}`); + await expect(page.getByTestId('escalation-chain-name')).toHaveText(escalationChainName); if (!escalationStep || !escalationStepValue) { return; diff --git a/integration-tests/utils/forms.ts b/integration-tests/utils/forms.ts index 4981ec5f3a..46142e64b2 100644 --- a/integration-tests/utils/forms.ts +++ b/integration-tests/utils/forms.ts @@ -54,7 +54,7 @@ const openSelect = async ({ placeholderText, selectType, startingLocator, -}: SelectDropdownValueArgs): Promise<void> => { +}: SelectDropdownValueArgs): Promise<Locator> => { /** * we currently mix three different dropdown components in the UI.. * so we need to support all of them :( @@ -73,6 +73,8 @@ const openSelect = async ({ const selectElement: Locator = (startingLocator || page).locator(selector); await selectElement.waitFor({ state: 'visible' }); await selectElement.click(); + + return selectElement; }; /** @@ -85,7 +87,15 @@ const chooseDropdownValue = async ({ page, value, optionExactMatch = true }: Sel page.locator(`div[id^="react-select-"][id$="-listbox"] >> ${textMatchSelector(optionExactMatch, value)}`).click(); export const selectDropdownValue = async (args: SelectDropdownValueArgs): Promise<void> => { - await openSelect(args); + const selectElement = await openSelect(args); + + /** + * use the select search to filter down the options + * TODO: get rid of the slice when we fix the GSelect component.. + * without slicing this would fire off an API request for every key-stroke + */ + await selectElement.type(args.value.slice(0, 5)); + await chooseDropdownValue(args); }; diff --git a/package.json b/package.json index ae9ad6a407..dfc6334ea2 100644 --- a/package.json +++ b/package.json @@ -59,7 +59,7 @@ "@grafana/eslint-config": "^5.0.0", "@grafana/toolkit": "^9.2.4", "@jest/globals": "^27.5.1", - "@playwright/test": "^1.28.0", + "@playwright/test": "^1.32.0", "@testing-library/jest-dom": "^5.16.5", "@testing-library/react": "12", "@testing-library/user-event": "^14.4.3", diff --git a/playwright.config.ts b/playwright.config.ts index 4977535d8b..e1bfa6d8c6 100644 --- a/playwright.config.ts +++ b/playwright.config.ts @@ -14,7 +14,8 @@ const config: PlaywrightTestConfig = { testDir: './integration-tests', globalSetup: './integration-tests/globalSetup.ts', /* Maximum time one test can run for. */ - timeout: 60 * 1000, + // TODO: set this back to 60 when GSelect component is refactored + timeout: 90 * 1000, expect: { /** * Maximum time expect() should wait for the condition to be met. @@ -27,8 +28,10 @@ const config: PlaywrightTestConfig = { /* Fail the build on CI if you accidentally left test.only in the source code. */ forbidOnly: !!process.env.CI, /* Retry on CI only */ - retries: process.env.CI ? 2 : 0, - /* Opt out of parallel tests on CI. */ + retries: process.env.CI ? 1 : 0, + // TODO: when GSelect component is refactored, run using 3 workers + // locally use one worker, on CI use 3 + // workers: process.env.CI ? 3 : 1, workers: 1, /* Reporter to use. See https://playwright.dev/docs/test-reporters */ reporter: 'html', diff --git a/src/PluginPage.tsx b/src/PluginPage.tsx index 21a8634182..a80120f2b1 100644 --- a/src/PluginPage.tsx +++ b/src/PluginPage.tsx @@ -23,7 +23,11 @@ function RealPlugin(props: AppPluginPageProps): React.ReactNode { {/* Render alerts at the top */} <Alerts /> <Header backendLicense={store.backendLicense} /> - {pages[page]?.text && !pages[page]?.hideTitle && <h3 className="page-title">{pages[page].text}</h3>} + {pages[page]?.text && !pages[page]?.hideTitle && ( + <h3 className="page-title" data-testid="page-title"> + {pages[page].text} + </h3> + )} {props.children} </RealPluginPage> ); diff --git a/src/components/Policy/EscalationPolicy.tsx b/src/components/Policy/EscalationPolicy.tsx index 329f8a10fc..ac0811c33e 100644 --- a/src/components/Policy/EscalationPolicy.tsx +++ b/src/components/Policy/EscalationPolicy.tsx @@ -297,7 +297,6 @@ export class EscalationPolicy extends React.Component<EscalationPolicyProps, any className={cx('select', 'control')} value={notify_schedule} onChange={this._getOnChangeHandler('notify_schedule')} - fromOrganization getOptionLabel={(item: SelectableValue) => { const team = teamStore.items[scheduleStore.items[item.value].team]; return ( @@ -351,7 +350,6 @@ export class EscalationPolicy extends React.Component<EscalationPolicyProps, any className={cx('select', 'control')} value={custom_button_trigger} onChange={this._getOnChangeHandler('custom_button_trigger')} - fromOrganization getOptionLabel={(item: SelectableValue) => { const team = teamStore.items[outgoingWebhookStore.items[item.value].team]; return ( diff --git a/src/containers/GSelect/GSelect.tsx b/src/containers/GSelect/GSelect.tsx index a471d2c257..919a9850f5 100644 --- a/src/containers/GSelect/GSelect.tsx +++ b/src/containers/GSelect/GSelect.tsx @@ -9,6 +9,7 @@ import { observer } from 'mobx-react'; import { useStore } from 'state/useStore'; import styles from './GSelect.module.css'; +// import { debounce } from 'lodash'; const cx = cn.bind(styles); @@ -30,7 +31,6 @@ interface GSelectProps { showWarningIfEmptyValue?: boolean; showError?: boolean; nullItemName?: string; - fromOrganization?: boolean; filterOptions?: (id: any) => boolean; dropdownRender?: (menu: ReactElement) => ReactElement; getOptionLabel?: <T>(item: SelectableValue<T>) => React.ReactNode; @@ -61,7 +61,6 @@ const GSelect = observer((props: GSelectProps) => { showWarningIfEmptyValue = false, getDescription, filterOptions, - // fromOrganization, width = null, icon = null, } = props; @@ -89,6 +88,11 @@ const GSelect = observer((props: GSelectProps) => { [model, onChange] ); + /** + * without debouncing this function when search is available + * we risk hammering the API endpoint for every single key stroke + * some context on 250ms as the choice here - https://stackoverflow.com/a/44755058/3902555 + */ const loadOptions = (query: string) => { return model.updateItems(query).then(() => { const searchResult = model.getSearchResult(query); @@ -106,6 +110,9 @@ const GSelect = observer((props: GSelectProps) => { }); }; + // TODO: why doesn't this work properly? + // const loadOptions = debounce(_loadOptions, showSearch ? 250 : 0); + const values = isMulti ? (value ? (value as string[]) : []) .filter((id) => id in model.items) diff --git a/src/pages/escalation-chains/EscalationChains.tsx b/src/pages/escalation-chains/EscalationChains.tsx index dbcb04ed05..9581a19361 100644 --- a/src/pages/escalation-chains/EscalationChains.tsx +++ b/src/pages/escalation-chains/EscalationChains.tsx @@ -287,7 +287,12 @@ class EscalationChainsPage extends React.Component<EscalationChainsPageProps, Es return ( <> <Block withBackground className={cx('header')}> - <Text size="large" editable onTextChange={this.handleEscalationChainNameChange}> + <Text + size="large" + editable + onTextChange={this.handleEscalationChainNameChange} + data-testid="escalation-chain-name" + > {escalationChain.name} </Text> <div className={cx('buttons')}> diff --git a/yarn.lock b/yarn.lock index 838cf5abfe..57472da424 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2603,13 +2603,15 @@ resolved "https://registry.yarnpkg.com/@petamoriken/float16/-/float16-3.6.6.tgz#641f73913a6be402b34e4bdfca98d6832ed55586" integrity sha512-3MUulwMtsdCA9lw8a/Kc0XDBJJVCkYTQ5aGd+///TbfkOMXoOGAzzoiYKwPEsLYZv7He7fKJ/mCacqKOO7REyg== -"@playwright/test@^1.28.0": - version "1.28.0" - resolved "https://registry.yarnpkg.com/@playwright/test/-/test-1.28.0.tgz#8de83f9d2291bba3f37883e33431b325661720d9" - integrity sha512-vrHs5DFTPwYox5SGKq/7TDn/S4q6RA1zArd7uhO6EyP9hj3XgZBBM12ktMbnDQNxh/fL1IUKsTNLxihmsU38lQ== +"@playwright/test@^1.32.0": + version "1.32.0" + resolved "https://registry.yarnpkg.com/@playwright/test/-/test-1.32.0.tgz#0cc4c179e62995cc123adb12fdfaa093fed282c4" + integrity sha512-zOdGloaF0jeec7hqoLqM5S3L2rR4WxMJs6lgiAeR70JlH7Ml54ZPoIIf3X7cvnKde3Q9jJ/gaxkFh8fYI9s1rg== dependencies: "@types/node" "*" - playwright-core "1.28.0" + playwright-core "1.32.0" + optionalDependencies: + fsevents "2.3.2" "@polka/url@^1.0.0-next.20": version "1.0.0-next.21" @@ -7083,7 +7085,7 @@ fs.realpath@^1.0.0: resolved "https://registry.yarnpkg.com/fs.realpath/-/fs.realpath-1.0.0.tgz#1504ad2523158caa40db4a2787cb01411994ea4f" integrity sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw== -fsevents@^2.3.2, fsevents@~2.3.2: +fsevents@2.3.2, fsevents@^2.3.2, fsevents@~2.3.2: version "2.3.2" resolved "https://registry.yarnpkg.com/fsevents/-/fsevents-2.3.2.tgz#8a526f78b8fdf4623b709e0b975c52c24c02fd1a" integrity sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA== @@ -10347,10 +10349,10 @@ pkg-up@^3.1.0: dependencies: find-up "^3.0.0" -playwright-core@1.28.0: - version "1.28.0" - resolved "https://registry.yarnpkg.com/playwright-core/-/playwright-core-1.28.0.tgz#61df5c714f45139cca07095eccb4891e520e06f2" - integrity sha512-nJLknd28kPBiCNTbqpu6Wmkrh63OEqJSFw9xOfL9qxfNwody7h6/L3O2dZoWQ6Oxcm0VOHjWmGiCUGkc0X3VZA== +playwright-core@1.32.0: + version "1.32.0" + resolved "https://registry.yarnpkg.com/playwright-core/-/playwright-core-1.32.0.tgz#730c2d1988d30377480b925aaa6c1b1e2442d67e" + integrity sha512-Z9Ij17X5Z3bjpp6XKujGBp9Gv4eViESac9aDmwgQFUEJBW0K80T21m/Z+XJQlu4cNsvPygw33b6V1Va6Bda5zQ== please-upgrade-node@^3.2.0: version "3.2.0"