diff --git a/.github/workflows/daily-e2e-tests.yml b/.github/workflows/daily-e2e-tests.yml index 36f02e6b13..037ea5e021 100644 --- a/.github/workflows/daily-e2e-tests.yml +++ b/.github/workflows/daily-e2e-tests.yml @@ -32,6 +32,7 @@ jobs: with: grafana-image-tag: ${{ matrix.grafana-image-tag }} run-expensive-tests: true + browsers: "chromium firefox webkit" secrets: inherit post-status-to-slack: diff --git a/.github/workflows/e2e-tests.yml b/.github/workflows/e2e-tests.yml index c053d7aa56..c43011fb36 100644 --- a/.github/workflows/e2e-tests.yml +++ b/.github/workflows/e2e-tests.yml @@ -5,6 +5,9 @@ name: e2e tests grafana-image-tag: required: true type: string + browsers: + required: true + type: string run-expensive-tests: description: > Whether or not to run Playwright tests that're annotated as "@expensive" @@ -126,14 +129,14 @@ jobs: - name: Install Playwright Browsers if: steps.playwright-cache.outputs.cache-hit != 'true' working-directory: grafana-plugin - run: ./node_modules/.bin/playwright install --with-deps chromium firefox webkit + run: ./node_modules/.bin/playwright install --with-deps ${{ inputs.browsers }} # use the cached browsers, but we still need to install the necessary system dependencies # (system deps are installed in the cache-miss step above by the --with-deps flag) - name: Install Playwright System Dependencies if: steps.playwright-cache.outputs.cache-hit == 'true' working-directory: grafana-plugin - run: ./node_modules/.bin/playwright install-deps chromium firefox webkit + run: ./node_modules/.bin/playwright install-deps ${{ inputs.browsers }} # we could instead use the --wait flag for the helm install command above # but there's no reason to block on that step @@ -163,6 +166,7 @@ jobs: GRAFANA_VIEWER_USERNAME: viewer GRAFANA_VIEWER_PASSWORD: viewer MAILSLURP_API_KEY: ${{ secrets.MAILSLURP_API_KEY }} + BROWSERS: ${{ inputs.browsers }} working-directory: ./grafana-plugin run: yarn test:e2e diff --git a/.github/workflows/linting-and-tests.yml b/.github/workflows/linting-and-tests.yml index f077805e88..bc45b41175 100644 --- a/.github/workflows/linting-and-tests.yml +++ b/.github/workflows/linting-and-tests.yml @@ -318,4 +318,5 @@ jobs: # https://raintank-corp.slack.com/archives/C01C4K8DETW/p1692279329797149 grafana-image-tag: 10.0.2 run-expensive-tests: false + browsers: "chromium" secrets: inherit diff --git a/CHANGELOG.md b/CHANGELOG.md index e03de07275..ce5d3cafcd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,15 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## Unreleased +## v1.3.60 (2023-11-20) + +### Fixed + +- Fixes forwarding of Amazon SNS headers @mderynck ([#3371](https://github.com/grafana/oncall/pull/3371)) +- Fixes issue when using the `/escalate` Slack command and selecting a team by @joeyorlando ([#3381](https://github.com/grafana/oncall/pull/3381)) +- Fix issue when RBAC is enabled where Viewers with "Notifications Receiver" role do not properly show up in schedule + rotations by @joeyorlando ([#3378](https://github.com/grafana/oncall/pull/3378)) + ## v1.3.59 (2023-11-16) ### Added diff --git a/engine/apps/schedules/ical_utils.py b/engine/apps/schedules/ical_utils.py index 8e9de95619..fab25fff43 100644 --- a/engine/apps/schedules/ical_utils.py +++ b/engine/apps/schedules/ical_utils.py @@ -79,7 +79,7 @@ def users_in_ical( organization : apps.user_management.models.organization.Organization The organization in question """ - required_permission = RBACPermission.Permissions.SCHEDULES_WRITE + required_permission = RBACPermission.Permissions.NOTIFICATIONS_READ emails_from_ical = [username.lower() for username in usernames_from_ical] diff --git a/engine/apps/schedules/tests/test_ical_utils.py b/engine/apps/schedules/tests/test_ical_utils.py index 430544a580..97e98b4076 100644 --- a/engine/apps/schedules/tests/test_ical_utils.py +++ b/engine/apps/schedules/tests/test_ical_utils.py @@ -9,7 +9,7 @@ from django.core.cache import cache from django.utils import timezone -from apps.api.permissions import LegacyAccessControlRole +from apps.api.permissions import LegacyAccessControlRole, RBACPermission from apps.schedules.ical_utils import ( get_cached_oncall_users_for_multiple_schedules, get_icalendar_tz_or_utc, @@ -93,17 +93,59 @@ def test_users_in_ical_email_case_insensitive(make_organization_and_user, make_u @pytest.mark.django_db -def test_users_in_ical_viewers_inclusion(make_organization_and_user, make_user_for_organization): +@pytest.mark.parametrize( + "role,included", + [ + (LegacyAccessControlRole.ADMIN, True), + (LegacyAccessControlRole.EDITOR, True), + (LegacyAccessControlRole.VIEWER, False), + (LegacyAccessControlRole.NONE, False), + ], +) +def test_users_in_ical_basic_role(make_organization_and_user, make_user_for_organization, role, included): organization, user = make_organization_and_user() - viewer = make_user_for_organization(organization, role=LegacyAccessControlRole.VIEWER) + organization.is_rbac_permissions_enabled = False + organization.save() + + other_user = make_user_for_organization(organization, role=role) + + usernames = [user.username, other_user.username] + expected_result = {user} + if included: + expected_result.add(other_user) - usernames = [user.username, viewer.username] result = users_in_ical(usernames, organization) - assert set(result) == {user} + assert set(result) == expected_result + + +@pytest.mark.django_db +@pytest.mark.parametrize( + "permission,included", + [ + (RBACPermission.Permissions.NOTIFICATIONS_READ, True), + (RBACPermission.Permissions.SCHEDULES_READ, False), + (None, False), + ], +) +def test_users_in_ical_rbac(make_organization_and_user, make_user_for_organization, permission, included): + organization, _ = make_organization_and_user() + organization.is_rbac_permissions_enabled = True + organization.save() + + viewer = make_user_for_organization(organization, role=LegacyAccessControlRole.VIEWER) + usernames = [viewer.username] + + # viewer doesn't yet have the required permission, they shouldn't be included + assert len(users_in_ical(usernames, organization)) == 0 + + viewer.permissions = [{"action": permission.value}] if permission else [] + viewer.save() + + assert users_in_ical(usernames, organization) == ([viewer] if included else []) @pytest.mark.django_db -def test_list_users_to_notify_from_ical_viewers_inclusion( +def test_list_users_to_notify_from_ical_viewers_exclusion( make_organization_and_user, make_user_for_organization, make_schedule, make_on_call_shift ): organization, user = make_organization_and_user() diff --git a/engine/apps/slack/scenarios/paging.py b/engine/apps/slack/scenarios/paging.py index 0361dd899b..ef972b31a9 100644 --- a/engine/apps/slack/scenarios/paging.py +++ b/engine/apps/slack/scenarios/paging.py @@ -570,9 +570,8 @@ def _get_team_select_blocks( initial_option_idx = 0 for idx, team in enumerate(teams): team_pk, team_name = team - team_pk_str = str(team_pk) - if value == team_pk_str: + if value and value.pk == team_pk: initial_option_idx = idx team_options.append( { @@ -581,7 +580,7 @@ def _get_team_select_blocks( "text": team_name, "emoji": True, }, - "value": team_pk_str, + "value": str(team_pk), } ) diff --git a/engine/apps/slack/scenarios/resolution_note.py b/engine/apps/slack/scenarios/resolution_note.py index 8a0f9c5a3d..af29172973 100644 --- a/engine/apps/slack/scenarios/resolution_note.py +++ b/engine/apps/slack/scenarios/resolution_note.py @@ -222,11 +222,12 @@ def post_or_update_resolution_note_in_thread(self, resolution_note: "ResolutionN blocks = self.get_resolution_note_blocks(resolution_note) if resolution_note_slack_message is None: + resolution_note_text = Truncator(resolution_note.text) try: result = self._slack_client.chat_postMessage( channel=alert_group_slack_message.channel_id, thread_ts=alert_group_slack_message.slack_id, - text=resolution_note.text, + text=resolution_note_text.chars(BLOCK_SECTION_TEXT_MAX_SIZE), blocks=blocks, ) except RESOLUTION_NOTE_EXCEPTIONS: @@ -256,11 +257,12 @@ def post_or_update_resolution_note_in_thread(self, resolution_note: "ResolutionN resolution_note.resolution_note_slack_message = resolution_note_slack_message resolution_note.save(update_fields=["resolution_note_slack_message"]) elif resolution_note_slack_message.posted_by_bot: + resolution_note_text = Truncator(resolution_note_slack_message.text) try: self._slack_client.chat_update( channel=alert_group_slack_message.channel_id, ts=resolution_note_slack_message.ts, - text=resolution_note_slack_message.text, + text=resolution_note_text.chars(BLOCK_SECTION_TEXT_MAX_SIZE), blocks=blocks, ) except RESOLUTION_NOTE_EXCEPTIONS: diff --git a/engine/apps/slack/tests/test_scenario_steps/test_paging.py b/engine/apps/slack/tests/test_scenario_steps/test_paging.py index 2821b8e616..64c0c29e84 100644 --- a/engine/apps/slack/tests/test_scenario_steps/test_paging.py +++ b/engine/apps/slack/tests/test_scenario_steps/test_paging.py @@ -318,24 +318,40 @@ def test_get_team_select_blocks( # team selected organization, _, _, slack_user_identity = make_organization_and_user_with_slack_identities() - team = make_team(organization) - arc = make_alert_receive_channel(organization, team=team, integration=AlertReceiveChannel.INTEGRATION_DIRECT_PAGING) - escalation_chain = make_escalation_chain(organization) - make_channel_filter(arc, is_default=True, escalation_chain=escalation_chain) + team1 = make_team(organization) + team2 = make_team(organization) + + def _setup_direct_paging_integration(team): + arc = make_alert_receive_channel( + organization, team=team, integration=AlertReceiveChannel.INTEGRATION_DIRECT_PAGING + ) + escalation_chain = make_escalation_chain(organization) + make_channel_filter(arc, is_default=True, escalation_chain=escalation_chain) + return arc + + _setup_direct_paging_integration(team1) + team2_direct_paging_arc = _setup_direct_paging_integration(team2) - blocks = _get_team_select_blocks(slack_user_identity, organization, True, team.pk, input_id_prefix) + blocks = _get_team_select_blocks(slack_user_identity, organization, True, team2, input_id_prefix) assert len(blocks) == 2 input_block, context_block = blocks - team_option = {"text": {"emoji": True, "text": team.name, "type": "plain_text"}, "value": str(team.pk)} + def _contstruct_team_option(team): + return {"text": {"emoji": True, "text": team.name, "type": "plain_text"}, "value": str(team.pk)} + + team1_option = _contstruct_team_option(team1) + team2_option = _contstruct_team_option(team2) + + def _sort_team_options(options): + return sorted(options, key=lambda o: o["value"]) assert input_block["type"] == "input" - assert len(input_block["element"]["options"]) == 1 - assert input_block["element"]["options"] == [team_option] - assert input_block["element"]["initial_option"] == team_option + assert len(input_block["element"]["options"]) == 2 + assert _sort_team_options(input_block["element"]["options"]) == _sort_team_options([team1_option, team2_option]) + assert input_block["element"]["initial_option"] == team2_option assert ( context_block["elements"][0]["text"] - == f"Integration <{arc.web_link}|{arc.verbal_name}> will be used for notification." + == f"Integration <{team2_direct_paging_arc.web_link}|{team2_direct_paging_arc.verbal_name}> will be used for notification." ) diff --git a/engine/apps/slack/tests/test_scenario_steps/test_resolution_note.py b/engine/apps/slack/tests/test_scenario_steps/test_resolution_note.py index a64d020889..1b4a87e60d 100644 --- a/engine/apps/slack/tests/test_scenario_steps/test_resolution_note.py +++ b/engine/apps/slack/tests/test_scenario_steps/test_resolution_note.py @@ -148,6 +148,78 @@ def test_get_resolution_note_blocks_truncate_text( assert blocks == expected_blocks +@pytest.mark.django_db +def test_post_or_update_resolution_note_in_thread_truncate_message_text( + make_organization_and_user_with_slack_identities, + make_alert_receive_channel, + make_alert_group, + make_slack_message, + make_resolution_note, +): + UpdateResolutionNoteStep = ScenarioStep.get_step("resolution_note", "UpdateResolutionNoteStep") + organization, user, slack_team_identity, _ = make_organization_and_user_with_slack_identities() + step = UpdateResolutionNoteStep(slack_team_identity) + + alert_receive_channel = make_alert_receive_channel(organization) + alert_group = make_alert_group(alert_receive_channel) + make_slack_message(alert_group=alert_group, channel_id="RANDOM_CHANNEL_ID", slack_id="RANDOM_MESSAGE_ID") + resolution_note = make_resolution_note(alert_group=alert_group, author=user, message_text="a" * 3000) + + with patch("apps.slack.client.SlackClient.api_call") as mock_slack_api_call: + mock_slack_api_call.return_value = { + "ts": "timestamp", + "message": {"ts": "timestamp"}, + "permalink": "https://link.to.message", + } + step.post_or_update_resolution_note_in_thread(resolution_note) + + assert mock_slack_api_call.called + post_message_call = mock_slack_api_call.mock_calls[0] + assert post_message_call.args[0] == "chat.postMessage" + assert post_message_call.kwargs["json"]["text"] == resolution_note.text[: BLOCK_SECTION_TEXT_MAX_SIZE - 1] + "…" + + +@pytest.mark.django_db +def test_post_or_update_resolution_note_in_thread_update_truncate_message_text( + make_organization_and_user_with_slack_identities, + make_alert_receive_channel, + make_alert_group, + make_slack_message, + make_resolution_note, + make_resolution_note_slack_message, +): + UpdateResolutionNoteStep = ScenarioStep.get_step("resolution_note", "UpdateResolutionNoteStep") + organization, user, slack_team_identity, _ = make_organization_and_user_with_slack_identities() + step = UpdateResolutionNoteStep(slack_team_identity) + + alert_receive_channel = make_alert_receive_channel(organization) + alert_group = make_alert_group(alert_receive_channel) + make_slack_message(alert_group=alert_group, channel_id="RANDOM_CHANNEL_ID", slack_id="RANDOM_MESSAGE_ID") + resolution_note = make_resolution_note(alert_group=alert_group, author=user, message_text="a" * 3000) + make_resolution_note_slack_message( + alert_group=alert_group, + resolution_note=resolution_note, + user=user, + posted_by_bot=True, + added_by_user=user, + ts=1, + text=resolution_note.text, + ) + + with patch("apps.slack.client.SlackClient.api_call") as mock_slack_api_call: + mock_slack_api_call.return_value = { + "ts": "timestamp", + "message": {"ts": "timestamp"}, + "permalink": "https://link.to.message", + } + step.post_or_update_resolution_note_in_thread(resolution_note) + + assert mock_slack_api_call.called + post_message_call = mock_slack_api_call.mock_calls[0] + assert post_message_call.args[0] == "chat.update" + assert post_message_call.kwargs["json"]["text"] == resolution_note.text[: BLOCK_SECTION_TEXT_MAX_SIZE - 1] + "…" + + @pytest.mark.django_db def test_get_resolution_notes_blocks_latest_limit( make_organization_and_user_with_slack_identities, diff --git a/engine/apps/user_management/middlewares.py b/engine/apps/user_management/middlewares.py index 10d5df1a5f..285d4dae88 100644 --- a/engine/apps/user_management/middlewares.py +++ b/engine/apps/user_management/middlewares.py @@ -11,6 +11,13 @@ logger = logging.getLogger(__name__) +AMAZON_SNS_HEADERS = [ + "x-amz-sns-subscription-arn", + "x-amz-sns-topic-arn", + "x-amz-sns-message-id", + "x-amz-sns-message-type", +] + class OrganizationMovedMiddleware(MiddlewareMixin): def process_exception(self, request, exception): @@ -33,9 +40,8 @@ def process_exception(self, request, exception): headers["Authorization"] = v if "amazon_sns" in request.path: - for k, v in request.META.items(): - if k.startswith("x-amz-sns-"): - headers[k] = v + for k in AMAZON_SNS_HEADERS: + headers[k] = request.headers.get(k) response = self.make_request(request.method, url, headers, request.body) return HttpResponse(response.content, status=response.status_code) diff --git a/engine/apps/user_management/tests/test_region.py b/engine/apps/user_management/tests/test_region.py index bf6d640e6f..8cf3bdcb7a 100644 --- a/engine/apps/user_management/tests/test_region.py +++ b/engine/apps/user_management/tests/test_region.py @@ -12,6 +12,7 @@ from apps.integrations.views import AlertManagerAPIView, AmazonSNS from apps.schedules.models import OnCallScheduleWeb from apps.user_management.exceptions import OrganizationMovedException +from apps.user_management.middlewares import AMAZON_SNS_HEADERS @pytest.mark.django_db @@ -254,10 +255,10 @@ def test_organization_moved_middleware_amazon_sns_headers( ) expected_sns_headers = { - "x-amz-sns-subscription-arn": "arn:aws:sns:xxxxxxxxxx:467989492352:oncall-test:3aab6edb-0c5e-4fa9-b876-64409d1f6c63", - "x-amz-sns-topic-arn": "arn:aws:sns:xxxxxxxxxx:467989492352:oncall-test", - "x-amz-sns-message-id": "473efe1d-8ea4-5252-8124-a3d5ff7408c5", - "x-amz-sns-message-type": "Notification", + "HTTP_X_AMZ_SNS_SUBSCRIPTION_ARN": "arn:aws:sns:xxxxxxxxxx:467989492352:oncall-test:3aab6edb-0c5e-4fa9-b876-64409d1f6c63", + "HTTP_X_AMZ_SNS_TOPIC_ARN": "arn:aws:sns:xxxxxxxxxx:467989492352:oncall-test", + "HTTP_X_AMZ_SNS_MESSAGE_ID": "473efe1d-8ea4-5252-8124-a3d5ff7408c5", + "HTTP_X_AMZ_SNS_MESSAGE_TYPE": "Notification", } expected_message = bytes(f"Redirected to {region.oncall_backend_url}", "utf-8") mocked_make_request.return_value = HttpResponse(expected_message, status=status.HTTP_200_OK) @@ -268,6 +269,9 @@ def test_organization_moved_middleware_amazon_sns_headers( data = {"value": "test"} response = client.post(url, data, format="json", **expected_sns_headers) assert mocked_make_request.called - assert expected_sns_headers.items() <= mocked_make_request.call_args.args[2].items() + for k in AMAZON_SNS_HEADERS: + assert expected_sns_headers.get(f'HTTP_{k.upper().replace("-","_")}') == mocked_make_request.call_args.args[ + 2 + ].get(k) assert response.content == expected_message assert response.status_code == status.HTTP_200_OK diff --git a/engine/requirements.txt b/engine/requirements.txt index f29283d285..e72530481a 100644 --- a/engine/requirements.txt +++ b/engine/requirements.txt @@ -5,7 +5,7 @@ whitenoise==5.3.0 twilio~=6.37.0 phonenumbers==8.10.0 celery[amqp,redis]==5.3.1 -redis==4.6.0 +redis==5.0.1 humanize==0.5.1 uwsgi==2.0.21 django-cors-headers==3.7.0 @@ -13,13 +13,8 @@ django-debug-toolbar==4.1 django-sns-view==0.1.2 python-telegram-bot==13.13 django-silk==5.0.3 -# we need to use our own fork of django-redis-cache -# reason being is that the "regular" repo (https://github.com/sebleier/django-redis-cache/) -# has `redis` pinned @ <4.0 -# celery==5.3.1 complains about this -# celery[amqp,redis] 5.3.1 depends on redis!=4.5.5 and >=4.5.2; extra == "redis" -git+https://github.com/grafana/django-redis-cache.git@bump-redis-version-to-v4.6 -hiredis==1.0.0 +django-redis==5.4.0 +hiredis==2.2.3 django-ratelimit==2.0.0 django-filter==2.4.0 icalendar==5.0.10 diff --git a/engine/settings/base.py b/engine/settings/base.py index dd749033dc..50eee03ddd 100644 --- a/engine/settings/base.py +++ b/engine/settings/base.py @@ -219,11 +219,11 @@ class DatabaseTypes: # Cache CACHES = { "default": { - "BACKEND": "redis_cache.RedisCache", + "BACKEND": "django_redis.cache.RedisCache", "LOCATION": REDIS_URI, "OPTIONS": { "DB": REDIS_DATABASE, - "PARSER_CLASS": "redis.connection.HiredisParser", + "PARSER_CLASS": "redis.connection._HiredisParser", "CONNECTION_POOL_CLASS": "redis.BlockingConnectionPool", "CONNECTION_POOL_CLASS_KWARGS": REDIS_SSL_CONFIG | { diff --git a/grafana-plugin/e2e-tests/.eslintrc b/grafana-plugin/e2e-tests/.eslintrc new file mode 100644 index 0000000000..f515a0eea2 --- /dev/null +++ b/grafana-plugin/e2e-tests/.eslintrc @@ -0,0 +1,6 @@ +{ + "rules": { + "rulesdir/no-relative-import-paths": "off", + "no-console": "off" + } +} diff --git a/grafana-plugin/e2e-tests/alerts/directPaging.test.ts b/grafana-plugin/e2e-tests/alerts/directPaging.test.ts index 572c94897c..7086cdd121 100644 --- a/grafana-plugin/e2e-tests/alerts/directPaging.test.ts +++ b/grafana-plugin/e2e-tests/alerts/directPaging.test.ts @@ -13,8 +13,8 @@ test('we can directly page a user', async ({ adminRolePage }) => { const { page } = adminRolePage; await goToOnCallPage(page, 'alert-groups'); + await page.waitForTimeout(1000); await clickButton({ page, buttonText: 'Escalation' }); - await fillInInput(page, 'textarea[name="message"]', message); await clickButton({ page, buttonText: 'Invite' }); @@ -23,8 +23,14 @@ test('we can directly page a user', async ({ adminRolePage }) => { await addRespondersPopup.getByText('Users').click(); await addRespondersPopup.getByText(adminRolePage.userName).click(); - await clickButton({ page, buttonText: 'Create' }); + // If user is not on call, confirm invitation + await page.waitForTimeout(1000); + const isConfirmationModalShown = await page.getByText('Confirm Participant Invitation').isVisible(); + if (isConfirmationModalShown) { + await page.getByTestId('confirm-non-oncall').click(); + } + await clickButton({ page, buttonText: 'Create' }); // Check we are redirected to the alert group page await page.waitForURL('**/alert-groups/I*'); // Alert group IDs always start with "I" await expect(page.getByTestId('incident-message')).toContainText(message); diff --git a/grafana-plugin/e2e-tests/escalationChains/escalationPolicy.test.ts b/grafana-plugin/e2e-tests/escalationChains/escalationPolicy.test.ts index 44679b1182..cf9126ba01 100644 --- a/grafana-plugin/e2e-tests/escalationChains/escalationPolicy.test.ts +++ b/grafana-plugin/e2e-tests/escalationChains/escalationPolicy.test.ts @@ -1,6 +1,6 @@ import {expect, test} from "../fixtures"; -import {generateRandomValue} from "../utils/forms"; import {createEscalationChain, EscalationStep, selectEscalationStepValue} from "../utils/escalationChain"; +import {generateRandomValue} from "../utils/forms"; test('escalation policy does not go back to "Default" after adding users to notify', async ({ adminRolePage }) => { const { page, userName } = adminRolePage; diff --git a/grafana-plugin/e2e-tests/escalationChains/searching.test.ts b/grafana-plugin/e2e-tests/escalationChains/searching.test.ts index 4d61ae489a..f814448f31 100644 --- a/grafana-plugin/e2e-tests/escalationChains/searching.test.ts +++ b/grafana-plugin/e2e-tests/escalationChains/searching.test.ts @@ -1,6 +1,6 @@ import { test, expect, Page } from '../fixtures'; -import { generateRandomValue } from '../utils/forms'; import { createEscalationChain } from '../utils/escalationChain'; +import { generateRandomValue } from '../utils/forms'; const assertEscalationChainSearchWorks = async ( page: Page, diff --git a/grafana-plugin/e2e-tests/fixtures.ts b/grafana-plugin/e2e-tests/fixtures.ts index fca7739a5b..12087944c0 100644 --- a/grafana-plugin/e2e-tests/fixtures.ts +++ b/grafana-plugin/e2e-tests/fixtures.ts @@ -1,10 +1,14 @@ -import * as fs from 'fs'; -import * as path from 'path'; import { test as base, Browser, Page, TestInfo } from '@playwright/test'; -import { GRAFANA_ADMIN_USERNAME, GRAFANA_EDITOR_USERNAME, GRAFANA_VIEWER_USERNAME } from './utils/constants'; import { VIEWER_USER_STORAGE_STATE, EDITOR_USER_STORAGE_STATE, ADMIN_USER_STORAGE_STATE } from '../playwright.config'; +import { GRAFANA_ADMIN_USERNAME, GRAFANA_EDITOR_USERNAME, GRAFANA_VIEWER_USERNAME } from './utils/constants'; + +import * as fs from 'fs'; +import * as path from 'path'; + + + export class BaseRolePage { page: Page; userName: string; diff --git a/grafana-plugin/e2e-tests/globalSetup.ts b/grafana-plugin/e2e-tests/globalSetup.ts index c7c4227a0a..835fc24776 100644 --- a/grafana-plugin/e2e-tests/globalSetup.ts +++ b/grafana-plugin/e2e-tests/globalSetup.ts @@ -1,5 +1,8 @@ +import { OrgRole } from '@grafana/data'; import { test as setup, chromium, expect, Page, BrowserContext, FullConfig, APIRequestContext } from '@playwright/test'; +import { VIEWER_USER_STORAGE_STATE, EDITOR_USER_STORAGE_STATE, ADMIN_USER_STORAGE_STATE } from '../playwright.config'; + import GrafanaAPIClient from './utils/clients/grafana'; import { GRAFANA_ADMIN_PASSWORD, @@ -14,8 +17,6 @@ import { } from './utils/constants'; import { clickButton, getInputByName } from './utils/forms'; import { goToGrafanaPage } from './utils/navigation'; -import { VIEWER_USER_STORAGE_STATE, EDITOR_USER_STORAGE_STATE, ADMIN_USER_STORAGE_STATE } from '../playwright.config'; -import { OrgRole } from '@grafana/data'; const grafanaApiClient = new GrafanaAPIClient(GRAFANA_ADMIN_USERNAME, GRAFANA_ADMIN_PASSWORD); diff --git a/grafana-plugin/e2e-tests/integrations/heartbeat.test.ts b/grafana-plugin/e2e-tests/integrations/heartbeat.test.ts index 202f1832e3..374ca6aec4 100644 --- a/grafana-plugin/e2e-tests/integrations/heartbeat.test.ts +++ b/grafana-plugin/e2e-tests/integrations/heartbeat.test.ts @@ -1,5 +1,4 @@ import { test, Page, expect } from '../fixtures'; - import { generateRandomValue, selectDropdownValue } from '../utils/forms'; import { createIntegration } from '../utils/integrations'; @@ -7,10 +6,7 @@ const HEARTBEAT_SETTINGS_FORM_TEST_ID = 'heartbeat-settings-form'; test.describe("updating an integration's heartbeat interval works", async () => { const _openHeartbeatSettingsForm = async (page: Page) => { - const integrationSettingsPopupElement = page.getByTestId('integration-settings-context-menu'); - await integrationSettingsPopupElement.waitFor({ state: 'visible' }); - await integrationSettingsPopupElement.click(); - + await page.getByTestId('integration-settings-context-menu-wrapper').getByRole('img').click(); await page.getByTestId('integration-heartbeat-settings').click(); }; @@ -60,6 +56,6 @@ test.describe("updating an integration's heartbeat interval works", async () => */ await page.request.get(endpoint); await page.reload({ waitUntil: 'networkidle' }); - await page.getByTestId('heartbeat-badge').waitFor({ state: 'visible' }); + await page.getByTestId('heartbeat-badge').waitFor(); }); }); diff --git a/grafana-plugin/e2e-tests/integrations/integrationsTable.test.ts b/grafana-plugin/e2e-tests/integrations/integrationsTable.test.ts index b2ad52ad75..85408fbdaa 100644 --- a/grafana-plugin/e2e-tests/integrations/integrationsTable.test.ts +++ b/grafana-plugin/e2e-tests/integrations/integrationsTable.test.ts @@ -1,29 +1,31 @@ -import { test, expect } from '../fixtures'; +import { test } from '../fixtures'; import { generateRandomValue } from '../utils/forms'; -import { createIntegration } from '../utils/integrations'; +import { createIntegration, searchIntegrationAndAssertItsPresence } from '../utils/integrations'; test('Integrations table shows data in Connections and Direct Paging tabs', async ({ adminRolePage: { page } }) => { - // // Create 2 integrations that are not Direct Paging const ID = generateRandomValue(); const WEBHOOK_INTEGRATION_NAME = `Webhook-${ID}`; const ALERTMANAGER_INTEGRATION_NAME = `Alertmanager-${ID}`; - const DIRECT_PAGING_INTEGRATION_NAME = `Direct paging`; + const DIRECT_PAGING_INTEGRATION_NAME = `Direct paging integration name`; + // Create 2 integrations that are not Direct Paging await createIntegration({ page, integrationSearchText: 'Webhook', integrationName: WEBHOOK_INTEGRATION_NAME }); + await page.waitForTimeout(1000); await page.getByRole('tab', { name: 'Tab Integrations' }).click(); - await createIntegration({ page, integrationSearchText: 'Alertmanager', shouldGoToIntegrationsPage: false, integrationName: ALERTMANAGER_INTEGRATION_NAME, }); + await page.waitForTimeout(1000); await page.getByRole('tab', { name: 'Tab Integrations' }).click(); // Create 1 Direct Paging integration if it doesn't exist - const integrationsTable = page.getByTestId('integrations-table'); await page.getByRole('tab', { name: 'Tab Direct Paging' }).click(); - const isDirectPagingAlreadyCreated = await page.getByText('Direct paging').isVisible(); + const integrationsTable = page.getByTestId('integrations-table'); + await page.waitForTimeout(2000); + const isDirectPagingAlreadyCreated = (await integrationsTable.getByText('Direct paging').count()) >= 1; if (!isDirectPagingAlreadyCreated) { await createIntegration({ page, @@ -31,17 +33,41 @@ test('Integrations table shows data in Connections and Direct Paging tabs', asyn shouldGoToIntegrationsPage: false, integrationName: DIRECT_PAGING_INTEGRATION_NAME, }); + await page.waitForTimeout(1000); } await page.getByRole('tab', { name: 'Tab Integrations' }).click(); // By default Connections tab is opened and newly created integrations are visible except Direct Paging one - await expect(integrationsTable.getByText(WEBHOOK_INTEGRATION_NAME)).toBeVisible(); - await expect(integrationsTable.getByText(ALERTMANAGER_INTEGRATION_NAME)).toBeVisible(); - await expect(integrationsTable).not.toContainText(DIRECT_PAGING_INTEGRATION_NAME); + await searchIntegrationAndAssertItsPresence({ page, integrationsTable, integrationName: WEBHOOK_INTEGRATION_NAME }); + await searchIntegrationAndAssertItsPresence({ + page, + integrationsTable, + integrationName: ALERTMANAGER_INTEGRATION_NAME, + }); + await searchIntegrationAndAssertItsPresence({ + page, + integrationsTable, + integrationName: DIRECT_PAGING_INTEGRATION_NAME, + visibleExpected: false, + }); // Then after switching to Direct Paging tab only Direct Paging integration is visible await page.getByRole('tab', { name: 'Tab Direct Paging' }).click(); - await expect(integrationsTable.getByText(WEBHOOK_INTEGRATION_NAME)).not.toBeVisible(); - await expect(integrationsTable.getByText(ALERTMANAGER_INTEGRATION_NAME)).not.toBeVisible(); - await expect(integrationsTable).toContainText(DIRECT_PAGING_INTEGRATION_NAME); + await searchIntegrationAndAssertItsPresence({ + page, + integrationsTable, + integrationName: WEBHOOK_INTEGRATION_NAME, + visibleExpected: false, + }); + await searchIntegrationAndAssertItsPresence({ + page, + integrationsTable, + integrationName: ALERTMANAGER_INTEGRATION_NAME, + visibleExpected: false, + }); + await searchIntegrationAndAssertItsPresence({ + page, + integrationsTable, + integrationName: 'Direct paging', + }); }); diff --git a/grafana-plugin/e2e-tests/integrations/maintenanceMode.test.ts b/grafana-plugin/e2e-tests/integrations/maintenanceMode.test.ts index 88ff3c6ec7..f3471840e7 100644 --- a/grafana-plugin/e2e-tests/integrations/maintenanceMode.test.ts +++ b/grafana-plugin/e2e-tests/integrations/maintenanceMode.test.ts @@ -13,8 +13,6 @@ import { goToOnCallPage } from '../utils/navigation'; type MaintenanceModeType = 'Debug' | 'Maintenance'; test.describe('maintenance mode works', () => { - test.slow(); // this test is doing a good amount of work, give it time - const MAINTENANCE_DURATION = '1 hour'; const REMAINING_TIME_TEXT = '59m left'; const REMAINING_TIME_TOOLTIP_TEST_ID = 'maintenance-mode-remaining-time-tooltip'; @@ -22,27 +20,27 @@ test.describe('maintenance mode works', () => { const createRoutedText = (escalationChainName: string): string => `alert group assigned to route "default" with escalation chain "${escalationChainName}"`; - const _openIntegrationSettingsPopup = async (page: Page): Promise => { - const integrationSettingsPopupElement = page.getByTestId('integration-settings-context-menu'); - await integrationSettingsPopupElement.waitFor({ state: 'visible' }); + const _openIntegrationSettingsPopup = async (page: Page, shouldDoubleClickSettingsIcon = false): Promise => { + await page.waitForTimeout(2000); + const integrationSettingsPopupElement = page + .getByTestId('integration-settings-context-menu-wrapper') + .getByRole('img'); await integrationSettingsPopupElement.click(); - return integrationSettingsPopupElement; + /** + * sometimes we need to click twice (e.g. adding the escalation chain route + * doesn't unfocus out of the select element after selecting an option) + */ + if (shouldDoubleClickSettingsIcon) { + await integrationSettingsPopupElement.click(); + } }; const getRemainingTimeTooltip = (page: Page): Locator => page.getByTestId(REMAINING_TIME_TOOLTIP_TEST_ID); const enableMaintenanceMode = async (page: Page, mode: MaintenanceModeType): Promise => { - const integrationSettingsPopupElement = await _openIntegrationSettingsPopup(page); - /** - * we need to click twice here, because adding the escalation chain route - * doesn't unfocus out of the select element after selecting an option - */ - await integrationSettingsPopupElement.click(); - + await _openIntegrationSettingsPopup(page, true); // open the maintenance mode settings drawer + fill in the maintenance details - const startMaintenanceModeButton = page.getByTestId('integration-start-maintenance'); - await startMaintenanceModeButton.waitFor({ state: 'visible' }); - await startMaintenanceModeButton.click(); + await page.getByTestId('integration-start-maintenance').click(); // fill in the form const maintenanceModeDrawer = page.getByTestId('maintenance-mode-drawer'); @@ -77,12 +75,10 @@ test.describe('maintenance mode works', () => { await goToOnCallPage(page, 'integrations'); await filterIntegrationsTableAndGoToDetailPage(page, integrationName); - await _openIntegrationSettingsPopup(page); + await _openIntegrationSettingsPopup(page, true); // click the stop maintenance button - const stopMaintenanceModeButton = page.getByTestId('integration-stop-maintenance'); - await stopMaintenanceModeButton.waitFor({ state: 'visible' }); - await stopMaintenanceModeButton.click(); + await page.getByTestId('integration-stop-maintenance').click(); // in the modal popup, confirm that we want to stop it await clickButton({ @@ -114,6 +110,8 @@ test.describe('maintenance mode works', () => { }; test('debug mode', async ({ adminRolePage: { page, userName } }) => { + test.slow(); + const { escalationChainName, integrationName } = await createIntegrationAndEscalationChainAndEnableMaintenanceMode( page, userName, @@ -130,6 +128,7 @@ test.describe('maintenance mode works', () => { }); test('"maintenance" mode', async ({ adminRolePage: { page, userName } }) => { + test.slow(); const { integrationName } = await createIntegrationAndEscalationChainAndEnableMaintenanceMode( page, userName, diff --git a/grafana-plugin/e2e-tests/integrations/uniqueIntegrationNames.test.ts b/grafana-plugin/e2e-tests/integrations/uniqueIntegrationNames.test.ts index be27aa868e..b30ac0796a 100644 --- a/grafana-plugin/e2e-tests/integrations/uniqueIntegrationNames.test.ts +++ b/grafana-plugin/e2e-tests/integrations/uniqueIntegrationNames.test.ts @@ -1,8 +1,10 @@ import { test, expect } from '../fixtures'; import { openCreateIntegrationModal } from '../utils/integrations'; +import { goToOnCallPage } from '../utils/navigation'; test('integrations have unique names', async ({ adminRolePage }) => { const { page } = adminRolePage; + await goToOnCallPage(page, 'integrations'); await openCreateIntegrationModal(page); const integrationNames = await page.getByTestId('integration-display-name').allInnerTexts(); diff --git a/grafana-plugin/e2e-tests/schedules/addOverride.test.ts b/grafana-plugin/e2e-tests/schedules/addOverride.test.ts index 2ee994943d..bcf56e9d89 100644 --- a/grafana-plugin/e2e-tests/schedules/addOverride.test.ts +++ b/grafana-plugin/e2e-tests/schedules/addOverride.test.ts @@ -1,7 +1,8 @@ +import dayjs from 'dayjs'; + import { test, expect } from '../fixtures'; import { clickButton, generateRandomValue } from '../utils/forms'; import { createOnCallSchedule, getOverrideFormDateInputs } from '../utils/schedule'; -import dayjs from 'dayjs'; test('default dates in override creation modal are correct', async ({ adminRolePage }) => { const { page, userName } = adminRolePage; diff --git a/grafana-plugin/e2e-tests/users/usersActions.test.ts b/grafana-plugin/e2e-tests/users/usersActions.test.ts index 127d993d80..129f517bac 100644 --- a/grafana-plugin/e2e-tests/users/usersActions.test.ts +++ b/grafana-plugin/e2e-tests/users/usersActions.test.ts @@ -1,136 +1,68 @@ -import { test, expect, Page } from '../fixtures'; +import { test, expect } from '../fixtures'; import { goToOnCallPage } from '../utils/navigation'; +import { viewUsers, accessProfileTabs } from '../utils/users'; test.describe('Users screen actions', () => { - test("Admin is allowed to edit other users' profile", async ({ adminRolePage }) => { - await _testButtons(adminRolePage.page, 'button.edit-other-profile-button[disabled]'); + test("Admin is allowed to edit other users' profile", async ({ adminRolePage: { page } }) => { + await goToOnCallPage(page, 'users'); + await expect(page.getByTestId('users-table').getByRole('button', { name: 'Edit', disabled: false })).toHaveCount(3); }); - test('Admin is allowed to view the list of users', async ({ adminRolePage }) => { - await _viewUsers(adminRolePage.page); + test('Admin is allowed to view the list of users', async ({ adminRolePage: { page } }) => { + await viewUsers(page); }); - test('Viewer is not allowed to view the list of users', async ({ viewerRolePage }) => { - await _viewUsers(viewerRolePage.page, false); + test('Viewer is not allowed to view the list of users', async ({ viewerRolePage: { page } }) => { + await viewUsers(page, false); }); test('Viewer cannot access restricted tabs from View My Profile', async ({ viewerRolePage }) => { const { page } = viewerRolePage; - await _accessProfileTabs(page, ['tab-mobile-app', 'tab-phone-verification', 'tab-slack', 'tab-telegram'], false); + await accessProfileTabs(page, ['tab-mobile-app', 'tab-phone-verification', 'tab-slack', 'tab-telegram'], false); }); test('Editor is allowed to view the list of users', async ({ editorRolePage }) => { - await _viewUsers(editorRolePage.page); + await viewUsers(editorRolePage.page); }); test("Editor cannot view other users' data", async ({ editorRolePage }) => { const { page } = editorRolePage; await goToOnCallPage(page, 'users'); - await page.waitForSelector('.current-user'); - - // check if these fields are Masked or Not (******) - const fieldIds = ['users-email', 'users-phone-number']; - - for (let i = 0; i < fieldIds.length - 1; ++i) { - const currentUsername = page.locator(`.current-user [data-testid="${fieldIds[i]}"]`); - - expect((await currentUsername.all()).length).toBe(1); // match for current user - (await currentUsername.all()).forEach((val) => expect(val).not.toHaveText('******')); + await page.getByTestId('users-email').and(page.getByText('editor')).waitFor(); - const otherUsername = page.locator(`.other-user [data-testid="${fieldIds[i]}"]`); - - expect((await otherUsername.all()).length).toBeGreaterThan(1); // match for other users (>= 1) - (await otherUsername.all()).forEach((val) => expect(val).toHaveText('******')); - } + await expect(page.getByTestId('users-email').and(page.getByText('editor'))).toHaveCount(1); + await expect(page.getByTestId('users-email').and(page.getByText('******'))).toHaveCount(2); + await expect(page.getByTestId('users-phone-number').and(page.getByText('******'))).toHaveCount(2); }); test('Editor can access tabs from View My Profile', async ({ editorRolePage }) => { const { page } = editorRolePage; // the other tabs depend on Cloud, skip for now - await _accessProfileTabs(page, ['tab-slack', 'tab-telegram'], true); + await accessProfileTabs(page, ['tab-slack', 'tab-telegram'], true); }); - test("Editor is not allowed to edit other users' profile", async ({ editorRolePage }) => { - await _testButtons(editorRolePage.page, 'button.edit-other-profile-button:not([disabled])'); + test("Editor is not allowed to edit other users' profile", async ({ editorRolePage: { page } }) => { + await goToOnCallPage(page, 'users'); + await expect(page.getByTestId('users-table').getByRole('button', { name: 'Edit', disabled: false })).toHaveCount(1); + await expect(page.getByTestId('users-table').getByRole('button', { name: 'Edit', disabled: true })).toHaveCount(2); }); test('Search updates the table view', async ({ adminRolePage }) => { const { page } = adminRolePage; await goToOnCallPage(page, 'users'); - await page.waitForTimeout(1000); + await page.waitForTimeout(2000); const searchInput = page.locator(`[data-testid="search-users"]`); await searchInput.fill('oncall'); - await page.waitForTimeout(5000); + await page.waitForTimeout(2000); const result = page.locator(`[data-testid="users-username"]`); expect(await result.count()).toBe(1); }); - - /* - * Helper methods - */ - - async function _testButtons(page: Page, selector: string) { - await goToOnCallPage(page, 'users'); - - const usersTableElement = page.getByTestId('users-table'); - await usersTableElement.waitFor({ state: 'visible' }); - - const buttonsList = await page.locator(selector); - - expect(buttonsList).toHaveCount(0); - } - - async function _accessProfileTabs(page: Page, tabs: string[], hasAccess: boolean) { - await goToOnCallPage(page, 'users'); - - await page.getByTestId('users-view-my-profile').click(); - - // the next queries could or could not resolve - // therefore we wait a generic 1000ms duration and assert based on visibility - await page.waitForTimeout(1000); - - for (let i = 0; i < tabs.length - 1; ++i) { - const tab = page.getByTestId(tabs[i]); - - if (await tab.isVisible()) { - await tab.click(); - - const query = page.getByText( - 'You do not have permission to perform this action. Ask an admin to upgrade your permissions.' - ); - - if (hasAccess) { - await expect(query).toBeHidden(); - } else { - await expect(query).toBeVisible(); - } - } - } - } - - async function _viewUsers(page: Page, isAllowedToView = true): Promise { - await goToOnCallPage(page, 'users'); - - if (isAllowedToView) { - const usersTableElement = page.getByTestId('users-table'); - await usersTableElement.waitFor({ state: 'visible' }); - - const userRowsContext = await usersTableElement.locator('tbody > tr').allTextContents(); - expect(userRowsContext.length).toBeGreaterThan(0); - } else { - const missingPermissionsMessageElement = page.getByTestId('view-users-missing-permission-message'); - await missingPermissionsMessageElement.waitFor({ state: 'visible' }); - - const missingPermissionMessage = await missingPermissionsMessageElement.textContent(); - expect(missingPermissionMessage).toMatch(/You are missing the .* to be able to view OnCall users/); - } - } }); diff --git a/grafana-plugin/e2e-tests/utils/alertGroup.ts b/grafana-plugin/e2e-tests/utils/alertGroup.ts index 339aad011d..1a7d854d83 100644 --- a/grafana-plugin/e2e-tests/utils/alertGroup.ts +++ b/grafana-plugin/e2e-tests/utils/alertGroup.ts @@ -1,4 +1,5 @@ import { Locator, Page, expect } from '@playwright/test'; + import { selectDropdownValue, selectValuePickerValue } from './forms'; import { goToOnCallPage } from './navigation'; diff --git a/grafana-plugin/e2e-tests/utils/clients/grafana.ts b/grafana-plugin/e2e-tests/utils/clients/grafana.ts index 2127410050..a9a2d0fdc0 100644 --- a/grafana-plugin/e2e-tests/utils/clients/grafana.ts +++ b/grafana-plugin/e2e-tests/utils/clients/grafana.ts @@ -95,7 +95,7 @@ export default class GrafanaAPIClient { // user was just created const respJson: CreateUserResponse = await res.json(); userId = respJson.id; - } else if (responseCode == 412) { + } else if (responseCode === 412) { // user already exists, go fetch their user id userId = await this.getUserIdByUsername(request, userName); } else { diff --git a/grafana-plugin/e2e-tests/utils/forms.ts b/grafana-plugin/e2e-tests/utils/forms.ts index 0649221098..73c9734ea7 100644 --- a/grafana-plugin/e2e-tests/utils/forms.ts +++ b/grafana-plugin/e2e-tests/utils/forms.ts @@ -1,4 +1,5 @@ import type { Locator, Page } from '@playwright/test'; + import { randomUUID } from 'crypto'; type SelectorType = 'gSelect' | 'grafanaSelect'; @@ -22,9 +23,6 @@ type SelectDropdownValueArgs = { type ClickButtonArgs = { page: Page; buttonText: string; - // if provided, search for the button by data-testid - dataTestId?: string; - // if provided, use this Locator as the root of our search for the button startingLocator?: Locator; }; @@ -36,17 +34,9 @@ export const fillInInputByPlaceholderValue = (page: Page, placeholderValue: stri export const getInputByName = (page: Page, name: string): Locator => page.locator(`input[name="${name}"]`); -export const clickButton = async ({ - page, - buttonText, - startingLocator, - dataTestId, -}: ClickButtonArgs): Promise => { - const baseLocator = dataTestId ? `button[data-testid="${dataTestId}"]` : 'button'; - const button = (startingLocator || page).locator(`${baseLocator}:not([disabled]) >> text=${buttonText}`); - - await button.waitFor({ state: 'visible' }); - await button.click(); +export const clickButton = async ({ page, buttonText, startingLocator }: ClickButtonArgs): Promise => { + const baseLocator = startingLocator || page; + await baseLocator.getByRole('button', { name: buttonText, disabled: false }).click(); }; /** @@ -94,7 +84,7 @@ export const selectDropdownValue = async (args: SelectDropdownValueArgs): Promis const { page, value, pressEnterInsteadOfSelectingOption } = args; const selectElement = await openSelect(args); - await selectElement.type(value); + await selectElement.pressSequentially(value); if (pressEnterInsteadOfSelectingOption) { await page.keyboard.press('Enter'); diff --git a/grafana-plugin/e2e-tests/utils/integrations.ts b/grafana-plugin/e2e-tests/utils/integrations.ts index 4a45211f97..72ef8ec766 100644 --- a/grafana-plugin/e2e-tests/utils/integrations.ts +++ b/grafana-plugin/e2e-tests/utils/integrations.ts @@ -1,4 +1,5 @@ -import { Page } from '@playwright/test'; +import { Locator, Page, expect } from '@playwright/test'; + import { clickButton, generateRandomValue, selectDropdownValue } from './forms'; import { goToOnCallPage } from './navigation'; @@ -60,8 +61,8 @@ export const assignEscalationChainToIntegration = async (page: Page, escalationC }; export const sendDemoAlert = async (page: Page): Promise => { - await clickButton({ page, buttonText: 'Send demo alert', dataTestId: 'send-demo-alert' }); - await clickButton({ page, buttonText: 'Send Alert', dataTestId: 'submit-send-alert' }); + await clickButton({ page, buttonText: 'Send demo alert' }); + await clickButton({ page, buttonText: 'Send Alert' }); await page.getByTestId('demo-alert-sent-notification').waitFor({ state: 'visible' }); }; @@ -85,9 +86,32 @@ export const filterIntegrationsTableAndGoToDetailPage = async (page: Page, integ pressEnterInsteadOfSelectingOption: true, }); - await ( - await page.waitForSelector( - `div[data-testid="integrations-table"] table > tbody > tr > td:first-child a >> text=${integrationName}` - ) - ).click(); + await page.getByTestId('integrations-table').getByText(`${integrationName}`).click(); +}; + +export const searchIntegrationAndAssertItsPresence = async ({ + page, + integrationName, + integrationsTable, + visibleExpected = true, +}: { + page: Page; + integrationsTable: Locator; + integrationName: string; + visibleExpected?: boolean; +}) => { + await page + .locator('div') + .filter({ hasText: /^Search or filter results\.\.\.$/ }) + .nth(1) + .click(); + await page.keyboard.insertText(integrationName); + await page.keyboard.press('Enter'); + await page.waitForTimeout(2000); + const nbOfResults = await integrationsTable.getByText(integrationName).count(); + if (visibleExpected) { + expect(nbOfResults).toBeGreaterThanOrEqual(1); + } else { + expect(nbOfResults).toBe(0); + } }; diff --git a/grafana-plugin/e2e-tests/utils/navigation.ts b/grafana-plugin/e2e-tests/utils/navigation.ts index f75eb04677..b5a1e4f7d4 100644 --- a/grafana-plugin/e2e-tests/utils/navigation.ts +++ b/grafana-plugin/e2e-tests/utils/navigation.ts @@ -1,4 +1,5 @@ import type { Page, Response } from '@playwright/test'; + import { BASE_URL } from './constants'; type GrafanaPage = '/plugins/grafana-oncall-app'; diff --git a/grafana-plugin/e2e-tests/utils/schedule.ts b/grafana-plugin/e2e-tests/utils/schedule.ts index 9d8e433587..3627109ad9 100644 --- a/grafana-plugin/e2e-tests/utils/schedule.ts +++ b/grafana-plugin/e2e-tests/utils/schedule.ts @@ -1,7 +1,8 @@ import { Page } from '@playwright/test'; +import dayjs from 'dayjs'; + import { clickButton, fillInInput, selectDropdownValue } from './forms'; import { goToOnCallPage } from './navigation'; -import dayjs from 'dayjs'; export const createOnCallSchedule = async (page: Page, scheduleName: string, userName: string): Promise => { // go to the schedules page diff --git a/grafana-plugin/e2e-tests/utils/users.ts b/grafana-plugin/e2e-tests/utils/users.ts new file mode 100644 index 0000000000..e7c5d15dff --- /dev/null +++ b/grafana-plugin/e2e-tests/utils/users.ts @@ -0,0 +1,45 @@ +import { Page, expect } from '@playwright/test'; + +import { goToOnCallPage } from './navigation'; + +export async function accessProfileTabs(page: Page, tabs: string[], hasAccess: boolean) { + await goToOnCallPage(page, 'users'); + + await page.getByTestId('users-view-my-profile').click(); + + // the next queries could or could not resolve + // therefore we wait a generic 1000ms duration and assert based on visibility + await page.waitForTimeout(1000); + + for (let i = 0; i < tabs.length - 1; ++i) { + const tab = page.getByTestId(tabs[i]); + + if (await tab.isVisible()) { + await tab.click(); + + const query = page.getByText( + 'You do not have permission to perform this action. Ask an admin to upgrade your permissions.' + ); + + if (hasAccess) { + await expect(query).toBeHidden(); + } else { + await expect(query).toBeVisible(); + } + } + } +} + +export async function viewUsers(page: Page, isAllowedToView = true): Promise { + await goToOnCallPage(page, 'users'); + + if (isAllowedToView) { + const usersTable = page.getByTestId('users-table'); + await usersTable.getByRole('row').nth(1).waitFor(); + await expect(usersTable.getByRole('row')).toHaveCount(4); + } else { + await expect(page.getByTestId('view-users-missing-permission-message')).toHaveText( + /You are missing the .* to be able to view OnCall users/ + ); + } +} diff --git a/grafana-plugin/package.json b/grafana-plugin/package.json index fb8278d3c7..02cd81faef 100644 --- a/grafana-plugin/package.json +++ b/grafana-plugin/package.json @@ -3,8 +3,8 @@ "version": "dev-oss", "description": "Grafana OnCall Plugin", "scripts": { - "lint": "eslint --cache --ext .js,.jsx,.ts,.tsx --max-warnings=0 ./src", - "lint:fix": "eslint --fix --cache --ext .js,.jsx,.ts,.tsx --max-warnings=0 --quiet ./src", + "lint": "eslint --cache --ext .js,.jsx,.ts,.tsx --max-warnings=0 ./src ./e2e-tests", + "lint:fix": "eslint --fix --cache --ext .js,.jsx,.ts,.tsx --max-warnings=0 --quiet ./src ./e2e-tests", "stylelint": "stylelint ./src/**/*.{css,scss,module.css,module.scss}", "stylelint:fix": "stylelint --fix ./src/**/*.{css,scss,module.css,module.scss}", "build": "grafana-toolkit plugin:build", @@ -64,7 +64,7 @@ "@grafana/eslint-config": "^5.1.0", "@grafana/toolkit": "^9.5.2", "@jest/globals": "^27.5.1", - "@playwright/test": "^1.35.1", + "@playwright/test": "^1.39.0", "@testing-library/jest-dom": "^5.16.5", "@testing-library/react": "12", "@testing-library/user-event": "^14.4.3", diff --git a/grafana-plugin/playwright.config.ts b/grafana-plugin/playwright.config.ts index a7af7d8679..689cd6cc1c 100644 --- a/grafana-plugin/playwright.config.ts +++ b/grafana-plugin/playwright.config.ts @@ -1,8 +1,6 @@ -import path from 'path'; - -import type { PlaywrightTestConfig } from '@playwright/test'; -import { devices } from '@playwright/test'; +import { PlaywrightTestConfig, PlaywrightTestProject, defineConfig, devices } from '@playwright/test'; +import path from 'path'; /** * Read environment variables from file. * https://github.com/motdotla/dotenv @@ -13,10 +11,17 @@ export const VIEWER_USER_STORAGE_STATE = path.join(__dirname, 'e2e-tests/.auth/v export const EDITOR_USER_STORAGE_STATE = path.join(__dirname, 'e2e-tests/.auth/editor.json'); export const ADMIN_USER_STORAGE_STATE = path.join(__dirname, 'e2e-tests/.auth/admin.json'); +const IS_CI = !!process.env.CI; +const BROWSERS = process.env.BROWSERS || 'chromium firefox webkit'; + +const SETUP_PROJECT_NAME = 'setup'; +const getEnabledBrowsers = (browsers: PlaywrightTestProject[]) => + browsers.filter(({ name }) => name === SETUP_PROJECT_NAME || BROWSERS.includes(name)); + /** * See https://playwright.dev/docs/test-configuration. */ -const config: PlaywrightTestConfig = { +export default defineConfig({ testDir: './e2e-tests', /* Maximum time all the tests can run for. */ @@ -32,16 +37,16 @@ const config: PlaywrightTestConfig = { timeout: 10000, }, /* Run tests in files in parallel */ - fullyParallel: true, + fullyParallel: false, /* Fail the build on CI if you accidentally left test.only in the source code. */ - forbidOnly: !!process.env.CI, + forbidOnly: IS_CI, /** * Retry on CI only * * NOTE: until we fix this issue (https://github.com/grafana/oncall/issues/1692) which occasionally leads - * to flaky tests.. let's just retry failed tests. If the same test fails 3 times, you know something must be up + * to flaky tests.. let's allow 1 retry per test */ - retries: !!process.env.CI ? 3 : 0, + retries: IS_CI ? 1 : 0, workers: 2, /* Reporter to use. See https://playwright.dev/docs/test-reporters */ reporter: 'html', @@ -52,52 +57,41 @@ const config: PlaywrightTestConfig = { /* Base URL to use in actions like `await page.goto('/')`. */ // baseURL: 'http://localhost:3000', - /* Collect trace when retrying the failed test. See https://playwright.dev/docs/trace-viewer */ trace: 'on', video: 'on', - headless: !!process.env.CI, + headless: IS_CI, }, - /* Configure projects for major browsers */ - projects: [ + /* Configure projects for major browsers. The final list is filtered based on BROWSERS env var */ + projects: getEnabledBrowsers([ { - name: 'setup', + name: SETUP_PROJECT_NAME, testMatch: /globalSetup\.ts/, }, { name: 'chromium', - use: { - ...devices['Desktop Chrome'], - }, - dependencies: ['setup'], + use: devices['Desktop Chrome'], + dependencies: [SETUP_PROJECT_NAME], }, { name: 'firefox', - use: { - ...devices['Desktop Firefox'], - }, - dependencies: ['setup'], + use: devices['Desktop Firefox'], + dependencies: [SETUP_PROJECT_NAME], }, { name: 'webkit', - use: { - ...devices['Desktop Safari'], - }, - dependencies: ['setup'], + use: devices['Desktop Safari'], + dependencies: [SETUP_PROJECT_NAME], }, /* Test against mobile viewports. */ // { // name: 'Mobile Chrome', - // use: { - // ...devices['Pixel 5'], - // }, + // use: devices['Pixel 5'], // }, // { // name: 'Mobile Safari', - // use: { - // ...devices['iPhone 12'], - // }, + // use: devices['iPhone 12'], // }, /* Test against branded browsers. */ @@ -113,7 +107,7 @@ const config: PlaywrightTestConfig = { // channel: 'chrome', // }, // }, - ], + ]), /* Folder for test artifacts such as screenshots, videos, traces, etc. */ // outputDir: 'test-results/', @@ -123,6 +117,4 @@ const config: PlaywrightTestConfig = { // command: 'npm run start', // port: 3000, // }, -}; - -export default config; +}); diff --git a/grafana-plugin/src/components/RenderConditionally/RenderConditionally.tsx b/grafana-plugin/src/components/RenderConditionally/RenderConditionally.tsx new file mode 100644 index 0000000000..e6a5d8b32d --- /dev/null +++ b/grafana-plugin/src/components/RenderConditionally/RenderConditionally.tsx @@ -0,0 +1,11 @@ +import React, { FC, ReactNode } from 'react'; + +interface RenderConditionallyProps { + shouldRender?: boolean; + children: ReactNode; +} + +const RenderConditionally: FC = ({ shouldRender, children }) => + shouldRender ? <>{children} : null; + +export default RenderConditionally; diff --git a/grafana-plugin/src/containers/AddResponders/AddResponders.tsx b/grafana-plugin/src/containers/AddResponders/AddResponders.tsx index 515bbe7ac1..69484de57b 100644 --- a/grafana-plugin/src/containers/AddResponders/AddResponders.tsx +++ b/grafana-plugin/src/containers/AddResponders/AddResponders.tsx @@ -214,7 +214,7 @@ const AddResponders = observer( - diff --git a/grafana-plugin/src/containers/CreateAlertReceiveChannelContainer/CreateAlertReceiveChannelContainer.module.css b/grafana-plugin/src/containers/CreateAlertReceiveChannelContainer/CreateAlertReceiveChannelContainer.module.css deleted file mode 100644 index e43787f697..0000000000 --- a/grafana-plugin/src/containers/CreateAlertReceiveChannelContainer/CreateAlertReceiveChannelContainer.module.css +++ /dev/null @@ -1,58 +0,0 @@ -.modal { - width: 840px; -} - -.cards { - display: flex; - flex-wrap: wrap; - gap: 24px; - max-height: 550px; - overflow: auto; - scroll-snap-type: y mandatory; - padding: 0 10px 10px 0; - min-width: 840px; -} - -.cards_centered { - justify-content: center; - align-items: center; -} - -.card { - width: 240px; - height: 88px; - scroll-snap-align: start; - scroll-snap-stop: normal; - display: flex; - flex-direction: row; - align-items: center; - justify-content: flex-start; - cursor: pointer; - position: relative; - gap: 20px; -} - -.card_featured { - width: 768px; -} - -.tag { - top: 28px; - right: 28px; - position: absolute; -} - -.title { - margin: 10px 0 10px 0; - max-width: 500px; -} - -.footer { - display: block; - margin-top: 10px; -} - -.search-integration { - width: 400px; - margin-bottom: 24px; -} diff --git a/grafana-plugin/src/containers/CreateAlertReceiveChannelContainer/CreateAlertReceiveChannelContainer.tsx b/grafana-plugin/src/containers/CreateAlertReceiveChannelContainer/CreateAlertReceiveChannelContainer.tsx deleted file mode 100644 index 98ee9d8189..0000000000 --- a/grafana-plugin/src/containers/CreateAlertReceiveChannelContainer/CreateAlertReceiveChannelContainer.tsx +++ /dev/null @@ -1,118 +0,0 @@ -import React, { ChangeEvent, useCallback, useState } from 'react'; - -import { EmptySearchResult, HorizontalGroup, Input, Modal, VerticalGroup, Tag, Field } from '@grafana/ui'; -import cn from 'classnames/bind'; -import { observer } from 'mobx-react'; - -import Block from 'components/GBlock/Block'; -import IntegrationLogo from 'components/IntegrationLogo/IntegrationLogo'; -import Text from 'components/Text/Text'; -import GrafanaTeamSelect from 'containers/GrafanaTeamSelect/GrafanaTeamSelect'; -import { AlertReceiveChannelOption } from 'models/alert_receive_channel/alert_receive_channel.types'; -import { GrafanaTeam } from 'models/grafana_team/grafana_team.types'; -import { useStore } from 'state/useStore'; - -import styles from './CreateAlertReceiveChannelContainer.module.css'; - -const cx = cn.bind(styles); - -interface CreateAlertReceiveChannelContainerProps { - onHide: () => void; - onCreate: (option: AlertReceiveChannelOption, team: GrafanaTeam['id']) => void; -} - -const CreateAlertReceiveChannelContainer = observer((props: CreateAlertReceiveChannelContainerProps) => { - const { onHide, onCreate } = props; - - const { alertReceiveChannelStore, userStore } = useStore(); - const user = userStore.currentUser; - - const [filterValue, setFilterValue] = useState(''); - - const [selectedTeam, setSelectedTeam] = useState(user.current_team); - - const handleNewIntegrationOptionSelectCallback = useCallback( - (option: AlertReceiveChannelOption) => { - onHide(); - onCreate(option, selectedTeam); - }, - [onCreate, onHide, selectedTeam] - ); - - const handleChangeFilter = useCallback((e: ChangeEvent) => { - setFilterValue(e.currentTarget.value); - }, []); - - const { alertReceiveChannelOptions } = alertReceiveChannelStore; - - const options = alertReceiveChannelOptions - ? alertReceiveChannelOptions.filter((option: AlertReceiveChannelOption) => - option.display_name.toLowerCase().includes(filterValue.toLowerCase()) - ) - : []; - - return ( - - Create Integration - - } - isOpen - closeOnEscape={false} - onDismiss={onHide} - className={cx('modal')} - > -
- - - -
-
-
- -
-
- {options.length ? ( - options.map((alertReceiveChannelChoice) => { - return ( - { - handleNewIntegrationOptionSelectCallback(alertReceiveChannelChoice); - }} - key={alertReceiveChannelChoice.value} - className={cx('card', { card_featured: alertReceiveChannelChoice.featured })} - > -
- -
-
- - - {alertReceiveChannelChoice.display_name} - - - {alertReceiveChannelChoice.short_description} - - -
- {alertReceiveChannelChoice.featured && ( - - )} -
- ); - }) - ) : ( - Could not find anything matching your query - )} -
-
- ); -}); - -export default CreateAlertReceiveChannelContainer; diff --git a/grafana-plugin/src/pages/integration/Integration.tsx b/grafana-plugin/src/pages/integration/Integration.tsx index 4744137697..7fa0a84c70 100644 --- a/grafana-plugin/src/pages/integration/Integration.tsx +++ b/grafana-plugin/src/pages/integration/Integration.tsx @@ -834,164 +834,167 @@ const IntegrationActions: React.FC = ({ - ( -
-
openIntegrationSettings()}> - Integration Settings -
- - {store.hasFeature(AppFeature.Labels) && ( - -
openLabelsForm()}> - Alert group labels -
-
- )} +
+ ( +
+
openIntegrationSettings()}> + Integration Settings +
- {showHeartbeatSettings() && ( - -
setIsHeartbeatFormOpen(true)} - data-testid="integration-heartbeat-settings" - > - Heartbeat Settings -
-
- )} + {store.hasFeature(AppFeature.Labels) && ( + +
openLabelsForm()}> + Alert group labels +
+
+ )} - {!alertReceiveChannel.maintenance_till && ( - -
- Start Maintenance -
-
- )} + {showHeartbeatSettings() && ( + +
setIsHeartbeatFormOpen(true)} + data-testid="integration-heartbeat-settings" + > + Heartbeat Settings +
+
+ )} - -
- Edit Templates -
-
+ {!alertReceiveChannel.maintenance_till && ( + +
+ Start Maintenance +
+
+ )} - {alertReceiveChannel.maintenance_till && ( -
{ - setConfirmModal({ - isOpen: true, - confirmText: 'Stop', - dismissText: 'Cancel', - onConfirm: onStopMaintenance, - title: 'Stop Maintenance', - body: ( - - Are you sure you want to stop the maintenance for{' '} - ? - - ), - }); - }} - data-testid="integration-stop-maintenance" - > - Stop Maintenance +
+ Edit Templates
- )} - {isLegacyIntegration && ( - -
- setConfirmModal({ - isOpen: true, - title: 'Migrate Integration?', - body: ( - + {alertReceiveChannel.maintenance_till && ( + +
{ + setConfirmModal({ + isOpen: true, + confirmText: 'Stop', + dismissText: 'Cancel', + onConfirm: onStopMaintenance, + title: 'Stop Maintenance', + body: ( - Are you sure you want to migrate ? + Are you sure you want to stop the maintenance for{' '} + ? + ), + }); + }} + data-testid="integration-stop-maintenance" + > + Stop Maintenance +
+
+ )} - - - Integration internal behaviour will be changed - - - Integration URL will stay the same, so no need to change {getMigrationDisplayName()}{' '} - configuration - - - - Integration templates will be reset to suit the new payload + {isLegacyIntegration && ( + +
+ setConfirmModal({ + isOpen: true, + title: 'Migrate Integration?', + body: ( + + + Are you sure you want to migrate ? - - It is needed to adjust routes manually to the new payload + + + - Integration internal behaviour will be changed + + - Integration URL will stay the same, so no need to change {getMigrationDisplayName()}{' '} + configuration + + + - Integration templates will be reset to suit the new payload + + + - It is needed to adjust routes manually to the new payload + + - - ), - onConfirm: onIntegrationMigrate, - dismissText: 'Cancel', - confirmText: 'Migrate', - }) - } - > - Migrate + ), + onConfirm: onIntegrationMigrate, + dismissText: 'Cancel', + confirmText: 'Migrate', + }) + } + > + Migrate +
+
+ )} + + openNotification('Integration ID is copied')} + > +
+ + + + UID: {alertReceiveChannel.id} +
- - )} +
- openNotification('Integration ID is copied')} - > -
- - +
- UID: {alertReceiveChannel.id} - -
- - -
- - -
-
{ - setConfirmModal({ - isOpen: true, - title: 'Delete Integration?', - body: ( - - Are you sure you want to delete ? - - ), - onConfirm: deleteIntegration, - dismissText: 'Cancel', - confirmText: 'Delete', - }); - }} - className="u-width-100" - > - - - - Delete Integration - - + +
+
{ + setConfirmModal({ + isOpen: true, + title: 'Delete Integration?', + body: ( + + Are you sure you want to delete ? + + ), + onConfirm: deleteIntegration, + dismissText: 'Cancel', + confirmText: 'Delete', + }); + }} + className="u-width-100" + > + + + + Delete Integration + + +
-
- -
- )} - > - {({ openMenu }) => } - +
+
+ )} + > + {({ openMenu }) => } + +
); diff --git a/grafana-plugin/src/pages/integrations/Integrations.tsx b/grafana-plugin/src/pages/integrations/Integrations.tsx index b3acc3b2ad..bfa29c7e11 100644 --- a/grafana-plugin/src/pages/integrations/Integrations.tsx +++ b/grafana-plugin/src/pages/integrations/Integrations.tsx @@ -29,6 +29,7 @@ import { initErrorDataState, } from 'components/PageErrorHandlingWrapper/PageErrorHandlingWrapper.helpers'; import PluginLink from 'components/PluginLink/PluginLink'; +import RenderConditionally from 'components/RenderConditionally/RenderConditionally'; import Text from 'components/Text/Text'; import TextEllipsisTooltip from 'components/TextEllipsisTooltip/TextEllipsisTooltip'; import TooltipBadge from 'components/TooltipBadge/TooltipBadge'; @@ -540,39 +541,39 @@ class Integrations extends React.Component
- -
- - -
-
{ - this.setState({ - confirmationModal: { - isOpen: true, - confirmText: 'Delete', - dismissText: 'Cancel', - onConfirm: () => this.handleDeleteAlertReceiveChannel(item.id), - title: 'Delete integration', - body: ( - - Are you sure you want to delete integration? - - ), - }, - }); - }} - style={{ width: '100%' }} - > - - - - Delete Integration - - + +
+ +
+
{ + this.setState({ + confirmationModal: { + isOpen: true, + confirmText: 'Delete', + dismissText: 'Cancel', + onConfirm: () => this.handleDeleteAlertReceiveChannel(item.id), + title: 'Delete integration', + body: ( + + Are you sure you want to delete integration? + + ), + }, + }); + }} + className="u-width-100" + > + + + + Delete Integration + + +
-
- + +
)} > diff --git a/grafana-plugin/src/plugin.json b/grafana-plugin/src/plugin.json index d87367a632..9e6311624e 100644 --- a/grafana-plugin/src/plugin.json +++ b/grafana-plugin/src/plugin.json @@ -57,7 +57,7 @@ { "type": "page", "name": "Integrations", - "path": "/a/grafana-oncall-app/integrations", + "path": "/a/grafana-oncall-app/integrations?tab=connections", "role": "Viewer", "action": "grafana-oncall-app.integrations:read", "addToNav": true diff --git a/grafana-plugin/yarn.lock b/grafana-plugin/yarn.lock index f6b902f346..2b225b09fd 100644 --- a/grafana-plugin/yarn.lock +++ b/grafana-plugin/yarn.lock @@ -3193,15 +3193,12 @@ resolved "https://registry.yarnpkg.com/@petamoriken/float16/-/float16-3.6.6.tgz#641f73913a6be402b34e4bdfca98d6832ed55586" integrity sha512-3MUulwMtsdCA9lw8a/Kc0XDBJJVCkYTQ5aGd+///TbfkOMXoOGAzzoiYKwPEsLYZv7He7fKJ/mCacqKOO7REyg== -"@playwright/test@^1.35.1": - version "1.35.1" - resolved "https://registry.yarnpkg.com/@playwright/test/-/test-1.35.1.tgz#a596b61e15b980716696f149cc7a2002f003580c" - integrity sha512-b5YoFe6J9exsMYg0pQAobNDR85T1nLumUYgUTtKm4d21iX2L7WqKq9dW8NGJ+2vX0etZd+Y7UeuqsxDXm9+5ZA== +"@playwright/test@^1.39.0": + version "1.39.0" + resolved "https://registry.yarnpkg.com/@playwright/test/-/test-1.39.0.tgz#d10ba8e38e44104499e25001945f07faa9fa91cd" + integrity sha512-3u1iFqgzl7zr004bGPYiN/5EZpRUSFddQBra8Rqll5N0/vfpqlP9I9EXqAoGacuAbX6c9Ulg/Cjqglp5VkK6UQ== dependencies: - "@types/node" "*" - playwright-core "1.35.1" - optionalDependencies: - fsevents "2.3.2" + playwright "1.39.0" "@polka/url@^1.0.0-next.20": version "1.0.0-next.21" @@ -11757,10 +11754,19 @@ pkg-up@^3.1.0: dependencies: find-up "^3.0.0" -playwright-core@1.35.1: - version "1.35.1" - resolved "https://registry.yarnpkg.com/playwright-core/-/playwright-core-1.35.1.tgz#52c1e6ffaa6a8c29de1a5bdf8cce0ce290ffb81d" - integrity sha512-pNXb6CQ7OqmGDRspEjlxE49w+4YtR6a3X6mT1hZXeJHWmsEz7SunmvZeiG/+y1yyMZdHnnn73WKYdtV1er0Xyg== +playwright-core@1.39.0: + version "1.39.0" + resolved "https://registry.yarnpkg.com/playwright-core/-/playwright-core-1.39.0.tgz#efeaea754af4fb170d11845b8da30b2323287c63" + integrity sha512-+k4pdZgs1qiM+OUkSjx96YiKsXsmb59evFoqv8SKO067qBA+Z2s/dCzJij/ZhdQcs2zlTAgRKfeiiLm8PQ2qvw== + +playwright@1.39.0: + version "1.39.0" + resolved "https://registry.yarnpkg.com/playwright/-/playwright-1.39.0.tgz#184c81cd6478f8da28bcd9e60e94fcebf566e077" + integrity sha512-naE5QT11uC/Oiq0BwZ50gDmy8c8WLPRTEWuSSFVG2egBka/1qMoSqYQcROMT9zLwJ86oPofcTH2jBY/5wWOgIw== + dependencies: + playwright-core "1.39.0" + optionalDependencies: + fsevents "2.3.2" please-upgrade-node@^3.2.0: version "3.2.0" diff --git a/helm/oncall/templates/celery/deployment.yaml b/helm/oncall/templates/celery/deployment.yaml index 32b4245c43..b2498dd16b 100644 --- a/helm/oncall/templates/celery/deployment.yaml +++ b/helm/oncall/templates/celery/deployment.yaml @@ -18,6 +18,9 @@ spec: {{- end }} labels: {{- include "oncall.celery.selectorLabels" . | nindent 8 }} + {{- if .Values.celery.podLabels }} + {{- toYaml .Values.celery.podLabels | nindent 8}} + {{- end }} spec: {{- with .Values.imagePullSecrets }} imagePullSecrets: diff --git a/helm/oncall/templates/engine/deployment.yaml b/helm/oncall/templates/engine/deployment.yaml index e94df2bad4..ccb770df63 100644 --- a/helm/oncall/templates/engine/deployment.yaml +++ b/helm/oncall/templates/engine/deployment.yaml @@ -20,6 +20,9 @@ spec: {{- end }} labels: {{- include "oncall.engine.selectorLabels" . | nindent 8 }} + {{- if .Values.engine.podLabels }} + {{- toYaml .Values.engine.podLabels | nindent 8}} + {{- end }} spec: {{- with .Values.imagePullSecrets }} imagePullSecrets: diff --git a/helm/oncall/templates/telegram-polling/deployment.yaml b/helm/oncall/templates/telegram-polling/deployment.yaml index acf90c9762..2e448897d2 100644 --- a/helm/oncall/templates/telegram-polling/deployment.yaml +++ b/helm/oncall/templates/telegram-polling/deployment.yaml @@ -14,6 +14,9 @@ spec: metadata: labels: {{- include "oncall.telegramPolling.selectorLabels" . | nindent 8 }} + {{- if .Values.telegramPolling.podLabels }} + {{- toYaml .Values.telegramPolling.podLabels | nindent 8 }} + {{- end }} spec: {{- with .Values.imagePullSecrets }} imagePullSecrets: diff --git a/helm/oncall/tests/podlabels_test.yaml b/helm/oncall/tests/podlabels_test.yaml new file mode 100644 index 0000000000..d19dfd80c8 --- /dev/null +++ b/helm/oncall/tests/podlabels_test.yaml @@ -0,0 +1,32 @@ +suite: test podlabels for deployments +templates: + - celery/deployment.yaml + - engine/deployment.yaml + - telegram-polling/deployment.yaml +release: + name: oncall +tests: + - it: podLabels={} -> should exclude podLabels + set: + telegramPolling: + enabled: true + asserts: + - notExists: + path: spec.template.metadata.labels.some-key + + - it: podLabels -> should use custom podLabels + set: + engine: + podLabels: + some-key: some-value + celery: + podLabels: + some-key: some-value + telegramPolling: + enabled: true + podLabels: + some-key: some-value + asserts: + - equal: + path: spec.template.metadata.labels.some-key + value: some-value diff --git a/helm/oncall/values.yaml b/helm/oncall/values.yaml index 29f715f12e..b741b1a47b 100644 --- a/helm/oncall/values.yaml +++ b/helm/oncall/values.yaml @@ -39,6 +39,9 @@ engine: # cpu: 100m # memory: 128Mi + # Labels for engine pods + podLabels: {} + ## Deployment update strategy ## ref: https://kubernetes.io/docs/concepts/workloads/controllers/deployment/#strategy updateStrategy: @@ -198,6 +201,9 @@ celery: # cpu: 100m # memory: 128Mi + # Labels for celery pods + podLabels: {} + ## Affinity for pod assignment ## ref: https://kubernetes.io/docs/concepts/configuration/assign-pod-node/#affinity-and-anti-affinity affinity: {} @@ -257,6 +263,9 @@ telegramPolling: # cpu: 100m # memory: 128Mi + # Labels for telegram-polling pods + podLabels: {} + # Extra volume mounts for the main container extraVolumeMounts: [] # - name: postgres-tls