]> git.proxmox.com Git - ceph.git/blob - ceph/src/pybind/mgr/dashboard/frontend/src/testing/unit-test-helper.ts
update ceph source to reef 18.2.1
[ceph.git] / ceph / src / pybind / mgr / dashboard / frontend / src / testing / unit-test-helper.ts
1 import { DebugElement, Type } from '@angular/core';
2 import { ComponentFixture, TestBed } from '@angular/core/testing';
3 import { AbstractControl } from '@angular/forms';
4 import { By } from '@angular/platform-browser';
5 import { BrowserDynamicTestingModule } from '@angular/platform-browser-dynamic/testing';
6
7 import { NgbModal, NgbNav, NgbNavItem, NgbNavLink } from '@ng-bootstrap/ng-bootstrap';
8 import _ from 'lodash';
9 import { of } from 'rxjs';
10
11 import { InventoryDevice } from '~/app/ceph/cluster/inventory/inventory-devices/inventory-device.model';
12 import { Pool } from '~/app/ceph/pool/pool';
13 import { RgwDaemon } from '~/app/ceph/rgw/models/rgw-daemon';
14 import { OrchestratorService } from '~/app/shared/api/orchestrator.service';
15 import { RgwDaemonService } from '~/app/shared/api/rgw-daemon.service';
16 import { TableActionsComponent } from '~/app/shared/datatable/table-actions/table-actions.component';
17 import { Icons } from '~/app/shared/enum/icons.enum';
18 import { CdFormGroup } from '~/app/shared/forms/cd-form-group';
19 import { CdTableAction } from '~/app/shared/models/cd-table-action';
20 import { CdTableSelection } from '~/app/shared/models/cd-table-selection';
21 import { CrushNode } from '~/app/shared/models/crush-node';
22 import { CrushRule, CrushRuleConfig } from '~/app/shared/models/crush-rule';
23 import { OrchestratorFeature } from '~/app/shared/models/orchestrator.enum';
24 import { Permission } from '~/app/shared/models/permissions';
25 import {
26 AlertmanagerAlert,
27 AlertmanagerNotification,
28 AlertmanagerNotificationAlert,
29 PrometheusRule
30 } from '~/app/shared/models/prometheus-alerts';
31
32 export function configureTestBed(configuration: any, entryComponents?: any) {
33 beforeEach(async () => {
34 if (entryComponents) {
35 // Declare entryComponents without having to add them to a module
36 // This is needed since Jest doesn't yet support not declaring entryComponents
37 await TestBed.configureTestingModule(configuration).overrideModule(
38 BrowserDynamicTestingModule,
39 {
40 set: { entryComponents: entryComponents }
41 }
42 );
43 } else {
44 await TestBed.configureTestingModule(configuration);
45 }
46 });
47 }
48
49 export class PermissionHelper {
50 tac: TableActionsComponent;
51 permission: Permission;
52 selection: { single: object; multiple: object[] };
53
54 /**
55 * @param permission The permissions used by this test.
56 * @param selection The selection used by this test. Configure this if
57 * the table actions require a more complex selection object to perform
58 * a correct test run.
59 * Defaults to `{ single: {}, multiple: [{}, {}] }`.
60 */
61 constructor(permission: Permission, selection?: { single: object; multiple: object[] }) {
62 this.permission = permission;
63 this.selection = _.defaultTo(selection, { single: {}, multiple: [{}, {}] });
64 }
65
66 setPermissionsAndGetActions(tableActions: CdTableAction[]): any {
67 const result = {};
68 [true, false].forEach((create) => {
69 [true, false].forEach((update) => {
70 [true, false].forEach((deleteP) => {
71 this.permission.create = create;
72 this.permission.update = update;
73 this.permission.delete = deleteP;
74
75 this.tac = new TableActionsComponent();
76 this.tac.selection = new CdTableSelection();
77 this.tac.tableActions = [...tableActions];
78 this.tac.permission = this.permission;
79 this.tac.ngOnInit();
80
81 const perms = [];
82 if (create) {
83 perms.push('create');
84 }
85 if (update) {
86 perms.push('update');
87 }
88 if (deleteP) {
89 perms.push('delete');
90 }
91 const permissionText = perms.join(',');
92
93 result[permissionText !== '' ? permissionText : 'no-permissions'] = {
94 actions: this.tac.tableActions.map((action) => action.name),
95 primary: this.testScenarios()
96 };
97 });
98 });
99 });
100
101 return result;
102 }
103
104 testScenarios() {
105 const result: any = {};
106 // 'multiple selections'
107 result.multiple = this.testScenario(this.selection.multiple);
108 // 'select executing item'
109 result.executing = this.testScenario([
110 _.merge({ cdExecuting: 'someAction' }, this.selection.single)
111 ]);
112 // 'select non-executing item'
113 result.single = this.testScenario([this.selection.single]);
114 // 'no selection'
115 result.no = this.testScenario([]);
116
117 return result;
118 }
119
120 private testScenario(selection: object[]) {
121 this.setSelection(selection);
122 const action: CdTableAction = this.tac.currentAction;
123 return action ? action.name : '';
124 }
125
126 setSelection(selection: object[]) {
127 this.tac.selection.selected = selection;
128 this.tac.onSelectionChange();
129 }
130 }
131
132 export class FormHelper {
133 form: CdFormGroup;
134
135 constructor(form: CdFormGroup) {
136 this.form = form;
137 }
138
139 /**
140 * Changes multiple values in multiple controls
141 */
142 setMultipleValues(values: { [controlName: string]: any }, markAsDirty?: boolean) {
143 Object.keys(values).forEach((key) => {
144 this.setValue(key, values[key], markAsDirty);
145 });
146 }
147
148 /**
149 * Changes the value of a control
150 */
151 setValue(control: AbstractControl | string, value: any, markAsDirty?: boolean): AbstractControl {
152 control = this.getControl(control);
153 if (markAsDirty) {
154 control.markAsDirty();
155 }
156 control.setValue(value);
157 return control;
158 }
159
160 private getControl(control: AbstractControl | string): AbstractControl {
161 if (typeof control === 'string') {
162 return this.form.get(control);
163 }
164 return control;
165 }
166
167 /**
168 * Change the value of the control and expect the control to be valid afterwards.
169 */
170 expectValidChange(control: AbstractControl | string, value: any, markAsDirty?: boolean) {
171 this.expectValid(this.setValue(control, value, markAsDirty));
172 }
173
174 /**
175 * Expect that the given control is valid.
176 */
177 expectValid(control: AbstractControl | string) {
178 // 'isValid' would be false for disabled controls
179 expect(this.getControl(control).errors).toBe(null);
180 }
181
182 /**
183 * Change the value of the control and expect a specific error.
184 */
185 expectErrorChange(
186 control: AbstractControl | string,
187 value: any,
188 error: string,
189 markAsDirty?: boolean
190 ) {
191 this.expectError(this.setValue(control, value, markAsDirty), error);
192 }
193
194 /**
195 * Expect a specific error for the given control.
196 */
197 expectError(control: AbstractControl | string, error: string) {
198 expect(this.getControl(control).hasError(error)).toBeTruthy();
199 }
200 }
201
202 /**
203 * Use this to mock 'modalService.open' to make the embedded component with it's fixture usable
204 * in tests. The function gives back all needed parts including the modal reference.
205 *
206 * Please make sure to call this function *inside* your mock and return the reference at the end.
207 */
208 export function modalServiceShow(componentClass: Type<any>, modalConfig: any) {
209 const modal: NgbModal = TestBed.inject(NgbModal);
210 const modalRef = modal.open(componentClass);
211 if (modalConfig) {
212 Object.assign(modalRef.componentInstance, modalConfig);
213 }
214 return modalRef;
215 }
216
217 export class FixtureHelper {
218 fixture: ComponentFixture<any>;
219
220 constructor(fixture?: ComponentFixture<any>) {
221 if (fixture) {
222 this.updateFixture(fixture);
223 }
224 }
225
226 updateFixture(fixture: ComponentFixture<any>) {
227 this.fixture = fixture;
228 }
229
230 /**
231 * Expect a list of id elements to be visible or not.
232 */
233 expectIdElementsVisible(ids: string[], visibility: boolean) {
234 ids.forEach((css) => {
235 this.expectElementVisible(`#${css}`, visibility);
236 });
237 }
238
239 /**
240 * Expect a specific element to be visible or not.
241 */
242 expectElementVisible(css: string, visibility: boolean) {
243 expect(visibility).toBe(Boolean(this.getElementByCss(css)));
244 }
245
246 expectFormFieldToBe(css: string, value: string) {
247 const props = this.getElementByCss(css).properties;
248 expect(props['value'] || props['checked'].toString()).toBe(value);
249 }
250
251 expectTextToBe(css: string, value: string) {
252 expect(this.getText(css)).toBe(value);
253 }
254
255 clickElement(css: string) {
256 this.getElementByCss(css).triggerEventHandler('click', null);
257 this.fixture.detectChanges();
258 }
259
260 selectElement(css: string, value: string) {
261 const nativeElement = this.getElementByCss(css).nativeElement;
262 nativeElement.value = value;
263 nativeElement.dispatchEvent(new Event('change'));
264 this.fixture.detectChanges();
265 }
266
267 getText(css: string) {
268 const e = this.getElementByCss(css);
269 return e ? e.nativeElement.textContent.trim() : null;
270 }
271
272 getTextAll(css: string) {
273 const elements = this.getElementByCssAll(css);
274 return elements.map((element) => {
275 return element ? element.nativeElement.textContent.trim() : null;
276 });
277 }
278
279 getElementByCss(css: string) {
280 this.fixture.detectChanges();
281 return this.fixture.debugElement.query(By.css(css));
282 }
283
284 getElementByCssAll(css: string) {
285 this.fixture.detectChanges();
286 return this.fixture.debugElement.queryAll(By.css(css));
287 }
288 }
289
290 export class PrometheusHelper {
291 createSilence(id: string) {
292 return {
293 id: id,
294 createdBy: `Creator of ${id}`,
295 comment: `A comment for ${id}`,
296 startsAt: new Date('2022-02-22T22:22:00').toISOString(),
297 endsAt: new Date('2022-02-23T22:22:00').toISOString(),
298 matchers: [
299 {
300 name: 'job',
301 value: 'someJob',
302 isRegex: true
303 }
304 ]
305 };
306 }
307
308 createRule(name: string, severity: string, alerts: any[]): PrometheusRule {
309 return {
310 name: name,
311 labels: {
312 severity: severity
313 },
314 alerts: alerts
315 } as PrometheusRule;
316 }
317
318 createAlert(name: string, state = 'active', timeMultiplier = 1): AlertmanagerAlert {
319 return {
320 fingerprint: name,
321 status: { state },
322 labels: {
323 alertname: name,
324 instance: 'someInstance',
325 job: 'someJob',
326 severity: 'someSeverity'
327 },
328 annotations: {
329 description: `${name} is ${state}`
330 },
331 generatorURL: `http://${name}`,
332 startsAt: new Date(new Date('2022-02-22').getTime() * timeMultiplier).toString()
333 } as AlertmanagerAlert;
334 }
335
336 createNotificationAlert(name: string, status = 'firing'): AlertmanagerNotificationAlert {
337 return {
338 status: status,
339 labels: {
340 alertname: name
341 },
342 annotations: {
343 description: `${name} is ${status}`
344 },
345 generatorURL: `http://${name}`
346 } as AlertmanagerNotificationAlert;
347 }
348
349 createNotification(alertNumber = 1, status = 'firing'): AlertmanagerNotification {
350 const alerts = [];
351 for (let i = 0; i < alertNumber; i++) {
352 alerts.push(this.createNotificationAlert('alert' + i, status));
353 }
354 return { alerts, status } as AlertmanagerNotification;
355 }
356
357 createLink(url: string) {
358 return `<a href="${url}" target="_blank"><i class="${Icons.lineChart}"></i></a>`;
359 }
360 }
361
362 export function expectItemTasks(item: any, executing: string, percentage?: number) {
363 if (executing) {
364 executing = executing + '...';
365 if (percentage) {
366 executing = `${executing} ${percentage}%`;
367 }
368 }
369 expect(item.cdExecuting).toBe(executing);
370 }
371
372 export class IscsiHelper {
373 static validateUser(formHelper: FormHelper, fieldName: string) {
374 formHelper.expectErrorChange(fieldName, 'short', 'pattern');
375 formHelper.expectValidChange(fieldName, 'thisIsCorrect');
376 formHelper.expectErrorChange(fieldName, '##?badChars?##', 'pattern');
377 formHelper.expectErrorChange(
378 fieldName,
379 'thisUsernameIsWayyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyTooBig',
380 'pattern'
381 );
382 }
383
384 static validatePassword(formHelper: FormHelper, fieldName: string) {
385 formHelper.expectErrorChange(fieldName, 'short', 'pattern');
386 formHelper.expectValidChange(fieldName, 'thisIsCorrect');
387 formHelper.expectErrorChange(fieldName, '##?badChars?##', 'pattern');
388 formHelper.expectErrorChange(fieldName, 'thisPasswordIsWayTooBig', 'pattern');
389 }
390 }
391
392 export class RgwHelper {
393 static readonly daemons = RgwHelper.getDaemonList();
394 static readonly DAEMON_QUERY_PARAM = `daemon_name=${RgwHelper.daemons[0].id}`;
395
396 static getDaemonList() {
397 const daemonList: RgwDaemon[] = [];
398 for (let daemonIndex = 1; daemonIndex <= 3; daemonIndex++) {
399 const rgwDaemon = new RgwDaemon();
400 rgwDaemon.id = `daemon${daemonIndex}`;
401 rgwDaemon.default = daemonIndex === 2;
402 rgwDaemon.zonegroup_name = `zonegroup${daemonIndex}`;
403 daemonList.push(rgwDaemon);
404 }
405 return daemonList;
406 }
407
408 static selectDaemon() {
409 const service = TestBed.inject(RgwDaemonService);
410 service.selectDaemon(this.daemons[0]);
411 }
412 }
413
414 export class Mocks {
415 static getCrushNode(
416 name: string,
417 id: number,
418 type: string,
419 type_id: number,
420 children?: number[],
421 device_class?: string
422 ): CrushNode {
423 return { name, type, type_id, id, children, device_class };
424 }
425
426 static getPool = (name: string, id: number): Pool => {
427 return _.merge(new Pool(name), {
428 pool: id,
429 type: 'replicated',
430 pg_num: 256,
431 pg_placement_num: 256,
432 pg_num_target: 256,
433 pg_placement_num_target: 256,
434 size: 3
435 });
436 };
437
438 /**
439 * Create the following test crush map:
440 * > default
441 * --> ssd-host
442 * ----> 3x osd with ssd
443 * --> mix-host
444 * ----> hdd-rack
445 * ------> 2x osd-rack with hdd
446 * ----> ssd-rack
447 * ------> 2x osd-rack with ssd
448 */
449 static getCrushMap(): CrushNode[] {
450 return [
451 // Root node
452 this.getCrushNode('default', -1, 'root', 11, [-2, -3]),
453 // SSD host
454 this.getCrushNode('ssd-host', -2, 'host', 1, [1, 0, 2]),
455 this.getCrushNode('osd.0', 0, 'osd', 0, undefined, 'ssd'),
456 this.getCrushNode('osd.1', 1, 'osd', 0, undefined, 'ssd'),
457 this.getCrushNode('osd.2', 2, 'osd', 0, undefined, 'ssd'),
458 // SSD and HDD mixed devices host
459 this.getCrushNode('mix-host', -3, 'host', 1, [-4, -5]),
460 // HDD rack
461 this.getCrushNode('hdd-rack', -4, 'rack', 3, [3, 4]),
462 this.getCrushNode('osd2.0', 3, 'osd-rack', 0, undefined, 'hdd'),
463 this.getCrushNode('osd2.1', 4, 'osd-rack', 0, undefined, 'hdd'),
464 // SSD rack
465 this.getCrushNode('ssd-rack', -5, 'rack', 3, [5, 6]),
466 this.getCrushNode('osd3.0', 5, 'osd-rack', 0, undefined, 'ssd'),
467 this.getCrushNode('osd3.1', 6, 'osd-rack', 0, undefined, 'ssd')
468 ];
469 }
470
471 /**
472 * Generates an simple crush map with multiple hosts that have OSDs with either ssd or hdd OSDs.
473 * Hosts with zero or even numbers at the end have SSD OSDs the other hosts have hdd OSDs.
474 *
475 * Host names follow the following naming convention:
476 * host.$index
477 * $index represents a number count started at 0 (like an index within an array) (same for OSDs)
478 *
479 * OSD names follow the following naming convention:
480 * osd.$hostIndex.$osdIndex
481 *
482 * The following crush map will be generated with the set defaults:
483 * > default
484 * --> host.0 (has only ssd OSDs)
485 * ----> osd.0.0
486 * ----> osd.0.1
487 * ----> osd.0.2
488 * ----> osd.0.3
489 * --> host.1 (has only hdd OSDs)
490 * ----> osd.1.0
491 * ----> osd.1.1
492 * ----> osd.1.2
493 * ----> osd.1.3
494 */
495 static generateSimpleCrushMap(hosts: number = 2, osds: number = 4): CrushNode[] {
496 const nodes = [];
497 const createOsdLeafs = (hostSuffix: number): number[] => {
498 let osdId = 0;
499 const osdIds = [];
500 const osdsInUse = hostSuffix * osds;
501 for (let o = 0; o < osds; o++) {
502 osdIds.push(osdId);
503 nodes.push(
504 this.getCrushNode(
505 `osd.${hostSuffix}.${osdId}`,
506 osdId + osdsInUse,
507 'osd',
508 0,
509 undefined,
510 hostSuffix % 2 === 0 ? 'ssd' : 'hdd'
511 )
512 );
513 osdId++;
514 }
515 return osdIds;
516 };
517 const createHostBuckets = (): number[] => {
518 let hostId = -2;
519 const hostIds = [];
520 for (let h = 0; h < hosts; h++) {
521 const hostSuffix = hostId * -1 - 2;
522 hostIds.push(hostId);
523 nodes.push(
524 this.getCrushNode(`host.${hostSuffix}`, hostId, 'host', 1, createOsdLeafs(hostSuffix))
525 );
526 hostId--;
527 }
528 return hostIds;
529 };
530 nodes.push(this.getCrushNode('default', -1, 'root', 11, createHostBuckets()));
531 return nodes;
532 }
533
534 static getCrushRuleConfig(
535 name: string,
536 root: string,
537 failure_domain: string,
538 device_class?: string
539 ): CrushRuleConfig {
540 return {
541 name,
542 root,
543 failure_domain,
544 device_class
545 };
546 }
547
548 static getCrushRule({
549 id = 0,
550 name = 'somePoolName',
551 type = 'replicated',
552 failureDomain = 'osd',
553 itemName = 'default' // This string also sets the device type - "default~ssd" <- ssd usage only
554 }: {
555 id?: number;
556 name?: string;
557 type?: string;
558 failureDomain?: string;
559 itemName?: string;
560 }): CrushRule {
561 const rule = new CrushRule();
562 rule.type = type === 'erasure' ? 3 : 1;
563 rule.rule_id = id;
564 rule.rule_name = name;
565 rule.steps = [
566 {
567 item_name: itemName,
568 item: -1,
569 op: 'take'
570 },
571 {
572 num: 0,
573 type: failureDomain,
574 op: 'choose_firstn'
575 },
576 {
577 op: 'emit'
578 }
579 ];
580 return rule;
581 }
582
583 static getInventoryDevice(
584 hostname: string,
585 uid: string,
586 path = 'sda',
587 available = false
588 ): InventoryDevice {
589 return {
590 hostname,
591 uid,
592 path,
593 available,
594 sys_api: {
595 vendor: 'AAA',
596 model: 'aaa',
597 size: 1024,
598 rotational: 'false',
599 human_readable_size: '1 KB'
600 },
601 rejected_reasons: [''],
602 device_id: 'AAA-aaa-id0',
603 human_readable_type: 'nvme/ssd',
604 osd_ids: []
605 };
606 }
607 }
608
609 export class TabHelper {
610 static getNgbNav(fixture: ComponentFixture<any>) {
611 const debugElem: DebugElement = fixture.debugElement;
612 return debugElem.query(By.directive(NgbNav)).injector.get(NgbNav);
613 }
614
615 static getNgbNavItems(fixture: ComponentFixture<any>) {
616 const debugElems = this.getNgbNavItemsDebugElems(fixture);
617 return debugElems.map((de) => de.injector.get(NgbNavItem));
618 }
619
620 static getTextContents(fixture: ComponentFixture<any>) {
621 const debugElems = this.getNgbNavItemsDebugElems(fixture);
622 return debugElems.map((de) => de.nativeElement.textContent);
623 }
624
625 private static getNgbNavItemsDebugElems(fixture: ComponentFixture<any>) {
626 const debugElem: DebugElement = fixture.debugElement;
627 return debugElem.queryAll(By.directive(NgbNavLink));
628 }
629 }
630
631 export class OrchestratorHelper {
632 /**
633 * Mock Orchestrator status.
634 * @param available is the Orchestrator enabled?
635 * @param features A list of enabled Orchestrator features.
636 */
637 static mockStatus(available: boolean, features?: OrchestratorFeature[]) {
638 const orchStatus = { available: available, description: '', features: {} };
639 if (features) {
640 features.forEach((feature: OrchestratorFeature) => {
641 orchStatus.features[feature] = { available: true };
642 });
643 }
644 spyOn(TestBed.inject(OrchestratorService), 'status').and.callFake(() => of(orchStatus));
645 }
646 }
647
648 export class TableActionHelper {
649 /**
650 * Verify table action buttons, including the button disabled state and disable description.
651 *
652 * @param fixture test fixture
653 * @param tableActions table actions
654 * @param expectResult expected values. e.g. {Create: { disabled: true, disableDesc: 'not supported'}}.
655 * Expect the Create button to be disabled with 'not supported' tooltip.
656 */
657 static verifyTableActions = async (
658 fixture: ComponentFixture<any>,
659 tableActions: CdTableAction[],
660 expectResult: {
661 [action: string]: { disabled: boolean; disableDesc: string };
662 }
663 ) => {
664 // click dropdown to update all actions buttons
665 const dropDownToggle = fixture.debugElement.query(By.css('.dropdown-toggle'));
666 dropDownToggle.triggerEventHandler('click', null);
667 fixture.detectChanges();
668 await fixture.whenStable();
669
670 const tableActionElement = fixture.debugElement.query(By.directive(TableActionsComponent));
671 const toClassName = TestBed.inject(TableActionsComponent).toClassName;
672 const getActionElement = (action: CdTableAction) =>
673 tableActionElement.query(By.css(`[ngbDropdownItem].${toClassName(action)}`));
674
675 const actions = {};
676 tableActions.forEach((action) => {
677 const actionElement = getActionElement(action);
678 if (expectResult[action.name]) {
679 actions[action.name] = {
680 disabled: actionElement.classes.disabled ? true : false,
681 disableDesc: actionElement.properties.title
682 };
683 }
684 });
685 expect(actions).toEqual(expectResult);
686 };
687 }