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