From 5150fde959976b79608956031a555c0bbef01598 Mon Sep 17 00:00:00 2001 From: David Taylor Date: Mon, 9 Mar 2020 13:38:35 -0400 Subject: [PATCH] Cycpress integration testing --- Dockerfile | 2 +- Dockerfile.builder | 4 +- README.md | 45 +- builder-run.sh | 2 +- .../integration-tests/tests/crud.scenario.ts | 182 +-- .../tests/monitoring.scenario.ts | 223 --- .../views/monitoring.view.ts | 29 - frontend/package.json | 15 +- .../integration-tests-cypress/.eslintrc | 11 + .../integration-tests-cypress/README.md | 51 + .../integration-tests-cypress/cypress.json | 14 + .../fixtures/example.json | 5 + .../integration-tests-cypress/package.json | 6 + .../plugins/index.js | 51 + .../reporter-config.json | 7 + .../support/README.md | 15 + .../support/index.ts | 39 + .../support/login.ts | 50 + .../integration-tests-cypress/support/nav.ts | 22 + .../support/project.ts | 40 + .../support/resources.ts | 58 + .../support/selectors.ts | 22 + .../tests/crud/k8-openshift-cruds.spec.ts | 169 ++ .../tests/crud/namespace-crud.spec.ts | 60 + .../tests/monitoring/monitoring.spec.ts | 152 ++ .../integration-tests-cypress/tsconfig.json | 12 + .../views/details-page.ts | 15 + .../integration-tests-cypress/views/form.ts | 3 + .../views/list-page.ts | 58 + .../integration-tests-cypress/views/modal.ts | 9 + .../views/yaml-editor.ts | 15 + frontend/public/components/edit-yaml.jsx | 13 +- .../public/components/factory/list-page.jsx | 4 +- frontend/public/components/factory/modal.tsx | 16 +- .../modals/create-namespace-modal.jsx | 1 + .../modals/delete-namespace-modal.jsx | 1 + .../public/components/monitoring/alerting.tsx | 20 +- .../components/monitoring/silence-form.tsx | 7 +- .../public/components/routes/create-route.tsx | 7 +- .../components/secrets/create-secret.tsx | 1 + .../components/sidebars/resource-sidebar.jsx | 5 +- .../public/components/storage-class-form.tsx | 7 +- .../public/components/storage/create-pvc.tsx | 7 +- .../public/components/utils/label-list.tsx | 6 +- .../components/utils/selector-input.jsx | 1 + .../public/components/utils/status-box.tsx | 4 +- frontend/public/declarations.d.ts | 2 + frontend/tsconfig.json | 4 +- frontend/yarn.lock | 1429 ++++++++++++++++- test-cypress.sh | 5 + test-prow-e2e.sh | 6 + 51 files changed, 2432 insertions(+), 500 deletions(-) create mode 100644 frontend/packages/integration-tests-cypress/.eslintrc create mode 100644 frontend/packages/integration-tests-cypress/README.md create mode 100644 frontend/packages/integration-tests-cypress/cypress.json create mode 100644 frontend/packages/integration-tests-cypress/fixtures/example.json create mode 100644 frontend/packages/integration-tests-cypress/package.json create mode 100644 frontend/packages/integration-tests-cypress/plugins/index.js create mode 100644 frontend/packages/integration-tests-cypress/reporter-config.json create mode 100644 frontend/packages/integration-tests-cypress/support/README.md create mode 100644 frontend/packages/integration-tests-cypress/support/index.ts create mode 100644 frontend/packages/integration-tests-cypress/support/login.ts create mode 100644 frontend/packages/integration-tests-cypress/support/nav.ts create mode 100644 frontend/packages/integration-tests-cypress/support/project.ts create mode 100644 frontend/packages/integration-tests-cypress/support/resources.ts create mode 100644 frontend/packages/integration-tests-cypress/support/selectors.ts create mode 100644 frontend/packages/integration-tests-cypress/tests/crud/k8-openshift-cruds.spec.ts create mode 100644 frontend/packages/integration-tests-cypress/tests/crud/namespace-crud.spec.ts create mode 100644 frontend/packages/integration-tests-cypress/tests/monitoring/monitoring.spec.ts create mode 100644 frontend/packages/integration-tests-cypress/tsconfig.json create mode 100644 frontend/packages/integration-tests-cypress/views/details-page.ts create mode 100644 frontend/packages/integration-tests-cypress/views/form.ts create mode 100644 frontend/packages/integration-tests-cypress/views/list-page.ts create mode 100644 frontend/packages/integration-tests-cypress/views/modal.ts create mode 100644 frontend/packages/integration-tests-cypress/views/yaml-editor.ts create mode 100755 test-cypress.sh diff --git a/Dockerfile b/Dockerfile index 70631fc24d..9a3d38bc8b 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,4 +1,4 @@ -FROM quay.io/coreos/tectonic-console-builder:v19 AS build +FROM quay.io/coreos/tectonic-console-builder:v20 AS build RUN mkdir -p /go/src/github.com/openshift/console/ ADD . /go/src/github.com/openshift/console/ diff --git a/Dockerfile.builder b/Dockerfile.builder index 4135b47e10..6dea006098 100644 --- a/Dockerfile.builder +++ b/Dockerfile.builder @@ -27,7 +27,9 @@ RUN chmod 777 -R ${HOME} RUN apt-get update \ && apt-get install --no-install-recommends -y -q \ - curl wget git unzip bzip2 jq + curl wget git unzip bzip2 jq \ + libgtk2.0-0 libgtk-3-0 libnotify-dev libgconf-2-4 libnss3 libxss1 libasound2 libxtst6 xauth xvfb + # ^^ additional Cypress dependencies: https://docs.cypress.io/guides/guides/continuous-integration.html#Dependencies RUN curl -LO https://storage.googleapis.com/kubernetes-release/release/v1.17.0/bin/linux/amd64/kubectl && \ chmod +x ./kubectl && \ diff --git a/README.md b/README.md index dc601d292f..d6889d552d 100644 --- a/README.md +++ b/README.md @@ -185,6 +185,22 @@ Run frontend tests: ### Integration Tests +#### Cypress + +Cypress integration tests are run via [Cypress.io](https://www.cypress.io/). + +Launch Cypress test runner: +``` +cd frontend +oc login ... +yarn run test-cypress +``` + +This will launch the Cypress test runner where you can run one or all cypress tests. + +[**_More information on Console's Cypress usage_**](frontend/packages/integration-tests-cypress/README.md) +#### Protractor + Integration tests are run in a headless browser driven by [protractor](http://www.protractortest.org/#/). Requirements include Chrome or Firefox, a working cluster, kubectl, and bridge itself (see building above). By default, it will look for Chrome in the system and use it, but if you want to use Firefox instead, set `BRIDGE_E2E_BROWSER_NAME` environment variable in your shell with the value `firefox`. @@ -220,7 +236,6 @@ For macOS, you can use: ``` yarn run webdriver-update-macos ``` - #### How the Integration Tests Run in CI The end-to-end tests run against pull requests using [ci-operator](https://github.com/openshift/ci-operator/). @@ -245,7 +260,7 @@ If you don't want to run the entire e2e tests, you can use a different suite fro $ ./test-gui.sh ``` -#### Hacking Integration Tests +##### Hacking Protractor Tests To see what the tests are actually doing, it is posible to run in none `headless` mode by setting the `NO_HEADLESS` environment variable: @@ -265,7 +280,7 @@ To avoid skipping remaining portion of tests upon encountering the first failure $ NO_FAILFAST=true ./test-gui.sh ``` -##### Debugging Integration Tests +##### Debugging Protractor Tests 1. `cd frontend; yarn run build` 2. Add `debugger;` statements to any e2e test @@ -275,6 +290,30 @@ $ NO_FAILFAST=true ./test-gui.sh 6. Will break on any `debugger;` statements 7. Pauses browser when not using `--headless` argument! +#### How the Integration Tests Run in CI + +The end-to-end tests run against pull requests using [ci-operator](https://github.com/openshift/ci-operator/). +The tests are defined in [this manifest](https://github.com/openshift/release/blob/master/ci-operator/jobs/openshift/console/openshift-console-master-presubmits.yaml) +in the [openshift/release](https://github.com/openshift/release) repo and were generated with [ci-operator-prowgen](https://github.com/openshift/ci-operator-prowgen). + +CI runs the [test-prow-e2e.sh](test-prow-e2e.sh) script, which runs the cypress tests and the protractor `e2e` test suite defined in [protractor.conf.ts](frontend/integration-tests/protractor.conf.ts). + +You can simulate an e2e run against an existing 4.0 cluster with the following commands (replace `/path/to/install-dir` with your OpenShift 4.0 install directory): + +``` +$ oc apply -f ./frontend/integration-tests/data/htpasswd-secret.yaml +$ oc patch oauths cluster --patch "$(cat ./frontend/integration-tests/data/patch-htpasswd.yaml)" --type=merge +$ export BRIDGE_BASE_ADDRESS="$(oc get consoles.config.openshift.io cluster -o jsonpath='{.status.consoleURL}')" +$ export BRIDGE_KUBEADMIN_PASSWORD=$(cat "/path/to/install-dir/auth/kubeadmin-password") +$ ./test-gui.sh e2e +``` + +If you don't want to run the entire e2e tests, you can use a different suite from [protractor.conf.ts](frontend/integration-tests/protractor.conf.ts). For instance, + +``` +$ ./test-gui.sh +``` + ### Deploying a Custom Image to an OpenShift Cluster Once you have made changes locally, these instructions will allow you to push diff --git a/builder-run.sh b/builder-run.sh index fd6df2282a..a7c937bee0 100755 --- a/builder-run.sh +++ b/builder-run.sh @@ -11,7 +11,7 @@ set -e # Without env vars: # ./builder-run.sh ./my-script --my-script-arg1 --my-script-arg2 -BUILDER_IMAGE="quay.io/coreos/tectonic-console-builder:v19" +BUILDER_IMAGE="quay.io/coreos/tectonic-console-builder:v20" # forward whitelisted env variables to docker ENV_STR=() diff --git a/frontend/integration-tests/tests/crud.scenario.ts b/frontend/integration-tests/tests/crud.scenario.ts index dc224a2534..22b6e852cd 100644 --- a/frontend/integration-tests/tests/crud.scenario.ts +++ b/frontend/integration-tests/tests/crud.scenario.ts @@ -4,12 +4,10 @@ import { browser, $, $$, by, ExpectedConditions as until, Key, element } from 'p import { safeLoad, safeDump } from 'js-yaml'; import * as _ from 'lodash'; import { execSync } from 'child_process'; -import { OrderedMap } from 'immutable'; import { appHost, testName, checkLogs, checkErrors } from '../protractor.conf'; import * as crudView from '../views/crud.view'; import * as yamlView from '../views/yaml.view'; -import * as namespaceView from '../views/namespace.view'; import * as createRoleBindingView from '../views/create-role-binding.view'; const K8S_CREATION_TIMEOUT = 15000; @@ -17,44 +15,6 @@ const K8S_CREATION_TIMEOUT = 15000; describe('Kubernetes resource CRUD operations', () => { const testLabel = 'automatedTestName'; const leakedResources = new Set(); - const k8sObjs = OrderedMap() - .set('pods', { kind: 'Pod' }) - .set('services', { kind: 'Service' }) - .set('serviceaccounts', { kind: 'ServiceAccount' }) - .set('secrets', { kind: 'Secret' }) - .set('configmaps', { kind: 'ConfigMap' }) - .set('persistentvolumes', { kind: 'PersistentVolume', namespaced: false }) - .set('storageclasses', { kind: 'StorageClass', namespaced: false }) - .set('ingresses', { kind: 'Ingress' }) - .set('cronjobs', { kind: 'CronJob' }) - .set('jobs', { kind: 'Job' }) - .set('daemonsets', { kind: 'DaemonSet' }) - .set('deployments', { kind: 'Deployment' }) - .set('replicasets', { kind: 'ReplicaSet' }) - .set('replicationcontrollers', { kind: 'ReplicationController' }) - .set('persistentvolumeclaims', { kind: 'PersistentVolumeClaim' }) - .set('statefulsets', { kind: 'StatefulSet' }) - .set('resourcequotas', { kind: 'ResourceQuota' }) - .set('limitranges', { kind: 'LimitRange' }) - .set('horizontalpodautoscalers', { kind: 'HorizontalPodAutoscaler' }) - .set('networkpolicies', { kind: 'NetworkPolicy' }) - .set('roles', { kind: 'Role' }); - const openshiftObjs = OrderedMap() - .set('deploymentconfigs', { kind: 'DeploymentConfig' }) - .set('buildconfigs', { kind: 'BuildConfig' }) - .set('imagestreams', { kind: 'ImageStream' }) - .set('routes', { kind: 'Route' }) - .set('user.openshift.io~v1~Group', { kind: 'user.openshift.io~v1~Group', namespaced: false }); - const serviceCatalogObjs = OrderedMap().set( - 'clusterservicebrokers', - { - kind: 'servicecatalog.k8s.io~v1beta1~ClusterServiceBroker', - namespaced: false, - }, - ); - let testObjs = browser.params.openshift === 'true' ? k8sObjs.merge(openshiftObjs) : k8sObjs; - testObjs = - browser.params.servicecatalog === 'true' ? testObjs.merge(serviceCatalogObjs) : testObjs; afterEach(() => { checkLogs(); @@ -63,9 +23,12 @@ describe('Kubernetes resource CRUD operations', () => { afterAll(() => { const leakedArray: Array = [...leakedResources]; - console.error( - `Leaked ${leakedArray.length} resources out of ${testObjs.size}:\n${leakedArray.join('\n')}`, - ); + if (!_.isEmpty(leakedArray)) { + console.error(`Leaked ${leakedArray.length} resources\n${leakedArray.join('\n')}.`); + } else { + console.log('No resources leaked.'); + } + leakedArray .map((r) => JSON.parse(r) as { name: string; plural: string; namespace?: string }) .filter((r) => r.namespace === undefined) @@ -78,104 +41,6 @@ describe('Kubernetes resource CRUD operations', () => { }); }); - testObjs.forEach(({ kind, namespaced = true }, resource) => { - describe(kind, () => { - const name = `${testName}-${_.kebabCase(kind)}`; - it('displays a list view for the resource', async () => { - await browser.get( - `${appHost}${ - namespaced ? `/k8s/ns/${testName}` : '/k8s/cluster' - }/${resource}?name=${testName}`, - ); - await crudView.isLoaded(); - }); - - if (namespaced) { - it('has a working namespace dropdown on namespaced objects', async () => { - await browser.wait(until.presenceOf(namespaceView.namespaceSelector)); - expect(namespaceView.namespaceSelector.getText()).toContain(testName); - }); - } else { - it('does not have a namespace dropdown on non-namespaced objects', async () => { - expect(namespaceView.namespaceSelector.isPresent()).toBe(false); - }); - } - - it('displays a YAML editor for creating a new resource instance', async () => { - await crudView.clickListPageCreateYAMLButton(); - const yamlLinkIsPresent = await crudView.createYAMLLink.isPresent(); - if (yamlLinkIsPresent) { - await crudView.createYAMLLink.click(); - } - await yamlView.isLoaded(); - - const content = await yamlView.getEditorContent(); - const newContent = _.defaultsDeep( - {}, - { metadata: { name, labels: { [testLabel]: testName } } }, - safeLoad(content), - ); - await yamlView.setEditorContent(safeDump(newContent)); - }); - - it('creates a new resource instance', async () => { - leakedResources.add( - JSON.stringify({ name, plural: resource, namespace: namespaced ? testName : undefined }), - ); - await yamlView.saveButton.click(); - - expect(crudView.errorMessage.isPresent()).toBe(false); - }); - - it('displays detail view for new resource instance', async () => { - await browser.wait(until.presenceOf(crudView.resourceTitle)); - expect(browser.getCurrentUrl()).toContain(`/${name}`); - expect(crudView.resourceTitle.getText()).toEqual(name); - }); - - it('search view displays created resource instance', async () => { - await browser.get( - `${appHost}/search/${ - namespaced ? `ns/${testName}` : 'all-namespaces' - }?kind=${kind}&q=${testLabel}%3d${testName}&name=${name}`, - ); - await crudView.resourceRowsPresent(); - await crudView - .rowForName(name) - .element(by.linkText(name)) - .click(); - await browser.wait(until.urlContains(`/${name}`)); - expect(crudView.resourceTitle.getText()).toEqual(name); - }); - - it('edit the resource instance', async () => { - if (kind !== 'ServiceAccount') { - await browser.get( - `${appHost}/search/${ - namespaced ? `ns/${testName}` : 'all-namespaces' - }?kind=${kind}&q=${testLabel}%3d${testName}&name=${name}`, - ); - await crudView.resourceRowsPresent(); - await crudView.editRow(kind)(name); - } - }); - - it('deletes the resource instance', async () => { - await browser.get( - `${appHost}${namespaced ? `/k8s/ns/${testName}` : '/k8s/cluster'}/${resource}`, - ); - await crudView.resourceRowsPresent(); - // Filter by resource name to make sure the resource is on the first page of results. - // Otherwise the tests fail since we do virtual scrolling and the element isn't found. - await crudView.filterForName(name); - await crudView.deleteRow(kind)(name); - leakedResources.delete( - JSON.stringify({ name, plural: resource, namespace: namespaced ? testName : undefined }), - ); - }); - }); - }); - describe('Role Bindings', () => { const bindingName = `${testName}-cluster-admin`; const roleName = 'cluster-admin'; @@ -228,41 +93,6 @@ describe('Kubernetes resource CRUD operations', () => { }); }); - describe('Namespace', () => { - const name = `${testName}-ns`; - - it('displays `Namespace` list view', async () => { - await browser.get(`${appHost}/k8s/cluster/namespaces`); - await crudView.isLoaded(); - - expect(crudView.rowForName(name).isPresent()).toBe(false); - }); - - it('creates the namespace', async () => { - await crudView.createYAMLButton.click(); - await browser.wait(until.presenceOf($('.modal-body__field'))); - await $$('.modal-body__field') - .get(0) - .$('input') - .sendKeys(name); - leakedResources.add(JSON.stringify({ name, plural: 'namespaces' })); - await $('#confirm-action').click(); - await browser.wait(until.invisibilityOf($('.modal-content')), K8S_CREATION_TIMEOUT); - - expect(browser.getCurrentUrl()).toContain(`/k8s/cluster/namespaces/${testName}-ns`); - }); - - it('deletes the namespace', async () => { - await browser.get(`${appHost}/k8s/cluster/namespaces`); - // Filter by resource name to make sure the resource is on the first page of results. - // Otherwise the tests fail since we do virtual scrolling and the element isn't found. - await crudView.filterForName(name); - await crudView.resourceRowsPresent(); - await crudView.deleteRow('Namespace')(name); - leakedResources.delete(JSON.stringify({ name, plural: 'namespaces' })); - }); - }); - describe('CustomResourceDefinitions', () => { const plural = `crd${testName}`; const group = 'test.example.com'; diff --git a/frontend/integration-tests/tests/monitoring.scenario.ts b/frontend/integration-tests/tests/monitoring.scenario.ts index 2465d28fa7..7d2f553da2 100644 --- a/frontend/integration-tests/tests/monitoring.scenario.ts +++ b/frontend/integration-tests/tests/monitoring.scenario.ts @@ -5,233 +5,10 @@ import { dropdownMenuForTestID } from '../views/form.view'; import * as crudView from '../views/crud.view'; import * as yamlView from '../views/yaml.view'; import * as monitoringView from '../views/monitoring.view'; -import * as namespaceView from '../views/namespace.view'; import * as sidenavView from '../views/sidenav.view'; import * as horizontalnavView from '../views/horizontal-nav.view'; import { execSync } from 'child_process'; -const testAlertName = 'Watchdog'; - -const testDetailsPage = (subTitle, alertName, expectLabel = true) => { - expect(monitoringView.detailsHeading.getText()).toContain(alertName); - expect(monitoringView.detailsSubHeadings.first().getText()).toContain(subTitle); - if (expectLabel) { - expect(monitoringView.labels.first().getText()).toEqual(`alertname\n=\n${alertName}`); - } -}; - -const testSilenceTimeInputs = async () => { - // Default start and end times - expect(monitoringView.silenceStartNowCheckbox.getAttribute('checked')).toBeTruthy(); - await browser.wait(until.presenceOf(monitoringView.silenceStartsAtInput)); - expect(monitoringView.silenceStartsAtInput.getAttribute('value')).toEqual('Now'); - expect(monitoringView.silenceDurationMenuButton.getText()).toEqual('2h'); - expect(monitoringView.silenceEndsAtInput.getAttribute('value')).toEqual('2h from now'); - - // Change duration - await monitoringView.silenceDurationMenuButton.click(); - await monitoringView.wait(until.elementToBeClickable(monitoringView.silenceDurationOption('1h'))); - await monitoringView.silenceDurationOption('1h').click(); - expect(monitoringView.silenceEndsAtInput.getAttribute('value')).toEqual('1h from now'); - - // Change to not start now - await monitoringView.silenceStartNowCheckbox.click(); - expect(monitoringView.silenceStartNowCheckbox.getAttribute('checked')).toBeFalsy(); - // Allow for some difference in times - expect(monitoringView.silenceStartsAtInput.getAttribute('value')).not.toEqual('Now'); - expect(monitoringView.silenceStartsAtInput.getAttribute('value')).toBeTruthy(); - monitoringView.silenceStartsAtInput.getAttribute('value').then((start: string) => { - expect(Date.parse(start) - Date.now()).toBeLessThan(10000); - monitoringView.silenceEndsAtInput.getAttribute('value').then((end: string) => { - expect(Date.parse(end) - Date.parse(start)).toEqual(60 * 60 * 1000); - }); - }); - - // Invalid start time - await monitoringView.silenceStartsAtInput.sendKeys('abc'); - expect(monitoringView.silenceEndsAtInput.getAttribute('value')).toEqual('-'); - - // Change to back to start now - await monitoringView.silenceStartNowCheckbox.click(); - expect(monitoringView.silenceStartNowCheckbox.getAttribute('checked')).toBeTruthy(); - expect(monitoringView.silenceEndsAtInput.getAttribute('value')).toEqual('1h from now'); - - // Change duration back again - await monitoringView.silenceDurationMenuButton.click(); - await monitoringView.wait(until.presenceOf(monitoringView.silenceDurationOption('2h'))); - await monitoringView.silenceDurationOption('2h').click(); - expect(monitoringView.silenceEndsAtInput.getAttribute('value')).toEqual('2h from now'); -}; - -describe('Monitoring: Alerts', () => { - afterEach(() => { - checkLogs(); - checkErrors(); - }); - - it('displays the Alerts list page', async () => { - await sidenavView.clickNavLink(['Monitoring', 'Alerting']); - await crudView.isLoaded(); - expect(monitoringView.listPageHeading.getText()).toContain('Alerting'); - }); - - it('does not have a namespace dropdown', async () => { - expect(namespaceView.namespaceSelector.isPresent()).toBe(false); - }); - - it('filters Alerts by name', async () => { - await monitoringView.wait(until.elementToBeClickable(crudView.nameFilter)); - await crudView.nameFilter.sendKeys(testAlertName); - expect(firstElementByTestID('alert-resource-link').getText()).toContain(testAlertName); - }); - - it('displays Alert details page', async () => { - await monitoringView.wait( - until.elementToBeClickable(firstElementByTestID('alert-resource-link')), - ); - expect(firstElementByTestID('alert-resource-link').getText()).toContain(testAlertName); - await firstElementByTestID('alert-resource-link').click(); - await monitoringView.wait(until.presenceOf(monitoringView.detailsHeadingAlertIcon)); - testDetailsPage('Alert Detail', testAlertName); - }); - - it('links to the Alerting Rule details page', async () => { - expect(monitoringView.ruleLink.getText()).toContain(testAlertName); - await monitoringView.ruleLink.click(); - await monitoringView.wait(until.presenceOf(monitoringView.detailsHeadingRuleIcon)); - testDetailsPage('Alerting Rule Details', testAlertName, false); - - // Active Alerts list should contain a link back to the Alert details page - await monitoringView.wait(until.elementToBeClickable(monitoringView.firstAlertsListLink)); - await monitoringView.firstAlertsListLink.click(); - await monitoringView.wait(until.presenceOf(monitoringView.detailsHeadingAlertIcon)); - testDetailsPage('Alert Details', testAlertName); - }); - - it('creates a new Silence from an existing alert', async () => { - await monitoringView.actionButton.click(); - await monitoringView.wait(until.presenceOf(monitoringView.saveButton)); - await testSilenceTimeInputs(); - await monitoringView.commentTextarea.sendKeys('Test Comment'); - await monitoringView.saveButton.click(); - expect(crudView.errorMessage.isPresent()).toBe(false); - - // After creating the Silence, should be redirected to its details page - await monitoringView.wait(until.presenceOf(monitoringView.detailsHeadingSilenceIcon)); - testDetailsPage('Silence Details', testAlertName); - }); - - it('shows the silenced Alert in the Silenced Alerts list', async () => { - await monitoringView.wait(until.elementToBeClickable(monitoringView.firstAlertsListLink)); - expect(monitoringView.firstAlertsListLink.getText()).toContain(testAlertName); - - // Click the link to navigate back to the Alert details link - await monitoringView.firstAlertsListLink.click(); - await monitoringView.wait(until.presenceOf(monitoringView.detailsHeadingAlertIcon)); - testDetailsPage('Alert Details', testAlertName); - }); - - // comment out failing tests, to be replaced by cypress test/pr - xit('shows the newly created Silence in the Silenced By list', async () => { - await monitoringView.wait( - until.elementToBeClickable(firstElementByTestID('silence-resource-link')), - ); - expect(firstElementByTestID('silence-resource-link').getText()).toContain(testAlertName); - - // Click the link to navigate back to the Silence details page - await firstElementByTestID('silence-resource-link').click(); - await monitoringView.wait(until.presenceOf(monitoringView.detailsHeadingSilenceIcon)); - testDetailsPage('Silence Details', testAlertName); - }); - - xit('expires the Silence', async () => { - await crudView.clickDetailsPageAction('Expire Silence'); - await monitoringView.wait(until.elementToBeClickable(monitoringView.modalConfirmButton)); - await monitoringView.modalConfirmButton.click(); - await monitoringView.wait(until.presenceOf(monitoringView.expiredSilenceIcon)); - }); -}); - -describe('Monitoring: Silences', () => { - afterEach(() => { - checkLogs(); - checkErrors(); - }); - - it('displays the Silences list page', async () => { - await sidenavView.clickNavLink(['Monitoring', 'Alerting']); - await crudView.isLoaded(); - await horizontalnavView.clickHorizontalTab('Silences'); - await monitoringView.wait(until.presenceOf(monitoringView.createButton)); - }); - - it('does not have a namespace dropdown', async () => { - expect(namespaceView.namespaceSelector.isPresent()).toBe(false); - }); - - it('creates a new Silence', async () => { - await monitoringView.createButton.click(); - await monitoringView.wait(until.presenceOf(monitoringView.matcherNameInput)); - await testSilenceTimeInputs(); - await monitoringView.matcherNameInput.sendKeys('alertname'); - await monitoringView.matcherValueInput.sendKeys(testAlertName); - await monitoringView.commentTextarea.sendKeys('Test Comment'); - await monitoringView.saveButton.click(); - expect(crudView.errorMessage.isPresent()).toBe(false); - }); - - // After creating the Silence, should be redirected to its details page - it('displays detail view for new Silence', async () => { - await monitoringView.wait(until.presenceOf(monitoringView.detailsHeadingSilenceIcon)); - testDetailsPage('Silence Details', testAlertName); - }); - - it('filters Silences by name', async () => { - await sidenavView.clickNavLink(['Monitoring', 'Alerting']); - await crudView.isLoaded(); - await horizontalnavView.clickHorizontalTab('Silences'); - await monitoringView.wait(until.elementToBeClickable(crudView.nameFilter)); - await crudView.nameFilter.sendKeys(testAlertName); - expect(firstElementByTestID('silence-resource-link').getText()).toContain(testAlertName); - }); - - it('displays Silence details page', async () => { - await monitoringView.wait( - until.elementToBeClickable(firstElementByTestID('silence-resource-link')), - ); - expect(firstElementByTestID('silence-resource-link').getText()).toContain(testAlertName); - await firstElementByTestID('silence-resource-link').click(); - await monitoringView.wait(until.presenceOf(monitoringView.detailsHeadingSilenceIcon)); - testDetailsPage('Silence Details', testAlertName); - }); - - it('edits the Silence', async () => { - await crudView.clickDetailsPageAction('Edit Silence'); - await monitoringView.wait(until.presenceOf(monitoringView.commentTextarea)); - await monitoringView.commentTextarea.sendKeys(' (edited)'); - await monitoringView.saveButton.click(); - expect(crudView.errorMessage.isPresent()).toBe(false); - - // After editing the Silence, should be redirected to its details page, where we check that the edit is reflected - await monitoringView.wait(until.presenceOf(monitoringView.detailsHeadingSilenceIcon)); - testDetailsPage('Silence Details', testAlertName); - expect(monitoringView.silenceComment.getText()).toEqual('Test Comment (edited)'); - }); - - xit('expires the Silence', async () => { - await sidenavView.clickNavLink(['Monitoring', 'Alerting']); - await crudView.isLoaded(); - await horizontalnavView.clickHorizontalTab('Silences'); - await crudView.nameFilter.sendKeys(testAlertName); - const row = crudView.rowForName(testAlertName); - await monitoringView.wait(until.presenceOf(row)); - await crudView.clickKebabAction(testAlertName, 'Expire Silence'); - await monitoringView.wait(until.elementToBeClickable(monitoringView.modalConfirmButton)); - await monitoringView.modalConfirmButton.click(); - await monitoringView.wait(until.not(until.presenceOf(row))); - }); -}); - describe('Alertmanager: YAML', () => { afterEach(() => { checkLogs(); diff --git a/frontend/integration-tests/views/monitoring.view.ts b/frontend/integration-tests/views/monitoring.view.ts index 71568008d2..46010ff379 100644 --- a/frontend/integration-tests/views/monitoring.view.ts +++ b/frontend/integration-tests/views/monitoring.view.ts @@ -6,38 +6,9 @@ import { firstElementByTestID } from '../protractor.conf'; export const wait = async (condition) => await browser.wait(condition, 20000); -// List pages -export const listPageHeading = $('.co-m-pane__heading'); -export const createButton = $('.co-m-pane__createLink--no-title button'); - -// Details pages -export const actionButton = $('.co-m-nav-title .pf-m-primary.co-action-buttons__btn'); -export const detailsHeading = $('.co-m-nav-title .co-resource-item'); -export const detailsHeadingAlertIcon = $('.co-m-nav-title .co-m-resource-alert'); -export const detailsHeadingRuleIcon = $('.co-m-nav-title .co-m-resource-alertrule'); -export const detailsHeadingSilenceIcon = $('.co-m-nav-title .co-m-resource-silence'); -export const detailsSubHeadings = $$('.co-m-pane__body h2'); export const labels = $$('.co-m-label'); -export const expiredSilenceIcon = $('.co-m-pane__details [data-test-id="ban-icon"]'); -export const ruleLink = $('.co-m-pane__details .co-resource-item__resource-name'); -export const silenceComment = $$('.co-m-pane__details dd').get(-2); -export const firstAlertsListLink = $$('.co-resource-list__item a.co-resource-item').first(); - -// Silence form -export const matcherNameInput = $('input[placeholder=Name]'); -export const matcherValueInput = $('input[placeholder=Value]'); -export const silenceStartNowCheckbox = $('input[type=checkbox]'); -export const silenceStartsAtInput = $$('[data-test-id="datetime"]').get(0); -export const silenceEndsAtInput = $$('[data-test-id="datetime"]').get(1); -export const silenceDurationMenuButton = $('[data-test-id="dropdown-button"]'); -export const silenceDurationOption = (duration: string) => - $(`[data-test-dropdown-menu="${duration}"]`); -export const commentTextarea = $('textarea'); export const saveButton = $('button[type=submit]'); -// Modal -export const modalConfirmButton = $('#confirm-action'); - // YAML form export const successAlert = $('.pf-m-success'); diff --git a/frontend/package.json b/frontend/package.json index 5b588ccaed..3aee965429 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -26,6 +26,8 @@ "test-gui-openshift": "yarn run test-suite --suite crud --params.openshift true", "test-gui": "yarn run test-suite --suite all", "test-suite": "ts-node -O '{\"module\":\"commonjs\"}' ./node_modules/.bin/protractor integration-tests/protractor.conf.ts", + "test-cypress": "cd packages/integration-tests-cypress && cypress open --env openshift=true", + "test-cypress-headless": "cd packages/integration-tests-cypress && node --max-old-space-size=4096 ../../node_modules/.bin/cypress run --env openshift=true --browser chrome --headless", "debug-test-suite": "TS_NODE_COMPILER_OPTIONS='{\"module\":\"commonjs\"}' node -r ts-node/register --inspect-brk ./node_modules/.bin/protractor integration-tests/protractor.conf.ts", "analyze": "NODE_ENV=production NODE_OPTIONS=--max-old-space-size=4096 ts-node -O '{\"module\":\"commonjs\"}' ./node_modules/.bin/webpack --mode=production --profile --json | awk '{if(NR>2)print}' > public/dist/stats.json && ts-node -O '{\"module\":\"commonjs\"}' ./node_modules/.bin/webpack-bundle-analyzer --mode static -r public/dist/report.html public/dist/stats.json", "prettier-all": "prettier --write '**/*.{js,jsx,ts,tsx,json}'", @@ -55,6 +57,10 @@ "transformIgnorePatterns": [ "/node_modules/(?!(lodash-es|@console|@novnc|@spice-project|@popperjs)/.*)" ], + "testPathIgnorePatterns": [ + "/node_modules/", + "/packages/integration-tests-cypress" + ], "testRegex": ".*\\.spec\\.(ts|tsx|js|jsx)$", "testURL": "http://localhost", "setupFiles": [ @@ -164,6 +170,7 @@ "@graphql-codegen/typescript-graphql-files-modules": "^1.15.1", "@graphql-codegen/typescript-operations": "^1.15.1", "@pmmmwh/react-refresh-webpack-plugin": "^0.4.1", + "@cypress/webpack-preprocessor": "^4.1.3", "@types/classnames": "^2.2.7", "@types/enzyme": "3.10.x", "@types/glob": "7.x", @@ -191,9 +198,13 @@ "chromedriver": "77.x", "circular-dependency-plugin": "5.x", "css-loader": "0.28.x", + "cypress": "^4.3.0", + "cypress-jest-adapter": "^0.1.1", + "cypress-multi-reporters": "^1.4.0", "enzyme": "3.10.x", "enzyme-adapter-react-16": "1.15.2", "eslint-plugin-graphql": "^4.0.0", + "eslint-plugin-cypress": "^2.10.3", "file-loader": "1.x", "find-up": "4.x", "fork-ts-checker-webpack-plugin": "0.x", @@ -212,6 +223,7 @@ "lint-staged": "^10.2.2", "mini-css-extract-plugin": "0.4.x", "mock-socket": "^9.0.3", + "mocha-junit-reporter": "^1.23.3", "moment": "2.22.x", "monaco-editor-core": "0.14.0", "monaco-editor-webpack-plugin": "^1.7.0", @@ -243,7 +255,8 @@ "resolutions": { "jquery": "3.5.1", "victory-shared-events": "34.3.8", - "minimist": "1.2.5" + "minimist": "1.2.5", + "@types/jest": "21.x" }, "husky": { "hooks": { diff --git a/frontend/packages/integration-tests-cypress/.eslintrc b/frontend/packages/integration-tests-cypress/.eslintrc new file mode 100644 index 0000000000..3012548910 --- /dev/null +++ b/frontend/packages/integration-tests-cypress/.eslintrc @@ -0,0 +1,11 @@ +{ + "env": { + "cypress/globals": true, + "node": true + }, + "extends": [ + "../.eslintrc", + "plugin:cypress/recommended" + ], + "plugins": ["cypress"] +} diff --git a/frontend/packages/integration-tests-cypress/README.md b/frontend/packages/integration-tests-cypress/README.md new file mode 100644 index 0000000000..3d48ffad5c --- /dev/null +++ b/frontend/packages/integration-tests-cypress/README.md @@ -0,0 +1,51 @@ +#### Getting Started +- [What is Cypress?](https://www.youtube.com/watch?v=dr10Z-HpsCQ) (video) +- [Cypress in a Nutshell](https://www.youtube.com/watch?v=LcGHiFnBh3Y) (video) + +#### Best Practices +- Each it() should be its own atomic test (run independently of other tests). Each it() should likely start with +cy.visit() or nav to page +- We are switching over our test ids from `< ... data-test-id=".."/>` to using the Cypress preferred +`< ... data-test=".."/>`. This allows us to better take advantage of certain Cypress tooling, like the +[Selector Playground](https://docs.cypress.io/guides/core-concepts/test-runner.html#Selector-Playground) +- Use [Cypress's Best Practices for Selecting Elements](https://docs.cypress.io/guides/references/best-practices.html) + +#### Migrating Protractor tests to Cypress + +When migrating a test suite from Protractor to Cypress, the following steps are recommended: +1. Create the new test suite in Cypress +2. If you need to create a new test id use the `data-test` attribute and the `cy.byTestID()` helper method. +If you need to access the legacy `data-test-id` attribute, use the `cy.byLegacyTestID()` helper method. +3. Remove test suite from Protractor + +#### Directory Structure +``` +frontend/packages/integration-tests-cypress/ +├── support <--- add commands to Cypress 'cy.' global, other support configurations +│   ├── index.ts +│   ├── nav.ts +│   ├── project.ts +│   ├── README.md +│   └── selectors.ts +├── fixtures <--- mock data +│   └── example.json +├── plugins +│   └── index.js <--- webpack-preprocessor, enviornment variables, baseUrl, custom tasks +├── tests <--- test suites +│   ├── crud +│   │   └── namespace-crud.spec.ts +│   └── monitoring +│   └── monitoring.spec.ts +└── views <--- helper objects containing assertions and commands + ├── details-page.ts + ├── list-page.ts + ├── form.ts + └── modal.ts +``` + +#### Additional Resources +- [Assertions](https://docs.cypress.io/guides/references/assertions.html#Chai) +- [Debugging](https://docs.cypress.io/guides/guides/debugging.html#Using-debugger) +- [Cypress.io docs](https://docs.cypress.io/guides/core-concepts/introduction-to-cypress.html#Cypress-Can-Be-Simple-Sometimes) +- [Cypress.io Recipes](https://docs.cypress.io/examples/examples/recipes.html#Fundamentals) + diff --git a/frontend/packages/integration-tests-cypress/cypress.json b/frontend/packages/integration-tests-cypress/cypress.json new file mode 100644 index 0000000000..2f39ddf29e --- /dev/null +++ b/frontend/packages/integration-tests-cypress/cypress.json @@ -0,0 +1,14 @@ +{ + "integrationFolder": "tests", + "screenshotsFolder": "../../gui_test_screenshots/cypress/screenshots", + "videosFolder": "../../gui_test_screenshots/cypress/videos", + "video": true, + "reporter": "../../node_modules/cypress-multi-reporters", + "reporterOptions": { + "configFile": "reporter-config.json" + }, + "supportFile": "support/index.ts", + "pluginsFile": "plugins/index.js", + "fixturesFolder": "fixtures", + "defaultCommandTimeout": 30000 +} diff --git a/frontend/packages/integration-tests-cypress/fixtures/example.json b/frontend/packages/integration-tests-cypress/fixtures/example.json new file mode 100644 index 0000000000..da18d9352a --- /dev/null +++ b/frontend/packages/integration-tests-cypress/fixtures/example.json @@ -0,0 +1,5 @@ +{ + "name": "Using fixtures to represent data", + "email": "hello@cypress.io", + "body": "Fixtures are a great way to mock data for responses to routes" +} \ No newline at end of file diff --git a/frontend/packages/integration-tests-cypress/package.json b/frontend/packages/integration-tests-cypress/package.json new file mode 100644 index 0000000000..a914e26a62 --- /dev/null +++ b/frontend/packages/integration-tests-cypress/package.json @@ -0,0 +1,6 @@ +{ + "name": "@console/cypress-integration-tests", + "version": "0.0.0-fixed", + "description": "Console integration tests to be moved into appropriate packages", + "private": true +} diff --git a/frontend/packages/integration-tests-cypress/plugins/index.js b/frontend/packages/integration-tests-cypress/plugins/index.js new file mode 100644 index 0000000000..c4d78fab29 --- /dev/null +++ b/frontend/packages/integration-tests-cypress/plugins/index.js @@ -0,0 +1,51 @@ +// This function is called when a project is opened or re-opened (e.g. due to +// the project's config changing) +const wp = require('@cypress/webpack-preprocessor'); + +module.exports = (on, config) => { + const options = { + webpackOptions: { + resolve: { + extensions: ['.ts', '.tsx', '.js'], + }, + module: { + rules: [ + { + test: /\.tsx?$/, + loader: 'ts-loader', + options: { transpileOnly: true }, + }, + ], + }, + }, + }; + // `on` is used to hook into various events Cypress emits + on('task', { + log(message) { + // eslint-disable-next-line no-console + console.log(message); + return null; + }, + logError(message) { + // eslint-disable-next-line no-console + console.error(message); + return null; + }, + }); + on('file:preprocessor', wp(options)); + /* In a Docker container, the default size of the /dev/shm shared memory space is 64MB. This is not typically enough + to run Chrome and can cause the browser to crash. You can fix this by passing the --disable-dev-shm-usage flag to + Chrome with the following workaround: */ + on('before:browser:launch', (browser = {}, launchOptions) => { + if (browser.family === 'chromium' && browser.name !== 'electron') { + launchOptions.args.push('--disable-dev-shm-usage'); + } + return launchOptions; + }); + // `config` is the resolved Cypress config + config.baseUrl = `${process.env.BRIDGE_BASE_ADDRESS || 'http://localhost:9000'}${( + process.env.BRIDGE_BASE_PATH || '/' + ).replace(/\/$/, '')}`; + config.env.BRIDGE_KUBEADMIN_PASSWORD = process.env.BRIDGE_KUBEADMIN_PASSWORD; + return config; +}; diff --git a/frontend/packages/integration-tests-cypress/reporter-config.json b/frontend/packages/integration-tests-cypress/reporter-config.json new file mode 100644 index 0000000000..9f1db2eac2 --- /dev/null +++ b/frontend/packages/integration-tests-cypress/reporter-config.json @@ -0,0 +1,7 @@ +{ + "reporterEnabled": "spec, mocha-junit-reporter", + "mochaJunitReporterReporterOptions": { + "mochaFile": "../../gui_test_screenshots/cypress-output-[hash].xml", + "toConsole": false + } +} diff --git a/frontend/packages/integration-tests-cypress/support/README.md b/frontend/packages/integration-tests-cypress/support/README.md new file mode 100644 index 0000000000..7f34beb38f --- /dev/null +++ b/frontend/packages/integration-tests-cypress/support/README.md @@ -0,0 +1,15 @@ +# Cypress Custom Commands + +Cypress comes with its own API for creating custom commands. This allows adding custom commands to the +Cypress global `cy` variable. + +Ex: `cy.createTestProject('test-qbvzv')` + +Custom commands work well when you’re needing to describe behavior that’s desirable across all of your tests. +Examples would be a cy.setup() or cy.login() or extending your application’s behavior like cy.get('.dropdown').dropdown('Apples'). +These are specific to your application and can be used everywhere. + +However, this pattern can be used and abused. Let’s not forget - writing Cypress tests is JavaScript, and +it’s often more efficient to write a function for repeatable behavior that’s specific to only a single spec file. + +Please follow [Cypress Best Practices for Custom Commands](https://docs.cypress.io/api/cypress-api/custom-commands.html#Best-Practices) diff --git a/frontend/packages/integration-tests-cypress/support/index.ts b/frontend/packages/integration-tests-cypress/support/index.ts new file mode 100644 index 0000000000..6ac9b195db --- /dev/null +++ b/frontend/packages/integration-tests-cypress/support/index.ts @@ -0,0 +1,39 @@ +import './login'; +import './project'; +import './selectors'; +import './nav'; +import './resources'; +import 'cypress-jest-adapter'; + +Cypress.Cookies.defaults({ + whitelist: ['openshift-session-token', 'csrf-token'], +}); + +export const checkErrors = () => + cy.window().then((win) => { + if (win.windowError) { + throw new Error(`window/js runtime error detected: ${win.windowError}`); + } + }); + +export const testName = `test-${Math.random() + .toString(36) + .replace(/[^a-z]+/g, '') + .substr(0, 5)}`; + +export const actions = Object.freeze({ + labels: 'Edit Labels', + annotations: 'Edit Annotations', + edit: 'Edit', + delete: 'Delete', +}); + +const actionOnKind = (action: string, kind: string) => { + const humanizedKind = (kind.includes('~') ? kind.split('~')[2] : kind) + .split(/(?=[A-Z])/) + .join(' '); + + return `${action} ${humanizedKind}`; +}; +export const editHumanizedKind = (kind: string) => actionOnKind(actions.edit, kind); +export const deleteHumanizedKind = (kind: string) => actionOnKind(actions.delete, kind); diff --git a/frontend/packages/integration-tests-cypress/support/login.ts b/frontend/packages/integration-tests-cypress/support/login.ts new file mode 100644 index 0000000000..2cb145169b --- /dev/null +++ b/frontend/packages/integration-tests-cypress/support/login.ts @@ -0,0 +1,50 @@ +import { submitButton } from '../views/form'; + +declare global { + // eslint-disable-next-line @typescript-eslint/no-namespace,no-redeclare + namespace Cypress { + interface Chainable { + login(providerName?: string, username?: string, password?: string): Chainable; + logout(): Chainable; + } + } +} + +const KUBEADMIN_USERNAME = 'kubeadmin'; +const KUBEADMIN_IDP = 'kube:admin'; + +// any command added below, must be added to global Cypress interface above + +// This will add to 'login(provider, username, password)' to cy +// ex: cy.login('test', 'test', 'test') +Cypress.Commands.add('login', (provider: string, username: string, password: string) => { + // if local, no need to login + if (!Cypress.env('BRIDGE_KUBEADMIN_PASSWORD')) { + cy.task('log', 'No BRIDGE_KUBEADMIN_PASSWORD set, skipping login'); + return; + } + const idp = provider || KUBEADMIN_IDP; + cy.task('log', ` Logging into IDP ${idp}, using baseUrl ${Cypress.config('baseUrl')}`); + cy.visit(''); // visits baseUrl which is set in plugins/index.js + cy.byLegacyTestID('login').should('be.visible'); + cy.contains(idp) + .should('be.visible') + .click(); + cy.get('#inputUsername').type(username || KUBEADMIN_USERNAME); + cy.get('#inputPassword').type(password || Cypress.env('BRIDGE_KUBEADMIN_PASSWORD')); + cy.get(submitButton).click(); + cy.byTestID('user-dropdown').should('be.visible'); +}); + +Cypress.Commands.add('logout', () => { + if (!Cypress.env('BRIDGE_KUBEADMIN_PASSWORD')) { + cy.task('log', 'No BRIDGE_KUBEADMIN_PASSWORD set, skipping logout'); + return; + } + cy.task('log', ' Logging out'); + cy.byTestID('user-dropdown') + .click() + .contains('Log out') + .click(); + cy.byLegacyTestID('login').should('be.visible'); +}); diff --git a/frontend/packages/integration-tests-cypress/support/nav.ts b/frontend/packages/integration-tests-cypress/support/nav.ts new file mode 100644 index 0000000000..74b9dc983c --- /dev/null +++ b/frontend/packages/integration-tests-cypress/support/nav.ts @@ -0,0 +1,22 @@ +export {}; // needed in files which don't have an import to trigger ES6 module usage +declare global { + // eslint-disable-next-line @typescript-eslint/no-namespace,no-redeclare + namespace Cypress { + interface Chainable { + clickNavLink(path: [string, string]): Chainable; + } + } +} + +// any command added below, must be added to global Cypress interface above + +Cypress.Commands.add('clickNavLink', (path: [string, string]) => { + cy.get('#page-sidebar') + .contains(path[0]) + .click(); + if (path.length === 2) { + cy.get('#page-sidebar') + .contains(path[1]) + .click(); + } +}); diff --git a/frontend/packages/integration-tests-cypress/support/project.ts b/frontend/packages/integration-tests-cypress/support/project.ts new file mode 100644 index 0000000000..724efc1858 --- /dev/null +++ b/frontend/packages/integration-tests-cypress/support/project.ts @@ -0,0 +1,40 @@ +import { detailsPage } from '../views/details-page'; +import { listPage } from '../views/list-page'; +import { modal } from '../views/modal'; + +declare global { + // eslint-disable-next-line @typescript-eslint/no-namespace,no-redeclare + namespace Cypress { + interface Chainable { + createProject(name: string): Chainable; + deleteProject(name: string): Chainable; + } + } +} + +// any command added below, must be added to global Cypress interface above + +// This will add to 'createProject(...)' to cy +// ex: cy.createProject(name) +Cypress.Commands.add('createProject', (name: string) => { + cy.visit(`/k8s/cluster/projects`); + listPage.clickCreateYAMLbutton(); + modal.shouldBeOpened(); + cy.byTestID('input-name').type(name); + modal.submit(); + modal.shouldBeClosed(); + // TODO, switch to 'listPage.titleShouldHaveText(name)', when we switch to new test id + cy.byLegacyTestID('resource-title').should('have.text', name); +}); + +Cypress.Commands.add('deleteProject', (name: string) => { + cy.visit(`/k8s/cluster/projects/${name}`); + detailsPage.clickPageActionFromDropdown('Delete Project'); + modal.shouldBeOpened(); + modal.submitShouldBeDisabled(); + cy.byTestID('project-name-input').type(name); + modal.submitShouldBeEnabled(); + modal.submit(); + modal.shouldBeClosed(); + listPage.titleShouldHaveText('Projects'); +}); diff --git a/frontend/packages/integration-tests-cypress/support/resources.ts b/frontend/packages/integration-tests-cypress/support/resources.ts new file mode 100644 index 0000000000..a9e3cf402b --- /dev/null +++ b/frontend/packages/integration-tests-cypress/support/resources.ts @@ -0,0 +1,58 @@ +import { plural } from 'pluralize'; + +import { K8sResourceKindReference } from '@console/internal/module/k8s'; + +export {}; +declare global { + // eslint-disable-next-line @typescript-eslint/no-namespace,no-redeclare + namespace Cypress { + interface Chainable { + resourceShouldBeDeleted( + namespace: string, + resource: K8sResourceKindReference | string, + name: string, + ): Chainable; + } + } +} + +// Convert types in the `user.openshift.io~v1~Group` format to `groups.v1.useropenshift.io` +// to pass to oc. +const toCLIType = (type: K8sResourceKindReference | string): string => { + if (!type.includes('~')) { + return type; + } + const [group, version, kind] = type.split('~'); + // Resources aren't required to follow this pattern when converting from kind to plural, + // but this should work for most resources and is good enough for our tests. + return `${plural(kind.toLowerCase())}.${version}.${group}`; +}; + +// any command added below, must be added to global Cypress interface above + +Cypress.Commands.add( + 'resourceShouldBeDeleted', + (namespace: string, resource: K8sResourceKindReference | string, name: string) => + cy + .exec( + `oc get -n ${namespace} ${toCLIType( + resource, + )}/${name} -o template --template '{{.metadata.deletionTimestamp}}'`, + { failOnNonZeroExit: false }, + ) + .then((result) => { + if (result.code !== 0) { + if (result.stderr.includes('NotFound')) { + cy.log( + `'oc get -n ${namespace} ${resource}/${name}' returned 'NotFound' indicating resource was successfully deleted`, + ); + } else { + // this typically would be a 'You must be logged in to the server (Unauthorized)' + assert.fail('', '', `Error during 'oc get ${resource}/${name}', ${result.stderr} `); + } + } else { + cy.log(`expect ${resource}/${name} to have a deletionTimestamp`); + expect(result.stdout).not.toContain(``); + } + }), +); diff --git a/frontend/packages/integration-tests-cypress/support/selectors.ts b/frontend/packages/integration-tests-cypress/support/selectors.ts new file mode 100644 index 0000000000..ddc6b1e2e8 --- /dev/null +++ b/frontend/packages/integration-tests-cypress/support/selectors.ts @@ -0,0 +1,22 @@ +export {}; +declare global { + // eslint-disable-next-line @typescript-eslint/no-namespace,no-redeclare + namespace Cypress { + interface Chainable { + byTestID(selector: string): Chainable; + byTestActionID(selector: string): Chainable; + byLegacyTestID(selector: string): Chainable; + } + } +} + +// any command added below, must be added to global Cypress interface above + +Cypress.Commands.add('byTestID', (selector: string) => cy.get(`[data-test="${selector}"]`)); +Cypress.Commands.add('byTestActionID', (selector: string) => + cy.get(`[data-test-action="${selector}"]:not(.pf-m-disabled)`), +); +// depreciated! new IDs should use 'data-test', ie. `cy.byTestID(...)` +Cypress.Commands.add('byLegacyTestID', (selector: string) => + cy.get(`[data-test-id="${selector}"]`), +); diff --git a/frontend/packages/integration-tests-cypress/tests/crud/k8-openshift-cruds.spec.ts b/frontend/packages/integration-tests-cypress/tests/crud/k8-openshift-cruds.spec.ts new file mode 100644 index 0000000000..13c3ec76d6 --- /dev/null +++ b/frontend/packages/integration-tests-cypress/tests/crud/k8-openshift-cruds.spec.ts @@ -0,0 +1,169 @@ +import { OrderedMap } from 'immutable'; +import { safeLoad, safeDump } from 'js-yaml'; +import * as _ from 'lodash'; + +import { testName, editHumanizedKind, deleteHumanizedKind, checkErrors } from '../../support'; +import { listPage } from '../../views/list-page'; +import { detailsPage } from '../../views/details-page'; +import { modal } from '../../views/modal'; +import * as yamlEditor from '../../views/yaml-editor'; +import { errorMessage } from '../../views/form'; + +describe('Kubernetes resource CRUD operations', () => { + before(() => { + cy.login(); + cy.createProject(testName); + }); + + afterEach(() => { + checkErrors(); + }); + + after(() => { + cy.deleteProject(testName); + cy.logout(); + }); + + const k8sObjs = OrderedMap() + .set('pods', { kind: 'Pod' }) + .set('services', { kind: 'Service' }) + .set('serviceaccounts', { kind: 'ServiceAccount' }) + .set('secrets', { kind: 'Secret' }) + .set('configmaps', { kind: 'ConfigMap' }) + .set('persistentvolumes', { kind: 'PersistentVolume', namespaced: false }) + .set('storageclasses', { kind: 'StorageClass', namespaced: false }) + .set('ingresses', { kind: 'Ingress' }) + .set('cronjobs', { kind: 'CronJob' }) + .set('jobs', { kind: 'Job' }) + .set('daemonsets', { kind: 'DaemonSet' }) + .set('deployments', { kind: 'Deployment' }) + .set('replicasets', { kind: 'ReplicaSet' }) + .set('replicationcontrollers', { kind: 'ReplicationController' }) + .set('persistentvolumeclaims', { kind: 'PersistentVolumeClaim' }) + .set('statefulsets', { kind: 'StatefulSet' }) + .set('resourcequotas', { kind: 'ResourceQuota' }) + .set('limitranges', { kind: 'LimitRange' }) + .set('horizontalpodautoscalers', { kind: 'HorizontalPodAutoscaler' }) + .set('networkpolicies', { kind: 'NetworkPolicy' }) + .set('roles', { kind: 'Role' }); + const openshiftObjs = OrderedMap() + .set('deploymentconfigs', { kind: 'DeploymentConfig' }) + .set('buildconfigs', { kind: 'BuildConfig' }) + .set('imagestreams', { kind: 'ImageStream' }) + .set('routes', { kind: 'Route' }) + .set('user.openshift.io~v1~Group', { kind: 'user.openshift.io~v1~Group', namespaced: false }); + const serviceCatalogObjs = OrderedMap().set( + 'clusterservicebrokers', + { + kind: 'servicecatalog.k8s.io~v1beta1~ClusterServiceBroker', + namespaced: false, + }, + ); + + let testObjs = Cypress.env('openshift') === true ? k8sObjs.merge(openshiftObjs) : k8sObjs; + testObjs = Cypress.env('servicecatalog') === true ? testObjs.merge(serviceCatalogObjs) : testObjs; + const testLabel = 'automated-test-name'; + const resourcesWithCreationForm = new Set(['StorageClass', 'Route', 'PersistentVolumeClaim']); + + testObjs.forEach(({ kind, namespaced = true }, resource) => { + describe(kind, () => { + const name = `${testName}-${_.kebabCase(kind)}`; + + const createResource = () => { + cy.log(`Create ${kind}: ${name}`); + cy.visit( + `${namespaced ? `/k8s/ns/${testName}` : '/k8s/cluster'}/${resource}?name=${testName}`, + ); + if (kind === 'Secret') { + listPage.clickCreateYAMLdropdownButton(); + } else { + listPage.clickCreateYAMLbutton(); + } + if (resourcesWithCreationForm.has(kind)) { + cy.byTestID('yaml-link').click(); + } + yamlEditor.isLoaded(); + let newContent; + // get, update, and set yaml editor content. + return yamlEditor.getEditorContent().then((content) => { + newContent = _.defaultsDeep( + {}, + { metadata: { name, labels: { [testLabel]: testName } } }, + safeLoad(content), + ); + yamlEditor.setEditorContent(safeDump(newContent, { sortKeys: true })); + cy.log('creates a new resource instance'); + yamlEditor.clickSaveCreateButton(); + cy.get(errorMessage).should('not.exist'); + }); + }; + + const deleteResource = () => { + cy.log(`Delete ${name}`); + cy.visit(`${namespaced ? `/k8s/ns/${testName}` : '/k8s/cluster'}/${resource}`); + listPage.filter.byName(name); + listPage.rows.countShouldBe(1); + listPage.rows.clickKebabAction(name, deleteHumanizedKind(kind)); + modal.shouldBeOpened(); + modal.submit(); + modal.shouldBeClosed(); + cy.resourceShouldBeDeleted(testName, resource, name); + }; + + before(() => { + createResource(); + }); + + after(() => { + deleteResource(); + }); + + it('displays detail view for newly created resource instance', () => { + cy.url().should('include', `/${name}`); + detailsPage.titleShouldContain(name); + }); + + it(`displays a list view for the resource`, () => { + cy.visit( + `${namespaced ? `/k8s/ns/${testName}` : '/k8s/cluster'}/${resource}?name=${testName}`, + ); + if (namespaced) { + cy.log('has a working namespace dropdown on namespaced objects'); + listPage.projectDropdownShouldExist(); + listPage.projectDropdownShouldContain(testName); + } else { + cy.log('does not have a namespace dropdown on non-namespaced objects'); + listPage.projectDropdownShouldNotExist(); + } + }); + + it('search view displays created resource instance', () => { + cy.visit( + `/search/${ + namespaced ? `ns/${testName}` : 'all-namespaces' + }?kind=${kind}&q=${testLabel}%3d${testName}&name=${name}`, + ); + listPage.rows.shouldExist(name); + + cy.log('link to to details page'); + listPage.rows.clickRowByName(name); + cy.url().should('include', `/${name}`); + detailsPage.titleShouldContain(name); + }); + + it('edits the resource instance', () => { + cy.visit( + `/search/${ + namespaced ? `ns/${testName}` : 'all-namespaces' + }?kind=${kind}&q=${testLabel}%3d${testName}&name=${name}`, + ); + listPage.rows.clickKebabAction(name, editHumanizedKind(kind)); + if (kind !== 'Secret') { + yamlEditor.isLoaded(); + yamlEditor.clickReloadButton(); + } + yamlEditor.clickSaveCreateButton(); + }); + }); + }); +}); diff --git a/frontend/packages/integration-tests-cypress/tests/crud/namespace-crud.spec.ts b/frontend/packages/integration-tests-cypress/tests/crud/namespace-crud.spec.ts new file mode 100644 index 0000000000..6ab4206e77 --- /dev/null +++ b/frontend/packages/integration-tests-cypress/tests/crud/namespace-crud.spec.ts @@ -0,0 +1,60 @@ +import { checkErrors, testName } from '../../support'; +import { listPage } from '../../views/list-page'; +import { modal } from '../../views/modal'; + +describe('Namespace', () => { + before(() => { + cy.login(); + cy.createProject(testName); + }); + + afterEach(() => { + checkErrors(); + }); + + after(() => { + cy.deleteProject(testName); + cy.logout(); + }); + + const newName = `${testName}-ns`; + + it('lists, creates, edits labels, and deletes', () => { + const labelAppFrontend = 'app=frontend'; + cy.log('test Namespace list page'); + cy.visit('/k8s/cluster/namespaces'); + listPage.rows.shouldNotExist(newName); + listPage.filter.byName(testName); + listPage.rows.shouldExist(testName); // created via cy.createProject(testName) above + + cy.log('creates the Namespace'); + listPage.clickCreateYAMLbutton(); + modal.shouldBeOpened(); + cy.byTestID('input-name').type(newName); + modal.submit(); + modal.shouldBeClosed(); + cy.url().should('include', `/k8s/cluster/namespaces/${newName}`); + + cy.log('updates the Namespace labels'); + cy.visit('/k8s/cluster/namespaces'); + listPage.filter.byName(newName); + listPage.rows.hasLabel(newName, 'No labels'); + listPage.rows.clickKebabAction(newName, 'Edit Labels'); + modal.shouldBeOpened(); + cy.byTestID('tags-input').type(labelAppFrontend); + modal.submit(); + modal.shouldBeClosed(); + listPage.rows.hasLabel(newName, labelAppFrontend); + + cy.log('delete the Namespace'); + cy.visit('/k8s/cluster/namespaces'); + listPage.filter.byName(newName); + listPage.rows.shouldExist(newName); + listPage.rows.clickKebabAction(newName, 'Delete Namespace'); + modal.shouldBeOpened(); + cy.byTestID('project-name-input').type(newName); + modal.submit(); + modal.shouldBeClosed(); + listPage.rows.shouldNotExist(newName); + }); +}); diff --git a/frontend/packages/integration-tests-cypress/tests/monitoring/monitoring.spec.ts b/frontend/packages/integration-tests-cypress/tests/monitoring/monitoring.spec.ts new file mode 100644 index 0000000000..de6e61574e --- /dev/null +++ b/frontend/packages/integration-tests-cypress/tests/monitoring/monitoring.spec.ts @@ -0,0 +1,152 @@ +import { checkErrors, testName } from '../../support'; +import { submitButton, errorMessage } from '../../views/form'; +import { listPage } from '../../views/list-page'; +import { detailsPage } from '../../views/details-page'; +import { modal } from '../../views/modal'; + +const shouldBeWatchdogAlertDetailsPage = () => { + cy.byTestID('resource-title').contains('Watchdog'); + detailsPage.sectionHeaderShouldExist('Alert Details'); + detailsPage.labelShouldExist('alertname=Watchdog'); +}; + +const shouldBeWatchdogAlertRulesPage = () => { + cy.byTestID('resource-title').contains('Watchdog'); + detailsPage.sectionHeaderShouldExist('Alerting Rule Details'); + detailsPage.sectionHeaderShouldExist('Active Alerts'); +}; + +const shouldBeWatchdogSilencePage = () => { + cy.byTestID('resource-title').contains('Watchdog'); + detailsPage.sectionHeaderShouldExist('Silence Details'); + detailsPage.labelShouldExist('alertname=Watchdog'); +}; + +describe('Monitoring: Alerts', () => { + before(() => { + cy.login(); + cy.createProject(testName); + }); + + afterEach(() => { + checkErrors(); + }); + + after(() => { + cy.deleteProject(testName); + cy.logout(); + }); + + it('displays and filters the Alerts list page, links to detail pages', () => { + cy.log('use vert. nav. menu to goto Monitoring -> Alerting'); + cy.clickNavLink(['Monitoring', 'Alerting']); + // TODO, switch to 'listPage.titleShouldHaveText('Alerting');', when we switch to new test id + cy.byLegacyTestID('resource-title').should('have.text', 'Alerting'); + listPage.projectDropdownShouldNotExist(); + listPage.rows.shouldBeLoaded(); + + cy.log('filter Alerts'); + listPage.filter.byName('Watchdog'); + listPage.rows.countShouldBe(1); + + cy.log('drills down to Alert details page'); + listPage.rows.shouldExist('Watchdog').click(); + shouldBeWatchdogAlertDetailsPage(); + + cy.log('drill down to the Alerting Rule details page'); + cy.byTestID('alert-rules-detail-resource-link') + .contains('Watchdog') + .click(); + shouldBeWatchdogAlertRulesPage(); + + cy.log('drill back up to the Alert details page'); + // Active Alerts list should contain a link back to the Alert details page + cy.byTestID('active-alerts') + .first() + .click(); + shouldBeWatchdogAlertDetailsPage(); + }); + + it('creates and expires a Silence', () => { + cy.visit('monitoring/alerts'); + listPage.rows.shouldBeLoaded(); + cy.log('filter to Watchdog alert'); + listPage.filter.byName('Watchdog'); + listPage.rows.countShouldBe(1); + listPage.rows.shouldExist('Watchdog').click(); + shouldBeWatchdogAlertDetailsPage(); + + cy.log('silence Watchdog alert'); + // After creating the Silence, should be redirected to its details page + detailsPage.clickPageActionButton('Silence Alert'); + // launches page form + cy.byTestID('start-immediately').should('be.checked'); + cy.byTestID('from').should('have.value', 'Now'); + cy.byLegacyTestID('dropdown-button').should('contain', '2h'); + cy.byTestID('until').should('have.value', '2h from now'); + // Change duration + cy.byLegacyTestID('dropdown-button') + .click() + .get(`[data-test-dropdown-menu="1h"]`) + .click(); + cy.byTestID('until').should('have.value', '1h from now'); + // Change to not start now + cy.byTestID('start-immediately').click(); + cy.byTestID('start-immediately').should('not.be.checked'); + // Allow for some difference in times + cy.byTestID('from').should('not.have.value', 'Now'); + return cy.byTestID('from').then(($fromElement) => { + const fromText = $fromElement[0].getAttribute('value'); + expect(Date.parse(fromText) - Date.now()).toBeLessThan(10000); + // eslint-disable-next-line promise/no-nesting + return cy.byTestID('until').then(($untilElement) => { + expect(Date.parse($untilElement[0].getAttribute('value')) - Date.parse(fromText)).toEqual( + 60 * 60 * 1000, + ); + }); + }); + // Invalid start time + cy.byTestID('from').type('abc'); + cy.byTestID('until').should('have.value', '-'); + // Change to back to start now + cy.byTestID('start-immediately').click(); + cy.byTestID('start-immediately').should('be.checked'); + cy.byTestID('until').should('have.value', '1h from now'); + // Change duration back again + cy.byLegacyTestID('dropdown-button') + .click() + .get(`[data-test-dropdown-menu="2h"]`) + .click(); + cy.byTestID('until').should('have.value', '2h from now'); + // add comment and submit + cy.byTestID('silence-comment').type('test comment'); + cy.get(submitButton).click(); + cy.get(errorMessage).should('not.exist'); + shouldBeWatchdogSilencePage(); + + cy.log('shows the silenced Alert in the Silenced Alerts list'); + // Click the link to navigate back to the Alert details link + cy.byTestID('firing-alerts') + .first() + .should('have.text', 'Watchdog') + .click(); + shouldBeWatchdogAlertDetailsPage(); + + cy.log('shows the newly created Silence in the Silenced By list'); + // Click the link to navigate back to the Silence details page + cy.byLegacyTestID('silence-resource-link') + .first() + .should('have.text', 'Watchdog') + .click(); + shouldBeWatchdogSilencePage(); + + cy.log('expires the Silence'); + detailsPage.clickPageActionFromDropdown('Expire Silence'); + modal.shouldBeOpened(); + modal.submit(); + modal.shouldBeClosed(); + cy.get(errorMessage).should('not.exist'); + // wait for expiredSilenceIcon to exist + cy.byLegacyTestID('ban-icon').should('exist'); + }); +}); diff --git a/frontend/packages/integration-tests-cypress/tsconfig.json b/frontend/packages/integration-tests-cypress/tsconfig.json new file mode 100644 index 0000000000..768a717deb --- /dev/null +++ b/frontend/packages/integration-tests-cypress/tsconfig.json @@ -0,0 +1,12 @@ +{ + "extends": "../../tsconfig", + "compilerOptions": { + "baseUrl": "../../node_modules", + "types": ["cypress"] + }, + "include": [ + "../../node_modules/cypress/types", + "../../public/declarations.d.ts", + "**/*.ts" + ] +} diff --git a/frontend/packages/integration-tests-cypress/views/details-page.ts b/frontend/packages/integration-tests-cypress/views/details-page.ts new file mode 100644 index 0000000000..6ea250d88f --- /dev/null +++ b/frontend/packages/integration-tests-cypress/views/details-page.ts @@ -0,0 +1,15 @@ +export const detailsPage = { + titleShouldContain: (title: string) => cy.byLegacyTestID('resource-title').contains(title), + sectionHeaderShouldExist: (sectionHeading: string) => + cy.get(`[data-test-section-heading="${sectionHeading}"]`).should('exist'), + labelShouldExist: (labelName: string) => cy.byTestID('label-list').contains(labelName), + clickPageActionFromDropdown: (actionID: string) => { + cy.byLegacyTestID('actions-menu-button').click(); + cy.byTestActionID(actionID).click(); + }, + clickPageActionButton: (action: string) => { + cy.byLegacyTestID('details-actions') + .contains(action) + .click(); + }, +}; diff --git a/frontend/packages/integration-tests-cypress/views/form.ts b/frontend/packages/integration-tests-cypress/views/form.ts new file mode 100644 index 0000000000..df9bfc176a --- /dev/null +++ b/frontend/packages/integration-tests-cypress/views/form.ts @@ -0,0 +1,3 @@ +export const submitButton = 'button[type=submit]'; +export const errorMessage = '.pf-c-alert.pf-m-inline.pf-m-danger'; +export const successMessage = '.pf-c-alert.pf-m-inline.pf-m-success'; diff --git a/frontend/packages/integration-tests-cypress/views/list-page.ts b/frontend/packages/integration-tests-cypress/views/list-page.ts new file mode 100644 index 0000000000..f672353c9e --- /dev/null +++ b/frontend/packages/integration-tests-cypress/views/list-page.ts @@ -0,0 +1,58 @@ +export const listPage = { + titleShouldHaveText: (title: string) => + cy.byLegacyTestID('resource-title').should('have.text', title), + projectDropdownShouldExist: () => cy.byLegacyTestID('namespace-bar-dropdown').should('exist'), + projectDropdownShouldContain: (name: string) => + cy.byLegacyTestID('namespace-bar-dropdown').contains(name), + projectDropdownShouldNotExist: () => + cy.byLegacyTestID('namespace-bar-dropdown').should('not.exist'), + clickCreateYAMLdropdownButton: () => { + return cy + .byLegacyTestID('dropdown-button') + .click() + .get('body') + .then(($body) => { + if ($body.find(`[data-test-dropdown-menu="yaml"]`).length) { + cy.get(`[data-test-dropdown-menu="yaml"]`).click(); + } + }); + }, + clickCreateYAMLbutton: () => { + return cy.byTestID('yaml-create').click(); + }, + filter: { + byName: (name: string) => { + cy.byLegacyTestID('item-filter').type(name); + }, + }, + rows: { + shouldBeLoaded: () => { + cy.get(`[data-test-rows="resource-row"`).should('be.visible'); + }, + countShouldBe: (count: number) => { + cy.get(`[data-test-rows="resource-row"`).should('have.length', count); + }, + clickKebabAction: (resourceName: string, actionName: string) => { + cy.get(`[data-test-rows="resource-row"]`) + .contains(resourceName) + .byLegacyTestID('kebab-button') + .click(); + cy.byTestActionID(actionName).click(); + }, + hasLabel: (resourceName: string, label: string) => { + cy.get(`[data-test-rows="resource-row"]`) + .contains(resourceName) + .byTestID('label-list') + .contains(label); + }, + shouldExist: (resourceName: string) => + cy.get(`[data-test-rows="resource-row"]`).contains(resourceName), + clickRowByName: (resourceName: string) => + cy + .get(`[data-test-rows="resource-row"]`) + .contains(resourceName) + .click(), + shouldNotExist: (resourceName: string) => + cy.get(`[data-test-id="${resourceName}"]`, { timeout: 90000 }).should('not.be.visible'), + }, +}; diff --git a/frontend/packages/integration-tests-cypress/views/modal.ts b/frontend/packages/integration-tests-cypress/views/modal.ts new file mode 100644 index 0000000000..43546660a1 --- /dev/null +++ b/frontend/packages/integration-tests-cypress/views/modal.ts @@ -0,0 +1,9 @@ +import { submitButton } from './form'; + +export const modal = { + shouldBeOpened: () => cy.byLegacyTestID('modal-cancel-action').should('be.visible'), + shouldBeClosed: () => cy.byLegacyTestID('modal-cancel-action').should('not.be.visible'), + submitShouldBeDisabled: () => cy.get(submitButton).should('be', 'disabled'), + submitShouldBeEnabled: () => cy.get(submitButton).should('not.be', 'disabled'), + submit: () => cy.get(submitButton).click(), +}; diff --git a/frontend/packages/integration-tests-cypress/views/yaml-editor.ts b/frontend/packages/integration-tests-cypress/views/yaml-editor.ts new file mode 100644 index 0000000000..ee95009284 --- /dev/null +++ b/frontend/packages/integration-tests-cypress/views/yaml-editor.ts @@ -0,0 +1,15 @@ +export const getEditorContent = () => { + return cy.window().then((win: any) => { + return win.monaco.editor.getModels()[0].getValue(); + }); +}; + +export const setEditorContent = (text: string) => { + return cy.window().then((win: any) => { + win.monaco.editor.getModels()[0].setValue(text); + }); +}; + +export const isLoaded = () => cy.window().should('have.property', 'yamlEditorReady', true); +export const clickSaveCreateButton = () => cy.byTestID('save-changes').click(); +export const clickReloadButton = () => cy.byTestID('reload-object').click(); diff --git a/frontend/public/components/edit-yaml.jsx b/frontend/public/components/edit-yaml.jsx index e2b7fe66b8..ab122dc2ac 100644 --- a/frontend/public/components/edit-yaml.jsx +++ b/frontend/public/components/edit-yaml.jsx @@ -173,6 +173,9 @@ export const EditYAML_ = connect(stateToProps)( loadYaml(reload = false, obj = this.props.obj) { if (this.state.initialized && !reload) { + if (window.Cypress) { + window.yamlEditorReady = true; + } return; } @@ -525,6 +528,7 @@ export const EditYAML_ = connect(stateToProps)( type="submit" variant="primary" id="save-changes" + data-test="save-changes" onClick={() => this.save()} > Create @@ -535,6 +539,7 @@ export const EditYAML_ = connect(stateToProps)( type="submit" variant="primary" id="save-changes" + data-test="save-changes" onClick={() => this.save()} > Save @@ -545,12 +550,18 @@ export const EditYAML_ = connect(stateToProps)( type="submit" variant="secondary" id="reload-object" + data-test="reload-object" onClick={() => this.reload()} > Reload )} - {download && ( diff --git a/frontend/public/components/factory/list-page.jsx b/frontend/public/components/factory/list-page.jsx index b79366b29b..c858ecd756 100644 --- a/frontend/public/components/factory/list-page.jsx +++ b/frontend/public/components/factory/list-page.jsx @@ -200,7 +200,7 @@ export const FireMan_ = connect(null, { filterList })( if (createProps.to) { createLink = ( - @@ -222,7 +222,7 @@ export const FireMan_ = connect(null, { filterList })( } else { createLink = (
-
diff --git a/frontend/public/components/factory/modal.tsx b/frontend/public/components/factory/modal.tsx index 0ee669a005..bf80635fde 100644 --- a/frontend/public/components/factory/modal.tsx +++ b/frontend/public/components/factory/modal.tsx @@ -136,11 +136,23 @@ export const ModalSubmitFooter: React.SFC = ({ {cancelText || 'Cancel'} {submitDanger ? ( - ) : ( - )} diff --git a/frontend/public/components/modals/create-namespace-modal.jsx b/frontend/public/components/modals/create-namespace-modal.jsx index aaaef6f792..b9192b8e5a 100644 --- a/frontend/public/components/modals/create-namespace-modal.jsx +++ b/frontend/public/components/modals/create-namespace-modal.jsx @@ -122,6 +122,7 @@ const CreateNamespaceModal = connect(

-
+
-
+
Labels
{_.isEmpty(labels) ? ( @@ -633,7 +633,7 @@ export const AlertsDetailsPage = withFallback( {_.get(rule, 'name')} @@ -681,7 +681,7 @@ const ActiveAlerts = ({ alerts, ruleID }) => ( {_.sortBy(alerts, alertDescription).map((a, i) => (
- + {alertDescription(a)}
@@ -736,7 +736,7 @@ export const AlertRulesDetailsPage = withFallback( ]} />

-
+
{_.sortBy(alerts, alertDescription).map((a, i) => (
- + {a.labels.alertname}
{alertDescription(a)}
@@ -878,7 +882,7 @@ const SilencesDetailsPage = withFallback( ]} />

-
+
)}
Matchers
-
+
{_.isEmpty(matchers) ? (
No matchers
) : ( diff --git a/frontend/public/components/monitoring/silence-form.tsx b/frontend/public/components/monitoring/silence-form.tsx index 7328062e13..b455e56ab7 100644 --- a/frontend/public/components/monitoring/silence-form.tsx +++ b/frontend/public/components/monitoring/silence-form.tsx @@ -185,9 +185,10 @@ const SilenceForm_: React.FC = ({ defaults, Info, title }) =>
{isStartNow ? ( - + ) : ( setStartsAt(v)} value={startsAt} @@ -207,12 +208,14 @@ const SilenceForm_: React.FC = ({ defaults, Info, title }) => {duration === durationOff ? ( setEndsAt(v)} value={endsAt} /> ) : ( @@ -222,6 +225,7 @@ const SilenceForm_: React.FC = ({ defaults, Info, title }) =>
diff --git a/frontend/public/components/routes/create-route.tsx b/frontend/public/components/routes/create-route.tsx index dc5a0860a4..cc961b1938 100644 --- a/frontend/public/components/routes/create-route.tsx +++ b/frontend/public/components/routes/create-route.tsx @@ -355,7 +355,12 @@ export class CreateRoute extends React.Component<{}, CreateRouteState> {

{title}
- + Edit YAML
diff --git a/frontend/public/components/secrets/create-secret.tsx b/frontend/public/components/secrets/create-secret.tsx index e8345e9865..e32095a66a 100644 --- a/frontend/public/components/secrets/create-secret.tsx +++ b/frontend/public/components/secrets/create-secret.tsx @@ -253,6 +253,7 @@ export const withSecretForm = (SubForm, modal?: boolean) =>