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:
Joey Orlando 2023-03-06 17:28:52 +01:00 committed by GitHub
parent ab493def5f
commit 8f22b2fd74
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
41 changed files with 1131 additions and 386 deletions

View file

@ -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
View 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

View file

@ -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.

View file

@ -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

View file

@ -14,3 +14,8 @@ yarn-error.log*
# This file is generated
grafana-plugin.yml
frontend_enterprise
# playwright
/test-results/
/playwright-report/
/playwright/.cache/

View file

@ -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

View file

@ -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

View file

@ -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);
});

View file

@ -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,
};

View file

@ -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)
);
})();
}
})();
});

View file

@ -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);

View file

@ -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,
};

View file

@ -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}'`);
});

View 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');
});

View 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);
};

View 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);
};

View 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"]';

View 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,
});
};

View 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();

View 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);
};

View 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' });
};

View 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);
};

View 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();

View 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');

View 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];

View 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' });
};

View 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);
};

View file

@ -22,4 +22,5 @@ module.exports = {
setupFilesAfterEnv: ['<rootDir>/jest.setup.ts'],
testTimeout: 10000,
testPathIgnorePatterns: ['/node_modules/', '/integration-tests/'],
};

View file

@ -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",

View 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;

View file

@ -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

View file

@ -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 (

View file

@ -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 = '') {

View file

@ -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

View file

@ -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">

View file

@ -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"],

View file

@ -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"

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -1,4 +1,4 @@
image:
repository: oncall/engine
tag: latest
pullPolicy: IfNotPresent
pullPolicy: IfNotPresent