diff --git a/.github/workflows/helm_tests.yml b/.github/workflows/helm_tests.yml deleted file mode 100644 index 658a9faf..00000000 --- a/.github/workflows/helm_tests.yml +++ /dev/null @@ -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 diff --git a/.github/workflows/integration_tests.yml b/.github/workflows/integration_tests.yml new file mode 100644 index 00000000..0bd077a6 --- /dev/null +++ b/.github/workflows/integration_tests.yml @@ -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 diff --git a/dev/README.md b/dev/README.md index 15b3fd9c..211307a9 100644 --- a/dev/README.md +++ b/dev/README.md @@ -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. diff --git a/grafana-plugin/.env.example b/grafana-plugin/.env.example index e69de29b..07915852 100644 --- a/grafana-plugin/.env.example +++ b/grafana-plugin/.env.example @@ -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 diff --git a/grafana-plugin/.gitignore b/grafana-plugin/.gitignore index 5a371043..8d0c1577 100644 --- a/grafana-plugin/.gitignore +++ b/grafana-plugin/.gitignore @@ -14,3 +14,8 @@ yarn-error.log* # This file is generated grafana-plugin.yml frontend_enterprise + +# playwright +/test-results/ +/playwright-report/ +/playwright/.cache/ diff --git a/grafana-plugin/e2e/features/add-channel-filter.feature b/grafana-plugin/e2e/features/add-channel-filter.feature deleted file mode 100644 index 8411740d..00000000 --- a/grafana-plugin/e2e/features/add-channel-filter.feature +++ /dev/null @@ -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 - diff --git a/grafana-plugin/e2e/features/delete-notification-steps.feature b/grafana-plugin/e2e/features/delete-notification-steps.feature deleted file mode 100644 index 0f3c6729..00000000 --- a/grafana-plugin/e2e/features/delete-notification-steps.feature +++ /dev/null @@ -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 \ No newline at end of file diff --git a/grafana-plugin/e2e/features/steps/addChannelFilter.js b/grafana-plugin/e2e/features/steps/addChannelFilter.js deleted file mode 100644 index 9c44b71c..00000000 --- a/grafana-plugin/e2e/features/steps/addChannelFilter.js +++ /dev/null @@ -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); -}); diff --git a/grafana-plugin/e2e/features/steps/common.js b/grafana-plugin/e2e/features/steps/common.js deleted file mode 100644 index bcb1dce9..00000000 --- a/grafana-plugin/e2e/features/steps/common.js +++ /dev/null @@ -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, -}; diff --git a/grafana-plugin/e2e/features/steps/deleteNotificationSteps.js b/grafana-plugin/e2e/features/steps/deleteNotificationSteps.js deleted file mode 100644 index b66dc62f..00000000 --- a/grafana-plugin/e2e/features/steps/deleteNotificationSteps.js +++ /dev/null @@ -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) - ); - })(); - } - })(); -}); diff --git a/grafana-plugin/e2e/features/support/world.js b/grafana-plugin/e2e/features/support/world.js deleted file mode 100644 index c490dbab..00000000 --- a/grafana-plugin/e2e/features/support/world.js +++ /dev/null @@ -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); diff --git a/grafana-plugin/e2e/utils/takeScreenshot.js b/grafana-plugin/e2e/utils/takeScreenshot.js deleted file mode 100644 index b495a309..00000000 --- a/grafana-plugin/e2e/utils/takeScreenshot.js +++ /dev/null @@ -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, -}; diff --git a/grafana-plugin/integration-tests/alerts/onCallSchedule.test.ts b/grafana-plugin/integration-tests/alerts/onCallSchedule.test.ts new file mode 100644 index 00000000..91026f4b --- /dev/null +++ b/grafana-plugin/integration-tests/alerts/onCallSchedule.test.ts @@ -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}'`); +}); diff --git a/grafana-plugin/integration-tests/alerts/sms.test.ts b/grafana-plugin/integration-tests/alerts/sms.test.ts new file mode 100644 index 00000000..4b22eeda --- /dev/null +++ b/grafana-plugin/integration-tests/alerts/sms.test.ts @@ -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'); +}); diff --git a/grafana-plugin/integration-tests/utils/alertGroup.ts b/grafana-plugin/integration-tests/utils/alertGroup.ts new file mode 100644 index 00000000..f07e4d36 --- /dev/null +++ b/grafana-plugin/integration-tests/utils/alertGroup.ts @@ -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 => { + 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 => { + 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); +}; diff --git a/grafana-plugin/integration-tests/utils/configurePlugin.ts b/grafana-plugin/integration-tests/utils/configurePlugin.ts new file mode 100644 index 00000000..17d4a91d --- /dev/null +++ b/grafana-plugin/integration-tests/utils/configurePlugin.ts @@ -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 => { + 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); +}; diff --git a/grafana-plugin/integration-tests/utils/constants.ts b/grafana-plugin/integration-tests/utils/constants.ts new file mode 100644 index 00000000..f6a9f9d2 --- /dev/null +++ b/grafana-plugin/integration-tests/utils/constants.ts @@ -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"]'; diff --git a/grafana-plugin/integration-tests/utils/escalationChain.ts b/grafana-plugin/integration-tests/utils/escalationChain.ts new file mode 100644 index 00000000..8f7f5317 --- /dev/null +++ b/grafana-plugin/integration-tests/utils/escalationChain.ts @@ -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.NotifyUsers]: 'Select User', + [EscalationStep.NotifyUsersFromOnCallSchedule]: 'Select Schedule', +}; + +export const createEscalationChain = async ( + page: Page, + escalationChainName: string, + escalationStep: EscalationStep, + escalationStepValue: string +): Promise => { + // 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, + }); +}; diff --git a/grafana-plugin/integration-tests/utils/forms.ts b/grafana-plugin/integration-tests/utils/forms.ts new file mode 100644 index 00000000..4981ec5f --- /dev/null +++ b/grafana-plugin/integration-tests/utils/forms.ts @@ -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 => { + 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 => { + /** + * we currently mix three different dropdown components in the UI.. + * so we need to support all of them :( + */ + const dropdownSelectors: Record = { + 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 => + page.locator(`div[id^="react-select-"][id$="-listbox"] >> ${textMatchSelector(optionExactMatch, value)}`).click(); + +export const selectDropdownValue = async (args: SelectDropdownValueArgs): Promise => { + 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 tag + */ +export const selectValuePickerValue = async ( + page: Page, + valuePickerText: string, + optionExactMatch = true +): Promise => + ( + await page.waitForSelector( + `div[class*="grafana-select-menu"] >> ${textMatchSelector(optionExactMatch, valuePickerText)}` + ) + ).click(); diff --git a/grafana-plugin/integration-tests/utils/index.ts b/grafana-plugin/integration-tests/utils/index.ts new file mode 100644 index 00000000..65513913 --- /dev/null +++ b/grafana-plugin/integration-tests/utils/index.ts @@ -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 => { + await login(page); + await configureOnCallPlugin(page); + await goToOnCallPage(page); +}; diff --git a/grafana-plugin/integration-tests/utils/integrations.ts b/grafana-plugin/integration-tests/utils/integrations.ts new file mode 100644 index 00000000..026042d4 --- /dev/null +++ b/grafana-plugin/integration-tests/utils/integrations.ts @@ -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 => { + // 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' }); +}; diff --git a/grafana-plugin/integration-tests/utils/login.ts b/grafana-plugin/integration-tests/utils/login.ts new file mode 100644 index 00000000..172d7732 --- /dev/null +++ b/grafana-plugin/integration-tests/utils/login.ts @@ -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 => { + 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); +}; diff --git a/grafana-plugin/integration-tests/utils/modals.ts b/grafana-plugin/integration-tests/utils/modals.ts new file mode 100644 index 00000000..9b6277f6 --- /dev/null +++ b/grafana-plugin/integration-tests/utils/modals.ts @@ -0,0 +1,5 @@ +import { Page } from '@playwright/test'; + +// close the currently opened modal +export const closeModal = async (page: Page): Promise => + (await page.waitForSelector('button[aria-label="Close dialogue"]')).click(); diff --git a/grafana-plugin/integration-tests/utils/navigation.ts b/grafana-plugin/integration-tests/utils/navigation.ts new file mode 100644 index 00000000..a43ad706 --- /dev/null +++ b/grafana-plugin/integration-tests/utils/navigation.ts @@ -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 => + page.goto(`${BASE_URL}${url}`, { waitUntil }); + +export const goToGrafanaPage = (page: Page, url?: GrafanaPage, waitUntil?: WaitUntil): Promise => + _goToPage(page, url, waitUntil); + +export const goToOnCallPage = (page: Page, onCallPage: OnCallPage = 'incidents'): Promise => + _goToPage(page, `/a/grafana-oncall-app/${onCallPage}`); + +export const goToOnCallPageByClickingOnTab = async (page: Page, onCallTab: OnCallPluginTab): Promise => + (await page.waitForSelector(`div[class*="LegacyNavTabsBar"] >> text=${onCallTab}`)).click(); + +export const waitForNoNetworkActivity = (page: Page): Promise => page.waitForLoadState('networkidle'); diff --git a/grafana-plugin/integration-tests/utils/phone.ts b/grafana-plugin/integration-tests/utils/phone.ts new file mode 100644 index 00000000..765687a4 --- /dev/null +++ b/grafana-plugin/integration-tests/utils/phone.ts @@ -0,0 +1,47 @@ +import MailSlurp, { GetPhoneNumbersPhoneCountryEnum, PhoneNumberProjection } from 'mailslurp-client'; + +import { MAILSLURP_API_KEY } from './constants'; + +const _getPhoneNumber = (): (() => Promise) => { + 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 => { + 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]; diff --git a/grafana-plugin/integration-tests/utils/schedule.ts b/grafana-plugin/integration-tests/utils/schedule.ts new file mode 100644 index 00000000..11dfefb8 --- /dev/null +++ b/grafana-plugin/integration-tests/utils/schedule.ts @@ -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 => { + // 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' }); +}; diff --git a/grafana-plugin/integration-tests/utils/userSettings.ts b/grafana-plugin/integration-tests/utils/userSettings.ts new file mode 100644 index 00000000..e671f027 --- /dev/null +++ b/grafana-plugin/integration-tests/utils/userSettings.ts @@ -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 => { + 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 => { + // 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 => { + 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 => { + // 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); +}; diff --git a/grafana-plugin/jest.config.js b/grafana-plugin/jest.config.js index ed696b01..dfdddb32 100644 --- a/grafana-plugin/jest.config.js +++ b/grafana-plugin/jest.config.js @@ -22,4 +22,5 @@ module.exports = { setupFilesAfterEnv: ['/jest.setup.ts'], testTimeout: 10000, + testPathIgnorePatterns: ['/node_modules/', '/integration-tests/'], }; diff --git a/grafana-plugin/package.json b/grafana-plugin/package.json index baf484db..ae9ad6a4 100644 --- a/grafana-plugin/package.json +++ b/grafana-plugin/package.json @@ -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", diff --git a/grafana-plugin/playwright.config.ts b/grafana-plugin/playwright.config.ts new file mode 100644 index 00000000..fb366a59 --- /dev/null +++ b/grafana-plugin/playwright.config.ts @@ -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; diff --git a/grafana-plugin/src/containers/AlertRules/AlertRules.tsx b/grafana-plugin/src/containers/AlertRules/AlertRules.tsx index 56cdd4a9..67d9e076 100644 --- a/grafana-plugin/src/containers/AlertRules/AlertRules.tsx +++ b/grafana-plugin/src/containers/AlertRules/AlertRules.tsx @@ -153,9 +153,9 @@ class AlertRules extends React.Component { const maintenanceMode = alertReceiveChannel.maintenance_mode; return ( <> -
+
-
+
@@ -186,6 +186,7 @@ class AlertRules extends React.Component { variant="secondary" size="sm" onClick={this.getSendDemoAlertClickHandler(alertReceiveChannel.id)} + data-testid="send-demo-alert" > Send demo alert @@ -259,7 +260,7 @@ class AlertRules extends React.Component { title="Edit integration name" onDismiss={() => this.setState({ editIntegrationName: undefined })} > -
+
-
+
{options.length ? ( options.map((alertReceiveChannelChoice) => { return ( diff --git a/grafana-plugin/src/models/escalation_chain/escalation_chain.ts b/grafana-plugin/src/models/escalation_chain/escalation_chain.ts index 817c010c..6f9cff31 100644 --- a/grafana-plugin/src/models/escalation_chain/escalation_chain.ts +++ b/grafana-plugin/src/models/escalation_chain/escalation_chain.ts @@ -16,6 +16,9 @@ export class EscalationChainStore extends BaseStore { @observable.shallow searchResult: { [key: string]: Array } = {}; + @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 = '') { diff --git a/grafana-plugin/src/pages/escalation-chains/EscalationChains.tsx b/grafana-plugin/src/pages/escalation-chains/EscalationChains.tsx index 71b7cfa8..3626a894 100644 --- a/grafana-plugin/src/pages/escalation-chains/EscalationChains.tsx +++ b/grafana-plugin/src/pages/escalation-chains/EscalationChains.tsx @@ -143,6 +143,7 @@ class EscalationChainsPage extends React.Component
- - - + {!loading && ( + + + + )}
{searchResult ? ( this.setState({ timelineFilter: value }); }} /> -
    +
      {timeline.map((item: TimeLineItem, idx: number) => (
    • diff --git a/grafana-plugin/tsconfig.json b/grafana-plugin/tsconfig.json index 56342bec..94022d3d 100644 --- a/grafana-plugin/tsconfig.json +++ b/grafana-plugin/tsconfig.json @@ -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"], diff --git a/grafana-plugin/yarn.lock b/grafana-plugin/yarn.lock index a478a372..43910fc3 100644 --- a/grafana-plugin/yarn.lock +++ b/grafana-plugin/yarn.lock @@ -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" diff --git a/helm/README.md b/helm/README.md index cedbb94f..b35fd43f 100644 --- a/helm/README.md +++ b/helm/README.md @@ -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 diff --git a/helm/kind.yml b/helm/kind.yml index b6b6f526..c61ac446 100644 --- a/helm/kind.yml +++ b/helm/kind.yml @@ -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 \ No newline at end of file + - 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 diff --git a/helm/simple.yml b/helm/simple.yml index 4a703006..84456649 100644 --- a/helm/simple.yml +++ b/helm/simple.yml @@ -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 diff --git a/helm/values-local-image.yml b/helm/values-local-image.yml index db44a5e3..8e67a3c8 100644 --- a/helm/values-local-image.yml +++ b/helm/values-local-image.yml @@ -1,4 +1,4 @@ image: repository: oncall/engine tag: latest - pullPolicy: IfNotPresent \ No newline at end of file + pullPolicy: IfNotPresent