first UI integration test - phone verification + receive SMS alert flow (#900)
**What this PR does**: Adds our first UI integration test using [Playwright](https://playwright.dev/) and runs the test on CI. Right now the test: - logs into Grafana - configures the plugin (if it isn't already) - creates an OnCall schedule, where the current user will be OnCall - creates an escalation chain to notify based on the newly created OnCall schedule - creates a webhook integration, attached to the created escalation chain - sends a demo alert for the new integration - goes to the alert groups page and validates that the escalation step to alert the OnCall user actually happened Currently the Playwright tests are run against the 3 default headless browsers, chromium, Firefox, and webkit. The CI job that runs these tests is run as a matrix against 3 tagged versions of `grafana`; `main`, `latest`, and `9.2.6`. Secondly, it adds most of the logic for a second test which: - logs into Grafana - configures the plugin (if it isn't already) - goes to the user's settings, verifies their phone number (using a tool called [MailSlurp](https://www.mailslurp.com/)) - configures the current user's default escalation policy to send alerts via SMS - creates an escalation policy and configures it to send alerts to our current user - creates an integration and assigns the created escalation policy - triggers a test alert + verifies that we receive the SMS alert text (again, using MailSlurp) **Which issue(s) this PR fixes**: Closes #873 **Checklist** - [x] Tests updated - [ ] Documentation added (N/A) - [ ] `CHANGELOG.md` updated (N/A)
This commit is contained in:
parent
ab493def5f
commit
8f22b2fd74
41 changed files with 1131 additions and 386 deletions
90
.github/workflows/helm_tests.yml
vendored
90
.github/workflows/helm_tests.yml
vendored
|
|
@ -1,90 +0,0 @@
|
|||
name: Helm End to End Testing
|
||||
|
||||
on:
|
||||
- pull_request
|
||||
|
||||
jobs:
|
||||
create-cluster:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v3
|
||||
|
||||
- name: Set up Docker Buildx # We need this step for docker caching
|
||||
uses: docker/setup-buildx-action@v2
|
||||
|
||||
- name: Build docker image locally # using github actions docker cache
|
||||
uses: docker/build-push-action@v2
|
||||
with:
|
||||
context: ./engine
|
||||
file: ./engine/Dockerfile
|
||||
push: false
|
||||
load: true
|
||||
tags: oncall/engine:latest
|
||||
cache-from: type=gha
|
||||
cache-to: type=gha,mode=max
|
||||
|
||||
- name: Create k8s Kind Cluster
|
||||
uses: helm/kind-action@v1.3.0
|
||||
with:
|
||||
config: ./helm/kind.yml
|
||||
|
||||
- name: Load image on the nodes of the cluster
|
||||
run: kind load docker-image --name=chart-testing oncall/engine:latest
|
||||
|
||||
- name: Install helm chart
|
||||
run: helm install test-release helm/oncall --values helm/simple.yml --values helm/values-local-image.yml
|
||||
|
||||
- name: Await k8s pods and other resources up
|
||||
uses: jupyterhub/action-k8s-await-workloads@v1
|
||||
with:
|
||||
workloads: "" # all
|
||||
namespace: "" # default
|
||||
timeout: 300
|
||||
max-restarts: 0
|
||||
|
||||
- name: Bootstrap organization and integration
|
||||
run: |
|
||||
export POD_NAME=$(kubectl get pods --namespace default -l "app.kubernetes.io/name=oncall,app.kubernetes.io/instance=test-release,app.kubernetes.io/component=engine" -o jsonpath="{.items[0].metadata.name}")
|
||||
export ONCALL_INTEGRATION_URL=http://localhost:30001$(kubectl exec -it $POD_NAME -- bash -c "python manage.py setup_end_to_end_test --bootstrap_integration")
|
||||
echo "ONCALL_INTEGRATION_URL=$ONCALL_INTEGRATION_URL" >> $GITHUB_ENV
|
||||
|
||||
- name: Send an alert to the integration
|
||||
run: |
|
||||
echo $ONCALL_INTEGRATION_URL
|
||||
export TEST_ID=test-0
|
||||
echo "TEST_ID=$TEST_ID" >> $GITHUB_ENV
|
||||
curl -X POST "$ONCALL_INTEGRATION_URL" \
|
||||
-H 'Content-Type: Application/json' \
|
||||
-d '{
|
||||
"alert_uid": "08d6891a-835c-e661-39fa-96b6a9e26552",
|
||||
"title": "'"$TEST_ID"'",
|
||||
"image_url": "https://upload.wikimedia.org/wikipedia/commons/e/ee/Grumpy_Cat_by_Gage_Skidmore.jpg",
|
||||
"state": "alerting",
|
||||
"link_to_upstream_details": "https://en.wikipedia.org/wiki/Downtime",
|
||||
"message": "Smth happened. Oh no!"
|
||||
}'
|
||||
|
||||
# GitHub Action reference: https://github.com/jupyterhub/action-k8s-namespace-report
|
||||
- name: Kubernetes namespace report
|
||||
uses: jupyterhub/action-k8s-namespace-report@v1
|
||||
if: always()
|
||||
|
||||
- name: Await 1 alert group and 1 alert created during the test (timeout 30 seconds)
|
||||
run: |
|
||||
export POD_NAME=$(kubectl get pods --namespace default -l "app.kubernetes.io/name=oncall,app.kubernetes.io/instance=test-release,app.kubernetes.io/component=engine" -o jsonpath="{.items[0].metadata.name}")
|
||||
tries=30
|
||||
while [ "$tries" -gt 0 ]; do
|
||||
if kubectl exec -it $POD_NAME -c oncall -- bash -c "python manage.py setup_end_to_end_test --return_results_for_test_id $TEST_ID" | grep -q '1, 1'
|
||||
then
|
||||
break
|
||||
fi
|
||||
|
||||
tries=$(( tries - 1 ))
|
||||
sleep 1
|
||||
done
|
||||
|
||||
if [ "$tries" -eq 0 ]; then
|
||||
echo 'Expected "1, 1" (alert groups, alerts). They were not created in 30 seconds during this integration test. Something is broken' >&2
|
||||
exit 1
|
||||
fi
|
||||
290
.github/workflows/integration_tests.yml
vendored
Normal file
290
.github/workflows/integration_tests.yml
vendored
Normal file
|
|
@ -0,0 +1,290 @@
|
|||
name: Integration Tests
|
||||
|
||||
on:
|
||||
- pull_request
|
||||
|
||||
# TODO: ideally we would be able to have one CI job which spins up the kind cluster and does the helm release
|
||||
# then we could have the UI and backend integration tests dependent on this job and not have to each
|
||||
# independently spin up the cluster. This doesn't seem to be supported however
|
||||
# https://github.com/docker/build-push-action/issues/225
|
||||
#
|
||||
# Probably one way to get around this would be to deploy the helm release to a sandbox k8s cluster somewhere? and reference
|
||||
# that in the various integration test jobs
|
||||
jobs:
|
||||
build-engine-docker-image:
|
||||
name: Build engine Docker image
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v3
|
||||
|
||||
- name: Set up Docker Buildx # We need this step for docker caching
|
||||
uses: docker/setup-buildx-action@v2
|
||||
|
||||
- name: Build docker image locally # using github actions docker cache
|
||||
uses: docker/build-push-action@v2
|
||||
with:
|
||||
context: ./engine
|
||||
file: ./engine/Dockerfile
|
||||
push: false
|
||||
load: true
|
||||
tags: oncall/engine:latest
|
||||
cache-from: type=gha
|
||||
cache-to: type=gha,mode=max
|
||||
outputs: type=docker,dest=/tmp/oncall-engine.tar
|
||||
|
||||
# https://github.com/docker/build-push-action/issues/225#issuecomment-727639184
|
||||
- name: Persist engine Docker image between jobs
|
||||
uses: actions/upload-artifact@v2
|
||||
with:
|
||||
name: oncall-engine
|
||||
path: /tmp/oncall-engine.tar
|
||||
|
||||
backend-integration-tests:
|
||||
name: Backend Integration Tests
|
||||
needs: build-engine-docker-image
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v3
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v2
|
||||
|
||||
- name: Download engine Docker image
|
||||
uses: actions/download-artifact@v2
|
||||
with:
|
||||
name: oncall-engine
|
||||
path: /tmp
|
||||
|
||||
- name: Create k8s Kind Cluster
|
||||
uses: helm/kind-action@v1.3.0
|
||||
with:
|
||||
config: ./helm/kind.yml
|
||||
|
||||
- name: Load image on the nodes of the cluster
|
||||
run: kind load image-archive --name=chart-testing /tmp/oncall-engine.tar
|
||||
|
||||
- name: Install helm chart
|
||||
run: |
|
||||
helm install test-release \
|
||||
--values ./simple.yml \
|
||||
--values ./values-local-image.yml \
|
||||
./oncall
|
||||
working-directory: helm
|
||||
|
||||
- name: Await k8s pods and other resources up
|
||||
uses: jupyterhub/action-k8s-await-workloads@v1
|
||||
with:
|
||||
workloads: "" # all
|
||||
namespace: "" # default
|
||||
timeout: 300
|
||||
max-restarts: -1
|
||||
|
||||
# GitHub Action reference: https://github.com/jupyterhub/action-k8s-namespace-report
|
||||
- name: Kubernetes namespace report
|
||||
uses: jupyterhub/action-k8s-namespace-report@v1
|
||||
if: always()
|
||||
|
||||
- name: Bootstrap organization and integration
|
||||
run: |
|
||||
export POD_NAME=$(kubectl get pods --namespace default -l "app.kubernetes.io/name=oncall,app.kubernetes.io/instance=test-release,app.kubernetes.io/component=engine" -o jsonpath="{.items[0].metadata.name}")
|
||||
export ONCALL_INTEGRATION_URL=http://localhost:30001$(kubectl exec -it $POD_NAME -- bash -c "python manage.py setup_end_to_end_test --bootstrap_integration")
|
||||
echo "ONCALL_INTEGRATION_URL=$ONCALL_INTEGRATION_URL" >> $GITHUB_ENV
|
||||
|
||||
- name: Send an alert to the integration
|
||||
run: |
|
||||
echo $ONCALL_INTEGRATION_URL
|
||||
export TEST_ID=test-0
|
||||
echo "TEST_ID=$TEST_ID" >> $GITHUB_ENV
|
||||
curl -X POST "$ONCALL_INTEGRATION_URL" \
|
||||
-H 'Content-Type: Application/json' \
|
||||
-d '{
|
||||
"alert_uid": "08d6891a-835c-e661-39fa-96b6a9e26552",
|
||||
"title": "'"$TEST_ID"'",
|
||||
"image_url": "https://upload.wikimedia.org/wikipedia/commons/e/ee/Grumpy_Cat_by_Gage_Skidmore.jpg",
|
||||
"state": "alerting",
|
||||
"link_to_upstream_details": "https://en.wikipedia.org/wiki/Downtime",
|
||||
"message": "Smth happened. Oh no!"
|
||||
}'
|
||||
|
||||
- name: Await 1 alert group and 1 alert created during the test (timeout 30 seconds)
|
||||
run: |
|
||||
export POD_NAME=$(kubectl get pods --namespace default -l "app.kubernetes.io/name=oncall,app.kubernetes.io/instance=test-release,app.kubernetes.io/component=engine" -o jsonpath="{.items[0].metadata.name}")
|
||||
tries=30
|
||||
while [ "$tries" -gt 0 ]; do
|
||||
if kubectl exec -it $POD_NAME -c oncall -- bash -c "python manage.py setup_end_to_end_test --return_results_for_test_id $TEST_ID" | grep -q '1, 1'
|
||||
then
|
||||
break
|
||||
fi
|
||||
|
||||
tries=$(( tries - 1 ))
|
||||
sleep 1
|
||||
done
|
||||
|
||||
if [ "$tries" -eq 0 ]; then
|
||||
echo 'Expected "1, 1" (alert groups, alerts). They were not created in 30 seconds during this integration test. Something is broken' >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
ui-integration-tests:
|
||||
needs: build-engine-docker-image
|
||||
runs-on: ubuntu-latest
|
||||
name: "UI Integration Tests - Grafana: ${{ matrix.grafana-image-tag }}"
|
||||
strategy:
|
||||
matrix:
|
||||
grafana-image-tag:
|
||||
- 9.2.6
|
||||
- main
|
||||
- latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v3
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v2
|
||||
|
||||
- name: Download engine Docker image
|
||||
uses: actions/download-artifact@v2
|
||||
with:
|
||||
name: oncall-engine
|
||||
path: /tmp
|
||||
|
||||
- name: Create k8s Kind Cluster
|
||||
uses: helm/kind-action@v1.3.0
|
||||
with:
|
||||
config: ./helm/kind.yml
|
||||
|
||||
- name: Load image on the nodes of the cluster
|
||||
run: kind load image-archive --name=chart-testing /tmp/oncall-engine.tar
|
||||
|
||||
# yarn caching doesn't properly work with subdirectories hence the following two steps
|
||||
# which calculate a cache key and restore the cache manually
|
||||
# see this GH issue for more details https://github.com/actions/setup-node/issues/488#issue-1231822552
|
||||
- uses: actions/setup-node@v3
|
||||
with:
|
||||
node-version: 14.17.0
|
||||
cache: "yarn"
|
||||
cache-dependency-path: grafana-plugin/yarn.lock
|
||||
|
||||
- name: Get yarn cache directory path
|
||||
id: yarn-cache-dir-path
|
||||
run: echo "dir=$(yarn config get cacheFolder)" >> $GITHUB_OUTPUT
|
||||
shell: bash
|
||||
working-directory: ./grafana-plugin
|
||||
|
||||
- name: Restore yarn cache
|
||||
uses: actions/cache@v3
|
||||
with:
|
||||
path: ${{ steps.yarn-cache-dir-path.outputs.dir }}
|
||||
key: yarn-cache-folder-${{ hashFiles('**/yarn.lock', '.yarnrc.yml') }}
|
||||
restore-keys: |
|
||||
yarn-cache-folder-
|
||||
|
||||
# https://stackoverflow.com/a/62244232
|
||||
# --prefer-offline tells yarn to use cached downloads (in the cache directory mentioned above)
|
||||
# during installation whenever possible instead of downloading from the server.
|
||||
- name: Install dependencies
|
||||
working-directory: ./grafana-plugin
|
||||
run: yarn install --frozen-lockfile --prefer-offline
|
||||
|
||||
# build the plugin frontend
|
||||
- name: Build plugin frontend
|
||||
working-directory: ./grafana-plugin
|
||||
run: yarn build:dev
|
||||
|
||||
# by settings grafana.plugins to [] and configuring grafana.extraVolumeMounts we are using the locally built
|
||||
# OnCall plugin rather than the latest published version
|
||||
# the /oncall-plugin hostPath refers to the kind volumeMount that points to the ./grafana-plugin dir
|
||||
# see ./helm/kind.yml for more details
|
||||
- name: Install helm chart
|
||||
run: |
|
||||
helm install helm-testing \
|
||||
--values ./helm/simple.yml \
|
||||
--values ./helm/values-local-image.yml \
|
||||
--set-json 'env=[{"name":"GRAFANA_CLOUD_NOTIFICATIONS_ENABLED","value":"False"}]' \
|
||||
--set oncall.twilio.accountSid="${{ secrets.TWILIO_ACCOUNT_SID }}" \
|
||||
--set oncall.twilio.authToken="${{ secrets.TWILIO_AUTH_TOKEN }}" \
|
||||
--set oncall.twilio.phoneNumber="\"${{ secrets.TWILIO_PHONE_NUMBER }}"\" \
|
||||
--set oncall.twilio.verifySid="${{ secrets.TWILIO_VERIFY_SID }}" \
|
||||
--set grafana.image.tag=${{ matrix.grafana-image-tag }} \
|
||||
--set grafana.env.GF_SECURITY_ADMIN_USER=oncall \
|
||||
--set grafana.env.GF_SECURITY_ADMIN_PASSWORD=oncall \
|
||||
--set grafana.env.GF_PLUGINS_ALLOW_LOADING_UNSIGNED_PLUGINS=grafana-oncall-app \
|
||||
--set-json "grafana.plugins=[]" \
|
||||
--set-json 'grafana.securityContext={"runAsUser": 0, "runAsGroup": 0, "fsGroup": 0}' \
|
||||
--set-json 'grafana.extraVolumeMounts=[{"name":"plugins","mountPath":"/var/lib/grafana/plugins/grafana-plugin","hostPath":"/oncall-plugin","readOnly":true}]' \
|
||||
./helm/oncall
|
||||
|
||||
# https://github.com/microsoft/playwright/issues/7249#issuecomment-1154603556
|
||||
# Figures out the version of playwright that's installed.
|
||||
# The result is stored in steps.playwright-version.outputs.version
|
||||
- name: Get installed Playwright version
|
||||
id: playwright-version
|
||||
working-directory: ./grafana-plugin
|
||||
run: echo "::set-output name=version::$(yarn list --pattern @playwright/test | grep @playwright/test@ | sed 's/[^0-9.]*\([0-9.]*\).*/\1/')"
|
||||
|
||||
# https://github.com/microsoft/playwright/issues/7249#issuecomment-1317670494
|
||||
# Attempt to restore the correct Playwright browser binaries based on the
|
||||
# currently installed version of Playwright (The browser binary versions
|
||||
# may change with Playwright versions).
|
||||
# Note: Playwright's cache directory is hard coded because that's what it
|
||||
# says to do in the docs. There doesn't appear to be a command that prints
|
||||
# it out for us.
|
||||
- name: Cache Playwright binaries
|
||||
uses: actions/cache@v3
|
||||
id: playwright-cache
|
||||
with:
|
||||
path: "~/.cache/ms-playwright"
|
||||
key: "${{ runner.os }}-playwright-${{ steps.playwright-version.outputs.version }}"
|
||||
|
||||
# TODO: If the Playwright browser binaries weren't able to be restored, install them
|
||||
# https://github.com/microsoft/playwright/issues/7249#issuecomment-1256878540
|
||||
- name: Install Playwright
|
||||
# if: steps.playwright-cache.outputs.cache-hit != 'true'
|
||||
working-directory: ./grafana-plugin
|
||||
run: |
|
||||
npx playwright install
|
||||
npx playwright install-deps
|
||||
|
||||
# - name: Install Playwright system dependencies
|
||||
# run: npx playwright install-deps
|
||||
# if: steps.playwright-cache.outputs.cache-hit == 'true'
|
||||
# working-directory: ./grafana-plugin
|
||||
|
||||
- name: Await k8s pods and other resources up
|
||||
uses: jupyterhub/action-k8s-await-workloads@v1
|
||||
with:
|
||||
workloads: "" # all
|
||||
namespace: "" # default
|
||||
timeout: 300
|
||||
max-restarts: -1
|
||||
|
||||
# GitHub Action reference: https://github.com/jupyterhub/action-k8s-namespace-report
|
||||
- name: Kubernetes namespace report
|
||||
uses: jupyterhub/action-k8s-namespace-report@v1
|
||||
if: always()
|
||||
|
||||
- name: Run Integration Tests
|
||||
env:
|
||||
# BASE_URL represents what is accessed via a browser
|
||||
BASE_URL: http://localhost:30002/grafana
|
||||
# ONCALL_API_URL is what is configured in the plugin configuration form
|
||||
# it is what the grafana container uses to communicate with the OnCall backend
|
||||
#
|
||||
# 172.17.0.1 is the docker bridge network default gateway. Requests originate in the grafana container
|
||||
# hit 172.17.0.1 which proxies the request onto the host where port 30001 is the node port that is mapped
|
||||
# to the OnCall API
|
||||
ONCALL_API_URL: http://172.17.0.1:30001
|
||||
GRAFANA_USERNAME: oncall
|
||||
GRAFANA_PASSWORD: oncall
|
||||
MAILSLURP_API_KEY: ${{ secrets.MAILSLURP_API_KEY }}
|
||||
working-directory: ./grafana-plugin
|
||||
run: yarn test:integration
|
||||
|
||||
- uses: actions/upload-artifact@v3
|
||||
if: always()
|
||||
with:
|
||||
name: playwright-report
|
||||
path: ./grafana-plugin/playwright-report/
|
||||
retention-days: 30
|
||||
|
|
@ -6,6 +6,7 @@
|
|||
- [Configuring Grafana](#configuring-grafana)
|
||||
- [Django Silk Profiling](#django-silk-profiling)
|
||||
- [Running backend services outside Docker](#running-backend-services-outside-docker)
|
||||
- [UI Integration Tests](#ui-integration-tests)
|
||||
- [Useful `make` commands](#useful-make-commands)
|
||||
- [Setting environment variables](#setting-environment-variables)
|
||||
- [Slack application setup](#slack-application-setup)
|
||||
|
|
@ -38,8 +39,8 @@ environment variable.
|
|||
message broker/cache. See [`COMPOSE_PROFILES`](#compose_profiles) below for more details on how to swap
|
||||
out/disable which components are run in Docker.
|
||||
3. Open Grafana in a browser [here](http://localhost:3000/plugins/grafana-oncall-app) (login: `oncall`, password: `oncall`).
|
||||
4. You should now see the OnCall plugin configuration page. You may safely ignore the warning about the invalid
|
||||
plugin signature. When opening the main plugin page, you may also ignore warnings about version mismatch and lack of
|
||||
4. You should now see the OnCall plugin configuration page. You may safely ignore the warning about the invalid
|
||||
plugin signature. When opening the main plugin page, you may also ignore warnings about version mismatch and lack of
|
||||
communication channels.
|
||||
5. Enjoy! Check our [OSS docs](https://grafana.com/docs/grafana-cloud/oncall/open-source/) if you want to set up Slack,
|
||||
Telegram, Twilio or SMS/calls through Grafana Cloud.
|
||||
|
|
@ -138,6 +139,19 @@ By default everything runs inside Docker. If you would like to run the backend s
|
|||
- `make run-backend-server` - runs the HTTP server
|
||||
- `make run-backend-celery` - runs Celery workers
|
||||
|
||||
## UI Integration Tests
|
||||
|
||||
We've developed a suite of "end-to-end" integration tests using [Playwright](https://playwright.dev/). These tests
|
||||
are run on pull request CI builds. New features should ideally include a new/modified integration test.
|
||||
|
||||
To run these tests locally simply do the following:
|
||||
|
||||
```bash
|
||||
cp ./grafana-plugin/.env.example cp ./grafana-plugin/.env
|
||||
# you may need to tweak the values in ./grafana-plugin/.env according to your local setup
|
||||
yarn test:integration
|
||||
```
|
||||
|
||||
## Useful `make` commands
|
||||
|
||||
See [`COMPOSE_PROFILES`](#compose_profiles) for more information on what this option is and how to configure it.
|
||||
|
|
|
|||
|
|
@ -0,0 +1,7 @@
|
|||
# copy this file to ./.env and fill out the values according to your local setup
|
||||
|
||||
# for integration test purposes
|
||||
BASE_URL=http://localhost:3000
|
||||
ONCALL_API_URL=http://host.docker.internal:8080/
|
||||
GRAFANA_USERNAME=oncall
|
||||
GRAFANA_PASSWORD=oncall
|
||||
5
grafana-plugin/.gitignore
vendored
5
grafana-plugin/.gitignore
vendored
|
|
@ -14,3 +14,8 @@ yarn-error.log*
|
|||
# This file is generated
|
||||
grafana-plugin.yml
|
||||
frontend_enterprise
|
||||
|
||||
# playwright
|
||||
/test-results/
|
||||
/playwright-report/
|
||||
/playwright/.cache/
|
||||
|
|
|
|||
|
|
@ -1,16 +0,0 @@
|
|||
@main
|
||||
Feature: Check Settings Page
|
||||
|
||||
Scenario: Add Channel Filter
|
||||
When Open settings page
|
||||
|
||||
Then We see settings page
|
||||
|
||||
When Click Add new Escalation chain
|
||||
|
||||
Then We see new Escalation chain popup
|
||||
|
||||
When We input Filtering Term
|
||||
|
||||
When Click Create
|
||||
|
||||
|
|
@ -1,13 +0,0 @@
|
|||
@main
|
||||
Feature: Delete Notification Steps
|
||||
|
||||
Scenario: Delete Notification Steps
|
||||
When Go to my settings page
|
||||
|
||||
Then We see my settings page
|
||||
|
||||
When Click edit button
|
||||
|
||||
Then We see settings popup
|
||||
|
||||
When Delete all notification policies
|
||||
|
|
@ -1,46 +0,0 @@
|
|||
const { When, Given, Before, AfterAll, Then, After, setDefaultTimeout } = require('@cucumber/cucumber');
|
||||
const { Builder, By, until } = require('selenium-webdriver');
|
||||
|
||||
const { takeScreenshot } = require('../../utils/takeScreenshot');
|
||||
|
||||
const { setup, checkTitle } = require('./common');
|
||||
|
||||
const assert = require('assert');
|
||||
|
||||
When('Open settings page', async function () {
|
||||
const menuItem = await this.driver.findElement(By.className('TEST-settings-menu-item'));
|
||||
|
||||
menuItem.click();
|
||||
});
|
||||
|
||||
Then('We see settings page', async function () {
|
||||
this.driver.wait(until.elementLocated(By.className('TEST-alert-rules')));
|
||||
});
|
||||
|
||||
When('Click Add new Escalation chain', async function () {
|
||||
const button = await this.driver.findElement(By.className('TEST-add-new-chain-button'));
|
||||
|
||||
button.click();
|
||||
});
|
||||
|
||||
Then('We see new Escalation chain popup', async function () {
|
||||
await this.driver.wait(until.elementLocated(By.className('TEST-channel-filter-form')));
|
||||
|
||||
await this.driver.sleep(3000);
|
||||
});
|
||||
|
||||
When('We input Filtering Term', async function () {
|
||||
await this.driver
|
||||
.findElement(By.className('TEST-filtering-term-input'))
|
||||
.sendKeys('CUCUMBER ' + Math.random().toFixed(2) * 100);
|
||||
|
||||
await this.driver.sleep(3000);
|
||||
});
|
||||
|
||||
When('Click Create', async function () {
|
||||
const button = await this.driver.findElement(By.className('TEST-create-channel-filter-form-button'));
|
||||
|
||||
button.click();
|
||||
|
||||
await this.driver.sleep(8000);
|
||||
});
|
||||
|
|
@ -1,85 +0,0 @@
|
|||
const {
|
||||
setWorldConstructor,
|
||||
When,
|
||||
Given,
|
||||
Before,
|
||||
Then,
|
||||
After,
|
||||
setDefaultTimeout,
|
||||
AfterAll,
|
||||
BeforeAll,
|
||||
BeforeStep,
|
||||
} = require('@cucumber/cucumber');
|
||||
const { Builder, By, until } = require('selenium-webdriver');
|
||||
const seleniumWebdriver = require('selenium-webdriver');
|
||||
const chrome = require('selenium-webdriver/chrome');
|
||||
|
||||
const assert = require('assert');
|
||||
|
||||
const AMIXR_DOMAIN = process.env.AMIXR_DOMAIN || 'develop.amixr.io';
|
||||
|
||||
Before({ tags: '@main' }, login);
|
||||
After(clear);
|
||||
|
||||
async function login() {
|
||||
await this.driver.get(`https://${AMIXR_DOMAIN}/app/auth/login`);
|
||||
|
||||
await this.driver.wait(until.elementLocated(By.className('TEST-login-email')));
|
||||
|
||||
await this.driver.findElement(By.className('TEST-login-email')).sendKeys(process.env.USER_EMAIL);
|
||||
|
||||
await this.driver.findElement(By.className('TEST-login-password')).sendKeys(process.env.PASSWORD);
|
||||
|
||||
await this.driver.findElement(By.className('TEST-login-button')).click();
|
||||
|
||||
await this.driver.sleep(8000);
|
||||
}
|
||||
|
||||
async function setup() {
|
||||
await this.driver.get(`https://${AMIXR_DOMAIN}`);
|
||||
|
||||
await this.driver.manage().addCookie({
|
||||
name: 'jwt',
|
||||
value: process.env.JWT,
|
||||
path: '/',
|
||||
domain: AMIXR_DOMAIN,
|
||||
secure: true,
|
||||
httpOnly: true,
|
||||
});
|
||||
|
||||
await this.driver.navigate().to(`https://${AMIXR_DOMAIN}/app/`);
|
||||
}
|
||||
|
||||
async function clear() {
|
||||
await this.driver.close();
|
||||
}
|
||||
|
||||
async function checkTitle() {
|
||||
/* var productElements = await this.driver.findElements(
|
||||
By.className('product')
|
||||
);*/
|
||||
|
||||
const title = await this.driver.getTitle();
|
||||
|
||||
assert.strictEqual(title, 'Alert Mixer (Amixr)');
|
||||
|
||||
/*var expectations = dataTable.hashes();
|
||||
for (let i = 0; i < expectations.length; i++) {
|
||||
const productName = await productElements[i]
|
||||
.findElement(By.tagName('h3'))
|
||||
.getText();
|
||||
assert.equal(productName, expectations[i].name);
|
||||
|
||||
const description = await productElements[i]
|
||||
.findElement(By.tagName('p'))
|
||||
.getText();
|
||||
assert.equal(
|
||||
description,
|
||||
`Description: ${expectations[i].description}`
|
||||
);
|
||||
}*/
|
||||
}
|
||||
module.exports = {
|
||||
setup,
|
||||
checkTitle,
|
||||
};
|
||||
|
|
@ -1,51 +0,0 @@
|
|||
const { When, Given, Before, Then, After, setDefaultTimeout } = require('@cucumber/cucumber');
|
||||
const { Builder, By, until } = require('selenium-webdriver');
|
||||
|
||||
const { takeScreenshot } = require('../../utils/takeScreenshot');
|
||||
|
||||
const { setup, checkTitle } = require('./common');
|
||||
|
||||
const assert = require('assert');
|
||||
|
||||
When('Go to my settings page', async function () {
|
||||
var menuItem = await this.driver.findElement(By.className('TEST-users-menu-item'));
|
||||
|
||||
menuItem.click();
|
||||
|
||||
this.driver.sleep(3000);
|
||||
});
|
||||
|
||||
Then('We see my settings page', async function f() {
|
||||
await this.driver.wait(until.elementLocated(By.className('TEST-users-page')));
|
||||
});
|
||||
|
||||
When('Click edit button', async function () {
|
||||
const editButton = await this.driver.findElement(By.className('TEST-edit-my-own-settings-button'));
|
||||
|
||||
editButton.click();
|
||||
});
|
||||
|
||||
Then('We see settings popup', async function () {
|
||||
await this.driver.findElement(By.className('TEST-user-settings-modal'));
|
||||
|
||||
this.driver.sleep(3000);
|
||||
});
|
||||
|
||||
When('Delete all notification policies', async function () {
|
||||
const deleteNotificationButtons = await this.driver.findElements(
|
||||
By.className('TEST-delete-notification-policy-button')
|
||||
);
|
||||
|
||||
await (async function () {
|
||||
for (const item of deleteNotificationButtons) {
|
||||
await (async function () {
|
||||
return new Promise((resolve) =>
|
||||
setTimeout(() => {
|
||||
item.click();
|
||||
resolve();
|
||||
}, 1500)
|
||||
);
|
||||
})();
|
||||
}
|
||||
})();
|
||||
});
|
||||
|
|
@ -1,40 +0,0 @@
|
|||
const {
|
||||
setWorldConstructor,
|
||||
When,
|
||||
Given,
|
||||
Before,
|
||||
Then,
|
||||
After,
|
||||
setDefaultTimeout,
|
||||
AfterAll,
|
||||
BeforeAll,
|
||||
} = require('@cucumber/cucumber');
|
||||
const seleniumWebdriver = require('selenium-webdriver');
|
||||
const { Builder, By, until } = require('selenium-webdriver');
|
||||
const chrome = require('selenium-webdriver/chrome');
|
||||
|
||||
function CustomWorld({ attach, parameters }) {
|
||||
this.attach = attach;
|
||||
this.parameters = parameters;
|
||||
|
||||
var options = new chrome.Options();
|
||||
|
||||
options.addArguments('headless');
|
||||
options.addArguments('window-size=1440,900');
|
||||
|
||||
this.driver = new Builder().forBrowser('chrome').build();
|
||||
|
||||
this.driver.manage().window().maximize();
|
||||
|
||||
this.driver.manage().setTimeouts({ implicit: 4000 });
|
||||
|
||||
// Returns a promise that resolves to the element
|
||||
this.waitForElement = function (locator) {
|
||||
const condition = seleniumWebdriver.until.elementLocated(locator);
|
||||
return this.driver.wait(condition);
|
||||
};
|
||||
}
|
||||
|
||||
setDefaultTimeout(20 * 1000);
|
||||
|
||||
setWorldConstructor(CustomWorld);
|
||||
|
|
@ -1,10 +0,0 @@
|
|||
let fs = require('fs');
|
||||
|
||||
async function takeScreenshot(driver) {
|
||||
const encodedString = await driver.takeScreenshot();
|
||||
await fs.writeFileSync('./image.png', encodedString, 'base64');
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
takeScreenshot,
|
||||
};
|
||||
|
|
@ -0,0 +1,29 @@
|
|||
import { test } from '@playwright/test';
|
||||
import { openOnCallPlugin } from '../utils';
|
||||
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 openOnCallPlugin(page);
|
||||
});
|
||||
|
||||
test('we can create an oncall schedule + receive an alert', async ({ page }) => {
|
||||
const escalationChainName = generateRandomValue();
|
||||
const integrationName = generateRandomValue();
|
||||
const onCallScheduleName = generateRandomValue();
|
||||
|
||||
await createOnCallSchedule(page, onCallScheduleName);
|
||||
await createEscalationChain(
|
||||
page,
|
||||
escalationChainName,
|
||||
EscalationStep.NotifyUsersFromOnCallSchedule,
|
||||
onCallScheduleName
|
||||
);
|
||||
|
||||
await createIntegrationAndSendDemoAlert(page, integrationName, escalationChainName);
|
||||
|
||||
await verifyThatAlertGroupIsTriggered(page, integrationName, `Notify on-call from Schedule '${onCallScheduleName}'`);
|
||||
});
|
||||
31
grafana-plugin/integration-tests/alerts/sms.test.ts
Normal file
31
grafana-plugin/integration-tests/alerts/sms.test.ts
Normal file
|
|
@ -0,0 +1,31 @@
|
|||
import { test, expect } from '@playwright/test';
|
||||
import { openOnCallPlugin } from '../utils';
|
||||
import { GRAFANA_USERNAME } from '../utils/constants';
|
||||
import { createEscalationChain, EscalationStep } from '../utils/escalationChain';
|
||||
import { generateRandomValue } from '../utils/forms';
|
||||
import { createIntegrationAndSendDemoAlert } from '../utils/integrations';
|
||||
import { waitForSms } from '../utils/phone';
|
||||
import { configureUserNotificationSettings, verifyUserPhoneNumber } from '../utils/userSettings';
|
||||
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await openOnCallPlugin(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();
|
||||
const integrationName = generateRandomValue();
|
||||
|
||||
await verifyUserPhoneNumber(page);
|
||||
await configureUserNotificationSettings(page, 'SMS');
|
||||
|
||||
await createEscalationChain(page, escalationChainName, EscalationStep.NotifyUsers, GRAFANA_USERNAME);
|
||||
await createIntegrationAndSendDemoAlert(page, integrationName, escalationChainName);
|
||||
|
||||
// wait for the SMS alert notification to arrive
|
||||
const smsAlertNotification = await waitForSms();
|
||||
|
||||
console.log('SMS Alert Notification: ', smsAlertNotification);
|
||||
expect(smsAlertNotification).toContain('OnCall');
|
||||
expect(smsAlertNotification).toContain('alert');
|
||||
});
|
||||
48
grafana-plugin/integration-tests/utils/alertGroup.ts
Normal file
48
grafana-plugin/integration-tests/utils/alertGroup.ts
Normal file
|
|
@ -0,0 +1,48 @@
|
|||
import { Page, expect } from '@playwright/test';
|
||||
import { selectDropdownValue, selectValuePickerValue } from './forms';
|
||||
import { goToOnCallPageByClickingOnTab } from './navigation';
|
||||
|
||||
const MAX_RETRIES = 5;
|
||||
|
||||
// const sleep = async (seconds: number) => new Promise((resolve) => setTimeout(resolve, seconds * 1000));
|
||||
|
||||
/**
|
||||
* recursively refreshes the page waiting for the background celery workers to have done their job of
|
||||
* escalating the alert group
|
||||
*/
|
||||
const incidentTimelineContainsStep = async (page: Page, triggeredStepText: string, retryNum = 0): Promise<boolean> => {
|
||||
if (retryNum > MAX_RETRIES) {
|
||||
return Promise.resolve(false);
|
||||
}
|
||||
|
||||
if (!page.locator('div[data-testid="incident-timeline-list"]').getByText(triggeredStepText)) {
|
||||
await page.reload({ waitUntil: 'networkidle' });
|
||||
return incidentTimelineContainsStep(page, triggeredStepText, (retryNum += 1));
|
||||
}
|
||||
return true;
|
||||
};
|
||||
|
||||
export const verifyThatAlertGroupIsTriggered = async (
|
||||
page: Page,
|
||||
integrationName: string,
|
||||
triggeredStepText: string
|
||||
): Promise<void> => {
|
||||
await goToOnCallPageByClickingOnTab(page, 'Alert Groups');
|
||||
|
||||
// filter by integration
|
||||
await selectDropdownValue({
|
||||
page,
|
||||
selectType: 'grafanaSelect',
|
||||
placeholderText: 'Search or filter results...',
|
||||
value: 'Integration',
|
||||
});
|
||||
await selectValuePickerValue(page, integrationName, false);
|
||||
|
||||
/**
|
||||
* wait for the alert groups to be filtered then
|
||||
* click on the alert group and go to the individual alert group page
|
||||
*/
|
||||
await (await page.waitForSelector('table > tbody > tr > td:nth-child(4) a')).click();
|
||||
|
||||
expect(await incidentTimelineContainsStep(page, triggeredStepText)).toBe(true);
|
||||
};
|
||||
29
grafana-plugin/integration-tests/utils/configurePlugin.ts
Normal file
29
grafana-plugin/integration-tests/utils/configurePlugin.ts
Normal file
|
|
@ -0,0 +1,29 @@
|
|||
import type { Page } from '@playwright/test';
|
||||
import { ONCALL_API_URL, ONCALL_LEFT_HAND_NAV_ICON_SELECTOR } 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> => {
|
||||
await goToGrafanaPage(page, '/plugins/grafana-oncall-app');
|
||||
|
||||
/**
|
||||
* 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(ONCALL_LEFT_HAND_NAV_ICON_SELECTOR);
|
||||
};
|
||||
8
grafana-plugin/integration-tests/utils/constants.ts
Normal file
8
grafana-plugin/integration-tests/utils/constants.ts
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
export const BASE_URL = process.env.BASE_URL || 'http://localhost:3000';
|
||||
export const ONCALL_API_URL = process.env.ONCALL_API_URL || 'http://host.docker.internal:8080';
|
||||
export const GRAFANA_USERNAME = process.env.GRAFANA_USERNAME || 'oncall';
|
||||
export const GRAFANA_PASSWORD = process.env.GRAFANA_PASSWORD || 'oncall';
|
||||
|
||||
export const MAILSLURP_API_KEY = process.env.MAILSLURP_API_KEY;
|
||||
|
||||
export const ONCALL_LEFT_HAND_NAV_ICON_SELECTOR = 'div.scrollbar-view img[src*="grafana-oncall-app/img/logo.svg"]';
|
||||
49
grafana-plugin/integration-tests/utils/escalationChain.ts
Normal file
49
grafana-plugin/integration-tests/utils/escalationChain.ts
Normal file
|
|
@ -0,0 +1,49 @@
|
|||
import { Page } from '@playwright/test';
|
||||
|
||||
import { clickButton, fillInInput, selectDropdownValue } from './forms';
|
||||
import { goToOnCallPageByClickingOnTab } from './navigation';
|
||||
|
||||
export enum EscalationStep {
|
||||
NotifyUsers = 'Notify users',
|
||||
NotifyUsersFromOnCallSchedule = 'Notify users from on-call schedule',
|
||||
}
|
||||
|
||||
const escalationStepValuePlaceholder: Record<EscalationStep, string> = {
|
||||
[EscalationStep.NotifyUsers]: 'Select User',
|
||||
[EscalationStep.NotifyUsersFromOnCallSchedule]: 'Select Schedule',
|
||||
};
|
||||
|
||||
export const createEscalationChain = async (
|
||||
page: Page,
|
||||
escalationChainName: string,
|
||||
escalationStep: EscalationStep,
|
||||
escalationStepValue: string
|
||||
): Promise<void> => {
|
||||
// go to the escalation chains page
|
||||
await goToOnCallPageByClickingOnTab(page, 'Escalation Chains');
|
||||
|
||||
// open the create escalation chain modal
|
||||
(await page.waitForSelector('text=New Escalation Chain')).click();
|
||||
|
||||
// fill in the name input
|
||||
await fillInInput(page, 'div[class*="EscalationChainForm"] input', escalationChainName);
|
||||
|
||||
// submit the form and wait for it to be created
|
||||
await clickButton({ page, buttonText: 'Create' });
|
||||
await page.waitForSelector(`text=${escalationChainName}`);
|
||||
|
||||
// add an escalation step
|
||||
await selectDropdownValue({
|
||||
page,
|
||||
selectType: 'grafanaSelect',
|
||||
placeholderText: 'Add escalation step...',
|
||||
value: escalationStep,
|
||||
});
|
||||
|
||||
await selectDropdownValue({
|
||||
page,
|
||||
selectType: 'grafanaSelect',
|
||||
placeholderText: escalationStepValuePlaceholder[escalationStep],
|
||||
value: escalationStepValue,
|
||||
});
|
||||
};
|
||||
109
grafana-plugin/integration-tests/utils/forms.ts
Normal file
109
grafana-plugin/integration-tests/utils/forms.ts
Normal file
|
|
@ -0,0 +1,109 @@
|
|||
import type { Locator, Page } from '@playwright/test';
|
||||
import { randomUUID } from 'crypto';
|
||||
|
||||
type SelectorType = 'gSelect' | 'grafanaSelect';
|
||||
type SelectDropdownValueArgs = {
|
||||
page: Page;
|
||||
value: string;
|
||||
// if set, search for a dropdown that contains this text as its placeholder
|
||||
placeholderText?: string;
|
||||
// specifies which type of select dropdown we are dealing with (since we currently mix-and-match 3 different components...)
|
||||
selectType?: SelectorType;
|
||||
// if provided, use this Locator as the root of our search for the dropdown
|
||||
startingLocator?: Locator;
|
||||
// if true, when selecting the dropdown option, use an exact match, otherwise use a substring contains match
|
||||
optionExactMatch?: boolean;
|
||||
};
|
||||
|
||||
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;
|
||||
};
|
||||
|
||||
export const fillInInput = (page: Page, selector: string, value: string) => page.fill(selector, value);
|
||||
|
||||
export const fillInInputByPlaceholderValue = (page: Page, placeholderValue: string, value: string) =>
|
||||
fillInInput(page, `input[placeholder*="${placeholderValue}"]`, value);
|
||||
|
||||
export const getInputByName = (page: Page, name: string): Locator => page.locator(`input[name="${name}"]`);
|
||||
|
||||
export const clickButton = async ({
|
||||
page,
|
||||
buttonText,
|
||||
startingLocator,
|
||||
dataTestId,
|
||||
}: ClickButtonArgs): Promise<void> => {
|
||||
const baseLocator = dataTestId ? `button[data-testid="${dataTestId}"]` : 'button';
|
||||
const button = (startingLocator || page).locator(`${baseLocator} >> text=${buttonText}`);
|
||||
|
||||
await button.waitFor({ state: 'visible' });
|
||||
await button.click();
|
||||
};
|
||||
|
||||
/**
|
||||
* at a minimum must specify selectType OR placeholderText
|
||||
* if both are specified selectType takes precedence
|
||||
*/
|
||||
const openSelect = async ({
|
||||
page,
|
||||
placeholderText,
|
||||
selectType,
|
||||
startingLocator,
|
||||
}: SelectDropdownValueArgs): Promise<void> => {
|
||||
/**
|
||||
* we currently mix three different dropdown components in the UI..
|
||||
* so we need to support all of them :(
|
||||
*/
|
||||
const dropdownSelectors: Record<SelectorType, string> = {
|
||||
gSelect: 'div[class*="GSelect"]',
|
||||
grafanaSelect: `div[class*="grafana-select-value-container"] ${
|
||||
placeholderText ? `>> text=${placeholderText} ` : ''
|
||||
}>> ..`,
|
||||
};
|
||||
|
||||
const dropdownSelector = dropdownSelectors[selectType];
|
||||
const placeholderSelector = `text=${placeholderText}`;
|
||||
const selector = dropdownSelector || placeholderSelector;
|
||||
|
||||
const selectElement: Locator = (startingLocator || page).locator(selector);
|
||||
await selectElement.waitFor({ state: 'visible' });
|
||||
await selectElement.click();
|
||||
};
|
||||
|
||||
/**
|
||||
* notice the difference in double quotes - https://playwright.dev/docs/selectors#text-selector
|
||||
*/
|
||||
const textMatchSelector = (optionExactMatch: boolean, value: string): string =>
|
||||
optionExactMatch ? `text="${value}"` : `text=${value}`;
|
||||
|
||||
const chooseDropdownValue = async ({ page, value, optionExactMatch = true }: SelectDropdownValueArgs): Promise<void> =>
|
||||
page.locator(`div[id^="react-select-"][id$="-listbox"] >> ${textMatchSelector(optionExactMatch, value)}`).click();
|
||||
|
||||
export const selectDropdownValue = async (args: SelectDropdownValueArgs): Promise<void> => {
|
||||
await openSelect(args);
|
||||
await chooseDropdownValue(args);
|
||||
};
|
||||
|
||||
export const generateRandomValue = (): string => randomUUID();
|
||||
|
||||
/**
|
||||
* wait for the options to appear
|
||||
*
|
||||
* note that they are not rendered next to the button in the HTML output
|
||||
* they're rendered closer to the <body> tag
|
||||
*/
|
||||
export const selectValuePickerValue = async (
|
||||
page: Page,
|
||||
valuePickerText: string,
|
||||
optionExactMatch = true
|
||||
): Promise<void> =>
|
||||
(
|
||||
await page.waitForSelector(
|
||||
`div[class*="grafana-select-menu"] >> ${textMatchSelector(optionExactMatch, valuePickerText)}`
|
||||
)
|
||||
).click();
|
||||
10
grafana-plugin/integration-tests/utils/index.ts
Normal file
10
grafana-plugin/integration-tests/utils/index.ts
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
import type { Page } from '@playwright/test';
|
||||
import { configureOnCallPlugin } from './configurePlugin';
|
||||
import { login } from './login';
|
||||
import { goToOnCallPage } from './navigation';
|
||||
|
||||
export const openOnCallPlugin = async (page: Page): Promise<void> => {
|
||||
await login(page);
|
||||
await configureOnCallPlugin(page);
|
||||
await goToOnCallPage(page);
|
||||
};
|
||||
40
grafana-plugin/integration-tests/utils/integrations.ts
Normal file
40
grafana-plugin/integration-tests/utils/integrations.ts
Normal file
|
|
@ -0,0 +1,40 @@
|
|||
import { Page } from '@playwright/test';
|
||||
import { clickButton, fillInInput, selectDropdownValue } from './forms';
|
||||
import { goToOnCallPageByClickingOnTab } from './navigation';
|
||||
|
||||
export const createIntegrationAndSendDemoAlert = async (
|
||||
page: Page,
|
||||
integrationName: string,
|
||||
escalationChainName: string
|
||||
): Promise<void> => {
|
||||
// go to the integrations page
|
||||
await goToOnCallPageByClickingOnTab(page, 'Integrations');
|
||||
|
||||
// open the create integration modal
|
||||
(await page.waitForSelector('text=New integration for receiving alerts')).click();
|
||||
|
||||
// create a webhook integration
|
||||
(await page.waitForSelector('div[data-testid="create-integration-modal"] >> text=Webhook')).click();
|
||||
|
||||
// wait for the integrations settings modal to open up... and then close it
|
||||
await clickButton({ page, buttonText: 'Open Escalations Settings' });
|
||||
|
||||
// update the integration name
|
||||
await (await page.waitForSelector('div[data-testid="integration-header"] >> h4 >> button')).click();
|
||||
await fillInInput(page, 'div[data-testid="edit-integration-name-modal"] >> input', integrationName);
|
||||
await clickButton({ page, buttonText: 'Update' });
|
||||
|
||||
const integrationSettingsElement = page.locator('div[data-testid="integration-settings"]');
|
||||
|
||||
// assign the escalation chain to the integration
|
||||
await selectDropdownValue({
|
||||
page,
|
||||
selectType: 'grafanaSelect',
|
||||
placeholderText: 'Select Escalation Chain',
|
||||
value: escalationChainName,
|
||||
startingLocator: integrationSettingsElement,
|
||||
});
|
||||
|
||||
// send demo alert
|
||||
await clickButton({ page, buttonText: 'Send demo alert', dataTestId: 'send-demo-alert' });
|
||||
};
|
||||
13
grafana-plugin/integration-tests/utils/login.ts
Normal file
13
grafana-plugin/integration-tests/utils/login.ts
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
import type { Page } from '@playwright/test';
|
||||
import { GRAFANA_PASSWORD, GRAFANA_USERNAME } from './constants';
|
||||
import { clickButton, fillInInputByPlaceholderValue } from './forms';
|
||||
import { goToGrafanaPage, waitForNoNetworkActivity } from './navigation';
|
||||
|
||||
export const login = async (page: Page): Promise<void> => {
|
||||
await goToGrafanaPage(page, '/login', 'load');
|
||||
|
||||
await fillInInputByPlaceholderValue(page, 'email or username', GRAFANA_USERNAME);
|
||||
await fillInInputByPlaceholderValue(page, 'password', GRAFANA_PASSWORD);
|
||||
await clickButton({ page, buttonText: 'Log in' });
|
||||
await waitForNoNetworkActivity(page);
|
||||
};
|
||||
5
grafana-plugin/integration-tests/utils/modals.ts
Normal file
5
grafana-plugin/integration-tests/utils/modals.ts
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
import { Page } from '@playwright/test';
|
||||
|
||||
// close the currently opened modal
|
||||
export const closeModal = async (page: Page): Promise<void> =>
|
||||
(await page.waitForSelector('button[aria-label="Close dialogue"]')).click();
|
||||
21
grafana-plugin/integration-tests/utils/navigation.ts
Normal file
21
grafana-plugin/integration-tests/utils/navigation.ts
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
import type { Page, Response } from '@playwright/test';
|
||||
import { BASE_URL } from './constants';
|
||||
|
||||
type WaitUntil = 'networkidle' | 'load';
|
||||
type GrafanaPage = '/login' | '/plugins/grafana-oncall-app';
|
||||
type OnCallPage = 'incidents' | 'integrations' | 'escalations';
|
||||
type OnCallPluginTab = 'Integrations' | 'Escalation Chains' | 'Users' | 'Schedules' | 'Alert Groups';
|
||||
|
||||
const _goToPage = (page: Page, url = '', waitUntil: WaitUntil = 'networkidle'): Promise<Response> =>
|
||||
page.goto(`${BASE_URL}${url}`, { waitUntil });
|
||||
|
||||
export const goToGrafanaPage = (page: Page, url?: GrafanaPage, waitUntil?: WaitUntil): Promise<Response> =>
|
||||
_goToPage(page, url, waitUntil);
|
||||
|
||||
export const goToOnCallPage = (page: Page, onCallPage: OnCallPage = 'incidents'): Promise<Response> =>
|
||||
_goToPage(page, `/a/grafana-oncall-app/${onCallPage}`);
|
||||
|
||||
export const goToOnCallPageByClickingOnTab = async (page: Page, onCallTab: OnCallPluginTab): Promise<void> =>
|
||||
(await page.waitForSelector(`div[class*="LegacyNavTabsBar"] >> text=${onCallTab}`)).click();
|
||||
|
||||
export const waitForNoNetworkActivity = (page: Page): Promise<void> => page.waitForLoadState('networkidle');
|
||||
47
grafana-plugin/integration-tests/utils/phone.ts
Normal file
47
grafana-plugin/integration-tests/utils/phone.ts
Normal file
|
|
@ -0,0 +1,47 @@
|
|||
import MailSlurp, { GetPhoneNumbersPhoneCountryEnum, PhoneNumberProjection } from 'mailslurp-client';
|
||||
|
||||
import { MAILSLURP_API_KEY } from './constants';
|
||||
|
||||
const _getPhoneNumber = (): (() => Promise<PhoneNumberProjection>) => {
|
||||
let cachedPhoneNumber: PhoneNumberProjection;
|
||||
|
||||
const __getPhoneNumber = async () => {
|
||||
if (cachedPhoneNumber) {
|
||||
return cachedPhoneNumber;
|
||||
}
|
||||
|
||||
const mailslurp = new MailSlurp({ apiKey: MAILSLURP_API_KEY });
|
||||
|
||||
const {
|
||||
content: [phoneNumber],
|
||||
} = await mailslurp.phoneController.getPhoneNumbers({
|
||||
size: 1,
|
||||
phoneCountry: GetPhoneNumbersPhoneCountryEnum.US,
|
||||
});
|
||||
|
||||
return phoneNumber;
|
||||
};
|
||||
|
||||
return __getPhoneNumber;
|
||||
};
|
||||
|
||||
export const getPhoneNumber = _getPhoneNumber();
|
||||
|
||||
export const waitForSms = async (): Promise<string> => {
|
||||
const mailslurp = new MailSlurp({ apiKey: MAILSLURP_API_KEY });
|
||||
const phoneNumber = await getPhoneNumber();
|
||||
|
||||
const [sms] = await mailslurp.waitController.waitForSms({
|
||||
waitForSmsConditions: {
|
||||
count: 1,
|
||||
unreadOnly: true,
|
||||
// only start waiting for smses that would've been received after this function has been invoked
|
||||
since: new Date(),
|
||||
phoneNumberId: phoneNumber.id,
|
||||
timeout: 30_000,
|
||||
},
|
||||
});
|
||||
return sms.body;
|
||||
};
|
||||
|
||||
export const getVerificationCodeFromSms = (smsBody: string): string => /\D*(\d*)/.exec(smsBody)[1];
|
||||
31
grafana-plugin/integration-tests/utils/schedule.ts
Normal file
31
grafana-plugin/integration-tests/utils/schedule.ts
Normal file
|
|
@ -0,0 +1,31 @@
|
|||
import { Page } from '@playwright/test';
|
||||
import { GRAFANA_USERNAME } from './constants';
|
||||
import { clickButton, fillInInput, selectDropdownValue, selectValuePickerValue } from './forms';
|
||||
import { goToOnCallPageByClickingOnTab } from './navigation';
|
||||
|
||||
export const createOnCallSchedule = async (page: Page, scheduleName: string): Promise<void> => {
|
||||
// go to the escalation chains page
|
||||
await goToOnCallPageByClickingOnTab(page, 'Schedules');
|
||||
|
||||
// create an oncall-rotation schedule
|
||||
await clickButton({ page, buttonText: 'New Schedule' });
|
||||
(await page.waitForSelector('button >> text=Create >> nth=0')).click();
|
||||
|
||||
// fill in the name input
|
||||
await fillInInput(page, 'div[class*="ScheduleForm"] input[name="name"]', scheduleName);
|
||||
|
||||
// Add a new layer w/ the current user to it
|
||||
await clickButton({ page, buttonText: 'Create Schedule' });
|
||||
|
||||
await clickButton({ page, buttonText: 'Add rotation' });
|
||||
await selectValuePickerValue(page, 'New Layer');
|
||||
|
||||
await selectDropdownValue({
|
||||
page,
|
||||
selectType: 'grafanaSelect',
|
||||
placeholderText: 'Add user',
|
||||
value: GRAFANA_USERNAME,
|
||||
});
|
||||
|
||||
await clickButton({ page, buttonText: 'Create' });
|
||||
};
|
||||
96
grafana-plugin/integration-tests/utils/userSettings.ts
Normal file
96
grafana-plugin/integration-tests/utils/userSettings.ts
Normal file
|
|
@ -0,0 +1,96 @@
|
|||
import { Locator, Page } from '@playwright/test';
|
||||
|
||||
import { clickButton, fillInInputByPlaceholderValue, selectDropdownValue } from './forms';
|
||||
import { closeModal } from './modals';
|
||||
import { goToOnCallPageByClickingOnTab } from './navigation';
|
||||
import { getPhoneNumber, getVerificationCodeFromSms, waitForSms } from './phone';
|
||||
|
||||
type NotifyBy = 'SMS' | 'Phone call';
|
||||
|
||||
const openUserSettingsModal = async (page: Page): Promise<void> => {
|
||||
await goToOnCallPageByClickingOnTab(page, 'Users');
|
||||
await clickButton({ page, buttonText: 'View my profile' });
|
||||
await page.locator('text=To edit user details such as Username, email, and role').waitFor({ state: 'visible' });
|
||||
};
|
||||
|
||||
const getForgetPhoneNumberButton = (page: Page): Locator => page.locator('button >> text=Forget Phone Number');
|
||||
|
||||
export const verifyUserPhoneNumber = async (page: Page): Promise<void> => {
|
||||
// open the user settings modal
|
||||
await openUserSettingsModal(page);
|
||||
|
||||
// go to the Phone Verification tab
|
||||
await page.locator('a[aria-label="Tab Phone Verification"]').click();
|
||||
|
||||
// check to see if we've already verified our phone number.. no need to do it more than once
|
||||
if (await getForgetPhoneNumberButton(page).isVisible()) {
|
||||
await closeModal(page);
|
||||
return;
|
||||
}
|
||||
|
||||
// get the phone number we will use
|
||||
const phoneNumber = await getPhoneNumber();
|
||||
|
||||
/**
|
||||
* input the phone number and submit the form
|
||||
* on the backend this should trigger twilio to send out an SMS verification code
|
||||
*/
|
||||
await fillInInputByPlaceholderValue(page, 'Please enter the phone number with country code', phoneNumber.phoneNumber);
|
||||
await clickButton({ page, buttonText: 'Send Code' });
|
||||
|
||||
// wait for the SMS verification code to arrive
|
||||
const sms = await waitForSms();
|
||||
|
||||
// take the SMS verification code that we just received, input it into the form, and submit the form
|
||||
await fillInInputByPlaceholderValue(page, 'Please enter the code', getVerificationCodeFromSms(sms));
|
||||
await clickButton({ page, buttonText: 'Verify' });
|
||||
|
||||
// wait for a confirmation that the number has been verified and then close the modal
|
||||
await getForgetPhoneNumberButton(page).click();
|
||||
await closeModal(page);
|
||||
};
|
||||
|
||||
/**
|
||||
* gets the first row of our default notification settings
|
||||
* and then gets the notification type dropdown
|
||||
*/
|
||||
const getFirstDefaultNotificationSettingTypeDropdown = async (page: Page): Promise<Locator> => {
|
||||
const defaultNotificationSettingsList = page.locator('ul[class*="Timeline-module"] >> nth=0');
|
||||
await defaultNotificationSettingsList.waitFor({ state: 'visible' });
|
||||
|
||||
const firstDefaultNotificationSettingRow = defaultNotificationSettingsList.locator('li >> nth=0');
|
||||
await firstDefaultNotificationSettingRow.waitFor({ state: 'visible' });
|
||||
|
||||
// get the notification type dropdown specifically
|
||||
return firstDefaultNotificationSettingRow.locator('div[class*="input-wrapper"] >> nth=1');
|
||||
};
|
||||
|
||||
export const configureUserNotificationSettings = async (page: Page, notifyBy: NotifyBy): Promise<void> => {
|
||||
// open the user settings modal
|
||||
await openUserSettingsModal(page);
|
||||
|
||||
/**
|
||||
* see if we already have a default notification setting
|
||||
* if we don't click the Add Notification Step button and add one
|
||||
* otherwise update the existing one
|
||||
*/
|
||||
const defaultNotificationsAddNotificationStepButton = page.locator(
|
||||
'div[class*="PersonalNotificationSettings"] >> nth=0 text=Add Notification Step'
|
||||
);
|
||||
if (await defaultNotificationsAddNotificationStepButton.isVisible()) {
|
||||
await defaultNotificationsAddNotificationStepButton.click();
|
||||
}
|
||||
|
||||
// select our notification type
|
||||
const firstDefaultNotificationTypeDropdopdown = await getFirstDefaultNotificationSettingTypeDropdown(page);
|
||||
await selectDropdownValue({
|
||||
page,
|
||||
value: notifyBy,
|
||||
selectType: 'grafanaSelect',
|
||||
startingLocator: firstDefaultNotificationTypeDropdopdown,
|
||||
optionExactMatch: false, // there are emojis at the end
|
||||
});
|
||||
|
||||
// close the modal
|
||||
await closeModal(page);
|
||||
};
|
||||
|
|
@ -22,4 +22,5 @@ module.exports = {
|
|||
setupFilesAfterEnv: ['<rootDir>/jest.setup.ts'],
|
||||
|
||||
testTimeout: 10000,
|
||||
testPathIgnorePatterns: ['/node_modules/', '/integration-tests/'],
|
||||
};
|
||||
|
|
|
|||
|
|
@ -11,6 +11,7 @@
|
|||
"build:dev": "grafana-toolkit plugin:build --skipTest --skipLint",
|
||||
"test": "jest --verbose",
|
||||
"test:silent": "jest --silent",
|
||||
"test:integration": "yarn playwright test",
|
||||
"dev": "grafana-toolkit plugin:dev",
|
||||
"watch": "grafana-toolkit plugin:dev --watch",
|
||||
"sign": "grafana-toolkit plugin:sign",
|
||||
|
|
@ -58,12 +59,14 @@
|
|||
"@grafana/eslint-config": "^5.0.0",
|
||||
"@grafana/toolkit": "^9.2.4",
|
||||
"@jest/globals": "^27.5.1",
|
||||
"@playwright/test": "^1.28.0",
|
||||
"@testing-library/jest-dom": "^5.16.5",
|
||||
"@testing-library/react": "12",
|
||||
"@testing-library/user-event": "^14.4.3",
|
||||
"@types/dompurify": "^2.3.4",
|
||||
"@types/jest": "27.5.1",
|
||||
"@types/lodash-es": "^4.17.6",
|
||||
"@types/node": "^18.11.9",
|
||||
"@types/query-string": "^6.3.0",
|
||||
"@types/react-copy-to-clipboard": "^5.0.4",
|
||||
"@types/react-dom": "^18.0.6",
|
||||
|
|
@ -86,6 +89,7 @@
|
|||
"jest-environment-jsdom": "^27.5.1",
|
||||
"lint-staged": "^10.2.11",
|
||||
"lodash-es": "^4.17.21",
|
||||
"mailslurp-client": "^15.14.1",
|
||||
"moment-timezone": "^0.5.35",
|
||||
"plop": "^2.7.4",
|
||||
"postcss-loader": "^7.0.1",
|
||||
|
|
|
|||
109
grafana-plugin/playwright.config.ts
Normal file
109
grafana-plugin/playwright.config.ts
Normal file
|
|
@ -0,0 +1,109 @@
|
|||
import type { PlaywrightTestConfig } from '@playwright/test';
|
||||
import { devices } from '@playwright/test';
|
||||
|
||||
/**
|
||||
* Read environment variables from file.
|
||||
* https://github.com/motdotla/dotenv
|
||||
*/
|
||||
require('dotenv').config();
|
||||
|
||||
/**
|
||||
* See https://playwright.dev/docs/test-configuration.
|
||||
*/
|
||||
const config: PlaywrightTestConfig = {
|
||||
testDir: './integration-tests',
|
||||
/* Maximum time one test can run for. */
|
||||
timeout: 60 * 1000,
|
||||
expect: {
|
||||
/**
|
||||
* Maximum time expect() should wait for the condition to be met.
|
||||
* For example in `await expect(locator).toHaveText();`
|
||||
*/
|
||||
timeout: 5000,
|
||||
},
|
||||
/* Run tests in files in parallel */
|
||||
fullyParallel: true,
|
||||
/* 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. */
|
||||
workers: 1,
|
||||
/* Reporter to use. See https://playwright.dev/docs/test-reporters */
|
||||
reporter: 'html',
|
||||
/* Shared settings for all the projects below. See https://playwright.dev/docs/api/class-testoptions. */
|
||||
use: {
|
||||
/* Maximum time each action such as `click()` can take. Defaults to 0 (no limit). */
|
||||
actionTimeout: 0,
|
||||
/* 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,
|
||||
},
|
||||
|
||||
/* Configure projects for major browsers */
|
||||
projects: [
|
||||
{
|
||||
name: 'chromium',
|
||||
use: {
|
||||
...devices['Desktop Chrome'],
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'firefox',
|
||||
use: {
|
||||
...devices['Desktop Firefox'],
|
||||
},
|
||||
},
|
||||
|
||||
// TODO: enable tests on Safari once the scroll bug when creating an integration is patched
|
||||
// {
|
||||
// name: 'webkit',
|
||||
// use: {
|
||||
// ...devices['Desktop Safari'],
|
||||
// },
|
||||
// },
|
||||
|
||||
/* Test against mobile viewports. */
|
||||
// {
|
||||
// name: 'Mobile Chrome',
|
||||
// use: {
|
||||
// ...devices['Pixel 5'],
|
||||
// },
|
||||
// },
|
||||
// {
|
||||
// name: 'Mobile Safari',
|
||||
// use: {
|
||||
// ...devices['iPhone 12'],
|
||||
// },
|
||||
// },
|
||||
|
||||
/* Test against branded browsers. */
|
||||
// {
|
||||
// name: 'Microsoft Edge',
|
||||
// use: {
|
||||
// channel: 'msedge',
|
||||
// },
|
||||
// },
|
||||
// {
|
||||
// name: 'Google Chrome',
|
||||
// use: {
|
||||
// channel: 'chrome',
|
||||
// },
|
||||
// },
|
||||
],
|
||||
|
||||
/* Folder for test artifacts such as screenshots, videos, traces, etc. */
|
||||
// outputDir: 'test-results/',
|
||||
|
||||
/* Run your local dev server before starting the tests */
|
||||
// webServer: {
|
||||
// command: 'npm run start',
|
||||
// port: 3000,
|
||||
// },
|
||||
};
|
||||
|
||||
export default config;
|
||||
|
|
@ -153,9 +153,9 @@ class AlertRules extends React.Component<AlertRulesProps, AlertRulesState> {
|
|||
const maintenanceMode = alertReceiveChannel.maintenance_mode;
|
||||
return (
|
||||
<>
|
||||
<div className={cx('root')}>
|
||||
<div className={cx('root')} data-testid="integration-settings">
|
||||
<Block className={cx('headerBlock')}>
|
||||
<div className={cx('integration__heading-container')}>
|
||||
<div className={cx('integration__heading-container')} data-testid="integration-header">
|
||||
<div className={cx('integration__heading-container-left')}>
|
||||
<Text.Title level={4}>
|
||||
<div className={cx('integration__heading-text')}>
|
||||
|
|
@ -186,6 +186,7 @@ class AlertRules extends React.Component<AlertRulesProps, AlertRulesState> {
|
|||
variant="secondary"
|
||||
size="sm"
|
||||
onClick={this.getSendDemoAlertClickHandler(alertReceiveChannel.id)}
|
||||
data-testid="send-demo-alert"
|
||||
>
|
||||
Send demo alert
|
||||
</Button>
|
||||
|
|
@ -259,7 +260,7 @@ class AlertRules extends React.Component<AlertRulesProps, AlertRulesState> {
|
|||
title="Edit integration name"
|
||||
onDismiss={() => this.setState({ editIntegrationName: undefined })}
|
||||
>
|
||||
<div className={cx('root')}>
|
||||
<div className={cx('root')} data-testid="edit-integration-name-modal">
|
||||
<Field invalid={isIntegrationNameempty} label="Integration name">
|
||||
<Input
|
||||
autoFocus
|
||||
|
|
|
|||
|
|
@ -61,7 +61,7 @@ const CreateAlertReceiveChannelContainer = observer((props: CreateAlertReceiveCh
|
|||
<div className={cx('search-integration')}>
|
||||
<Input autoFocus value={filterValue} placeholder="Search integrations ..." onChange={handleChangeFilter} />
|
||||
</div>
|
||||
<div className={cx('cards', { cards_centered: !options.length })}>
|
||||
<div className={cx('cards', { cards_centered: !options.length })} data-testid="create-integration-modal">
|
||||
{options.length ? (
|
||||
options.map((alertReceiveChannelChoice) => {
|
||||
return (
|
||||
|
|
|
|||
|
|
@ -16,6 +16,9 @@ export class EscalationChainStore extends BaseStore {
|
|||
@observable.shallow
|
||||
searchResult: { [key: string]: Array<EscalationChain['id']> } = {};
|
||||
|
||||
@observable
|
||||
loading = false;
|
||||
|
||||
constructor(rootStore: RootStore) {
|
||||
super(rootStore);
|
||||
|
||||
|
|
@ -66,6 +69,8 @@ export class EscalationChainStore extends BaseStore {
|
|||
|
||||
@action
|
||||
async updateItems(query = '') {
|
||||
this.loading = true;
|
||||
|
||||
const results = await makeRequest(`${this.path}`, {
|
||||
params: { search: query },
|
||||
});
|
||||
|
|
@ -85,6 +90,8 @@ export class EscalationChainStore extends BaseStore {
|
|||
...this.searchResult,
|
||||
[query]: results.map((item: EscalationChain) => item.id),
|
||||
};
|
||||
|
||||
this.loading = false;
|
||||
}
|
||||
|
||||
getSearchResult(query = '') {
|
||||
|
|
|
|||
|
|
@ -143,6 +143,7 @@ class EscalationChainsPage extends React.Component<EscalationChainsPageProps, Es
|
|||
} = this.state;
|
||||
|
||||
const { escalationChainStore } = store;
|
||||
const { loading } = escalationChainStore;
|
||||
const searchResult = escalationChainStore.getSearchResult(escalationChainsFilters.searchTerm);
|
||||
|
||||
return (
|
||||
|
|
@ -161,17 +162,19 @@ class EscalationChainsPage extends React.Component<EscalationChainsPageProps, Es
|
|||
{!searchResult || searchResult.length ? (
|
||||
<div className={cx('escalations')}>
|
||||
<div className={cx('left-column')}>
|
||||
<WithPermissionControlTooltip userAction={UserActions.IntegrationsWrite}>
|
||||
<Button
|
||||
onClick={() => {
|
||||
this.setState({ showCreateEscalationChainModal: true });
|
||||
}}
|
||||
icon="plus"
|
||||
className={cx('new-escalation-chain')}
|
||||
>
|
||||
New Escalation Chain
|
||||
</Button>
|
||||
</WithPermissionControlTooltip>
|
||||
{!loading && (
|
||||
<WithPermissionControlTooltip userAction={UserActions.IntegrationsWrite}>
|
||||
<Button
|
||||
onClick={() => {
|
||||
this.setState({ showCreateEscalationChainModal: true });
|
||||
}}
|
||||
icon="plus"
|
||||
className={cx('new-escalation-chain')}
|
||||
>
|
||||
New Escalation Chain
|
||||
</Button>
|
||||
</WithPermissionControlTooltip>
|
||||
)}
|
||||
<div className={cx('escalations-list')}>
|
||||
{searchResult ? (
|
||||
<GList
|
||||
|
|
|
|||
|
|
@ -460,7 +460,7 @@ class IncidentPage extends React.Component<IncidentPageProps, IncidentPageState>
|
|||
this.setState({ timelineFilter: value });
|
||||
}}
|
||||
/>
|
||||
<ul className={cx('timeline')}>
|
||||
<ul className={cx('timeline')} data-testid="incident-timeline-list">
|
||||
{timeline.map((item: TimeLineItem, idx: number) => (
|
||||
<li key={idx} className={cx('timeline-item')}>
|
||||
<HorizontalGroup align="flex-start">
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"extends": "@grafana/toolkit/src/config/tsconfig.plugin.json",
|
||||
"include": ["src", "frontend_enterprise/src"],
|
||||
"include": ["src", "frontend_enterprise/src", "integration-tests", "playwright.config.ts"],
|
||||
"types": ["node", "@emotion/core"],
|
||||
"compilerOptions": {
|
||||
"rootDirs": ["src", "frontend_enterprise/src"],
|
||||
|
|
|
|||
|
|
@ -2603,6 +2603,14 @@
|
|||
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==
|
||||
dependencies:
|
||||
"@types/node" "*"
|
||||
playwright-core "1.28.0"
|
||||
|
||||
"@polka/url@^1.0.0-next.20":
|
||||
version "1.0.0-next.21"
|
||||
resolved "https://registry.yarnpkg.com/@polka/url/-/url-1.0.0-next.21.tgz#5de5a2385a35309427f6011992b544514d559aa1"
|
||||
|
|
@ -3403,7 +3411,7 @@
|
|||
resolved "https://registry.yarnpkg.com/@types/minimist/-/minimist-1.2.2.tgz#ee771e2ba4b3dc5b372935d549fd9617bf345b8c"
|
||||
integrity sha512-jhuKLIRrhvCPLqwPcx6INqmKeiA5EWrsCOPhrlFSrbrmU4ZMPjj5Ul/oLCMDO98XRUIwVm78xICz4EPCektzeQ==
|
||||
|
||||
"@types/node@*":
|
||||
"@types/node@*", "@types/node@^18.11.9":
|
||||
version "18.11.9"
|
||||
resolved "https://registry.yarnpkg.com/@types/node/-/node-18.11.9.tgz#02d013de7058cea16d36168ef2fc653464cfbad4"
|
||||
integrity sha512-CRpX21/kGdzjOpFsZSkcrXMGIBWMGNIHXXBVFSH+ggkftxg+XYP20TESbh+zFvFj3EQOl5byk0HTRn1IL6hbqg==
|
||||
|
|
@ -5285,6 +5293,13 @@ create-require@^1.1.0:
|
|||
resolved "https://registry.yarnpkg.com/create-require/-/create-require-1.1.1.tgz#c1d7e8f1e5f6cfc9ff65f9cd352d37348756c333"
|
||||
integrity sha512-dcKFX3jn0MpIaXjisoRvexIJVEKzaq7z2rZKxf+MSr9TkdmHmsU4m2lcLojrj/FHl8mk5VxMmYA+ftRkP/3oKQ==
|
||||
|
||||
cross-fetch@^3.1.5:
|
||||
version "3.1.5"
|
||||
resolved "https://registry.yarnpkg.com/cross-fetch/-/cross-fetch-3.1.5.tgz#e1389f44d9e7ba767907f7af8454787952ab534f"
|
||||
integrity sha512-lvb1SBsI0Z7GDwmuid+mU3kWVBwTVUbe7S0H52yaaAdQOXq2YktTCZdlAcNKFzE6QtRz0snpw9bNiPeOIkkQvw==
|
||||
dependencies:
|
||||
node-fetch "2.6.7"
|
||||
|
||||
cross-spawn@^7.0.0, cross-spawn@^7.0.2, cross-spawn@^7.0.3:
|
||||
version "7.0.3"
|
||||
resolved "https://registry.yarnpkg.com/cross-spawn/-/cross-spawn-7.0.3.tgz#f73a85b9d5d41d045551c177e2882d4ac85728a6"
|
||||
|
|
@ -6261,6 +6276,11 @@ es-to-primitive@^1.2.1:
|
|||
is-date-object "^1.0.1"
|
||||
is-symbol "^1.0.2"
|
||||
|
||||
es6-promise@^4.2.8:
|
||||
version "4.2.8"
|
||||
resolved "https://registry.yarnpkg.com/es6-promise/-/es6-promise-4.2.8.tgz#4eb21594c972bc40553d276e510539143db53e0a"
|
||||
integrity sha512-HJDGx5daxeIvxdBxvG2cb9g4tEvwIk3i8+nhX0yGrYmZUzbkdg8QbDevheDB8gd0//uPj4c1EQua8Q+MViT0/w==
|
||||
|
||||
escalade@^3.1.1:
|
||||
version "3.1.1"
|
||||
resolved "https://registry.yarnpkg.com/escalade/-/escalade-3.1.1.tgz#d8cfdc7000965c5a0174b4a82eaa5c0552742e40"
|
||||
|
|
@ -9240,6 +9260,15 @@ lz-string@^1.4.4:
|
|||
resolved "https://registry.yarnpkg.com/lz-string/-/lz-string-1.4.4.tgz#c0d8eaf36059f705796e1e344811cf4c498d3a26"
|
||||
integrity sha512-0ckx7ZHRPqb0oUm8zNr+90mtf9DQB60H1wMCjBtfi62Kl3a7JbHob6gA2bC+xRvZoOL+1hzUK8jeuEIQE8svEQ==
|
||||
|
||||
mailslurp-client@^15.14.1:
|
||||
version "15.14.1"
|
||||
resolved "https://registry.yarnpkg.com/mailslurp-client/-/mailslurp-client-15.14.1.tgz#df755e1a527587725fbebcd3458ce6c5f678c704"
|
||||
integrity sha512-LsIy9TL7fVYQYiJiwHn4PjGGMNfiwGb+GSLRRXdNuv7wlqaCbt8BJGLVA8YV7VWm8yFuvikOQxAwYwNMDV4hjg==
|
||||
dependencies:
|
||||
cross-fetch "^3.1.5"
|
||||
es6-promise "^4.2.8"
|
||||
url "^0.11.0"
|
||||
|
||||
make-dir@^2.1.0:
|
||||
version "2.1.0"
|
||||
resolved "https://registry.yarnpkg.com/make-dir/-/make-dir-2.1.0.tgz#5f0310e18b8be898cc07009295a30ae41e91e6f5"
|
||||
|
|
@ -9697,6 +9726,13 @@ node-abort-controller@^3.0.1:
|
|||
resolved "https://registry.yarnpkg.com/node-abort-controller/-/node-abort-controller-3.0.1.tgz#f91fa50b1dee3f909afabb7e261b1e1d6b0cb74e"
|
||||
integrity sha512-/ujIVxthRs+7q6hsdjHMaj8hRG9NuWmwrz+JdRwZ14jdFoKSkm+vDsCbF9PLpnSqjaWQJuTmVtcWHNLr+vrOFw==
|
||||
|
||||
node-fetch@2.6.7:
|
||||
version "2.6.7"
|
||||
resolved "https://registry.yarnpkg.com/node-fetch/-/node-fetch-2.6.7.tgz#24de9fba827e3b4ae44dc8b20256a379160052ad"
|
||||
integrity sha512-ZjMPFEfVx5j+y2yF35Kzx5sF7kDzxuDj6ziH4FFbOp87zKDZNx8yExJIb05OGF4Nlt9IHFIMBkRl41VdvcNdbQ==
|
||||
dependencies:
|
||||
whatwg-url "^5.0.0"
|
||||
|
||||
node-int64@^0.4.0:
|
||||
version "0.4.0"
|
||||
resolved "https://registry.yarnpkg.com/node-int64/-/node-int64-0.4.0.tgz#87a9065cdb355d3182d8f94ce11188b825c68a3b"
|
||||
|
|
@ -10311,6 +10347,11 @@ 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==
|
||||
|
||||
please-upgrade-node@^3.2.0:
|
||||
version "3.2.0"
|
||||
resolved "https://registry.yarnpkg.com/please-upgrade-node/-/please-upgrade-node-3.2.0.tgz#aeddd3f994c933e4ad98b99d9a556efa0e2fe942"
|
||||
|
|
@ -11014,6 +11055,11 @@ pump@^3.0.0:
|
|||
end-of-stream "^1.1.0"
|
||||
once "^1.3.1"
|
||||
|
||||
punycode@1.3.2:
|
||||
version "1.3.2"
|
||||
resolved "https://registry.yarnpkg.com/punycode/-/punycode-1.3.2.tgz#9653a036fb7c1ee42342f2325cceefea3926c48d"
|
||||
integrity sha512-RofWgt/7fL5wP1Y7fxE7/EmTLzQVnB0ycyibJ0OOHIlJqTNzglYFxVwETOcIoJqJmpDXJ9xImDv+Fq34F/d4Dw==
|
||||
|
||||
punycode@^2.1.0, punycode@^2.1.1:
|
||||
version "2.1.1"
|
||||
resolved "https://registry.yarnpkg.com/punycode/-/punycode-2.1.1.tgz#b58b010ac40c22c5657616c8d2c2c02c7bf479ec"
|
||||
|
|
@ -11034,6 +11080,11 @@ query-string@*:
|
|||
split-on-first "^1.0.0"
|
||||
strict-uri-encode "^2.0.0"
|
||||
|
||||
querystring@0.2.0:
|
||||
version "0.2.0"
|
||||
resolved "https://registry.yarnpkg.com/querystring/-/querystring-0.2.0.tgz#b209849203bb25df820da756e747005878521620"
|
||||
integrity sha512-X/xY82scca2tau62i9mDyU9K+I+djTMUsvwf7xnUX5GLvVzgJybOJf4Y6o9Zx3oJK/LSXg5tTZBjwzqVPaPO2g==
|
||||
|
||||
querystringify@^2.1.1:
|
||||
version "2.2.0"
|
||||
resolved "https://registry.yarnpkg.com/querystringify/-/querystringify-2.2.0.tgz#3345941b4153cb9d082d8eee4cda2016a9aef7f6"
|
||||
|
|
@ -13298,6 +13349,11 @@ tr46@^2.1.0:
|
|||
dependencies:
|
||||
punycode "^2.1.1"
|
||||
|
||||
tr46@~0.0.3:
|
||||
version "0.0.3"
|
||||
resolved "https://registry.yarnpkg.com/tr46/-/tr46-0.0.3.tgz#8184fd347dac9cdc185992f3a6622e14b9d9ab6a"
|
||||
integrity sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==
|
||||
|
||||
trim-newlines@^3.0.0:
|
||||
version "3.0.1"
|
||||
resolved "https://registry.yarnpkg.com/trim-newlines/-/trim-newlines-3.0.1.tgz#260a5d962d8b752425b32f3a7db0dcacd176c144"
|
||||
|
|
@ -13632,6 +13688,14 @@ url-parse@^1.5.3:
|
|||
querystringify "^2.1.1"
|
||||
requires-port "^1.0.0"
|
||||
|
||||
url@^0.11.0:
|
||||
version "0.11.0"
|
||||
resolved "https://registry.yarnpkg.com/url/-/url-0.11.0.tgz#3838e97cfc60521eb73c525a8e55bfdd9e2e28f1"
|
||||
integrity sha512-kbailJa29QrtXnxgq+DdCEGlbTeYM2eJUxsz6vjZavrCYPMIFHMKQmSKYAIuUK2i7hgPm28a8piX5NTUtM/LKQ==
|
||||
dependencies:
|
||||
punycode "1.3.2"
|
||||
querystring "0.2.0"
|
||||
|
||||
use-isomorphic-layout-effect@^1.1.2:
|
||||
version "1.1.2"
|
||||
resolved "https://registry.yarnpkg.com/use-isomorphic-layout-effect/-/use-isomorphic-layout-effect-1.1.2.tgz#497cefb13d863d687b08477d9e5a164ad8c1a6fb"
|
||||
|
|
@ -13782,6 +13846,11 @@ web-worker@^1.2.0:
|
|||
resolved "https://registry.yarnpkg.com/web-worker/-/web-worker-1.2.0.tgz#5d85a04a7fbc1e7db58f66595d7a3ac7c9c180da"
|
||||
integrity sha512-PgF341avzqyx60neE9DD+XS26MMNMoUQRz9NOZwW32nPQrF6p77f1htcnjBSEV8BGMKZ16choqUG4hyI0Hx7mA==
|
||||
|
||||
webidl-conversions@^3.0.0:
|
||||
version "3.0.1"
|
||||
resolved "https://registry.yarnpkg.com/webidl-conversions/-/webidl-conversions-3.0.1.tgz#24534275e2a7bc6be7bc86611cc16ae0a5654871"
|
||||
integrity sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==
|
||||
|
||||
webidl-conversions@^5.0.0:
|
||||
version "5.0.0"
|
||||
resolved "https://registry.yarnpkg.com/webidl-conversions/-/webidl-conversions-5.0.0.tgz#ae59c8a00b121543a2acc65c0434f57b0fc11aff"
|
||||
|
|
@ -13854,6 +13923,14 @@ whatwg-mimetype@^2.3.0:
|
|||
resolved "https://registry.yarnpkg.com/whatwg-mimetype/-/whatwg-mimetype-2.3.0.tgz#3d4b1e0312d2079879f826aff18dbeeca5960fbf"
|
||||
integrity sha512-M4yMwr6mAnQz76TbJm914+gPpB/nCwvZbJU28cUD6dR004SAxDLOOSUaB1JDRqLtaOV/vi0IC5lEAGFgrjGv/g==
|
||||
|
||||
whatwg-url@^5.0.0:
|
||||
version "5.0.0"
|
||||
resolved "https://registry.yarnpkg.com/whatwg-url/-/whatwg-url-5.0.0.tgz#966454e8765462e37644d3626f6742ce8b70965d"
|
||||
integrity sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==
|
||||
dependencies:
|
||||
tr46 "~0.0.3"
|
||||
webidl-conversions "^3.0.0"
|
||||
|
||||
whatwg-url@^8.0.0, whatwg-url@^8.5.0:
|
||||
version "8.7.0"
|
||||
resolved "https://registry.yarnpkg.com/whatwg-url/-/whatwg-url-8.7.0.tgz#656a78e510ff8f3937bc0bcbe9f5c0ac35941b77"
|
||||
|
|
|
|||
|
|
@ -18,11 +18,13 @@
|
|||
4. Install the helm chart
|
||||
|
||||
```bash
|
||||
helm install helm-testing \
|
||||
./oncall --wait --timeout 30m \
|
||||
--wait-for-jobs \
|
||||
--values simple.yml \
|
||||
--values values-arm64.yml
|
||||
helm install helm-testing \
|
||||
--wait \
|
||||
--timeout 30m \
|
||||
--wait-for-jobs \
|
||||
--values ./simple.yml \
|
||||
--values ./values-arm64.yml \
|
||||
./oncall
|
||||
```
|
||||
|
||||
5. Get credentials
|
||||
|
|
|
|||
|
|
@ -1,9 +1,19 @@
|
|||
kind: Cluster
|
||||
apiVersion: kind.x-k8s.io/v1alpha4
|
||||
nodes:
|
||||
- role: control-plane
|
||||
extraPortMappings:
|
||||
- containerPort: 30001
|
||||
hostPort: 30001
|
||||
- containerPort: 30002
|
||||
hostPort: 30002
|
||||
- role: control-plane
|
||||
extraPortMappings:
|
||||
- containerPort: 30001
|
||||
hostPort: 30001
|
||||
- containerPort: 30002
|
||||
hostPort: 30002
|
||||
# https://stackoverflow.com/a/62695918
|
||||
extraMounts:
|
||||
# this basically mounts our local ./grafana-plugin (frontend) directory into the kind node
|
||||
# so that we can later use a volumeMount to mount from the kind-control-plane Docker container -> grafana
|
||||
# k8s pod. This will allow us to mount the current frontend source code
|
||||
#
|
||||
# NOTE: this is a bit hacky and implies that kind create is run from the root of the project
|
||||
# but for now it works... alternative would be to use something like $(pwd)/grafana-plugin
|
||||
- hostPath: ./grafana-plugin
|
||||
containerPath: /oncall-plugin
|
||||
|
|
|
|||
|
|
@ -11,9 +11,9 @@ service:
|
|||
port: 8080
|
||||
nodePort: 30001
|
||||
grafana:
|
||||
service:
|
||||
type: NodePort
|
||||
nodePort: 30002
|
||||
service:
|
||||
type: NodePort
|
||||
nodePort: 30002
|
||||
database:
|
||||
# can be either mysql or postgresql
|
||||
type: postgresql
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
image:
|
||||
repository: oncall/engine
|
||||
tag: latest
|
||||
pullPolicy: IfNotPresent
|
||||
pullPolicy: IfNotPresent
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue