Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Create E2E tests for cluster CRUD operations #6284

Merged
merged 19 commits into from
Aug 17, 2022
Merged
Show file tree
Hide file tree
Changes from 18 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion .vscode/settings.json
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,9 @@
"pvcs",
"testid",
"tolerations",
"userpreferences",
"virtualmachine",
"vuex"
"vuex",
"whatsnew"
],
}
122 changes: 122 additions & 0 deletions cypress/e2e/tests/pages/cluster-manager.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,122 @@
const { baseUrl } = Cypress.config();
richard-cox marked this conversation as resolved.
Show resolved Hide resolved
const clusterManagerPath = `${ baseUrl }/c/local/manager/provisioning.cattle.io.cluster`;
const clusterRequestBase = `${ baseUrl }/v1/provisioning.cattle.io.clusters/fleet-default`;
const timestamp = +new Date();
const clusterNamePartial = `e2e-test-create`;
const clusterName = `${ clusterNamePartial }-${ timestamp }`;
const clusterNameImport = `${ clusterNamePartial }-${ timestamp }-import`;

describe('Cluster Manager', () => {
beforeEach(() => {
cy.login();
});

it('can create new RKE2 custom cluster', () => {
cy.userPreferences();
cy.visit(clusterManagerPath);
cy.getId('cluster-manager-list-create').click();
cy.getId('cluster-manager-create-rke-switch').click();
cy.getId('cluster-manager-create-grid-2-0').click();
cy.getId('name-ns-description-name').type(clusterName);
cy.getId('rke2-custom-create-save').click();

cy.url().should('include', `${ clusterManagerPath }/fleet-default/${ clusterName }#registration`);
});

it('can create new imported generic cluster', () => {
cy.visit(clusterManagerPath);
cy.getId('cluster-manager-list-import').click();
cy.getId('cluster-manager-create-grid-1-0').click();
cy.getId('name-ns-description-name').type(clusterNameImport);
cy.getId('cluster-manager-import-save').click();

cy.url().should('include', `${ clusterManagerPath }/fleet-default/${ clusterNameImport }#registration`);
});

it('can see cluster details', () => {
richard-cox marked this conversation as resolved.
Show resolved Hide resolved
cy.visit(clusterManagerPath);
// Click action menu button for the cluster row within the table matching given name
cy.contains(clusterName).parent().parent().parent()
.within(() => cy.getId('-action-button', '$').click());
cy.getId('action-menu-0-item').click();

cy.contains(`Custom - ${ clusterName }`).should('exist');
});

it('can navigate to local cluster explore product', () => {
const clusterName = 'local';

cy.visit(clusterManagerPath);
// Click explore button for the cluster row within the table matching given name
cy.contains(clusterName).parent().parent().parent()
.within(() => cy.getId('cluster-manager-list-explore-management').click());

cy.url().should('include', `/c/${ clusterName }/explorer`);
});

it('can edit RKE2 custom cluster and see changes afterwards', () => {
cy.intercept('PUT', `${ clusterRequestBase }/${ clusterName }`).as('saveRequest');

cy.visit(clusterManagerPath);
// Click action menu button for the cluster row within the table matching given name
cy.contains(clusterName).parent().parent().parent()
.within(() => cy.getId('-action-button', '$').click());
cy.getId('action-menu-0-item').click();
cy.getId('name-ns-description-description').type(clusterName);
cy.getId('rke2-custom-create-save').click();

cy.wait('@saveRequest').then(() => {
cy.visit(`${ clusterManagerPath }/fleet-default/${ clusterName }?mode=edit#basic`);
cy.getId('name-ns-description-description').find('input').should('have.value', clusterName);
});
});

it('can view RKE2 cluster YAML editor', () => {
cy.visit(clusterManagerPath);
// Click action menu button for the cluster row within the table matching given name
cy.contains(clusterName).parent().parent().parent()
.within(() => cy.getId('-action-button', '$').click());
cy.getId('action-menu-1-item').click();
cy.getId('yaml-editor-code-mirror').contains(clusterName);
});

it('can delete cluster', () => {
cnotv marked this conversation as resolved.
Show resolved Hide resolved
cy.intercept('DELETE', `${ clusterRequestBase }/${ clusterName }`).as('deleteRequest');

cy.visit(clusterManagerPath);
// Click action menu button for the cluster row within the table matching given name
cy.contains(clusterName).as('rowCell').parent().parent()
.parent()
.within(() => cy.getId('-action-button', '$').click());
cy.getId('action-menu-4-item').click();
cy.getId('prompt-remove-input').type(clusterName);
cy.getId('prompt-remove-confirm-button').click();

cy.wait('@deleteRequest').then(() => {
cy.get('@rowCell').should('not.exist');
});
});

it('can delete multiple clusters', () => {
cy.intercept('DELETE', `${ clusterRequestBase }/${ clusterNameImport }`).as('deleteRequest');

cy.visit(clusterManagerPath);
// Get row from a given name
cy.contains(clusterNameImport).as('rowCell')
// Click checkbox for the cluster row within the table matching given name
.parent().parent()
.parent()
.within(() => cy.getId('-checkbox', '$').click({ multiple: true }));
// Single buttons are replaced with action menu on mobile
cy.getId('sortable-table-promptRemove').click({ force: true });
cy.get('@rowCell').then((row) => {
// In the markdown we have ALWAYS whitespace
cy.getId('prompt-remove-input').type(row.text().trim());
});
cy.getId('prompt-remove-confirm-button').click();

cy.wait('@deleteRequest').then(() => {
cy.get('@rowCell').should('not.exist');
});
});
});
12 changes: 10 additions & 2 deletions cypress/support/commands.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { LoginPagePo } from '@/cypress/e2e/po/pages/login-page.po';
import { Matcher } from '~/cypress/support/types';

