summaryrefslogtreecommitdiffstats
path: root/src/pybind/mgr/dashboard/frontend/src/testing/unit-test-helper.ts
diff options
context:
space:
mode:
Diffstat (limited to 'src/pybind/mgr/dashboard/frontend/src/testing/unit-test-helper.ts')
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/testing/unit-test-helper.ts692
1 files changed, 692 insertions, 0 deletions
diff --git a/src/pybind/mgr/dashboard/frontend/src/testing/unit-test-helper.ts b/src/pybind/mgr/dashboard/frontend/src/testing/unit-test-helper.ts
new file mode 100644
index 000000000..66033f033
--- /dev/null
+++ b/src/pybind/mgr/dashboard/frontend/src/testing/unit-test-helper.ts
@@ -0,0 +1,692 @@
+import { DebugElement, Type } from '@angular/core';
+import { ComponentFixture, TestBed } from '@angular/core/testing';
+import { AbstractControl } from '@angular/forms';
+import { By } from '@angular/platform-browser';
+import { BrowserDynamicTestingModule } from '@angular/platform-browser-dynamic/testing';
+
+import { NgbModal, NgbNav, NgbNavItem } from '@ng-bootstrap/ng-bootstrap';
+import _ from 'lodash';
+import { configureTestSuite } from 'ng-bullet';
+import { of } from 'rxjs';
+
+import { InventoryDevice } from '~/app/ceph/cluster/inventory/inventory-devices/inventory-device.model';
+import { Pool } from '~/app/ceph/pool/pool';
+import { RgwDaemon } from '~/app/ceph/rgw/models/rgw-daemon';
+import { OrchestratorService } from '~/app/shared/api/orchestrator.service';
+import { RgwDaemonService } from '~/app/shared/api/rgw-daemon.service';
+import { TableActionsComponent } from '~/app/shared/datatable/table-actions/table-actions.component';
+import { Icons } from '~/app/shared/enum/icons.enum';
+import { CdFormGroup } from '~/app/shared/forms/cd-form-group';
+import { CdTableAction } from '~/app/shared/models/cd-table-action';
+import { CdTableSelection } from '~/app/shared/models/cd-table-selection';
+import { CrushNode } from '~/app/shared/models/crush-node';
+import { CrushRule, CrushRuleConfig } from '~/app/shared/models/crush-rule';
+import { OrchestratorFeature } from '~/app/shared/models/orchestrator.enum';
+import { Permission } from '~/app/shared/models/permissions';
+import {
+ AlertmanagerAlert,
+ AlertmanagerNotification,
+ AlertmanagerNotificationAlert,
+ PrometheusRule
+} from '~/app/shared/models/prometheus-alerts';
+
+export function configureTestBed(configuration: any, entryComponents?: any) {
+ configureTestSuite(() => {
+ if (entryComponents) {
+ // Declare entryComponents without having to add them to a module
+ // This is needed since Jest doesn't yet support not declaring entryComponents
+ TestBed.configureTestingModule(configuration).overrideModule(BrowserDynamicTestingModule, {
+ set: { entryComponents: entryComponents }
+ });
+ } else {
+ TestBed.configureTestingModule(configuration);
+ }
+ });
+}
+
+export class PermissionHelper {
+ tac: TableActionsComponent;
+ permission: Permission;
+ selection: { single: object; multiple: object[] };
+
+ /**
+ * @param permission The permissions used by this test.
+ * @param selection The selection used by this test. Configure this if
+ * the table actions require a more complex selection object to perform
+ * a correct test run.
+ * Defaults to `{ single: {}, multiple: [{}, {}] }`.
+ */
+ constructor(permission: Permission, selection?: { single: object; multiple: object[] }) {
+ this.permission = permission;
+ this.selection = _.defaultTo(selection, { single: {}, multiple: [{}, {}] });
+ }
+
+ setPermissionsAndGetActions(tableActions: CdTableAction[]): any {
+ const result = {};
+ [true, false].forEach((create) => {
+ [true, false].forEach((update) => {
+ [true, false].forEach((deleteP) => {
+ this.permission.create = create;
+ this.permission.update = update;
+ this.permission.delete = deleteP;
+
+ this.tac = new TableActionsComponent();
+ this.tac.selection = new CdTableSelection();
+ this.tac.tableActions = [...tableActions];
+ this.tac.permission = this.permission;
+ this.tac.ngOnInit();
+
+ const perms = [];
+ if (create) {
+ perms.push('create');
+ }
+ if (update) {
+ perms.push('update');
+ }
+ if (deleteP) {
+ perms.push('delete');
+ }
+ const permissionText = perms.join(',');
+
+ result[permissionText !== '' ? permissionText : 'no-permissions'] = {
+ actions: this.tac.tableActions.map((action) => action.name),
+ primary: this.testScenarios()
+ };
+ });
+ });
+ });
+
+ return result;
+ }
+
+ testScenarios() {
+ const result: any = {};
+ // 'multiple selections'
+ result.multiple = this.testScenario(this.selection.multiple);
+ // 'select executing item'
+ result.executing = this.testScenario([
+ _.merge({ cdExecuting: 'someAction' }, this.selection.single)
+ ]);
+ // 'select non-executing item'
+ result.single = this.testScenario([this.selection.single]);
+ // 'no selection'
+ result.no = this.testScenario([]);
+
+ return result;
+ }
+
+ private testScenario(selection: object[]) {
+ this.setSelection(selection);
+ const action: CdTableAction = this.tac.currentAction;
+ return action ? action.name : '';
+ }
+
+ setSelection(selection: object[]) {
+ this.tac.selection.selected = selection;
+ this.tac.onSelectionChange();
+ }
+}
+
+export class FormHelper {
+ form: CdFormGroup;
+
+ constructor(form: CdFormGroup) {
+ this.form = form;
+ }
+
+ /**
+ * Changes multiple values in multiple controls
+ */
+ setMultipleValues(values: { [controlName: string]: any }, markAsDirty?: boolean) {
+ Object.keys(values).forEach((key) => {
+ this.setValue(key, values[key], markAsDirty);
+ });
+ }
+
+ /**
+ * Changes the value of a control
+ */
+ setValue(control: AbstractControl | string, value: any, markAsDirty?: boolean): AbstractControl {
+ control = this.getControl(control);
+ if (markAsDirty) {
+ control.markAsDirty();
+ }
+ control.setValue(value);
+ return control;
+ }
+
+ private getControl(control: AbstractControl | string): AbstractControl {
+ if (typeof control === 'string') {
+ return this.form.get(control);
+ }
+ return control;
+ }
+
+ /**
+ * Change the value of the control and expect the control to be valid afterwards.
+ */
+ expectValidChange(control: AbstractControl | string, value: any, markAsDirty?: boolean) {
+ this.expectValid(this.setValue(control, value, markAsDirty));
+ }
+
+ /**
+ * Expect that the given control is valid.
+ */
+ expectValid(control: AbstractControl | string) {
+ // 'isValid' would be false for disabled controls
+ expect(this.getControl(control).errors).toBe(null);
+ }
+
+ /**
+ * Change the value of the control and expect a specific error.
+ */
+ expectErrorChange(
+ control: AbstractControl | string,
+ value: any,
+ error: string,
+ markAsDirty?: boolean
+ ) {
+ this.expectError(this.setValue(control, value, markAsDirty), error);
+ }
+
+ /**
+ * Expect a specific error for the given control.
+ */
+ expectError(control: AbstractControl | string, error: string) {
+ expect(this.getControl(control).hasError(error)).toBeTruthy();
+ }
+}
+
+/**
+ * Use this to mock 'modalService.open' to make the embedded component with it's fixture usable
+ * in tests. The function gives back all needed parts including the modal reference.
+ *
+ * Please make sure to call this function *inside* your mock and return the reference at the end.
+ */
+export function modalServiceShow(componentClass: Type<any>, modalConfig: any) {
+ const modal: NgbModal = TestBed.inject(NgbModal);
+ const modalRef = modal.open(componentClass);
+ if (modalConfig) {
+ Object.assign(modalRef.componentInstance, modalConfig);
+ }
+ return modalRef;
+}
+
+export class FixtureHelper {
+ fixture: ComponentFixture<any>;
+
+ constructor(fixture?: ComponentFixture<any>) {
+ if (fixture) {
+ this.updateFixture(fixture);
+ }
+ }
+
+ updateFixture(fixture: ComponentFixture<any>) {
+ this.fixture = fixture;
+ }
+
+ /**
+ * Expect a list of id elements to be visible or not.
+ */
+ expectIdElementsVisible(ids: string[], visibility: boolean) {
+ ids.forEach((css) => {
+ this.expectElementVisible(`#${css}`, visibility);
+ });
+ }
+
+ /**
+ * Expect a specific element to be visible or not.
+ */
+ expectElementVisible(css: string, visibility: boolean) {
+ expect(visibility).toBe(Boolean(this.getElementByCss(css)));
+ }
+
+ expectFormFieldToBe(css: string, value: string) {
+ const props = this.getElementByCss(css).properties;
+ expect(props['value'] || props['checked'].toString()).toBe(value);
+ }
+
+ expectTextToBe(css: string, value: string) {
+ expect(this.getText(css)).toBe(value);
+ }
+
+ clickElement(css: string) {
+ this.getElementByCss(css).triggerEventHandler('click', null);
+ this.fixture.detectChanges();
+ }
+
+ selectElement(css: string, value: string) {
+ const nativeElement = this.getElementByCss(css).nativeElement;
+ nativeElement.value = value;
+ nativeElement.dispatchEvent(new Event('change'));
+ this.fixture.detectChanges();
+ }
+
+ getText(css: string) {
+ const e = this.getElementByCss(css);
+ return e ? e.nativeElement.textContent.trim() : null;
+ }
+
+ getTextAll(css: string) {
+ const elements = this.getElementByCssAll(css);
+ return elements.map((element) => {
+ return element ? element.nativeElement.textContent.trim() : null;
+ });
+ }
+
+ getElementByCss(css: string) {
+ this.fixture.detectChanges();
+ return this.fixture.debugElement.query(By.css(css));
+ }
+
+ getElementByCssAll(css: string) {
+ this.fixture.detectChanges();
+ return this.fixture.debugElement.queryAll(By.css(css));
+ }
+}
+
+export class PrometheusHelper {
+ createSilence(id: string) {
+ return {
+ id: id,
+ createdBy: `Creator of ${id}`,
+ comment: `A comment for ${id}`,
+ startsAt: new Date('2022-02-22T22:22:00').toISOString(),
+ endsAt: new Date('2022-02-23T22:22:00').toISOString(),
+ matchers: [
+ {
+ name: 'job',
+ value: 'someJob',
+ isRegex: true
+ }
+ ]
+ };
+ }
+
+ createRule(name: string, severity: string, alerts: any[]): PrometheusRule {
+ return {
+ name: name,
+ labels: {
+ severity: severity
+ },
+ alerts: alerts
+ } as PrometheusRule;
+ }
+
+ createAlert(name: string, state = 'active', timeMultiplier = 1): AlertmanagerAlert {
+ return {
+ fingerprint: name,
+ status: { state },
+ labels: {
+ alertname: name,
+ instance: 'someInstance',
+ job: 'someJob',
+ severity: 'someSeverity'
+ },
+ annotations: {
+ description: `${name} is ${state}`
+ },
+ generatorURL: `http://${name}`,
+ startsAt: new Date(new Date('2022-02-22').getTime() * timeMultiplier).toString()
+ } as AlertmanagerAlert;
+ }
+
+ createNotificationAlert(name: string, status = 'firing'): AlertmanagerNotificationAlert {
+ return {
+ status: status,
+ labels: {
+ alertname: name
+ },
+ annotations: {
+ description: `${name} is ${status}`
+ },
+ generatorURL: `http://${name}`
+ } as AlertmanagerNotificationAlert;
+ }
+
+ createNotification(alertNumber = 1, status = 'firing'): AlertmanagerNotification {
+ const alerts = [];
+ for (let i = 0; i < alertNumber; i++) {
+ alerts.push(this.createNotificationAlert('alert' + i, status));
+ }
+ return { alerts, status } as AlertmanagerNotification;
+ }
+
+ createLink(url: string) {
+ return `<a href="${url}" target="_blank"><i class="${Icons.lineChart}"></i></a>`;
+ }
+}
+
+export function expectItemTasks(item: any, executing: string, percentage?: number) {
+ if (executing) {
+ executing = executing + '...';
+ if (percentage) {
+ executing = `${executing} ${percentage}%`;
+ }
+ }
+ expect(item.cdExecuting).toBe(executing);
+}
+
+export class IscsiHelper {
+ static validateUser(formHelper: FormHelper, fieldName: string) {
+ formHelper.expectErrorChange(fieldName, 'short', 'pattern');
+ formHelper.expectValidChange(fieldName, 'thisIsCorrect');
+ formHelper.expectErrorChange(fieldName, '##?badChars?##', 'pattern');
+ formHelper.expectErrorChange(
+ fieldName,
+ 'thisUsernameIsWayyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyTooBig',
+ 'pattern'
+ );
+ }
+
+ static validatePassword(formHelper: FormHelper, fieldName: string) {
+ formHelper.expectErrorChange(fieldName, 'short', 'pattern');
+ formHelper.expectValidChange(fieldName, 'thisIsCorrect');
+ formHelper.expectErrorChange(fieldName, '##?badChars?##', 'pattern');
+ formHelper.expectErrorChange(fieldName, 'thisPasswordIsWayTooBig', 'pattern');
+ }
+}
+
+export class RgwHelper {
+ static readonly daemons = RgwHelper.getDaemonList();
+ static readonly DAEMON_QUERY_PARAM = `daemon_name=${RgwHelper.daemons[0].id}`;
+
+ static getDaemonList() {
+ const daemonList: RgwDaemon[] = [];
+ for (let daemonIndex = 1; daemonIndex <= 3; daemonIndex++) {
+ const rgwDaemon = new RgwDaemon();
+ rgwDaemon.id = `daemon${daemonIndex}`;
+ rgwDaemon.default = daemonIndex === 2;
+ rgwDaemon.zonegroup_name = `zonegroup${daemonIndex}`;
+ daemonList.push(rgwDaemon);
+ }
+ return daemonList;
+ }
+
+ static selectDaemon() {
+ const service = TestBed.inject(RgwDaemonService);
+ service.selectDaemon(this.daemons[0]);
+ }
+}
+
+export class Mocks {
+ static getCrushNode(
+ name: string,
+ id: number,
+ type: string,
+ type_id: number,
+ children?: number[],
+ device_class?: string
+ ): CrushNode {
+ return { name, type, type_id, id, children, device_class };
+ }
+
+ static getPool = (name: string, id: number): Pool => {
+ return _.merge(new Pool(name), {
+ pool: id,
+ type: 'replicated',
+ pg_num: 256,
+ pg_placement_num: 256,
+ pg_num_target: 256,
+ pg_placement_num_target: 256,
+ size: 3
+ });
+ };
+
+ /**
+ * Create the following test crush map:
+ * > default
+ * --> ssd-host
+ * ----> 3x osd with ssd
+ * --> mix-host
+ * ----> hdd-rack
+ * ------> 2x osd-rack with hdd
+ * ----> ssd-rack
+ * ------> 2x osd-rack with ssd
+ */
+ static getCrushMap(): CrushNode[] {
+ return [
+ // Root node
+ this.getCrushNode('default', -1, 'root', 11, [-2, -3]),
+ // SSD host
+ this.getCrushNode('ssd-host', -2, 'host', 1, [1, 0, 2]),
+ this.getCrushNode('osd.0', 0, 'osd', 0, undefined, 'ssd'),
+ this.getCrushNode('osd.1', 1, 'osd', 0, undefined, 'ssd'),
+ this.getCrushNode('osd.2', 2, 'osd', 0, undefined, 'ssd'),
+ // SSD and HDD mixed devices host
+ this.getCrushNode('mix-host', -3, 'host', 1, [-4, -5]),
+ // HDD rack
+ this.getCrushNode('hdd-rack', -4, 'rack', 3, [3, 4]),
+ this.getCrushNode('osd2.0', 3, 'osd-rack', 0, undefined, 'hdd'),
+ this.getCrushNode('osd2.1', 4, 'osd-rack', 0, undefined, 'hdd'),
+ // SSD rack
+ this.getCrushNode('ssd-rack', -5, 'rack', 3, [5, 6]),
+ this.getCrushNode('osd3.0', 5, 'osd-rack', 0, undefined, 'ssd'),
+ this.getCrushNode('osd3.1', 6, 'osd-rack', 0, undefined, 'ssd')
+ ];
+ }
+
+ /**
+ * Generates an simple crush map with multiple hosts that have OSDs with either ssd or hdd OSDs.
+ * Hosts with zero or even numbers at the end have SSD OSDs the other hosts have hdd OSDs.
+ *
+ * Host names follow the following naming convention:
+ * host.$index
+ * $index represents a number count started at 0 (like an index within an array) (same for OSDs)
+ *
+ * OSD names follow the following naming convention:
+ * osd.$hostIndex.$osdIndex
+ *
+ * The following crush map will be generated with the set defaults:
+ * > default
+ * --> host.0 (has only ssd OSDs)
+ * ----> osd.0.0
+ * ----> osd.0.1
+ * ----> osd.0.2
+ * ----> osd.0.3
+ * --> host.1 (has only hdd OSDs)
+ * ----> osd.1.0
+ * ----> osd.1.1
+ * ----> osd.1.2
+ * ----> osd.1.3
+ */
+ static generateSimpleCrushMap(hosts: number = 2, osds: number = 4): CrushNode[] {
+ const nodes = [];
+ const createOsdLeafs = (hostSuffix: number): number[] => {
+ let osdId = 0;
+ const osdIds = [];
+ const osdsInUse = hostSuffix * osds;
+ for (let o = 0; o < osds; o++) {
+ osdIds.push(osdId);
+ nodes.push(
+ this.getCrushNode(
+ `osd.${hostSuffix}.${osdId}`,
+ osdId + osdsInUse,
+ 'osd',
+ 0,
+ undefined,
+ hostSuffix % 2 === 0 ? 'ssd' : 'hdd'
+ )
+ );
+ osdId++;
+ }
+ return osdIds;
+ };
+ const createHostBuckets = (): number[] => {
+ let hostId = -2;
+ const hostIds = [];
+ for (let h = 0; h < hosts; h++) {
+ const hostSuffix = hostId * -1 - 2;
+ hostIds.push(hostId);
+ nodes.push(
+ this.getCrushNode(`host.${hostSuffix}`, hostId, 'host', 1, createOsdLeafs(hostSuffix))
+ );
+ hostId--;
+ }
+ return hostIds;
+ };
+ nodes.push(this.getCrushNode('default', -1, 'root', 11, createHostBuckets()));
+ return nodes;
+ }
+
+ static getCrushRuleConfig(
+ name: string,
+ root: string,
+ failure_domain: string,
+ device_class?: string
+ ): CrushRuleConfig {
+ return {
+ name,
+ root,
+ failure_domain,
+ device_class
+ };
+ }
+
+ static getCrushRule({
+ id = 0,
+ name = 'somePoolName',
+ min = 1,
+ max = 10,
+ type = 'replicated',
+ failureDomain = 'osd',
+ itemName = 'default' // This string also sets the device type - "default~ssd" <- ssd usage only
+ }: {
+ max?: number;
+ min?: number;
+ id?: number;
+ name?: string;
+ type?: string;
+ failureDomain?: string;
+ itemName?: string;
+ }): CrushRule {
+ const typeNumber = type === 'erasure' ? 3 : 1;
+ const rule = new CrushRule();
+ rule.max_size = max;
+ rule.min_size = min;
+ rule.rule_id = id;
+ rule.ruleset = typeNumber;
+ rule.rule_name = name;
+ rule.steps = [
+ {
+ item_name: itemName,
+ item: -1,
+ op: 'take'
+ },
+ {
+ num: 0,
+ type: failureDomain,
+ op: 'choose_firstn'
+ },
+ {
+ op: 'emit'
+ }
+ ];
+ return rule;
+ }
+
+ static getInventoryDevice(
+ hostname: string,
+ uid: string,
+ path = 'sda',
+ available = false
+ ): InventoryDevice {
+ return {
+ hostname,
+ uid,
+ path,
+ available,
+ sys_api: {
+ vendor: 'AAA',
+ model: 'aaa',
+ size: 1024,
+ rotational: 'false',
+ human_readable_size: '1 KB'
+ },
+ rejected_reasons: [''],
+ device_id: 'AAA-aaa-id0',
+ human_readable_type: 'nvme/ssd',
+ osd_ids: []
+ };
+ }
+}
+
+export class TabHelper {
+ static getNgbNav(fixture: ComponentFixture<any>) {
+ const debugElem: DebugElement = fixture.debugElement;
+ return debugElem.query(By.directive(NgbNav)).injector.get(NgbNav);
+ }
+
+ static getNgbNavItems(fixture: ComponentFixture<any>) {
+ const debugElems = this.getNgbNavItemsDebugElems(fixture);
+ return debugElems.map((de) => de.injector.get(NgbNavItem));
+ }
+
+ static getTextContents(fixture: ComponentFixture<any>) {
+ const debugElems = this.getNgbNavItemsDebugElems(fixture);
+ return debugElems.map((de) => de.nativeElement.textContent);
+ }
+
+ private static getNgbNavItemsDebugElems(fixture: ComponentFixture<any>) {
+ const debugElem: DebugElement = fixture.debugElement;
+ return debugElem.queryAll(By.directive(NgbNavItem));
+ }
+}
+
+export class OrchestratorHelper {
+ /**
+ * Mock Orchestrator status.
+ * @param available is the Orchestrator enabled?
+ * @param features A list of enabled Orchestrator features.
+ */
+ static mockStatus(available: boolean, features?: OrchestratorFeature[]) {
+ const orchStatus = { available: available, description: '', features: {} };
+ if (features) {
+ features.forEach((feature: OrchestratorFeature) => {
+ orchStatus.features[feature] = { available: true };
+ });
+ }
+ spyOn(TestBed.inject(OrchestratorService), 'status').and.callFake(() => of(orchStatus));
+ }
+}
+
+export class TableActionHelper {
+ /**
+ * Verify table action buttons, including the button disabled state and disable description.
+ *
+ * @param fixture test fixture
+ * @param tableActions table actions
+ * @param expectResult expected values. e.g. {Create: { disabled: true, disableDesc: 'not supported'}}.
+ * Expect the Create button to be disabled with 'not supported' tooltip.
+ */
+ static verifyTableActions = async (
+ fixture: ComponentFixture<any>,
+ tableActions: CdTableAction[],
+ expectResult: {
+ [action: string]: { disabled: boolean; disableDesc: string };
+ }
+ ) => {
+ // click dropdown to update all actions buttons
+ const dropDownToggle = fixture.debugElement.query(By.css('.dropdown-toggle'));
+ dropDownToggle.triggerEventHandler('click', null);
+ fixture.detectChanges();
+ await fixture.whenStable();
+
+ const tableActionElement = fixture.debugElement.query(By.directive(TableActionsComponent));
+ const toClassName = TestBed.inject(TableActionsComponent).toClassName;
+ const getActionElement = (action: CdTableAction) =>
+ tableActionElement.query(By.css(`[ngbDropdownItem].${toClassName(action)}`));
+
+ const actions = {};
+ tableActions.forEach((action) => {
+ const actionElement = getActionElement(action);
+ if (expectResult[action.name]) {
+ actions[action.name] = {
+ disabled: actionElement.classes.disabled,
+ disableDesc: actionElement.properties.title
+ };
+ }
+ });
+ expect(actions).toEqual(expectResult);
+ };
+}