/**
* Login local authentication, including first login and bootstrap if not cached
Expand Down Expand Up @@ -49,11 +50,18 @@ Cypress.Commands.add('byLabel', (label) => {
return cy.get('.labeled-input').contains(label).siblings('input');
});

/**
* Wrap the cy.find() command to simplify the selector declaration of the data-testid
*/
Cypress.Commands.add('findId', (id: string, matcher?: Matcher = '') => {
return cy.find(`[data-testid${ matcher }="${ id }"]`);
});

/**
* Wrap the cy.get() command to simplify the selector declaration of the data-testid
*/
Cypress.Commands.add('getId', (id: string) => {
return cy.get(`[data-testid="${ id }"]`);
Cypress.Commands.add('getId', (id: string, matcher?: Matcher = '') => {
return cy.get(`[data-testid${ matcher }="${ id }"]`);
});

/**
Expand Down
29 changes: 28 additions & 1 deletion cypress/support/e2e.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@

import { Matcher } from '~/cypress/support/types';
import './commands';

declare global {
Expand All @@ -7,7 +8,33 @@ declare global {
interface Chainable {
login(username?: string, password?: string, cacheSession?: boolean): Chainable<Element>;
byLabel(label: string,): Chainable<Element>;
getId(id: string,): Chainable<Element>;

/**
* Wrapper for cy.get() to simply define the data-testid value that allows you to pass a matcher to find the element.
* @param id Value used for the data-testid attribute of the element.
* @param matcher Matching character used for attribute value:
* - `$`: Suffixed with this value
* - `^`: Prefixed with this value
* - `~`: Contains this value as whitespace separated words
* - `*`: Contains this value
*/
getId(id: string, matcher?: Matcher): Chainable<Element>;

/**
* Wrapper for cy.find() to simply define the data-testid value that allows you to pass a matcher to find the element.
* @param id Value used for the data-testid attribute of the element.
* @param matcher Matching character used for attribute value:
* - `$`: Suffixed with this value
* - `^`: Prefixed with this value
* - `~`: Contains this value as whitespace separated words
* - `*`: Contains this value
*/
findId(id: string, matcher?: Matcher): Chainable<Element>;

/**
* Override user preferences to default values, allowing to pass custom preferences for a deterministic scenario
* Leave empty for reset to default values
*/
// eslint-disable-next-line no-undef
userPreferences(preferences?: Partial<UserPreferences>): Chainable<null>;
}
Expand Down
1 change: 1 addition & 0 deletions cypress/support/types.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export type Matcher = '$' | '^' | '~' | '*' | '';
49 changes: 48 additions & 1 deletion docs/developer/testing.md
Original file line number Diff line number Diff line change
Expand Up @@ -68,7 +68,7 @@ It is possible to start the project and run all the tests at once with a single

As Cypress common practice, some custom commands have been created within `command.ts` file to simplify the development process. Please consult Cypress documentation for more details about when and how to use them.

Worth mentioning the `cy.getId()` command, as it is mainly used to select elements. This would require to add `data-testid` to your element inside the markup.
Worth mentioning the `cy.getId()` and `cy.findId()` commands, as it is mainly used to select elements. This would require to add `data-testid` to your element inside the markup and optionally matchers.

### Writing tests

Expand Down Expand Up @@ -107,6 +107,53 @@ describe.only('Burger Side Nav Menu', () => {
it.only('Opens and closes on menu icon click', () => {
```

### Data testid naming

While defining naming, always consider deterministic usage and rely on specific values. For cases where the content is required, e.g. select name specific elements as in cluster selection, consider use the `contain()` method. Further guideline and explanation in the official documentation related section.

In case of complex component, define a prefix for your `data-testid` with a the prop `componentTestid` and a default value. This will help you to define unique value and composable identifier in case of more elements, as well to avoid custom term for each test if not necessary, e.g. no multiple elements.

E.g. given the action menu:

```ts
/**
* Inherited global identifier prefix for tests
* Define a term based on the parent component to avoid conflicts on multiple components
*/
componentTestid: {
type: String,
default: 'action-menu'
}
```

```html
<li
v-for="(option, i) in options"
:key="opt.action"
:data-testid="componentTestid + '-' + i + '-item'"
>
```

### Debugging

To summarize what [defined in the documentation](https://docs.cypress.io/guides/guides/debugging), the following modalities of debugging are provided:

- `debugger` flag
- `.debug()` as chained command
- `cy.pause()` for analyzing the state of the test
- Inspect commands in the Cypress dashboard to view the logs
- `.then(console.log)` to append the log to the resolved promise

### Cypress Dashboard

E2E tests can be displayed in Cypress dashboard by adding the key `"projectId": "YOUR_PROJECT_ID_HERE"` to the `cypress.json` file and run the script by passing the parameters

```bash
yarn cy:run --record --key YOUR_RECORD_KEY_HERE
```

These values are provided when you create a new project within Cypress dashboard or within `Project settings`.

## Unit tests

The dashboard is configured to run unit tests with Jest in combination of vue-test-utils, for Vue scoped cases.
Expand Down
2 changes: 2 additions & 0 deletions shell/assets/translations/en-us.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -1200,6 +1200,8 @@ cluster:
commandInstructionsInsecure: 'If you get a &quot;certificate signed by unknown authority&quot; error, your {vendor} installation has a self-signed or untrusted SSL certificate. Run the command below instead to bypass the certificate verification:'
clusterRoleBindingInstructions: 'If you get permission errors creating some of the resources, your user may not have the <code>cluster-admin</code> role. Use this command to apply it:'
clusterRoleBindingCommand: 'kubectl create clusterrolebinding cluster-admin-binding --clusterrole cluster-admin --user <your username from your kubeconfig>'
explore: Explore
exploreHarvester: Explore
importAction: Import Existing
kubernetesVersion:
label: Kubernetes Version
Expand Down
12 changes: 11 additions & 1 deletion shell/components/ActionMenu.vue
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,15 @@ export default {
type: PointerEvent,
default: null
},

/**
* Inherited global identifier prefix for tests
* Define a term based on the parent component to avoid conflicts on multiple components
*/
componentTestid: {
type: String,
default: 'action-menu'
}
},

data() {
Expand Down Expand Up @@ -224,10 +233,11 @@ export default {
<div class="background" @click="hide" @contextmenu.prevent></div>
<ul class="list-unstyled menu" :style="style">
<li
v-for="opt in menuOptions"
v-for="(opt, i) in menuOptions"
:key="opt.action"
:disabled="opt.disabled"
:class="{divider: opt.divider}"
:data-testid="componentTestid + '-' + i + '-item'"
@click="execute(opt, $event)"
>
<i v-if="opt.icon" :class="{icon: true, [opt.icon]: true}" />
Expand Down
22 changes: 21 additions & 1 deletion shell/components/CruResource.vue
Original file line number Diff line number Diff line change
Expand Up @@ -119,6 +119,15 @@ export default {
namespaceKey: {
type: String,
default: 'metadata.namespace'
},

/**
* Inherited global identifier prefix for tests
* Define a term based on the parent component to avoid conflicts on multiple components
*/
componentTestid: {
type: String,
default: 'form'
}
},

Expand Down Expand Up @@ -505,6 +514,7 @@ export default {
:mode="mode"
:is-form="showAsForm"
:show-cancel="showCancel"
:component-testid="componentTestid"
@cancel-confirmed="confirmCancel"
>
<!-- Pass down templates provided by the caller -->
Expand All @@ -516,6 +526,7 @@ export default {
<div v-if="!isView">
<button
v-if="showYaml"
:data-testid="componentTestid + '-yaml'"
type="button"
class="btn role-secondary"
@click="showPreviewYaml"
Expand All @@ -527,6 +538,7 @@ export default {
ref="save"
:disabled="!canSave"
:mode="finishButtonMode || mode"
:data-testid="componentTestid + '-save'"
@click="clickSave($event)"
/>
</div>
Expand Down Expand Up @@ -568,12 +580,14 @@ export default {
v-if="showPreview"
type="button"
class="btn role-secondary"
:data-testid="componentTestid + '-yaml-yaml'"
@click="yamlUnpreview"
>
<t k="resourceYaml.buttons.continue" />
</button>
<button
v-if="!showPreview && isEdit"
:data-testid="componentTestid + '-yaml-yaml-preview'"
:disabled="!canDiff"
type="button"
class="btn role-secondary"
Expand All @@ -583,11 +597,17 @@ export default {
</button>
</div>
<div v-if="_selectedSubtype || !subtypes.length" class="controls-right">
<button type="button" class="btn role-secondary" @click="checkCancel(false)">
<button
:data-testid="componentTestid + '-yaml-cancel'"
type="button"
class="btn role-secondary"
@click="checkCancel(false)"
>
<t k="cruResource.backToForm" />
</button>
<AsyncButton
v-if="!showSubtypeSelection"
:data-testid="componentTestid + '-yaml-save'"
:disabled="!canSave"
:action-label="isEdit ? t('generic.save') : t('generic.create')"
@click="cb=>yamlSave(cb)"
Expand Down
Loading