From 0595e66883802282e9cd343d60fb3ff380858805 Mon Sep 17 00:00:00 2001 From: Sofia Kurilova Date: Fri, 12 Apr 2024 15:54:32 +0000 Subject: [PATCH 01/26] Adds version analytics event (#306) --- .../ui/src/app/components/version/version.component.spec.ts | 2 ++ modules/ui/src/app/components/version/version.component.ts | 5 +++++ 2 files changed, 7 insertions(+) diff --git a/modules/ui/src/app/components/version/version.component.spec.ts b/modules/ui/src/app/components/version/version.component.spec.ts index a821c8328..f7fe3136e 100644 --- a/modules/ui/src/app/components/version/version.component.spec.ts +++ b/modules/ui/src/app/components/version/version.component.spec.ts @@ -38,6 +38,8 @@ describe('VersionComponent', () => { const versionBehaviorSubject$ = new BehaviorSubject(null); beforeEach(() => { + // @ts-expect-error data layer should be defined + window.dataLayer = window.dataLayer || []; mockService = jasmine.createSpyObj(['getVersion', 'fetchVersion']); mockService.getVersion.and.returnValue(versionBehaviorSubject$); TestBed.configureTestingModule({ diff --git a/modules/ui/src/app/components/version/version.component.ts b/modules/ui/src/app/components/version/version.component.ts index a85a3ce40..7693ecfcf 100644 --- a/modules/ui/src/app/components/version/version.component.ts +++ b/modules/ui/src/app/components/version/version.component.ts @@ -68,6 +68,11 @@ export class VersionComponent implements OnInit, OnDestroy { this.openConsentDialog(version); this.consentShownEvent.emit(); } + // @ts-expect-error data layer is not null + window.dataLayer.push({ + event: 'testrun_version', + testrunVersion: version?.installed_version, + }); }) ); } From ba477e6e01c8051d4cbea7e086ee56910d262bf3 Mon Sep 17 00:00:00 2001 From: Sofia Kurilova Date: Fri, 12 Apr 2024 15:54:59 +0000 Subject: [PATCH 02/26] Techdebt: adds state for testrun page (#392) --- modules/ui/src/app/app.component.html | 20 +- modules/ui/src/app/app.component.spec.ts | 43 ++- modules/ui/src/app/app.component.ts | 43 +-- modules/ui/src/app/app.store.spec.ts | 63 +++- modules/ui/src/app/app.store.ts | 72 +++- .../progress-initiate-form.component.html | 2 +- .../progress-initiate-form.component.spec.ts | 21 +- .../progress-initiate-form.component.ts | 8 +- .../progress-status-card.component.html | 2 +- .../progress-status-card.component.spec.ts | 11 +- .../progress-status-card.component.ts | 3 +- .../progress-table.component.html | 4 +- .../progress-table.component.spec.ts | 10 +- .../progress-table.component.ts | 23 +- .../app/pages/testrun/progress.component.html | 152 ++++---- .../pages/testrun/progress.component.spec.ts | 238 ++++--------- .../app/pages/testrun/progress.component.ts | 164 ++------- .../app/pages/testrun/testrun.store.spec.ts | 326 ++++++++++++++++++ .../ui/src/app/pages/testrun/testrun.store.ts | 276 +++++++++++++++ .../src/app/services/test-run.service.spec.ts | 25 +- .../ui/src/app/services/test-run.service.ts | 37 +- modules/ui/src/app/store/actions.ts | 16 + modules/ui/src/app/store/reducers.spec.ts | 42 ++- modules/ui/src/app/store/reducers.ts | 18 + modules/ui/src/app/store/selectors.spec.ts | 21 ++ modules/ui/src/app/store/selectors.ts | 15 + modules/ui/src/app/store/state.ts | 9 +- 27 files changed, 1092 insertions(+), 572 deletions(-) create mode 100644 modules/ui/src/app/pages/testrun/testrun.store.spec.ts create mode 100644 modules/ui/src/app/pages/testrun/testrun.store.ts diff --git a/modules/ui/src/app/app.component.html b/modules/ui/src/app/app.component.html index 8b3a8f7dc..8c472f8ce 100644 --- a/modules/ui/src/app/app.component.html +++ b/modules/ui/src/app/app.component.html @@ -16,7 +16,7 @@ @@ -92,7 +92,7 @@

Testrun

- + Testrun + *ngIf="vm.hasConnectionSettings === false"> Step 1: To perform a device test, please, select ports in Testrun + *ngIf="vm.hasConnectionSettings === true && vm.hasDevices === false"> Step 2: To perform a device test please Testrun Step 3: Once device is created, you are able to { 'focusFirstElementInContainer', ]); - (mockService.systemStatus$ as unknown) = of({}); - mockService.isTestrunStarted$ = of(true); - TestBed.configureTestingModule({ imports: [ RouterTestingModule, @@ -135,6 +134,9 @@ describe('AppComponent', () => { { selector: selectError, value: null }, { selector: selectMenuOpened, value: false }, { selector: selectHasDevices, value: false }, + { selector: selectIsTestrunStarted, value: false }, + { selector: selectSystemStatus, value: null }, + { selector: selectIsOpenStartTestrun, value: false }, ], }), { provide: FocusManagerService, useValue: mockFocusManagerService }, @@ -389,8 +391,7 @@ describe('AppComponent', () => { describe('Callout component visibility', () => { describe('with no connection settings', () => { beforeEach(() => { - component.hasConnectionSetting$ = of(false); - component.ngOnInit(); + store.overrideSelector(selectHasConnectionSettings, false); fixture.detectChanges(); }); @@ -429,11 +430,12 @@ describe('AppComponent', () => { describe('with system status as "Idle"', () => { beforeEach(() => { - component.hasConnectionSetting$ = of(true); + component.appStore.updateIsStatusLoaded(true); + store.overrideSelector(selectHasConnectionSettings, true); store.overrideSelector(selectHasDevices, true); - mockService.systemStatus$ = of(MOCK_PROGRESS_DATA_IDLE); - mockService.isTestrunStarted$ = of(false); - component.ngOnInit(); + store.overrideSelector(selectSystemStatus, MOCK_PROGRESS_DATA_IDLE); + store.overrideSelector(selectIsTestrunStarted, false); + fixture.detectChanges(); }); @@ -507,7 +509,11 @@ describe('AppComponent', () => { describe('with devices setted but without systemStatus data', () => { beforeEach(() => { store.overrideSelector(selectHasDevices, true); - mockService.isTestrunStarted$ = of(false); + store.overrideSelector(selectIsTestrunStarted, false); + component.appStore.updateIsStatusLoaded(true); + store.overrideSelector(selectHasConnectionSettings, true); + store.overrideSelector(selectSystemStatus, null); + fixture.detectChanges(); }); @@ -547,7 +553,7 @@ describe('AppComponent', () => { describe('with devices setted, without systemStatus data, but run the tests ', () => { beforeEach(() => { store.overrideSelector(selectHasDevices, true); - mockService.isTestrunStarted$ = of(true); + store.overrideSelector(selectIsTestrunStarted, true); fixture.detectChanges(); }); @@ -561,7 +567,10 @@ describe('AppComponent', () => { describe('with devices setted and systemStatus data', () => { beforeEach(() => { store.overrideSelector(selectHasDevices, true); - mockService.systemStatus$ = of(MOCK_PROGRESS_DATA_IN_PROGRESS); + store.overrideSelector( + selectSystemStatus, + MOCK_PROGRESS_DATA_IN_PROGRESS + ); fixture.detectChanges(); }); @@ -575,12 +584,11 @@ describe('AppComponent', () => { describe('error', () => { describe('with settingMissedError with one port is missed', () => { beforeEach(() => { - component.settingMissedError$ = of({ + store.overrideSelector(selectError, { isSettingMissed: true, devicePortMissed: true, internetPortMissed: false, }); - component.ngOnInit(); fixture.detectChanges(); }); @@ -595,12 +603,11 @@ describe('AppComponent', () => { describe('with settingMissedError with two ports are missed', () => { beforeEach(() => { - component.settingMissedError$ = of({ + store.overrideSelector(selectError, { isSettingMissed: true, devicePortMissed: true, internetPortMissed: true, }); - component.ngOnInit(); fixture.detectChanges(); }); @@ -615,7 +622,7 @@ describe('AppComponent', () => { describe('with no settingMissedError', () => { beforeEach(() => { - component.settingMissedError$ = of(null); + store.overrideSelector(selectError, null); store.overrideSelector(selectHasDevices, true); fixture.detectChanges(); }); diff --git a/modules/ui/src/app/app.component.ts b/modules/ui/src/app/app.component.ts index 307638e75..bb26490cd 100644 --- a/modules/ui/src/app/app.component.ts +++ b/modules/ui/src/app/app.component.ts @@ -13,33 +13,23 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -import { Component, ElementRef, OnInit, ViewChild } from '@angular/core'; +import { Component, ElementRef, ViewChild } from '@angular/core'; import { MatIconRegistry } from '@angular/material/icon'; import { DomSanitizer } from '@angular/platform-browser'; import { MatDrawer } from '@angular/material/sidenav'; -import { TestRunService } from './services/test-run.service'; -import { Observable } from 'rxjs'; -import { TestrunStatus, StatusOfTestrun } from './model/testrun-status'; +import { StatusOfTestrun } from './model/testrun-status'; import { Router } from '@angular/router'; import { CalloutType } from './model/callout-type'; -import { tap, shareReplay } from 'rxjs/operators'; import { Routes } from './model/routes'; import { FocusManagerService } from './services/focus-manager.service'; import { State, Store } from '@ngrx/store'; import { AppState } from './store/state'; -import { - selectError, - selectHasConnectionSettings, - selectInterfaces, - selectMenuOpened, -} from './store/selectors'; import { setIsOpenAddDevice, toggleMenu, updateFocusNavigation, } from './store/actions'; import { appFeatureKey } from './store/reducers'; -import { SettingMissedError, SystemInterfaces } from './model/setting'; import { GeneralSettingsComponent } from './pages/settings/general-settings.component'; import { AppStore } from './app.store'; @@ -56,22 +46,11 @@ const CLOSE_URL = '/assets/icons/close.svg'; styleUrls: ['./app.component.scss'], providers: [AppStore], }) -export class AppComponent implements OnInit { +export class AppComponent { public readonly CalloutType = CalloutType; public readonly StatusOfTestrun = StatusOfTestrun; public readonly Routes = Routes; - systemStatus$!: Observable; - isTestrunStarted$!: Observable; - hasConnectionSetting$: Observable = this.store.select( - selectHasConnectionSettings - ); - isStatusLoaded = false; private openedSettingFromToggleBtn = true; - isMenuOpen: Observable = this.store.select(selectMenuOpened); - interfaces: Observable = - this.store.select(selectInterfaces); - settingMissedError$: Observable = - this.store.select(selectError); @ViewChild('settingsDrawer') public settingsDrawer!: MatDrawer; @ViewChild('toggleSettingsBtn') public toggleSettingsBtn!: HTMLButtonElement; @@ -82,15 +61,14 @@ export class AppComponent implements OnInit { constructor( private matIconRegistry: MatIconRegistry, private domSanitizer: DomSanitizer, - private testRunService: TestRunService, private route: Router, private store: Store, private state: State, private readonly focusManagerService: FocusManagerService, - private appStore: AppStore + public appStore: AppStore ) { this.appStore.getDevices(); - this.testRunService.getSystemStatus(); + this.appStore.getSystemStatus(); this.matIconRegistry.addSvgIcon( 'devices', this.domSanitizer.bypassSecurityTrustResourceUrl(DEVICES_LOGO_URL) @@ -117,15 +95,6 @@ export class AppComponent implements OnInit { ); } - ngOnInit(): void { - this.systemStatus$ = this.testRunService.systemStatus$.pipe( - tap(() => (this.isStatusLoaded = true)), - shareReplay({ refCount: true, bufferSize: 1 }) - ); - - this.isTestrunStarted$ = this.testRunService.isTestrunStarted$; - } - navigateToDeviceRepository(): void { this.route.navigate([Routes.Devices]); this.store.dispatch(setIsOpenAddDevice({ isOpenAddDevice: true })); @@ -133,7 +102,7 @@ export class AppComponent implements OnInit { navigateToRuntime(): void { this.route.navigate([Routes.Testing]); - this.testRunService.setIsOpenStartTestrun(true); + this.appStore.setIsOpenStartTestrun(); } async closeSetting(hasDevices: boolean): Promise { diff --git a/modules/ui/src/app/app.store.spec.ts b/modules/ui/src/app/app.store.spec.ts index 93e7c8113..d53840850 100644 --- a/modules/ui/src/app/app.store.spec.ts +++ b/modules/ui/src/app/app.store.spec.ts @@ -18,11 +18,20 @@ import { of, skip, take } from 'rxjs'; import { AppStore, CONSENT_SHOWN_KEY } from './app.store'; import { MockStore, provideMockStore } from '@ngrx/store/testing'; import { AppState } from './store/state'; -import { selectHasDevices } from './store/selectors'; +import { + selectError, + selectHasConnectionSettings, + selectHasDevices, + selectInterfaces, + selectIsTestrunStarted, + selectMenuOpened, + selectSystemStatus, +} from './store/selectors'; import { TestRunService } from './services/test-run.service'; import SpyObj = jasmine.SpyObj; import { device } from './mocks/device.mock'; -import { setDevices } from './store/actions'; +import { setDevices, setTestrunStatus } from './store/actions'; +import { MOCK_PROGRESS_DATA_IN_PROGRESS } from './mocks/progress.mock'; const mock = (() => { let store: { [key: string]: string } = {}; @@ -48,7 +57,7 @@ describe('AppStore', () => { let mockService: SpyObj; beforeEach(() => { - mockService = jasmine.createSpyObj(['fetchDevices']); + mockService = jasmine.createSpyObj(['fetchDevices', 'fetchSystemStatus']); TestBed.configureTestingModule({ providers: [ @@ -62,6 +71,13 @@ describe('AppStore', () => { appStore = TestBed.inject(AppStore); store.overrideSelector(selectHasDevices, true); + store.overrideSelector(selectHasConnectionSettings, true); + store.overrideSelector(selectMenuOpened, true); + store.overrideSelector(selectInterfaces, {}); + store.overrideSelector(selectError, null); + store.overrideSelector(selectSystemStatus, MOCK_PROGRESS_DATA_IN_PROGRESS); + store.overrideSelector(selectIsTestrunStarted, false); + spyOn(store, 'dispatch').and.callFake(() => {}); }); @@ -82,6 +98,15 @@ describe('AppStore', () => { appStore.updateConsent(true); }); + + it('should update isStatusLoaded', (done: DoneFn) => { + appStore.viewModel$.pipe(skip(1), take(1)).subscribe(store => { + expect(store.isStatusLoaded).toEqual(true); + done(); + }); + + appStore.updateIsStatusLoaded(true); + }); }); describe('selectors', () => { @@ -90,6 +115,13 @@ describe('AppStore', () => { expect(store).toEqual({ consentShown: false, hasDevices: true, + isTestrunStarted: false, + isStatusLoaded: false, + systemStatus: MOCK_PROGRESS_DATA_IN_PROGRESS, + hasConnectionSettings: true, + isMenuOpen: true, + interfaces: {}, + settingMissedError: null, }); done(); }); @@ -127,5 +159,30 @@ describe('AppStore', () => { expect(store.dispatch).toHaveBeenCalledWith(setDevices({ devices })); }); }); + + describe('getSystemStatus', () => { + const status = MOCK_PROGRESS_DATA_IN_PROGRESS; + + beforeEach(() => { + mockService.fetchSystemStatus.and.returnValue(of(status)); + }); + + it('should dispatch action setTestrunStatus', () => { + appStore.getSystemStatus(); + + expect(store.dispatch).toHaveBeenCalledWith( + setTestrunStatus({ systemStatus: status }) + ); + }); + + it('should update store', done => { + appStore.viewModel$.pipe(skip(1), take(1)).subscribe(store => { + expect(store.systemStatus).toEqual(status); + done(); + }); + + appStore.getSystemStatus(); + }); + }); }); }); diff --git a/modules/ui/src/app/app.store.ts b/modules/ui/src/app/app.store.ts index 86d3560c6..d214d4848 100644 --- a/modules/ui/src/app/app.store.ts +++ b/modules/ui/src/app/app.store.ts @@ -17,26 +17,61 @@ import { Injectable } from '@angular/core'; import { ComponentStore } from '@ngrx/component-store'; import { tap } from 'rxjs/operators'; -import { selectHasDevices } from './store/selectors'; +import { + selectError, + selectHasConnectionSettings, + selectHasDevices, + selectInterfaces, + selectIsTestrunStarted, + selectMenuOpened, + selectSystemStatus, +} from './store/selectors'; import { Store } from '@ngrx/store'; import { AppState } from './store/state'; import { TestRunService } from './services/test-run.service'; -import { exhaustMap } from 'rxjs'; +import { exhaustMap, Observable } from 'rxjs'; import { Device } from './model/device'; -import { setDevices } from './store/actions'; +import { + setDevices, + setTestrunStatus, + setIsOpenStartTestrun, +} from './store/actions'; +import { TestrunStatus } from './model/testrun-status'; +import { SettingMissedError, SystemInterfaces } from './model/setting'; export const CONSENT_SHOWN_KEY = 'CONSENT_SHOWN'; export interface AppComponentState { consentShown: boolean; + isStatusLoaded: boolean; + isTestrunStarted: boolean; + systemStatus: TestrunStatus | null; } @Injectable() export class AppStore extends ComponentStore { private consentShown$ = this.select(state => state.consentShown); + private isStatusLoaded$ = this.select(state => state.isStatusLoaded); private hasDevices$ = this.store.select(selectHasDevices); + private hasConnectionSetting$ = this.store.select( + selectHasConnectionSettings + ); + private isMenuOpen$ = this.store.select(selectMenuOpened); + private interfaces$: Observable = + this.store.select(selectInterfaces); + private settingMissedError$: Observable = + this.store.select(selectError); + private systemStatus$ = this.store.select(selectSystemStatus); + private isTestrunStarted$ = this.store.select(selectIsTestrunStarted); viewModel$ = this.select({ consentShown: this.consentShown$, hasDevices: this.hasDevices$, + isTestrunStarted: this.isTestrunStarted$, + isStatusLoaded: this.isStatusLoaded$, + systemStatus: this.systemStatus$, + hasConnectionSettings: this.hasConnectionSetting$, + isMenuOpen: this.isMenuOpen$, + interfaces: this.interfaces$, + settingMissedError: this.settingMissedError$, }); updateConsent = this.updater((state, consentShown: boolean) => ({ @@ -44,6 +79,11 @@ export class AppStore extends ComponentStore { consentShown, })); + updateIsStatusLoaded = this.updater((state, isStatusLoaded: boolean) => ({ + ...state, + isStatusLoaded, + })); + setContent = this.effect(trigger$ => { return trigger$.pipe( tap(() => { @@ -65,12 +105,38 @@ export class AppStore extends ComponentStore { ); }); + getSystemStatus = this.effect(trigger$ => { + return trigger$.pipe( + exhaustMap(() => { + return this.testRunService.fetchSystemStatus().pipe( + tap((res: TestrunStatus) => { + this.updateIsStatusLoaded(true); + this.store.dispatch(setTestrunStatus({ systemStatus: res })); + }) + ); + }) + ); + }); + + setIsOpenStartTestrun = this.effect(trigger$ => { + return trigger$.pipe( + tap(() => { + this.store.dispatch( + setIsOpenStartTestrun({ isOpenStartTestrun: true }) + ); + }) + ); + }); + constructor( private store: Store, private testRunService: TestRunService ) { super({ consentShown: sessionStorage.getItem(CONSENT_SHOWN_KEY) !== null, + isStatusLoaded: false, + isTestrunStarted: false, + systemStatus: null, }); } } diff --git a/modules/ui/src/app/pages/testrun/components/progress-initiate-form/progress-initiate-form.component.html b/modules/ui/src/app/pages/testrun/components/progress-initiate-form/progress-initiate-form.component.html index 0cbb2d3d3..93563201e 100644 --- a/modules/ui/src/app/pages/testrun/components/progress-initiate-form/progress-initiate-form.component.html +++ b/modules/ui/src/app/pages/testrun/components/progress-initiate-form/progress-initiate-form.component.html @@ -76,7 +76,7 @@ type="button"> Change Device - - -
- - - - -
+ + - - -
- - - + +
+
+ +
+
+

+ {{ data.device.manufacturer }} {{ data.device.model }} v{{ + data.device.firmware + }} +

+ + +
+
+ + +
+
+ + +
+
+ + + - - -
- -
-
+ +
+ +
+
- - - + + + + diff --git a/modules/ui/src/app/pages/testrun/progress.component.spec.ts b/modules/ui/src/app/pages/testrun/progress.component.spec.ts index 22c4fcc52..cd8f0c93c 100644 --- a/modules/ui/src/app/pages/testrun/progress.component.spec.ts +++ b/modules/ui/src/app/pages/testrun/progress.component.spec.ts @@ -15,7 +15,6 @@ */ import { ComponentFixture, - discardPeriodicTasks, fakeAsync, TestBed, tick, @@ -25,34 +24,37 @@ import { ProgressComponent } from './progress.component'; import { TestRunService } from '../../services/test-run.service'; import { of } from 'rxjs'; import { - EMPTY_RESULT, MOCK_PROGRESS_DATA_CANCELLED, - MOCK_PROGRESS_DATA_CANCELLED_EMPTY, MOCK_PROGRESS_DATA_CANCELLING, MOCK_PROGRESS_DATA_COMPLIANT, MOCK_PROGRESS_DATA_IN_PROGRESS, - MOCK_PROGRESS_DATA_IN_PROGRESS_EMPTY, MOCK_PROGRESS_DATA_MONITORING, MOCK_PROGRESS_DATA_NOT_STARTED, MOCK_PROGRESS_DATA_WAITING_FOR_DEVICE, - TEST_DATA_TABLE_RESULT, } from '../../mocks/progress.mock'; import { MatButtonModule } from '@angular/material/button'; import { MatIconModule } from '@angular/material/icon'; import { MatToolbarModule } from '@angular/material/toolbar'; import { Component, Input } from '@angular/core'; -import { Observable } from 'rxjs/internal/Observable'; import { IResult, TestrunStatus } from '../../model/testrun-status'; import { MatDialogModule, MatDialogRef } from '@angular/material/dialog'; -import { BehaviorSubject } from 'rxjs/internal/BehaviorSubject'; import { ProgressInitiateFormComponent } from './components/progress-initiate-form/progress-initiate-form.component'; +import { DownloadReportComponent } from '../../components/download-report/download-report.component'; import { DeleteFormComponent } from '../../components/delete-form/delete-form.component'; import { SpinnerComponent } from '../../components/spinner/spinner.component'; import { LoaderService } from '../../services/loader.service'; import { FocusManagerService } from '../../services/focus-manager.service'; import { MockStore, provideMockStore } from '@ngrx/store/testing'; import { AppState } from '../../store/state'; -import { selectDevices, selectHasDevices } from '../../store/selectors'; +import { + selectDevices, + selectHasDevices, + selectIsOpenStartTestrun, + selectIsTestrunStarted, + selectSystemStatus, +} from '../../store/selectors'; +import { TestrunStore } from './testrun.store'; +import { setTestrunStatus } from '../../store/actions'; describe('ProgressComponent', () => { let component: ProgressComponent; @@ -62,13 +64,11 @@ describe('ProgressComponent', () => { const testRunServiceMock: jasmine.SpyObj = jasmine.createSpyObj([ - 'getSystemStatus', - 'setSystemStatus', - 'systemStatus$', 'stopTestrun', 'getDevices', 'isOpenStartTestrun$', 'isTestrunStarted$', + 'fetchSystemStatus', ]); const loaderServiceMock: jasmine.SpyObj = jasmine.createSpyObj( @@ -78,12 +78,8 @@ describe('ProgressComponent', () => { const stateServiceMock: jasmine.SpyObj = jasmine.createSpyObj('stateServiceMock', ['focusFirstElementInContainer']); - testRunServiceMock.isOpenStartTestrun$ = new BehaviorSubject(false); - testRunServiceMock.isTestrunStarted$ = new BehaviorSubject(false); - describe('Class tests', () => { beforeEach(() => { - testRunServiceMock.systemStatus$ = of(MOCK_PROGRESS_DATA_IN_PROGRESS); testRunServiceMock.stopTestrun.and.returnValue(of(true)); TestBed.configureTestingModule({ @@ -94,16 +90,23 @@ describe('ProgressComponent', () => { FakeDownloadOptionsComponent, ], providers: [ + TestrunStore, { provide: TestRunService, useValue: testRunServiceMock }, { provide: FocusManagerService, useValue: stateServiceMock }, + { provide: LoaderService, useValue: loaderServiceMock }, { provide: MatDialogRef, useValue: {}, }, provideMockStore({ selectors: [ - { selector: selectDevices, value: [] }, { selector: selectHasDevices, value: false }, + { selector: selectIsOpenStartTestrun, value: false }, + { selector: selectIsTestrunStarted, value: false }, + { + selector: selectSystemStatus, + value: MOCK_PROGRESS_DATA_IN_PROGRESS, + }, ], }), ], @@ -124,22 +127,22 @@ describe('ProgressComponent', () => { }) .compileComponents(); + testRunServiceMock.fetchSystemStatus.and.returnValue( + of(MOCK_PROGRESS_DATA_IN_PROGRESS) + ); store = TestBed.inject(MockStore); fixture = TestBed.createComponent(ProgressComponent); + spyOn(store, 'dispatch').and.callFake(() => {}); component = fixture.componentInstance; }); - afterEach(() => { - testRunServiceMock.getSystemStatus.calls.reset(); - }); - it('should create', () => { expect(component).toBeTruthy(); }); describe('openTestRunModal on first flow', () => { beforeEach(() => { - testRunServiceMock.isOpenStartTestrun$ = new BehaviorSubject(true); + store.overrideSelector(selectIsOpenStartTestrun, true); component.ngOnInit(); }); @@ -166,12 +169,14 @@ describe('ProgressComponent', () => { }); it('should update system status to Cancelling', () => { - component.currentStatus = { ...MOCK_PROGRESS_DATA_IN_PROGRESS }; - + store.overrideSelector( + selectSystemStatus, + MOCK_PROGRESS_DATA_IN_PROGRESS + ); component.stopTestrun(); - expect(testRunServiceMock.setSystemStatus).toHaveBeenCalledWith( - MOCK_PROGRESS_DATA_CANCELLING + expect(store.dispatch).toHaveBeenCalledWith( + setTestrunStatus({ systemStatus: MOCK_PROGRESS_DATA_CANCELLING }) ); }); }); @@ -182,134 +187,17 @@ describe('ProgressComponent', () => { afterClosed: () => of(true), } as MatDialogRef); - component.openStopTestrunDialog(); + component.openStopTestrunDialog(MOCK_PROGRESS_DATA_CANCELLING); expect(stopTestrunSpy).toHaveBeenCalled(); }); describe('#ngOnInit', () => { - it('should set systemStatus$ value', () => { - component.ngOnInit(); - - component.systemStatus$.subscribe(res => { - expect(res).toEqual(MOCK_PROGRESS_DATA_IN_PROGRESS); - }); - }); - - it('should set hasDevices$ value', () => { - component.ngOnInit(); - - component.hasDevices$.subscribe(res => { - expect(res).toEqual(false); - }); - }); - - describe('dataSource$', () => { - it('should set value with empty values if result length < total for status "In Progress"', () => { - const expectedResult = TEST_DATA_TABLE_RESULT; - - testRunServiceMock.systemStatus$ = of(MOCK_PROGRESS_DATA_IN_PROGRESS); - component.ngOnInit(); - - component.dataSource$.subscribe(res => { - expect(res).toEqual(expectedResult); - }); - }); - - it('should set value with empty values for status "Monitoring"', () => { - const expectedResult = EMPTY_RESULT; - - testRunServiceMock.systemStatus$ = of(MOCK_PROGRESS_DATA_MONITORING); - component.ngOnInit(); - - component.dataSource$.subscribe(res => { - expect(res).toEqual(expectedResult); - }); - }); - - it('should set value with empty values for status "Waiting for Device"', () => { - const expectedResult = EMPTY_RESULT; - - testRunServiceMock.systemStatus$ = of( - MOCK_PROGRESS_DATA_WAITING_FOR_DEVICE - ); - component.ngOnInit(); - - component.dataSource$.subscribe(res => { - expect(res).toEqual(expectedResult); - }); - }); - - it('should set value with empty values for status "Cancelled" and empty result', () => { - const expectedResult = EMPTY_RESULT; - - testRunServiceMock.systemStatus$ = of( - MOCK_PROGRESS_DATA_CANCELLED_EMPTY - ); - component.ngOnInit(); - - component.dataSource$.subscribe(res => { - expect(res).toEqual(expectedResult); - }); - }); - }); - - it('should call focusFirstElementInContainer when testrun stops after cancelling', () => { - testRunServiceMock.systemStatus$ = of(MOCK_PROGRESS_DATA_COMPLIANT); - component.isCancelling = true; - + it('should get systemStatus value', () => { + const spyOpenSetting = spyOn(component.testrunStore, 'getStatus'); component.ngOnInit(); - fixture.detectChanges(); - - expect( - stateServiceMock.focusFirstElementInContainer - ).toHaveBeenCalled(); - }); - - describe('hideLoading', () => { - it('should called if testrun is finished', () => { - testRunServiceMock.systemStatus$ = of(MOCK_PROGRESS_DATA_COMPLIANT); - - component.ngOnInit(); - - component.systemStatus$.subscribe(() => { - expect(loaderServiceMock.setLoading).toHaveBeenCalledWith(false); - }); - }); - - it('should called if testrun is in progress and have some test finished', () => { - testRunServiceMock.systemStatus$ = of(MOCK_PROGRESS_DATA_IN_PROGRESS); - - component.ngOnInit(); - component.systemStatus$.subscribe(() => { - expect(loaderServiceMock.setLoading).toHaveBeenCalledWith(false); - }); - }); - }); - - describe('showLoading', () => { - it('should be called if testrun is monitoring', () => { - testRunServiceMock.systemStatus$ = of(MOCK_PROGRESS_DATA_MONITORING); - - component.ngOnInit(); - - component.systemStatus$.subscribe(() => { - expect(loaderServiceMock.setLoading).toHaveBeenCalledWith(true); - }); - }); - - it('should be called if testrun is in progress and have some test finished', () => { - testRunServiceMock.systemStatus$ = of( - MOCK_PROGRESS_DATA_IN_PROGRESS_EMPTY - ); - - component.ngOnInit(); - - component.systemStatus$.subscribe(() => { - expect(loaderServiceMock.setLoading).toHaveBeenCalledWith(true); - }); - }); + expect(spyOpenSetting).toHaveBeenCalled(); }); }); }); @@ -326,8 +214,10 @@ describe('ProgressComponent', () => { FakeDownloadOptionsComponent, ], providers: [ + TestrunStore, { provide: TestRunService, useValue: testRunServiceMock }, { provide: FocusManagerService, useValue: stateServiceMock }, + { provide: LoaderService, useValue: loaderServiceMock }, { provide: MatDialogRef, useValue: {}, @@ -359,16 +249,15 @@ describe('ProgressComponent', () => { store = TestBed.inject(MockStore); fixture = TestBed.createComponent(ProgressComponent); compiled = fixture.nativeElement as HTMLElement; + testRunServiceMock.fetchSystemStatus.and.returnValue( + of(MOCK_PROGRESS_DATA_IN_PROGRESS) + ); component = fixture.componentInstance; }); - afterEach(() => { - testRunServiceMock.getSystemStatus.calls.reset(); - }); - describe('with not devices$ data', () => { beforeEach(() => { - (testRunServiceMock.systemStatus$ as unknown) = of(null); + store.overrideSelector(selectSystemStatus, null); store.overrideSelector(selectHasDevices, false); fixture.detectChanges(); }); @@ -384,7 +273,7 @@ describe('ProgressComponent', () => { describe('with not systemStatus$ data', () => { beforeEach(() => { - (testRunServiceMock.systemStatus$ as unknown) = of(null); + store.overrideSelector(selectSystemStatus, null); store.overrideSelector(selectHasDevices, true); fixture.detectChanges(); }); @@ -435,7 +324,10 @@ describe('ProgressComponent', () => { describe('with available systemStatus$ data, status "In Progress"', () => { beforeEach(() => { - testRunServiceMock.systemStatus$ = of(MOCK_PROGRESS_DATA_IN_PROGRESS); + store.overrideSelector( + selectSystemStatus, + MOCK_PROGRESS_DATA_IN_PROGRESS + ); store.overrideSelector(selectHasDevices, true); fixture.detectChanges(); }); @@ -488,21 +380,12 @@ describe('ProgressComponent', () => { }); }); - describe('pullingSystemStatusData with available status "In Progress"', () => { - it('should call again getSystemStatus)', fakeAsync(() => { - testRunServiceMock.systemStatus$ = of(MOCK_PROGRESS_DATA_IN_PROGRESS); - store.overrideSelector(selectHasDevices, true); - fixture.detectChanges(); - tick(5000); - - expect(testRunServiceMock.getSystemStatus).toHaveBeenCalledTimes(1); - discardPeriodicTasks(); - })); - }); - describe('with available systemStatus$ data, as Completed', () => { beforeEach(() => { - testRunServiceMock.systemStatus$ = of(MOCK_PROGRESS_DATA_COMPLIANT); + store.overrideSelector( + selectSystemStatus, + MOCK_PROGRESS_DATA_COMPLIANT + ); store.overrideSelector(selectHasDevices, true); fixture.detectChanges(); }); @@ -530,7 +413,10 @@ describe('ProgressComponent', () => { describe('with available systemStatus$ data, as Cancelled', () => { beforeEach(() => { - testRunServiceMock.systemStatus$ = of(MOCK_PROGRESS_DATA_CANCELLED); + store.overrideSelector( + selectSystemStatus, + MOCK_PROGRESS_DATA_CANCELLED + ); store.overrideSelector(selectHasDevices, true); fixture.detectChanges(); }); @@ -552,7 +438,8 @@ describe('ProgressComponent', () => { describe('with available systemStatus$ data, as Waiting for Device', () => { beforeEach(() => { - testRunServiceMock.systemStatus$ = of( + store.overrideSelector( + selectSystemStatus, MOCK_PROGRESS_DATA_WAITING_FOR_DEVICE ); store.overrideSelector(selectHasDevices, true); @@ -576,7 +463,10 @@ describe('ProgressComponent', () => { describe('with available systemStatus$ data, as Monitoring', () => { beforeEach(() => { - testRunServiceMock.systemStatus$ = of(MOCK_PROGRESS_DATA_MONITORING); + store.overrideSelector( + selectSystemStatus, + MOCK_PROGRESS_DATA_MONITORING + ); store.overrideSelector(selectHasDevices, true); fixture.detectChanges(); }); @@ -598,7 +488,10 @@ describe('ProgressComponent', () => { describe('with available systemStatus$ data, when Testrun not started on Idle status', () => { beforeEach(() => { - testRunServiceMock.systemStatus$ = of(MOCK_PROGRESS_DATA_NOT_STARTED); + store.overrideSelector( + selectSystemStatus, + MOCK_PROGRESS_DATA_NOT_STARTED + ); store.overrideSelector(selectHasDevices, true); fixture.detectChanges(); }); @@ -621,7 +514,7 @@ describe('ProgressComponent', () => { template: '
', }) class FakeProgressStatusCardComponent { - @Input() systemStatus$!: Observable; + @Input() systemStatus!: TestrunStatus; } @Component({ @@ -629,7 +522,8 @@ class FakeProgressStatusCardComponent { template: '
', }) class FakeProgressTableComponent { - @Input() dataSource$!: Observable; + @Input() dataSource!: IResult[] | undefined; + @Input() stepsToResolveCount!: number; } @Component({ diff --git a/modules/ui/src/app/pages/testrun/progress.component.ts b/modules/ui/src/app/pages/testrun/progress.component.ts index 7a643de7a..8f599b9d4 100644 --- a/modules/ui/src/app/pages/testrun/progress.component.ts +++ b/modules/ui/src/app/pages/testrun/progress.component.ts @@ -19,24 +19,12 @@ import { OnDestroy, OnInit, } from '@angular/core'; -import { Observable } from 'rxjs/internal/Observable'; -import { TestRunService } from '../../services/test-run.service'; import { - IResult, StatusOfTestrun, TestrunStatus, - TestsData, TestsResponse, } from '../../model/testrun-status'; -import { - interval, - map, - shareReplay, - Subject, - takeUntil, - tap, - timer, -} from 'rxjs'; +import { Subject, takeUntil, timer } from 'rxjs'; import { MatDialog } from '@angular/material/dialog'; import { ProgressInitiateFormComponent } from './components/progress-initiate-form/progress-initiate-form.component'; import { DeleteFormComponent } from '../../components/delete-form/delete-form.component'; @@ -44,11 +32,7 @@ import { LoaderService } from '../../services/loader.service'; import { LOADER_TIMEOUT_CONFIG_TOKEN } from '../../services/loaderConfig'; import { FocusManagerService } from '../../services/focus-manager.service'; import { combineLatest } from 'rxjs/internal/observable/combineLatest'; -import { Store } from '@ngrx/store'; -import { AppState } from '../../store/state'; -import { selectHasDevices } from '../../store/selectors'; - -const EMPTY_RESULT = new Array(100).fill(null).map(() => ({}) as IResult); +import { TestrunStore } from './testrun.store'; @Component({ selector: 'app-progress', @@ -58,34 +42,25 @@ const EMPTY_RESULT = new Array(100).fill(null).map(() => ({}) as IResult); providers: [ LoaderService, { provide: LOADER_TIMEOUT_CONFIG_TOKEN, useValue: 0 }, + TestrunStore, ], }) export class ProgressComponent implements OnInit, OnDestroy { - public systemStatus$!: Observable; - public dataSource$!: Observable; - public hasDevices$!: Observable; public readonly StatusOfTestrun = StatusOfTestrun; - private destroy$: Subject = new Subject(); - private destroyInterval$: Subject = new Subject(); - private startInterval = false; - public currentStatus: TestrunStatus | null = null; - isCancelling = false; + viewModel$ = this.testrunStore.viewModel$; constructor( - private readonly testRunService: TestRunService, - private readonly loaderService: LoaderService, public dialog: MatDialog, - private readonly state: FocusManagerService, - private store: Store + private readonly focusManagerService: FocusManagerService, + public testrunStore: TestrunStore ) {} ngOnInit(): void { - this.hasDevices$ = this.store.select(selectHasDevices); - + this.testrunStore.getStatus(); combineLatest([ - this.testRunService.isOpenStartTestrun$, - this.testRunService.isTestrunStarted$, + this.testrunStore.isOpenStartTestrun$, + this.testrunStore.isTestrunStarted$, ]) .pipe(takeUntil(this.destroy$)) .subscribe(([isOpenStartTestrun, isTestrunStarted]) => { @@ -93,70 +68,6 @@ export class ProgressComponent implements OnInit, OnDestroy { this.openTestRunModal(); } }); - - this.systemStatus$ = this.testRunService.systemStatus$.pipe( - tap(res => { - this.currentStatus = res; - if (this.testrunInProgress(res.status) && !this.startInterval) { - this.pullingSystemStatusData(); - } - if ( - res.status === StatusOfTestrun.WaitingForDevice || - res.status === StatusOfTestrun.Monitoring || - (res.status === StatusOfTestrun.InProgress && - this.resultIsEmpty(res.tests)) - ) { - this.showLoading(); - } - if ( - res.status === StatusOfTestrun.InProgress && - !this.resultIsEmpty(res.tests) - ) { - this.hideLoading(); - } - if ( - !this.testrunInProgress(res.status) && - res.status !== StatusOfTestrun.Cancelling - ) { - if (this.isCancelling) { - this.state.focusFirstElementInContainer(); - } - this.isCancelling = false; - this.destroyInterval$.next(true); - this.startInterval = false; - this.hideLoading(); - } - }), - shareReplay({ refCount: true, bufferSize: 1 }) - ); - - this.dataSource$ = this.systemStatus$.pipe( - map((res: TestrunStatus) => { - const results = (res.tests as TestsData)?.results || []; - if ( - res.status === StatusOfTestrun.Monitoring || - res.status === StatusOfTestrun.WaitingForDevice || - (res.status === StatusOfTestrun.Cancelled && !results.length) - ) { - return EMPTY_RESULT; - } - - const total = (res.tests as TestsData)?.total || 100; - if ( - res.status === StatusOfTestrun.InProgress && - results.length < total - ) { - return [ - ...results, - ...new Array(total - results.length) - .fill(null) - .map(() => ({}) as IResult), - ]; - } - - return results; - }) - ); } testrunInProgress(status?: string): boolean { @@ -167,21 +78,11 @@ export class ProgressComponent implements OnInit, OnDestroy { ); } - private pullingSystemStatusData(): void { - this.startInterval = true; - interval(5000) - .pipe( - takeUntil(this.destroyInterval$), - tap(() => this.testRunService.getSystemStatus(this.isCancelling)) - ) - .subscribe(); - } - - public openStopTestrunDialog() { + public openStopTestrunDialog(systemStatus: TestrunStatus) { const dialogRef = this.dialog.open(DeleteFormComponent, { - ariaLabel: `Stop testrun ${this.getTestRunName()}`, + ariaLabel: `Stop testrun ${this.getTestRunName(systemStatus)}`, data: { - title: `Stop testrun ${this.getTestRunName()}`, + title: `Stop testrun ${this.getTestRunName(systemStatus)}`, content: 'Are you sure you would like to stop testrun without a report generation?', }, @@ -207,41 +108,28 @@ export class ProgressComponent implements OnInit, OnDestroy { this.sendCloseRequest(); } - private getTestRunName(): string { - if (this.currentStatus?.device) { - const device = this.currentStatus.device; + private getTestRunName(systemStatus: TestrunStatus): string { + if (systemStatus?.device) { + const device = systemStatus.device; return `${device.manufacturer} ${device.model} v${device.firmware}`; } else { return ''; } } private setCancellingStatus() { - this.isCancelling = true; - if (this.currentStatus) { - this.currentStatus.status = StatusOfTestrun.Cancelling; - this.testRunService.setSystemStatus(this.currentStatus); - } + this.testrunStore.setCancellingStatus(); } private showLoading() { - this.loaderService.setLoading(true); + this.testrunStore.showLoading(); } - - private hideLoading() { - this.loaderService.setLoading(false); - } - private sendCloseRequest() { - this.testRunService - .stopTestrun() - .pipe(takeUntil(this.destroy$)) - .subscribe(); + this.testrunStore.stopTestrun(); } ngOnDestroy() { this.destroy$.next(true); this.destroy$.unsubscribe(); - this.destroyInterval$.next(true); - this.destroyInterval$.unsubscribe(); + this.testrunStore.destroyInterval(); } openTestRunModal(): void { @@ -256,19 +144,21 @@ export class ProgressComponent implements OnInit, OnDestroy { dialogRef ?.afterClosed() .pipe(takeUntil(this.destroy$)) - .subscribe(() => { + .subscribe((startTestrun: boolean) => { + if (startTestrun) { + this.testrunStore.setIsTestrunStarted(true); + this.testrunStore.getStatus(); + } + this.testrunStore.setIsOpenStartTestrun(false); timer(10) .pipe(takeUntil(this.destroy$)) .subscribe(() => { - this.state.focusFirstElementInContainer(); + this.focusManagerService.focusFirstElementInContainer(); }); }); } resultIsEmpty(tests: TestsResponse | undefined) { - return ( - (tests as TestsData)?.results?.length === 0 || - (tests as IResult[])?.length === 0 - ); + this.testrunStore.resultIsEmpty(tests); } } diff --git a/modules/ui/src/app/pages/testrun/testrun.store.spec.ts b/modules/ui/src/app/pages/testrun/testrun.store.spec.ts new file mode 100644 index 000000000..705e5a7db --- /dev/null +++ b/modules/ui/src/app/pages/testrun/testrun.store.spec.ts @@ -0,0 +1,326 @@ +/* + * Copyright 2023 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +import { TestRunService } from '../../services/test-run.service'; +import SpyObj = jasmine.SpyObj; +import { + discardPeriodicTasks, + fakeAsync, + TestBed, + tick, +} from '@angular/core/testing'; +import { MockStore, provideMockStore } from '@ngrx/store/testing'; +import { AppState } from '../../store/state'; +import { skip, take, of } from 'rxjs'; +import { + selectHasConnectionSettings, + selectHasDevices, + selectIsOpenStartTestrun, + selectIsTestrunStarted, + selectSystemStatus, +} from '../../store/selectors'; +import { + setIsOpenStartTestrun, + setIsTestrunStarted, + setTestrunStatus, +} from '../../store/actions'; +import { TestrunStore } from './testrun.store'; +import { + EMPTY_RESULT, + MOCK_PROGRESS_DATA_CANCELLED_EMPTY, + MOCK_PROGRESS_DATA_CANCELLING, + MOCK_PROGRESS_DATA_COMPLIANT, + MOCK_PROGRESS_DATA_IN_PROGRESS, + MOCK_PROGRESS_DATA_IN_PROGRESS_EMPTY, + MOCK_PROGRESS_DATA_MONITORING, + MOCK_PROGRESS_DATA_WAITING_FOR_DEVICE, + TEST_DATA_RESULT_WITH_RECOMMENDATIONS, + TEST_DATA_TABLE_RESULT, +} from '../../mocks/progress.mock'; +import { LoaderService } from '../../services/loader.service'; + +describe('TestrunStore', () => { + let testrunStore: TestrunStore; + let mockService: SpyObj; + let store: MockStore; + const loaderServiceMock: jasmine.SpyObj = jasmine.createSpyObj( + 'loaderServiceMock', + ['setLoading', 'getLoading'] + ); + + beforeEach(() => { + mockService = jasmine.createSpyObj(['stopTestrun', 'fetchSystemStatus']); + + TestBed.configureTestingModule({ + providers: [ + TestrunStore, + { provide: TestRunService, useValue: mockService }, + { provide: LoaderService, useValue: loaderServiceMock }, + provideMockStore({ + selectors: [ + { selector: selectHasDevices, value: false }, + { selector: selectSystemStatus, value: null }, + { selector: selectIsTestrunStarted, value: true }, + { selector: selectHasConnectionSettings, value: true }, + { selector: selectIsOpenStartTestrun, value: false }, + ], + }), + ], + }); + + testrunStore = TestBed.inject(TestrunStore); + store = TestBed.inject(MockStore); + spyOn(store, 'dispatch').and.callFake(() => {}); + }); + + afterEach(() => { + mockService.fetchSystemStatus.calls.reset(); + }); + + it('should be created', () => { + expect(testrunStore).toBeTruthy(); + }); + + describe('selectors', () => { + it('should select state', done => { + testrunStore.viewModel$.pipe(take(1)).subscribe(store => { + expect(store).toEqual({ + hasDevices: false, + systemStatus: null, + dataSource: undefined, + stepsToResolveCount: 0, + isCancelling: false, + startInterval: false, + }); + done(); + }); + }); + }); + + describe('updaters', () => { + it('should update dataSource and stepsToResolveCount', (done: DoneFn) => { + const dataSource = [...TEST_DATA_RESULT_WITH_RECOMMENDATIONS]; + + testrunStore.viewModel$.pipe(skip(2), take(1)).subscribe(store => { + expect(store.dataSource).toEqual(dataSource); + expect(store.stepsToResolveCount).toEqual(1); + done(); + }); + + testrunStore.setDataSource(dataSource); + }); + + it('should update isCancelling', (done: DoneFn) => { + testrunStore.viewModel$.pipe(skip(1), take(1)).subscribe(store => { + expect(store.isCancelling).toEqual(true); + done(); + }); + + testrunStore.updateCancelling(true); + }); + + it('should update startInterval', (done: DoneFn) => { + testrunStore.viewModel$.pipe(skip(1), take(1)).subscribe(store => { + expect(store.startInterval).toEqual(true); + done(); + }); + + testrunStore.updateStartInterval(true); + }); + }); + + describe('effects', () => { + describe('getStatus', () => { + const status = { ...MOCK_PROGRESS_DATA_IN_PROGRESS }; + beforeEach(() => { + mockService.fetchSystemStatus.and.returnValue(of(status)); + }); + + it('should dispatch action setTestrunStatus', () => { + testrunStore.getStatus(); + + expect(store.dispatch).toHaveBeenCalledWith( + setTestrunStatus({ systemStatus: status }) + ); + }); + + it('should change status to Cancelling if cancelling', () => { + testrunStore.updateCancelling(true); + testrunStore.getStatus(); + + expect(store.dispatch).toHaveBeenCalledWith( + setTestrunStatus({ systemStatus: MOCK_PROGRESS_DATA_CANCELLING }) + ); + }); + + describe('pullingSystemStatusData with available status "In Progress"', () => { + it('should call again getSystemStatus', fakeAsync(() => { + testrunStore.updateStartInterval(false); + store.overrideSelector( + selectSystemStatus, + MOCK_PROGRESS_DATA_IN_PROGRESS + ); + + testrunStore.getStatus(); + + tick(7000); + + expect(mockService.fetchSystemStatus).toHaveBeenCalledTimes(1); + discardPeriodicTasks(); + })); + }); + + describe('dataSource', () => { + it('should set value with empty values if result length < total for status "In Progress"', done => { + const expectedResult = TEST_DATA_TABLE_RESULT; + + mockService.fetchSystemStatus.and.returnValue( + of(MOCK_PROGRESS_DATA_IN_PROGRESS) + ); + testrunStore.getStatus(); + + testrunStore.viewModel$.pipe(take(1)).subscribe(store => { + expect(store.dataSource).toEqual(expectedResult); + done(); + }); + }); + + it('should set value with empty values for status "Monitoring"', done => { + const expectedResult = EMPTY_RESULT; + + mockService.fetchSystemStatus.and.returnValue( + of(MOCK_PROGRESS_DATA_MONITORING) + ); + testrunStore.getStatus(); + + testrunStore.viewModel$.pipe(take(1)).subscribe(store => { + expect(store.dataSource).toEqual(expectedResult); + done(); + }); + }); + + it('should set value with empty values for status "Waiting for Device"', done => { + const expectedResult = EMPTY_RESULT; + + mockService.fetchSystemStatus.and.returnValue( + of(MOCK_PROGRESS_DATA_WAITING_FOR_DEVICE) + ); + testrunStore.getStatus(); + + testrunStore.viewModel$.pipe(take(1)).subscribe(store => { + expect(store.dataSource).toEqual(expectedResult); + done(); + }); + }); + + it('should set value with empty values for status "Cancelled" and empty result', done => { + const expectedResult = EMPTY_RESULT; + + mockService.fetchSystemStatus.and.returnValue( + of(MOCK_PROGRESS_DATA_CANCELLED_EMPTY) + ); + testrunStore.getStatus(); + + testrunStore.viewModel$.pipe(take(1)).subscribe(store => { + expect(store.dataSource).toEqual(expectedResult); + done(); + }); + }); + + describe('hideLoading', () => { + it('should called if testrun is finished', () => { + mockService.fetchSystemStatus.and.returnValue( + of(MOCK_PROGRESS_DATA_COMPLIANT) + ); + testrunStore.getStatus(); + + expect(loaderServiceMock.setLoading).toHaveBeenCalledWith(false); + }); + + it('should called if testrun is in progress and have some test finished', () => { + mockService.fetchSystemStatus.and.returnValue( + of(MOCK_PROGRESS_DATA_IN_PROGRESS) + ); + testrunStore.getStatus(); + + expect(loaderServiceMock.setLoading).toHaveBeenCalledWith(false); + }); + }); + + describe('showLoading', () => { + it('should be called if testrun is monitoring', () => { + mockService.fetchSystemStatus.and.returnValue( + of(MOCK_PROGRESS_DATA_MONITORING) + ); + testrunStore.getStatus(); + + expect(loaderServiceMock.setLoading).toHaveBeenCalledWith(true); + }); + + it('should be called if testrun is in progress and have some test finished', () => { + mockService.fetchSystemStatus.and.returnValue( + of(MOCK_PROGRESS_DATA_IN_PROGRESS_EMPTY) + ); + testrunStore.getStatus(); + + expect(loaderServiceMock.setLoading).toHaveBeenCalledWith(true); + }); + }); + }); + }); + + describe('stopTestrun', () => { + beforeEach(() => { + mockService.stopTestrun.and.returnValue(of(true)); + }); + + it('should call stopTestrun', () => { + testrunStore.stopTestrun(); + + expect(mockService.stopTestrun).toHaveBeenCalled(); + }); + }); + + describe('setIsOpenStartTestrun', () => { + it('should dispatch action setIsOpenStartTestrun', () => { + testrunStore.setIsOpenStartTestrun(true); + + expect(store.dispatch).toHaveBeenCalledWith( + setIsOpenStartTestrun({ isOpenStartTestrun: true }) + ); + }); + }); + + describe('setIsTestrunStarted', () => { + it('should dispatch action setIsTestrunStarted', () => { + testrunStore.setIsTestrunStarted(true); + + expect(store.dispatch).toHaveBeenCalledWith( + setIsTestrunStarted({ isTestrunStarted: true }) + ); + }); + }); + + describe('setCancellingStatus', () => { + it('should update state', done => { + testrunStore.viewModel$.pipe(skip(1), take(1)).subscribe(store => { + expect(store.isCancelling).toEqual(true); + done(); + }); + testrunStore.setCancellingStatus(); + }); + }); + }); +}); diff --git a/modules/ui/src/app/pages/testrun/testrun.store.ts b/modules/ui/src/app/pages/testrun/testrun.store.ts new file mode 100644 index 000000000..bad911f93 --- /dev/null +++ b/modules/ui/src/app/pages/testrun/testrun.store.ts @@ -0,0 +1,276 @@ +/* + * Copyright 2023 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { Injectable } from '@angular/core'; +import { ComponentStore } from '@ngrx/component-store'; +import { TestRunService } from '../../services/test-run.service'; +import { exhaustMap, interval, Subject } from 'rxjs'; +import { tap, withLatestFrom } from 'rxjs/operators'; +import { AppState } from '../../store/state'; +import { Store } from '@ngrx/store'; +import { + selectHasDevices, + selectIsOpenStartTestrun, + selectIsTestrunStarted, + selectSystemStatus, +} from '../../store/selectors'; +import { + setIsOpenStartTestrun, + setIsTestrunStarted, + setTestrunStatus, +} from '../../store/actions'; +import { + IResult, + StatusOfTestrun, + TestrunStatus, + TestsData, + TestsResponse, +} from '../../model/testrun-status'; +import { takeUntil } from 'rxjs/internal/operators/takeUntil'; +import { FocusManagerService } from '../../services/focus-manager.service'; +import { LoaderService } from '../../services/loader.service'; + +const EMPTY_RESULT = new Array(100).fill(null).map(() => ({}) as IResult); + +export interface TestrunComponentState { + dataSource: IResult[] | undefined; + isCancelling: boolean; + startInterval: boolean; + stepsToResolveCount: number; +} + +@Injectable() +export class TestrunStore extends ComponentStore { + private destroyInterval$: Subject = new Subject(); + private dataSource$ = this.select(state => state.dataSource); + private isCancelling$ = this.select(state => state.isCancelling); + private startInterval$ = this.select(state => state.startInterval); + private stepsToResolveCount$ = this.select( + state => state.stepsToResolveCount + ); + private hasDevices$ = this.store.select(selectHasDevices); + private systemStatus$ = this.store.select(selectSystemStatus); + isTestrunStarted$ = this.store.select(selectIsTestrunStarted); + isOpenStartTestrun$ = this.store.select(selectIsOpenStartTestrun); + viewModel$ = this.select({ + hasDevices: this.hasDevices$, + systemStatus: this.systemStatus$, + dataSource: this.dataSource$, + stepsToResolveCount: this.stepsToResolveCount$, + isCancelling: this.isCancelling$, + startInterval: this.startInterval$, + }); + + setDataSource = this.updater((state, dataSource: IResult[] | undefined) => { + const stepsToResolveCount = + dataSource?.filter(result => result.recommendations).length || 0; + return { + ...state, + stepsToResolveCount, + dataSource, + }; + }); + + updateCancelling = this.updater((state, isCancelling: boolean) => { + return { + ...state, + isCancelling, + }; + }); + + updateStartInterval = this.updater((state, startInterval: boolean) => { + return { + ...state, + startInterval, + }; + }); + + getStatus = this.effect(trigger$ => { + return trigger$.pipe( + exhaustMap(() => { + return this.testRunService.fetchSystemStatus().pipe( + withLatestFrom(this.isCancelling$, this.startInterval$), + // change status if cancelling in process + tap(([res, isCancelling]) => { + if (isCancelling && res.status !== StatusOfTestrun.Cancelled) { + res.status = StatusOfTestrun.Cancelling; + } + }), + // perform some additional actions + tap(([res, , startInterval]) => { + this.store.dispatch(setTestrunStatus({ systemStatus: res })); + + if (this.testrunInProgress(res.status) && !startInterval) { + this.pullingSystemStatusData(); + } + if ( + res.status === StatusOfTestrun.WaitingForDevice || + res.status === StatusOfTestrun.Monitoring || + (res.status === StatusOfTestrun.InProgress && + this.resultIsEmpty(res.tests)) + ) { + this.showLoading(); + } + if ( + res.status === StatusOfTestrun.InProgress && + !this.resultIsEmpty(res.tests) + ) { + this.hideLoading(); + } + if ( + !this.testrunInProgress(res.status) && + res.status !== StatusOfTestrun.Cancelling + ) { + this.updateCancelling(false); + this.destroyInterval$.next(true); + this.updateStartInterval(false); + this.hideLoading(); + } + }), + // update data source + tap(([res]) => { + const results = (res.tests as TestsData)?.results || []; + if ( + res.status === StatusOfTestrun.Monitoring || + res.status === StatusOfTestrun.WaitingForDevice || + (res.status === StatusOfTestrun.Cancelled && !results.length) + ) { + this.setDataSource(EMPTY_RESULT); + return; + } + + const total = (res.tests as TestsData)?.total || 100; + if ( + res.status === StatusOfTestrun.InProgress && + results.length < total + ) { + this.setDataSource([ + ...results, + ...new Array(total - results.length) + .fill(null) + .map(() => ({}) as IResult), + ]); + return; + } + + this.setDataSource(results); + }) + ); + }) + ); + }); + stopTestrun = this.effect(trigger$ => { + return trigger$.pipe( + exhaustMap(() => { + return this.testRunService.stopTestrun(); + }) + ); + }); + + setIsOpenStartTestrun = this.effect(trigger$ => { + return trigger$.pipe( + tap(isOpenStartTestrun => { + this.store.dispatch(setIsOpenStartTestrun({ isOpenStartTestrun })); + }) + ); + }); + + setIsTestrunStarted = this.effect(trigger$ => { + return trigger$.pipe( + tap(isTestrunStarted => { + this.store.dispatch(setIsTestrunStarted({ isTestrunStarted })); + }) + ); + }); + + destroyInterval = this.effect(trigger$ => { + return trigger$.pipe( + tap(() => { + this.destroyInterval$.next(true); + this.destroyInterval$.unsubscribe(); + }) + ); + }); + + setCancellingStatus = this.effect(trigger$ => { + return trigger$.pipe( + withLatestFrom(this.systemStatus$), + tap(([, systemStatus]) => { + this.updateCancelling(true); + if (systemStatus) { + this.store.dispatch( + setTestrunStatus({ + systemStatus: this.getCancellingStatus(systemStatus), + }) + ); + } + }) + ); + }); + + resultIsEmpty(tests: TestsResponse | undefined) { + return ( + (tests as TestsData)?.results?.length === 0 || + (tests as IResult[])?.length === 0 + ); + } + + showLoading() { + this.loaderService.setLoading(true); + } + + private pullingSystemStatusData(): void { + this.updateStartInterval(true); + interval(5000) + .pipe( + takeUntil(this.destroyInterval$), + tap(() => this.getStatus()) + ) + .subscribe(); + } + + private getCancellingStatus(systemStatus: TestrunStatus): TestrunStatus { + const status = Object.assign({}, systemStatus); + status.status = StatusOfTestrun.Cancelling; + return status; + } + + private testrunInProgress(status?: string): boolean { + return ( + status === StatusOfTestrun.InProgress || + status === StatusOfTestrun.WaitingForDevice || + status === StatusOfTestrun.Monitoring + ); + } + + private hideLoading() { + this.loaderService.setLoading(false); + } + + constructor( + private testRunService: TestRunService, + private store: Store, + private readonly focusManagerService: FocusManagerService, + private readonly loaderService: LoaderService + ) { + super({ + isCancelling: false, + startInterval: false, + dataSource: undefined, + stepsToResolveCount: 0, + }); + } +} diff --git a/modules/ui/src/app/services/test-run.service.spec.ts b/modules/ui/src/app/services/test-run.service.spec.ts index ab9a338ad..85a0197e5 100644 --- a/modules/ui/src/app/services/test-run.service.spec.ts +++ b/modules/ui/src/app/services/test-run.service.spec.ts @@ -22,10 +22,7 @@ import { Device, TestModule } from '../model/device'; import { TestRunService, UNAVAILABLE_VERSION } from './test-run.service'; import { SystemConfig, SystemInterfaces } from '../model/setting'; -import { - MOCK_PROGRESS_DATA_CANCELLING, - MOCK_PROGRESS_DATA_IN_PROGRESS, -} from '../mocks/progress.mock'; +import { MOCK_PROGRESS_DATA_IN_PROGRESS } from '../mocks/progress.mock'; import { StatusOfTestResult, TestrunStatus } from '../model/testrun-status'; import { device } from '../mocks/device.mock'; import { NEW_VERSION, VERSION } from '../mocks/version.mock'; @@ -155,30 +152,14 @@ describe('TestRunService', () => { req.flush(mockSystemInterfaces); }); - describe('getSystemStatus', () => { + describe('fetchSystemStatus', () => { it('should get system status data with no changes', () => { const result = { ...MOCK_PROGRESS_DATA_IN_PROGRESS }; - service.systemStatus$.subscribe(res => { + service.fetchSystemStatus().subscribe(res => { expect(res).toEqual(result); }); - service.getSystemStatus(); - const req = httpTestingController.expectOne( - 'http://localhost:8000/system/status' - ); - expect(req.request.method).toBe('GET'); - req.flush(result); - }); - - it('should get cancelling data if status is cancelling', () => { - const result = { ...MOCK_PROGRESS_DATA_IN_PROGRESS }; - - service.systemStatus$.subscribe(res => { - expect(res).toEqual(MOCK_PROGRESS_DATA_CANCELLING); - }); - - service.getSystemStatus(true); const req = httpTestingController.expectOne( 'http://localhost:8000/system/status' ); diff --git a/modules/ui/src/app/services/test-run.service.ts b/modules/ui/src/app/services/test-run.service.ts index f23ddb5af..3b8f9b353 100644 --- a/modules/ui/src/app/services/test-run.service.ts +++ b/modules/ui/src/app/services/test-run.service.ts @@ -18,11 +18,10 @@ import { Injectable } from '@angular/core'; import { BehaviorSubject } from 'rxjs/internal/BehaviorSubject'; import { Observable } from 'rxjs/internal/Observable'; import { Device, TestModule } from '../model/device'; -import { catchError, map, of, ReplaySubject, retry } from 'rxjs'; +import { catchError, map, of, retry } from 'rxjs'; import { SystemConfig, SystemInterfaces } from '../model/setting'; import { StatusOfTestResult, - StatusOfTestrun, StatusResultClassName, TestrunStatus, } from '../model/testrun-status'; @@ -75,24 +74,10 @@ export class TestRunService { }, ]; - private isOpenStartTestrunSub$ = new BehaviorSubject(false); - public isOpenStartTestrun$ = this.isOpenStartTestrunSub$.asObservable(); - private systemStatusSubject = new ReplaySubject(1); - public systemStatus$ = this.systemStatusSubject.asObservable(); - private isTestrunStartedSub$ = new BehaviorSubject(false); - public isTestrunStarted$ = this.isTestrunStartedSub$.asObservable(); private version = new BehaviorSubject(null); constructor(private http: HttpClient) {} - setIsOpenStartTestrun(isOpen: boolean): void { - this.isOpenStartTestrunSub$.next(isOpen); - } - - setSystemStatus(status: TestrunStatus): void { - this.systemStatusSubject.next(status); - } - fetchDevices(): Observable { return this.http.get(`${API_URL}/devices`); } @@ -111,22 +96,8 @@ export class TestRunService { return this.http.get(`${API_URL}/system/interfaces`); } - /** - * Gets system status. - * Status Cancelling exist only on FE. Every status except Cancelled - * should be overriden with Cancelling value during cancelling process - * @param isCancelling - indicates if status should be overridden with Cancelling value - */ - getSystemStatus(isCancelling?: boolean): void { - this.http.get(`${API_URL}/system/status`).subscribe( - (res: TestrunStatus) => { - if (isCancelling && res.status !== StatusOfTestrun.Cancelled) { - res.status = StatusOfTestrun.Cancelling; - } - this.setSystemStatus(res); - }, - err => console.error('HTTP Error', err) - ); + fetchSystemStatus() { + return this.http.get(`${API_URL}/system/status`); } stopTestrun(): Observable { @@ -197,8 +168,6 @@ export class TestRunService { } startTestrun(device: Device): Observable { - this.isTestrunStartedSub$.next(true); - return this.http .post( `${API_URL}/system/start`, diff --git a/modules/ui/src/app/store/actions.ts b/modules/ui/src/app/store/actions.ts index f4582b7d0..5bd7220a5 100644 --- a/modules/ui/src/app/store/actions.ts +++ b/modules/ui/src/app/store/actions.ts @@ -22,6 +22,7 @@ import { } from '../model/setting'; import { SystemInterfaces } from '../model/setting'; import { Device } from '../model/device'; +import { TestrunStatus } from '../model/testrun-status'; // App component export const toggleMenu = createAction('[App Component] Toggle Menu'); @@ -74,3 +75,18 @@ export const setDevices = createAction( '[Shared] Set Devices', props<{ devices: Device[] }>() ); + +export const setTestrunStatus = createAction( + '[Shared] Set Testrun Status', + props<{ systemStatus: TestrunStatus }>() +); + +export const setIsOpenStartTestrun = createAction( + '[Shared] Set Is Open Start Testrun', + props<{ isOpenStartTestrun: boolean }>() +); + +export const setIsTestrunStarted = createAction( + '[Shared] Set Testrun Started', + props<{ isTestrunStarted: boolean }>() +); diff --git a/modules/ui/src/app/store/reducers.spec.ts b/modules/ui/src/app/store/reducers.spec.ts index 3fccf462f..70a4b9a25 100644 --- a/modules/ui/src/app/store/reducers.spec.ts +++ b/modules/ui/src/app/store/reducers.spec.ts @@ -17,15 +17,17 @@ import * as fromReducer from './reducers'; import { initialAppComponentState, initialSharedState } from './state'; import { fetchInterfacesSuccess, - setDevices, setHasConnectionSettings, setHasDevices, setIsOpenAddDevice, + setIsOpenStartTestrun, + setIsTestrunStarted, + setTestrunStatus, toggleMenu, updateError, updateFocusNavigation, } from './actions'; -import { device } from '../mocks/device.mock'; +import { MOCK_PROGRESS_DATA_CANCELLING } from '../mocks/progress.mock'; describe('Reducer', () => { describe('unknown action', () => { @@ -138,13 +140,41 @@ describe('Reducer', () => { }); }); - describe('setDevices action', () => { + describe('setTestrunStatus action', () => { it('should update state', () => { const initialState = initialSharedState; - const devices = [device, device]; - const action = setDevices({ devices }); + const action = setTestrunStatus({ + systemStatus: MOCK_PROGRESS_DATA_CANCELLING, + }); + const state = fromReducer.sharedReducer(initialState, action); + const newState = { + ...initialState, + ...{ systemStatus: MOCK_PROGRESS_DATA_CANCELLING }, + }; + + expect(state).toEqual(newState); + expect(state).not.toBe(initialState); + }); + }); + + describe('setIsOpenStartTestrun action', () => { + it('should update state', () => { + const initialState = initialSharedState; + const action = setIsOpenStartTestrun({ isOpenStartTestrun: true }); + const state = fromReducer.sharedReducer(initialState, action); + const newState = { ...initialState, ...{ isOpenStartTestrun: true } }; + + expect(state).toEqual(newState); + expect(state).not.toBe(initialState); + }); + }); + + describe('setIsTestrunStarted action', () => { + it('should update state', () => { + const initialState = initialSharedState; + const action = setIsTestrunStarted({ isTestrunStarted: true }); const state = fromReducer.sharedReducer(initialState, action); - const newState = { ...initialState, ...{ devices } }; + const newState = { ...initialState, ...{ isTestrunStarted: true } }; expect(state).toEqual(newState); expect(state).not.toBe(initialState); diff --git a/modules/ui/src/app/store/reducers.ts b/modules/ui/src/app/store/reducers.ts index fff777847..a214a832e 100644 --- a/modules/ui/src/app/store/reducers.ts +++ b/modules/ui/src/app/store/reducers.ts @@ -64,6 +64,24 @@ export const sharedReducer = createReducer( ...state, devices, }; + }), + on(Actions.setTestrunStatus, (state, { systemStatus }) => { + return { + ...state, + systemStatus, + }; + }), + on(Actions.setIsOpenStartTestrun, (state, { isOpenStartTestrun }) => { + return { + ...state, + isOpenStartTestrun, + }; + }), + on(Actions.setIsTestrunStarted, (state, { isTestrunStarted }) => { + return { + ...state, + isTestrunStarted, + }; }) ); diff --git a/modules/ui/src/app/store/selectors.spec.ts b/modules/ui/src/app/store/selectors.spec.ts index 9443829b8..0cafab942 100644 --- a/modules/ui/src/app/store/selectors.spec.ts +++ b/modules/ui/src/app/store/selectors.spec.ts @@ -22,7 +22,10 @@ import { selectHasDevices, selectInterfaces, selectIsOpenAddDevice, + selectIsOpenStartTestrun, + selectIsTestrunStarted, selectMenuOpened, + selectSystemStatus, } from './selectors'; describe('Selectors', () => { @@ -40,6 +43,9 @@ describe('Selectors', () => { devices: [], hasDevices: false, isOpenAddDevice: false, + isOpenStartTestrun: false, + systemStatus: null, + isTestrunStarted: false, }, }; @@ -77,4 +83,19 @@ describe('Selectors', () => { const result = selectIsOpenAddDevice.projector(initialState); expect(result).toEqual(false); }); + + it('should select systemStatus', () => { + const result = selectSystemStatus.projector(initialState); + expect(result).toEqual(null); + }); + + it('should select isTestrunStarted', () => { + const result = selectIsTestrunStarted.projector(initialState); + expect(result).toEqual(false); + }); + + it('should select isOpenStartTestrun', () => { + const result = selectIsOpenStartTestrun.projector(initialState); + expect(result).toEqual(false); + }); }); diff --git a/modules/ui/src/app/store/selectors.ts b/modules/ui/src/app/store/selectors.ts index 29173bcc8..b5aa1edbd 100644 --- a/modules/ui/src/app/store/selectors.ts +++ b/modules/ui/src/app/store/selectors.ts @@ -56,3 +56,18 @@ export const selectError = createSelector( selectAppState, (state: AppState) => state.appComponent.settingMissedError ); + +export const selectSystemStatus = createSelector( + selectAppState, + (state: AppState) => state.shared.systemStatus +); + +export const selectIsTestrunStarted = createSelector( + selectAppState, + (state: AppState) => state.shared.isTestrunStarted +); + +export const selectIsOpenStartTestrun = createSelector( + selectAppState, + (state: AppState) => state.shared.isOpenStartTestrun +); diff --git a/modules/ui/src/app/store/state.ts b/modules/ui/src/app/store/state.ts index 0693cdfd4..ed31d419a 100644 --- a/modules/ui/src/app/store/state.ts +++ b/modules/ui/src/app/store/state.ts @@ -39,13 +39,15 @@ export interface SharedState { //used in app, devices, testrun hasDevices: boolean; //app, testrun - systemStatus?: TestrunStatus | null; + systemStatus: TestrunStatus | null; //app, testrun - isTestrunStarted?: boolean; + isTestrunStarted: boolean; //app, settings hasConnectionSettings: boolean | null; // app, devices isOpenAddDevice: boolean; + // app, testrun + isOpenStartTestrun: boolean; } export const initialAppComponentState: AppComponentState = { @@ -62,4 +64,7 @@ export const initialSharedState: SharedState = { isOpenAddDevice: false, hasDevices: false, devices: [], + isOpenStartTestrun: false, + systemStatus: null, + isTestrunStarted: false, }; From 6badbe040cae6cb2c5608377b60d5f586b7c459f Mon Sep 17 00:00:00 2001 From: Sofia Kurilova Date: Tue, 16 Apr 2024 11:13:59 +0000 Subject: [PATCH 03/26] Fix tests (#397) * Fix tests * Update node version --- .github/workflows/testing.yml | 4 +-- modules/ui/src/app/app.component.spec.ts | 5 ++- .../device-item/device-item.component.spec.ts | 9 +++++- .../device-repository.component.spec.ts | 7 +++- .../pages/testrun/progress.component.spec.ts | 5 +++ .../app/pages/testrun/testrun.store.spec.ts | 32 +++++++++++++------ 6 files changed, 47 insertions(+), 15 deletions(-) diff --git a/.github/workflows/testing.yml b/.github/workflows/testing.yml index f45851445..533343870 100644 --- a/.github/workflows/testing.yml +++ b/.github/workflows/testing.yml @@ -76,7 +76,7 @@ jobs: - name: Install Node uses: actions/setup-node@b39b52d1213e96004bfcb1c61a8a6fa8ab84f3e8 # v4.0.1 with: - node-version: 18.13.0 + node-version: 18.18.0 - name: Install Chromium Browser run: sudo apt install chromium-browser - name: Install dependencies @@ -99,7 +99,7 @@ jobs: - name: Install Node uses: actions/setup-node@b39b52d1213e96004bfcb1c61a8a6fa8ab84f3e8 # v4.0.1 with: - node-version: 18.13.0 + node-version: 18.18.0 - name: Install dependencies run: npm install && npm ci working-directory: ./modules/ui diff --git a/modules/ui/src/app/app.component.spec.ts b/modules/ui/src/app/app.component.spec.ts index a91f953a0..9c87b5693 100644 --- a/modules/ui/src/app/app.component.spec.ts +++ b/modules/ui/src/app/app.component.spec.ts @@ -326,6 +326,7 @@ describe('AppComponent', () => { describe('menu button', () => { beforeEach(() => { + mockFocusManagerService.focusFirstElementInContainer.calls.reset(); store.overrideSelector(selectHasDevices, false); fixture.detectChanges(); }); @@ -365,7 +366,9 @@ describe('AppComponent', () => { menuBtn.dispatchEvent(new KeyboardEvent('keydown', { key: 'Tab' })); - expect(document.activeElement).toBe(document.body); + expect( + mockFocusManagerService.focusFirstElementInContainer + ).not.toHaveBeenCalled(); }); }); diff --git a/modules/ui/src/app/components/device-item/device-item.component.spec.ts b/modules/ui/src/app/components/device-item/device-item.component.spec.ts index b7dd90524..9319bb0ef 100644 --- a/modules/ui/src/app/components/device-item/device-item.component.spec.ts +++ b/modules/ui/src/app/components/device-item/device-item.component.spec.ts @@ -18,6 +18,8 @@ import { Device, DeviceView } from '../../model/device'; import { DeviceItemComponent } from './device-item.component'; import { DeviceRepositoryModule } from '../../pages/devices/device-repository.module'; +import { MatIconTestingModule } from '@angular/material/icon/testing'; +import { BrowserAnimationsModule } from '@angular/platform-browser/animations'; describe('DeviceItemComponent', () => { let component: DeviceItemComponent; @@ -26,7 +28,12 @@ describe('DeviceItemComponent', () => { beforeEach(() => { TestBed.configureTestingModule({ - imports: [DeviceRepositoryModule, DeviceItemComponent], + imports: [ + DeviceRepositoryModule, + DeviceItemComponent, + MatIconTestingModule, + BrowserAnimationsModule, + ], }); fixture = TestBed.createComponent(DeviceItemComponent); component = fixture.componentInstance; diff --git a/modules/ui/src/app/pages/devices/device-repository.component.spec.ts b/modules/ui/src/app/pages/devices/device-repository.component.spec.ts index 98c923d91..74919ea90 100644 --- a/modules/ui/src/app/pages/devices/device-repository.component.spec.ts +++ b/modules/ui/src/app/pages/devices/device-repository.component.spec.ts @@ -30,6 +30,7 @@ import { DeleteFormComponent } from '../../components/delete-form/delete-form.co import SpyObj = jasmine.SpyObj; import { FocusManagerService } from '../../services/focus-manager.service'; import { DevicesStore } from './devices.store'; +import { MatIconTestingModule } from '@angular/material/icon/testing'; describe('DeviceRepositoryComponent', () => { let component: DeviceRepositoryComponent; @@ -50,7 +51,11 @@ describe('DeviceRepositoryComponent', () => { mockDevicesStore.testModules = MOCK_TEST_MODULES; await TestBed.configureTestingModule({ - imports: [DeviceRepositoryModule, BrowserAnimationsModule], + imports: [ + DeviceRepositoryModule, + BrowserAnimationsModule, + MatIconTestingModule, + ], providers: [ { provide: DevicesStore, useValue: mockDevicesStore }, { provide: FocusManagerService, useValue: stateServiceMock }, diff --git a/modules/ui/src/app/pages/testrun/progress.component.spec.ts b/modules/ui/src/app/pages/testrun/progress.component.spec.ts index cd8f0c93c..ca1e19176 100644 --- a/modules/ui/src/app/pages/testrun/progress.component.spec.ts +++ b/modules/ui/src/app/pages/testrun/progress.component.spec.ts @@ -55,6 +55,7 @@ import { } from '../../store/selectors'; import { TestrunStore } from './testrun.store'; import { setTestrunStatus } from '../../store/actions'; +import { BrowserAnimationsModule } from '@angular/platform-browser/animations'; describe('ProgressComponent', () => { let component: ProgressComponent; @@ -69,6 +70,7 @@ describe('ProgressComponent', () => { 'isOpenStartTestrun$', 'isTestrunStarted$', 'fetchSystemStatus', + 'getTestModules', ]); const loaderServiceMock: jasmine.SpyObj = jasmine.createSpyObj( @@ -116,6 +118,8 @@ describe('ProgressComponent', () => { MatToolbarModule, MatDialogModule, SpinnerComponent, + DownloadReportPdfComponent, + BrowserAnimationsModule, ], }) .overrideComponent(ProgressComponent, { @@ -235,6 +239,7 @@ describe('ProgressComponent', () => { MatToolbarModule, MatDialogModule, SpinnerComponent, + BrowserAnimationsModule, ], }) .overrideComponent(ProgressComponent, { diff --git a/modules/ui/src/app/pages/testrun/testrun.store.spec.ts b/modules/ui/src/app/pages/testrun/testrun.store.spec.ts index 705e5a7db..53f173e41 100644 --- a/modules/ui/src/app/pages/testrun/testrun.store.spec.ts +++ b/modules/ui/src/app/pages/testrun/testrun.store.spec.ts @@ -61,7 +61,10 @@ describe('TestrunStore', () => { ); beforeEach(() => { - mockService = jasmine.createSpyObj(['stopTestrun', 'fetchSystemStatus']); + mockService = jasmine.createSpyObj('mockService', [ + 'stopTestrun', + 'fetchSystemStatus', + ]); TestBed.configureTestingModule({ providers: [ @@ -85,10 +88,6 @@ describe('TestrunStore', () => { spyOn(store, 'dispatch').and.callFake(() => {}); }); - afterEach(() => { - mockService.fetchSystemStatus.calls.reset(); - }); - it('should be created', () => { expect(testrunStore).toBeTruthy(); }); @@ -143,16 +142,20 @@ describe('TestrunStore', () => { describe('effects', () => { describe('getStatus', () => { - const status = { ...MOCK_PROGRESS_DATA_IN_PROGRESS }; beforeEach(() => { - mockService.fetchSystemStatus.and.returnValue(of(status)); + testrunStore.updateStartInterval(true); + mockService.fetchSystemStatus.and.returnValue( + of({ ...MOCK_PROGRESS_DATA_MONITORING }) + ); }); it('should dispatch action setTestrunStatus', () => { testrunStore.getStatus(); expect(store.dispatch).toHaveBeenCalledWith( - setTestrunStatus({ systemStatus: status }) + setTestrunStatus({ + systemStatus: { ...MOCK_PROGRESS_DATA_MONITORING }, + }) ); }); @@ -166,18 +169,27 @@ describe('TestrunStore', () => { }); describe('pullingSystemStatusData with available status "In Progress"', () => { + beforeEach(() => { + mockService.fetchSystemStatus.and.returnValue( + of({ ...MOCK_PROGRESS_DATA_IN_PROGRESS }) + ); + mockService.fetchSystemStatus.calls.reset(); + }); + it('should call again getSystemStatus', fakeAsync(() => { testrunStore.updateStartInterval(false); + testrunStore.updateCancelling(false); store.overrideSelector( selectSystemStatus, MOCK_PROGRESS_DATA_IN_PROGRESS ); testrunStore.getStatus(); + expect(mockService.fetchSystemStatus).toHaveBeenCalled(); - tick(7000); + tick(5000); - expect(mockService.fetchSystemStatus).toHaveBeenCalledTimes(1); + expect(mockService.fetchSystemStatus).toHaveBeenCalledTimes(2); discardPeriodicTasks(); })); }); From e7e0de67d204770f71deaf564229dd16b7cb0ac7 Mon Sep 17 00:00:00 2001 From: Olga Mardvilko Date: Wed, 17 Apr 2024 13:11:37 +0200 Subject: [PATCH 04/26] 331379891: (feat) disable connection settings when testrun is in progress (#371) * 331379891: (feat) disable connection settings when testrun is in progress * 331379891: (fix) include more testrun results as progress * 331379891: (fix) fix spelling * 333349715: (fix) GAR 1.3 The disabled system settings panel contains a focusable element (#388) Co-authored-by: Volha Mardvilka --------- Co-authored-by: Volha Mardvilka --- modules/ui/src/app/app.component.html | 1 + modules/ui/src/app/app.component.spec.ts | 5 +-- modules/ui/src/app/app.component.ts | 10 ++--- .../settings/general-settings.component.html | 10 +++-- .../settings/general-settings.component.scss | 22 ++++++++++ .../general-settings.component.spec.ts | 41 +++++++++++++++++++ .../settings/general-settings.component.ts | 26 ++++++++++++ .../app/pages/testrun/progress.component.html | 8 ++-- .../pages/testrun/progress.component.spec.ts | 5 +++ .../app/pages/testrun/progress.component.ts | 10 ++--- .../src/app/services/test-run.service.spec.ts | 37 ++++++++++++++++- .../ui/src/app/services/test-run.service.ts | 9 ++++ modules/ui/src/styles.scss | 5 +++ 13 files changed, 166 insertions(+), 23 deletions(-) diff --git a/modules/ui/src/app/app.component.html b/modules/ui/src/app/app.component.html index 8c472f8ce..f207f387a 100644 --- a/modules/ui/src/app/app.component.html +++ b/modules/ui/src/app/app.component.html @@ -182,6 +182,7 @@

Testrun

class="settings-drawer"> diff --git a/modules/ui/src/app/app.component.spec.ts b/modules/ui/src/app/app.component.spec.ts index 9c87b5693..95e60eac3 100644 --- a/modules/ui/src/app/app.component.spec.ts +++ b/modules/ui/src/app/app.component.spec.ts @@ -93,6 +93,7 @@ describe('AppComponent', () => { 'setIsOpenStartTestrun', 'fetchDevices', 'getTestModules', + 'testrunInProgress', ]); mockFocusManagerService = jasmine.createSpyObj('mockFocusManagerService', [ @@ -657,10 +658,8 @@ describe('AppComponent', () => { template: '
', }) class FakeGeneralSettingsComponent { - @Input() interfaces = []; - @Input() hasConnectionSettings = false; + @Input() settingsDisable = false; @Output() closeSettingEvent = new EventEmitter(); - @Output() reloadInterfacesEvent = new EventEmitter(); getSystemInterfaces = () => {}; getSystemConfig = () => {}; } diff --git a/modules/ui/src/app/app.component.ts b/modules/ui/src/app/app.component.ts index bb26490cd..c569720a3 100644 --- a/modules/ui/src/app/app.component.ts +++ b/modules/ui/src/app/app.component.ts @@ -32,6 +32,7 @@ import { import { appFeatureKey } from './store/reducers'; import { GeneralSettingsComponent } from './pages/settings/general-settings.component'; import { AppStore } from './app.store'; +import { TestRunService } from './services/test-run.service'; const DEVICES_LOGO_URL = '/assets/icons/devices.svg'; const DEVICES_RUN_URL = '/assets/icons/device_run.svg'; @@ -65,6 +66,7 @@ export class AppComponent { private store: Store, private state: State, private readonly focusManagerService: FocusManagerService, + private testRunService: TestRunService, public appStore: AppStore ) { this.appStore.getDevices(); @@ -149,11 +151,7 @@ export class AppComponent { this.appStore.setContent(); } - testrunInProgress(status?: string): boolean { - return ( - status === StatusOfTestrun.InProgress || - status === StatusOfTestrun.WaitingForDevice || - status === StatusOfTestrun.Monitoring - ); + isTestrunInProgress(status?: string) { + return this.testRunService.testrunInProgress(status); } } diff --git a/modules/ui/src/app/pages/settings/general-settings.component.html b/modules/ui/src/app/pages/settings/general-settings.component.html index 2e77950a6..36849b42e 100644 --- a/modules/ui/src/app/pages/settings/general-settings.component.html +++ b/modules/ui/src/app/pages/settings/general-settings.component.html @@ -25,6 +25,7 @@

System settings

+
System settings [options]="vm.internetOptions"> -

+

If a port is missing from this list, you can Refresh @@ -105,7 +107,9 @@

System settings

class="save-button" color="primary" (click)="saveSetting()" - [disabled]="!isFormValues || vm.isLessThanOneInterface"> + [disabled]=" + !isFormValues || vm.isLessThanOneInterface || settingsDisable + "> Save
diff --git a/modules/ui/src/app/pages/settings/general-settings.component.scss b/modules/ui/src/app/pages/settings/general-settings.component.scss index c1f2e259c..6bec40683 100644 --- a/modules/ui/src/app/pages/settings/general-settings.component.scss +++ b/modules/ui/src/app/pages/settings/general-settings.component.scss @@ -119,3 +119,25 @@ } } } + +.settings-disabled-overlay { + position: absolute; + width: 100%; + left: 0; + right: 0; + top: 75px; + bottom: 45px; + background-color: rgba(255, 255, 255, 0.7); + z-index: 2; +} + +.disabled { + .message-link { + cursor: default; + pointer-events: none; + + &:focus-visible { + outline: none; + } + } +} diff --git a/modules/ui/src/app/pages/settings/general-settings.component.spec.ts b/modules/ui/src/app/pages/settings/general-settings.component.spec.ts index 097cc2f0e..8095468fd 100644 --- a/modules/ui/src/app/pages/settings/general-settings.component.spec.ts +++ b/modules/ui/src/app/pages/settings/general-settings.component.spec.ts @@ -116,6 +116,47 @@ describe('GeneralSettingsComponent', () => { expect(mockLoaderService.setLoading).toHaveBeenCalledWith(true); }); + describe('#settingsDisable', () => { + it('should disable setting form when get settingDisable as true ', () => { + spyOn(component.settingForm, 'disable'); + + component.settingsDisable = true; + + expect(component.settingForm.disable).toHaveBeenCalled(); + }); + + it('should enable setting form when get settingDisable as false ', () => { + spyOn(component.settingForm, 'enable'); + + component.settingsDisable = false; + + expect(component.settingForm.enable).toHaveBeenCalled(); + }); + + it('should disable "Save" button when get settingDisable as true', () => { + component.settingsDisable = true; + + const saveBtn = compiled.querySelector( + '.save-button' + ) as HTMLButtonElement; + + expect(saveBtn.disabled).toBeTrue(); + }); + + it('should disable "Refresh" link when settingDisable', () => { + component.settingsDisable = true; + + const refreshLink = compiled.querySelector( + '.message-link' + ) as HTMLAnchorElement; + + refreshLink.click(); + + expect(refreshLink.hasAttribute('aria-disabled')).toBeTrue(); + expect(mockLoaderService.setLoading).not.toHaveBeenCalled(); + }); + }); + describe('#closeSetting', () => { beforeEach(() => { component.ngOnInit(); diff --git a/modules/ui/src/app/pages/settings/general-settings.component.ts b/modules/ui/src/app/pages/settings/general-settings.component.ts index c4244e4c4..7c903207c 100644 --- a/modules/ui/src/app/pages/settings/general-settings.component.ts +++ b/modules/ui/src/app/pages/settings/general-settings.component.ts @@ -15,10 +15,13 @@ */ import { Component, + ElementRef, EventEmitter, + Input, OnDestroy, OnInit, Output, + ViewChild, } from '@angular/core'; import { FormBuilder, FormControl, FormGroup } from '@angular/forms'; import { Subject, takeUntil, tap } from 'rxjs'; @@ -38,7 +41,17 @@ import { LoaderService } from '../../services/loader.service'; providers: [SettingsStore], }) export class GeneralSettingsComponent implements OnInit, OnDestroy { + @ViewChild('reloadSettingLink') public reloadSettingLink!: ElementRef; @Output() closeSettingEvent = new EventEmitter(); + + private isSettingsDisable = false; + get settingsDisable(): boolean { + return this.isSettingsDisable; + } + @Input() set settingsDisable(value: boolean) { + this.isSettingsDisable = value; + value ? this.disableSettings() : this.enableSettings(); + } public readonly CalloutType = CalloutType; public readonly EventType = EventType; public readonly FormKey = FormKey; @@ -88,6 +101,9 @@ export class GeneralSettingsComponent implements OnInit, OnDestroy { } reloadSetting(): void { + if (this.settingsDisable) { + return; + } this.showLoading(); this.getSystemInterfaces(); this.settingsStore.getSystemConfig(); @@ -111,6 +127,16 @@ export class GeneralSettingsComponent implements OnInit, OnDestroy { } } + private disableSettings(): void { + this.settingForm?.disable(); + this.reloadSettingLink?.nativeElement.setAttribute('aria-disabled', 'true'); + } + + private enableSettings(): void { + this.settingForm?.enable(); + this.reloadSettingLink?.nativeElement.removeAttribute('aria-disabled'); + } + private createSettingForm() { this.settingForm = this.fb.group( { diff --git a/modules/ui/src/app/pages/testrun/progress.component.html b/modules/ui/src/app/pages/testrun/progress.component.html index 1e214ecb3..cec555993 100644 --- a/modules/ui/src/app/pages/testrun/progress.component.html +++ b/modules/ui/src/app/pages/testrun/progress.component.html @@ -17,7 +17,7 @@ }} - - -

Testrun

-
- - - - - -
- - - - No ports are detected. Please define a valid ones using - - - Selected port is missing! Please define a valid one using - + + + + + +

Testrun

+
+ + + + + +
+
+ + + + No ports are detected. Please define a valid ones using + + + Selected port is missing! Please define a valid one using + + System settings + panel. + + + + Step 1: To perform a device test, please, select ports in Testrun > panel. - - - Step 1: To perform a device test, please, select ports in - System settings - panel. - - - Step 2: To perform a device test please - Create a Device - first. - - - Step 3: Once device is created, you are able to - start testing. - - -
-
+ + Step 2: To perform a device test please + Create a Device + first. + + + Step 3: Once device is created, you are able to + start testing. + + +
+ +
+ Testrun (closeSettingEvent)="closeSetting(vm.hasDevices)"> - + + + + + { let component: AppComponent; @@ -94,6 +95,7 @@ describe('AppComponent', () => { 'fetchDevices', 'getTestModules', 'testrunInProgress', + 'fetchCertificates', ]); mockFocusManagerService = jasmine.createSpyObj('mockFocusManagerService', [ @@ -113,6 +115,7 @@ describe('AppComponent', () => { BypassComponent, CalloutComponent, MatIconTestingModule, + CertificatesComponent, ], providers: [ { provide: TestRunService, useValue: mockService }, @@ -651,6 +654,27 @@ describe('AppComponent', () => { expect(spyToggle).toHaveBeenCalledTimes(0); }); + + it('should render certificates button', () => { + const generalSettingsButton = compiled.querySelector( + '.app-toolbar-button-certificates' + ); + + expect(generalSettingsButton).toBeDefined(); + }); + + it('should call settingsDrawer open on click settings button', () => { + fixture.detectChanges(); + + const settingsBtn = compiled.querySelector( + '.app-toolbar-button-certificates' + ) as HTMLButtonElement; + spyOn(component.certDrawer, 'open'); + + settingsBtn.click(); + + expect(component.certDrawer.open).toHaveBeenCalledTimes(1); + }); }); @Component({ diff --git a/modules/ui/src/app/app.component.ts b/modules/ui/src/app/app.component.ts index c569720a3..e959c37e0 100644 --- a/modules/ui/src/app/app.component.ts +++ b/modules/ui/src/app/app.component.ts @@ -54,7 +54,10 @@ export class AppComponent { private openedSettingFromToggleBtn = true; @ViewChild('settingsDrawer') public settingsDrawer!: MatDrawer; + @ViewChild('certDrawer') public certDrawer!: MatDrawer; @ViewChild('toggleSettingsBtn') public toggleSettingsBtn!: HTMLButtonElement; + @ViewChild('toggleCertificatesBtn') + public toggleCertificatesBtn!: HTMLButtonElement; @ViewChild('navigation') public navigation!: ElementRef; @ViewChild('settings') public settings!: GeneralSettingsComponent; viewModel$ = this.appStore.viewModel$; @@ -71,6 +74,7 @@ export class AppComponent { ) { this.appStore.getDevices(); this.appStore.getSystemStatus(); + this.appStore.getCertificates(); this.matIconRegistry.addSvgIcon( 'devices', this.domSanitizer.bypassSecurityTrustResourceUrl(DEVICES_LOGO_URL) @@ -107,6 +111,10 @@ export class AppComponent { this.appStore.setIsOpenStartTestrun(); } + async closeCertificates(): Promise { + await this.certDrawer.close(); + } + async closeSetting(hasDevices: boolean): Promise { return await this.settingsDrawer.close().then(() => { if (hasDevices) { @@ -147,6 +155,10 @@ export class AppComponent { await this.settingsDrawer.open(); } + async openCert() { + await this.certDrawer.open(); + } + consentShown() { this.appStore.setContent(); } diff --git a/modules/ui/src/app/app.module.ts b/modules/ui/src/app/app.module.ts index 753aaadc4..c182fa3ea 100644 --- a/modules/ui/src/app/app.module.ts +++ b/modules/ui/src/app/app.module.ts @@ -46,6 +46,7 @@ import { CdkTrapFocus } from '@angular/cdk/a11y'; import { SettingsDropdownComponent } from './pages/settings/components/settings-dropdown/settings-dropdown.component'; import { ShutdownAppComponent } from './components/shutdown-app/shutdown-app.component'; import { WindowProvider } from './providers/window.provider'; +import { CertificatesComponent } from './pages/certificates/certificates.component'; @NgModule({ declarations: [AppComponent, GeneralSettingsComponent], @@ -74,6 +75,7 @@ import { WindowProvider } from './providers/window.provider'; CdkTrapFocus, SettingsDropdownComponent, ShutdownAppComponent, + CertificatesComponent, ], providers: [ WindowProvider, diff --git a/modules/ui/src/app/app.store.spec.ts b/modules/ui/src/app/app.store.spec.ts index d53840850..a11f85943 100644 --- a/modules/ui/src/app/app.store.spec.ts +++ b/modules/ui/src/app/app.store.spec.ts @@ -32,6 +32,7 @@ import SpyObj = jasmine.SpyObj; import { device } from './mocks/device.mock'; import { setDevices, setTestrunStatus } from './store/actions'; import { MOCK_PROGRESS_DATA_IN_PROGRESS } from './mocks/progress.mock'; +import { certificate } from './mocks/certificate.mock'; const mock = (() => { let store: { [key: string]: string } = {}; @@ -57,7 +58,11 @@ describe('AppStore', () => { let mockService: SpyObj; beforeEach(() => { - mockService = jasmine.createSpyObj(['fetchDevices', 'fetchSystemStatus']); + mockService = jasmine.createSpyObj([ + 'fetchDevices', + 'fetchSystemStatus', + 'fetchCertificates', + ]); TestBed.configureTestingModule({ providers: [ @@ -107,6 +112,15 @@ describe('AppStore', () => { appStore.updateIsStatusLoaded(true); }); + + it('should update certificates', (done: DoneFn) => { + appStore.viewModel$.pipe(skip(1), take(1)).subscribe(store => { + expect(store.certificates).toEqual([certificate]); + done(); + }); + + appStore.updateCertificates([certificate]); + }); }); describe('selectors', () => { @@ -122,6 +136,7 @@ describe('AppStore', () => { isMenuOpen: true, interfaces: {}, settingMissedError: null, + certificates: [], }); done(); }); @@ -184,5 +199,22 @@ describe('AppStore', () => { appStore.getSystemStatus(); }); }); + + describe('fetchCertificates', () => { + const certificates = [certificate]; + + beforeEach(() => { + mockService.fetchCertificates.and.returnValue(of(certificates)); + }); + + it('should update certificates', done => { + appStore.viewModel$.pipe(skip(1), take(1)).subscribe(store => { + expect(store.certificates).toEqual(certificates); + done(); + }); + + appStore.getCertificates(); + }); + }); }); }); diff --git a/modules/ui/src/app/app.store.ts b/modules/ui/src/app/app.store.ts index d214d4848..ee5c952fe 100644 --- a/modules/ui/src/app/app.store.ts +++ b/modules/ui/src/app/app.store.ts @@ -38,6 +38,7 @@ import { } from './store/actions'; import { TestrunStatus } from './model/testrun-status'; import { SettingMissedError, SystemInterfaces } from './model/setting'; +import { Certificate } from './model/certificate'; export const CONSENT_SHOWN_KEY = 'CONSENT_SHOWN'; export interface AppComponentState { @@ -45,11 +46,13 @@ export interface AppComponentState { isStatusLoaded: boolean; isTestrunStarted: boolean; systemStatus: TestrunStatus | null; + certificates: Certificate[]; } @Injectable() export class AppStore extends ComponentStore { private consentShown$ = this.select(state => state.consentShown); private isStatusLoaded$ = this.select(state => state.isStatusLoaded); + private certificates$ = this.select(state => state.certificates); private hasDevices$ = this.store.select(selectHasDevices); private hasConnectionSetting$ = this.store.select( selectHasConnectionSettings @@ -72,6 +75,7 @@ export class AppStore extends ComponentStore { isMenuOpen: this.isMenuOpen$, interfaces: this.interfaces$, settingMissedError: this.settingMissedError$, + certificates: this.certificates$, }); updateConsent = this.updater((state, consentShown: boolean) => ({ @@ -84,6 +88,11 @@ export class AppStore extends ComponentStore { isStatusLoaded, })); + updateCertificates = this.updater((state, certificates: Certificate[]) => ({ + ...state, + certificates, + })); + setContent = this.effect(trigger$ => { return trigger$.pipe( tap(() => { @@ -128,6 +137,17 @@ export class AppStore extends ComponentStore { ); }); + getCertificates = this.effect(trigger$ => { + return trigger$.pipe( + exhaustMap(() => { + return this.testRunService.fetchCertificates().pipe( + tap((certificates: Certificate[]) => { + this.updateCertificates(certificates); + }) + ); + }) + ); + }); constructor( private store: Store, private testRunService: TestRunService @@ -137,6 +157,7 @@ export class AppStore extends ComponentStore { isStatusLoaded: false, isTestrunStarted: false, systemStatus: null, + certificates: [], }); } } diff --git a/modules/ui/src/app/mocks/certificate.mock.ts b/modules/ui/src/app/mocks/certificate.mock.ts new file mode 100644 index 000000000..b2a38847b --- /dev/null +++ b/modules/ui/src/app/mocks/certificate.mock.ts @@ -0,0 +1,22 @@ +/** + * Copyright 2023 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +import { Certificate } from '../model/certificate'; + +export const certificate = { + name: 'iot.bms.google.com', + organisation: 'Google, Inc.', + expires: '2024-09-01T09:00:12Z', +} as Certificate; diff --git a/modules/ui/src/app/model/certificate.ts b/modules/ui/src/app/model/certificate.ts new file mode 100644 index 000000000..b3abd24d6 --- /dev/null +++ b/modules/ui/src/app/model/certificate.ts @@ -0,0 +1,20 @@ +/** + * Copyright 2023 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +export interface Certificate { + name: string; + organisation: string; + expires: string; +} diff --git a/modules/ui/src/app/pages/certificates/certificate-item/certificate-item.component.html b/modules/ui/src/app/pages/certificates/certificate-item/certificate-item.component.html new file mode 100644 index 000000000..2f1a4c778 --- /dev/null +++ b/modules/ui/src/app/pages/certificates/certificate-item/certificate-item.component.html @@ -0,0 +1,19 @@ +
+ workspace_premium +
+

{{ certificate.name }}

+

{{ certificate.organisation }}

+

+ {{ certificate.expires | date: 'dd MMM yyyy' }} +

+
+ + +
diff --git a/modules/ui/src/app/pages/certificates/certificate-item/certificate-item.component.scss b/modules/ui/src/app/pages/certificates/certificate-item/certificate-item.component.scss new file mode 100644 index 000000000..d3300ea6d --- /dev/null +++ b/modules/ui/src/app/pages/certificates/certificate-item/certificate-item.component.scss @@ -0,0 +1,67 @@ +/** + * Copyright 2023 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +@use '@angular/material' as mat; +@import '../../../../theming/colors'; +@import '../../../../theming/variables'; + +:host:first-child .certificate-item-container { + border-top: 1px solid $lighter-grey; +} +.certificate-item-container { + display: grid; + grid-template-columns: 24px minmax(200px, 1fr) 24px; + gap: 16px; + box-sizing: border-box; + height: 88px; + padding: 12px 0; + border-bottom: 1px solid $lighter-grey; +} + +.certificate-item-icon { + color: $grey-700; +} + +.certificate-item-delete { + padding: 0; + margin-left: -50%; + border-radius: 4px; + color: $grey-700; + display: flex; + align-items: start; + justify-content: center; + & ::ng-deep .mat-mdc-button-persistent-ripple { + border-radius: 4px; + } +} + +.certificate-item-information { + overflow: hidden; + p { + font-family: $font-secondary, sans-serif; + margin: 0; + } + .certificate-item-name { + font-size: 16px; + color: $grey-800; + height: 24px; + } + .certificate-item-organisation, + .certificate-item-expires { + font-size: 14px; + color: $grey-700; + height: 20px; + } +} diff --git a/modules/ui/src/app/pages/certificates/certificate-item/certificate-item.component.spec.ts b/modules/ui/src/app/pages/certificates/certificate-item/certificate-item.component.spec.ts new file mode 100644 index 000000000..7705605e3 --- /dev/null +++ b/modules/ui/src/app/pages/certificates/certificate-item/certificate-item.component.spec.ts @@ -0,0 +1,56 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; + +import { CertificateItemComponent } from './certificate-item.component'; +import { certificate } from '../../../mocks/certificate.mock'; + +describe('CertificateItemComponent', () => { + let component: CertificateItemComponent; + let fixture: ComponentFixture; + let compiled: HTMLElement; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [CertificateItemComponent], + }).compileComponents(); + + fixture = TestBed.createComponent(CertificateItemComponent); + compiled = fixture.nativeElement as HTMLElement; + component = fixture.componentInstance; + component.certificate = certificate; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); + + describe('DOM tests', () => { + it('should have certificate name', () => { + const name = compiled.querySelector('.certificate-item-name'); + + expect(name?.textContent?.trim()).toEqual('iot.bms.google.com'); + }); + + it('should have certificate organization', () => { + const organization = compiled.querySelector( + '.certificate-item-organisation' + ); + + expect(organization?.textContent?.trim()).toEqual('Google, Inc.'); + }); + + it('should have certificate expire date', () => { + const date = compiled.querySelector('.certificate-item-expires'); + + expect(date?.textContent?.trim()).toEqual('01 Sep 2024'); + }); + + it('should have delete button', () => { + const deleteButton = fixture.nativeElement.querySelector( + '#main .test-button' + ) as HTMLButtonElement; + + expect(deleteButton).toBeDefined(); + }); + }); +}); diff --git a/modules/ui/src/app/pages/certificates/certificate-item/certificate-item.component.ts b/modules/ui/src/app/pages/certificates/certificate-item/certificate-item.component.ts new file mode 100644 index 000000000..ccdbf43cc --- /dev/null +++ b/modules/ui/src/app/pages/certificates/certificate-item/certificate-item.component.ts @@ -0,0 +1,16 @@ +import { Component, Input } from '@angular/core'; +import { Certificate } from '../../../model/certificate'; +import { MatIcon } from '@angular/material/icon'; +import { DatePipe } from '@angular/common'; +import { MatButtonModule } from '@angular/material/button'; + +@Component({ + selector: 'app-certificate-item', + standalone: true, + imports: [MatIcon, DatePipe, MatButtonModule], + templateUrl: './certificate-item.component.html', + styleUrl: './certificate-item.component.scss', +}) +export class CertificateItemComponent { + @Input() certificate!: Certificate; +} diff --git a/modules/ui/src/app/pages/certificates/certificates.component.html b/modules/ui/src/app/pages/certificates/certificates.component.html new file mode 100644 index 000000000..d61e2867f --- /dev/null +++ b/modules/ui/src/app/pages/certificates/certificates.component.html @@ -0,0 +1,45 @@ + +
+

Certificates

+ +
+
+ +
+ +
+ +
diff --git a/modules/ui/src/app/pages/certificates/certificates.component.scss b/modules/ui/src/app/pages/certificates/certificates.component.scss new file mode 100644 index 000000000..aa0903cde --- /dev/null +++ b/modules/ui/src/app/pages/certificates/certificates.component.scss @@ -0,0 +1,94 @@ +/** + * Copyright 2023 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +@use '@angular/material' as mat; +@import '../../../theming/colors'; +@import '../../../theming/variables'; + +:host { + display: flex; + flex-direction: column; + height: 100%; + flex: 1 0 auto; +} + +.certificates-drawer-header { + display: flex; + justify-content: space-between; + align-items: center; + padding: 12px 12px 16px 24px; + + &-title { + margin: 0; + font-size: 22px; + font-style: normal; + font-weight: 400; + line-height: 28px; + color: $dark-grey; + } + + &-button { + min-width: 24px; + width: 24px; + height: 24px; + margin: 4px; + padding: 8px !important; + box-sizing: content-box; + + .close-button-icon { + width: 24px; + height: 24px; + margin: 0; + } + } +} + +.certificates-drawer-content { + overflow: hidden; + flex: 1; + display: grid; + grid-template-rows: auto 1fr auto; +} + +.browse-files-button { + margin: 18px 16px; + padding: 8px 24px; + font-size: 14px; + font-weight: 500; + line-height: 20px; + letter-spacing: 0.25px; +} + +.content-certificates { + padding: 0 16px; + border-bottom: 1px solid $lighter-grey; + overflow-y: scroll; +} + +.certificates-drawer-footer { + padding: 16px 24px 8px 16px; + margin-top: auto; + display: flex; + flex-shrink: 0; + justify-content: flex-end; + + .close-button { + padding: 0 24px; + font-size: 14px; + font-weight: 500; + line-height: 20px; + letter-spacing: 0.25px; + } +} diff --git a/modules/ui/src/app/pages/certificates/certificates.component.spec.ts b/modules/ui/src/app/pages/certificates/certificates.component.spec.ts new file mode 100644 index 000000000..ead4cca0b --- /dev/null +++ b/modules/ui/src/app/pages/certificates/certificates.component.spec.ts @@ -0,0 +1,98 @@ +/** + * Copyright 2023 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +import { ComponentFixture, TestBed } from '@angular/core/testing'; + +import { CertificatesComponent } from './certificates.component'; +import { MatIconTestingModule } from '@angular/material/icon/testing'; +import { MatIcon } from '@angular/material/icon'; +import { certificate } from '../../mocks/certificate.mock'; +import { LiveAnnouncer } from '@angular/cdk/a11y'; +import SpyObj = jasmine.SpyObj; + +describe('CertificatesComponent', () => { + let component: CertificatesComponent; + let mockLiveAnnouncer: SpyObj; + let fixture: ComponentFixture; + + beforeEach(async () => { + mockLiveAnnouncer = jasmine.createSpyObj(['announce']); + await TestBed.configureTestingModule({ + imports: [CertificatesComponent, MatIconTestingModule, MatIcon], + providers: [{ provide: LiveAnnouncer, useValue: mockLiveAnnouncer }], + }).compileComponents(); + + fixture = TestBed.createComponent(CertificatesComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); + + describe('DOM tests', () => { + it('should emit closeSettingEvent when header button clicked', () => { + const headerCloseButton = fixture.nativeElement.querySelector( + '.certificates-drawer-header-button' + ) as HTMLButtonElement; + spyOn(component.closeCertificatedEvent, 'emit'); + + headerCloseButton.click(); + + expect(mockLiveAnnouncer.announce).toHaveBeenCalledWith( + 'The certificates panel is closed.' + ); + expect(component.closeCertificatedEvent.emit).toHaveBeenCalled(); + }); + + it('should emit closeSettingEvent when close button clicked', () => { + const headerCloseButton = fixture.nativeElement.querySelector( + '.close-button' + ) as HTMLButtonElement; + spyOn(component.closeCertificatedEvent, 'emit'); + + headerCloseButton.click(); + + expect(mockLiveAnnouncer.announce).toHaveBeenCalledWith( + 'The certificates panel is closed.' + ); + expect(component.closeCertificatedEvent.emit).toHaveBeenCalled(); + }); + + it('should have upload file button', () => { + const uploadCertificatesButton = fixture.nativeElement.querySelector( + '.browse-files-button' + ) as HTMLButtonElement; + + expect(uploadCertificatesButton).toBeDefined(); + }); + + describe('with certificates', () => { + beforeEach(() => { + component.certificates = [certificate, certificate]; + fixture.detectChanges(); + }); + + it('should have certificates list', () => { + const certificateList = fixture.nativeElement.querySelectorAll( + 'app-certificate-item' + ); + + expect(certificateList.length).toEqual(2); + }); + }); + }); +}); diff --git a/modules/ui/src/app/pages/certificates/certificates.component.ts b/modules/ui/src/app/pages/certificates/certificates.component.ts new file mode 100644 index 000000000..6959ad487 --- /dev/null +++ b/modules/ui/src/app/pages/certificates/certificates.component.ts @@ -0,0 +1,41 @@ +/** + * Copyright 2023 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +import { Component, EventEmitter, Input, Output } from '@angular/core'; +import { MatIcon } from '@angular/material/icon'; +import { CertificateItemComponent } from './certificate-item/certificate-item.component'; +import { NgForOf } from '@angular/common'; +import { MatButtonModule } from '@angular/material/button'; +import { Certificate } from '../../model/certificate'; +import { LiveAnnouncer } from '@angular/cdk/a11y'; + +@Component({ + selector: 'app-certificates', + standalone: true, + imports: [MatIcon, CertificateItemComponent, NgForOf, MatButtonModule], + templateUrl: './certificates.component.html', + styleUrl: './certificates.component.scss', +}) +export class CertificatesComponent { + @Input() certificates: Certificate[] = []; + @Output() closeCertificatedEvent = new EventEmitter(); + + constructor(private liveAnnouncer: LiveAnnouncer) {} + + closeCertificates() { + this.liveAnnouncer.announce('The certificates panel is closed.'); + this.closeCertificatedEvent.emit(); + } +} diff --git a/modules/ui/src/app/pages/settings/components/settings-dropdown/settings-dropdown.component.scss b/modules/ui/src/app/pages/settings/components/settings-dropdown/settings-dropdown.component.scss index 7cd228c75..16093e336 100644 --- a/modules/ui/src/app/pages/settings/components/settings-dropdown/settings-dropdown.component.scss +++ b/modules/ui/src/app/pages/settings/components/settings-dropdown/settings-dropdown.component.scss @@ -38,11 +38,11 @@ letter-spacing: 0.2px; &.top { - color: #3c4043; + color: $grey-800; } &.bottom { - color: #5f6368; + color: $grey-700; } } diff --git a/modules/ui/src/app/services/test-run.service.spec.ts b/modules/ui/src/app/services/test-run.service.spec.ts index 915a58eae..a48e2b042 100644 --- a/modules/ui/src/app/services/test-run.service.spec.ts +++ b/modules/ui/src/app/services/test-run.service.spec.ts @@ -32,6 +32,8 @@ import { device } from '../mocks/device.mock'; import { NEW_VERSION, VERSION } from '../mocks/version.mock'; import { MockStore, provideMockStore } from '@ngrx/store/testing'; import { AppState } from '../store/state'; +import { Certificate } from '../model/certificate'; +import { certificate } from '../mocks/certificate.mock'; const MOCK_SYSTEM_CONFIG: SystemConfig = { network: { @@ -457,4 +459,20 @@ describe('TestRunService', () => { ); req.flush(true); }); + + it('fetchCertificates should return certificates', () => { + const certificates = [certificate] as Certificate[]; + + service.fetchCertificates().subscribe(res => { + expect(res).toEqual(certificates); + }); + + const req = httpTestingController.expectOne( + 'http://localhost:8000/system/config/certs/list' + ); + + expect(req.request.method).toBe('GET'); + + req.flush(certificates); + }); }); diff --git a/modules/ui/src/app/services/test-run.service.ts b/modules/ui/src/app/services/test-run.service.ts index 7796b00d3..6e3e2c6a3 100644 --- a/modules/ui/src/app/services/test-run.service.ts +++ b/modules/ui/src/app/services/test-run.service.ts @@ -29,6 +29,7 @@ import { import { Version } from '../model/version'; import { Store } from '@ngrx/store'; import { AppState } from '../store/state'; +import { Certificate } from '../model/certificate'; const API_URL = `http://${window.location.hostname}:8000`; export const SYSTEM_STOP = '/system/stop'; @@ -217,4 +218,8 @@ export class TestRunService { }) .pipe(map(() => true)); } + + fetchCertificates(): Observable { + return this.http.get(`${API_URL}/system/config/certs/list`); + } } From 8319405d86ca401cd5bab4c95f1fe3e344eb8b6e Mon Sep 17 00:00:00 2001 From: J Boddey Date: Thu, 2 May 2024 19:00:01 +0100 Subject: [PATCH 10/26] List modules (#425) --- framework/python/src/api/api.py | 20 ++++++++++++++++++++ framework/python/src/core/testrun.py | 13 +++++++++---- 2 files changed, 29 insertions(+), 4 deletions(-) diff --git a/framework/python/src/api/api.py b/framework/python/src/api/api.py index 758da6656..7f72d8810 100644 --- a/framework/python/src/api/api.py +++ b/framework/python/src/api/api.py @@ -95,6 +95,9 @@ def __init__(self, test_run): self._router.add_api_route("/device/edit", self.edit_device, methods=["POST"]) + + self._router.add_api_route("/system/modules", + self.get_test_modules) # Allow all origins to access the API origins = ["*"] @@ -551,6 +554,20 @@ async def get_results(self, response: Response, response.status_code = 404 return self._generate_msg(False, "Test results could not be found") + async def get_test_modules(self): + + LOGGER.debug("Received request to list test modules") + + test_modules = [] + + for test_module in self._get_test_run().get_test_orc().get_test_modules(): + + # Only add module if it is an actual, enabled test module + if (test_module.enabled and test_module.enable_container): + test_modules.append(test_module.display_name) + + return test_modules + def _validate_device_json(self, json_obj): # Check all required properties are present @@ -575,3 +592,6 @@ def _validate_device_json(self, json_obj): return False return True + + def _get_test_run(self): + return self._test_run diff --git a/framework/python/src/core/testrun.py b/framework/python/src/core/testrun.py index 71070e2cc..22607a520 100644 --- a/framework/python/src/core/testrun.py +++ b/framework/python/src/core/testrun.py @@ -100,14 +100,18 @@ def __init__(self, if validate: self._session.add_runtime_param('validate') - self.load_all_devices() - self._net_orc = net_orc.NetworkOrchestrator( session=self._session) self._test_orc = test_orc.TestOrchestrator( self._session, self._net_orc) + # Load device repository + self.load_all_devices() + + # Load test modules + self._test_orc.start() + if self._no_ui: # Check Testrun is able to start @@ -324,8 +328,6 @@ def start(self): if self._net_only: LOGGER.info('Network only option configured, no tests will be run') else: - self._test_orc.start() - self.get_net_orc().get_listener().register_callback( self._device_stable, [NetworkEvent.DEVICE_STABLE] @@ -382,6 +384,9 @@ def get_config_file(self): def get_net_orc(self): return self._net_orc + def get_test_orc(self): + return self._test_orc + def _start_network(self): # Start the network orchestrator if not self.get_net_orc().start(): From c62d47f679d10fcf77f675fe70dc36b9e04f3f53 Mon Sep 17 00:00:00 2001 From: Sofia Kurilova Date: Mon, 6 May 2024 10:35:22 +0000 Subject: [PATCH 11/26] Adds delete certificate (#424) Adds delete certificate --- modules/ui/src/app/app.component.html | 3 +- modules/ui/src/app/app.component.spec.ts | 9 ++++ modules/ui/src/app/app.component.ts | 4 ++ modules/ui/src/app/app.store.spec.ts | 18 ++++++- modules/ui/src/app/app.store.ts | 22 ++++++++- modules/ui/src/app/mocks/certificate.mock.ts | 6 +++ .../certificate-item.component.html | 3 +- .../certificate-item.component.spec.ts | 22 +++++++-- .../certificate-item.component.ts | 3 +- .../certificates/certificates.component.html | 3 +- .../certificates.component.spec.ts | 31 +++++++++++- .../certificates/certificates.component.ts | 49 +++++++++++++++++-- .../progress-status-card.component.spec.ts | 4 +- .../src/app/services/test-run.service.spec.ts | 14 ++++++ .../ui/src/app/services/test-run.service.ts | 8 +++ 15 files changed, 182 insertions(+), 17 deletions(-) diff --git a/modules/ui/src/app/app.component.html b/modules/ui/src/app/app.component.html index d52e04772..f47583973 100644 --- a/modules/ui/src/app/app.component.html +++ b/modules/ui/src/app/app.component.html @@ -98,7 +98,7 @@

Testrun

tune + [disable]="isTestrunInProgress(vm.systemStatus?.status)">
@@ -207,6 +207,7 @@

Testrun

class="settings-drawer"> diff --git a/modules/ui/src/app/app.component.spec.ts b/modules/ui/src/app/app.component.spec.ts index e827a06ca..606485335 100644 --- a/modules/ui/src/app/app.component.spec.ts +++ b/modules/ui/src/app/app.component.spec.ts @@ -62,6 +62,7 @@ import { } from './store/selectors'; import { MatIconTestingModule } from '@angular/material/icon/testing'; import { CertificatesComponent } from './pages/certificates/certificates.component'; +import { of } from 'rxjs'; describe('AppComponent', () => { let component: AppComponent; @@ -96,8 +97,10 @@ describe('AppComponent', () => { 'getTestModules', 'testrunInProgress', 'fetchCertificates', + 'deleteCertificate', ]); + mockService.deleteCertificate.and.returnValue(of(true)); mockFocusManagerService = jasmine.createSpyObj('mockFocusManagerService', [ 'focusFirstElementInContainer', ]); @@ -675,6 +678,12 @@ describe('AppComponent', () => { expect(component.certDrawer.open).toHaveBeenCalledTimes(1); }); + + it('should call delete certificate', () => { + component.deleteCertificate('name'); + + expect(mockService.deleteCertificate).toHaveBeenCalledWith('name'); + }); }); @Component({ diff --git a/modules/ui/src/app/app.component.ts b/modules/ui/src/app/app.component.ts index e959c37e0..822690b2c 100644 --- a/modules/ui/src/app/app.component.ts +++ b/modules/ui/src/app/app.component.ts @@ -166,4 +166,8 @@ export class AppComponent { isTestrunInProgress(status?: string) { return this.testRunService.testrunInProgress(status); } + + deleteCertificate(name: string) { + this.appStore.deleteCertificate(name); + } } diff --git a/modules/ui/src/app/app.store.spec.ts b/modules/ui/src/app/app.store.spec.ts index a11f85943..ffdb18921 100644 --- a/modules/ui/src/app/app.store.spec.ts +++ b/modules/ui/src/app/app.store.spec.ts @@ -32,7 +32,7 @@ import SpyObj = jasmine.SpyObj; import { device } from './mocks/device.mock'; import { setDevices, setTestrunStatus } from './store/actions'; import { MOCK_PROGRESS_DATA_IN_PROGRESS } from './mocks/progress.mock'; -import { certificate } from './mocks/certificate.mock'; +import { certificate, certificate2 } from './mocks/certificate.mock'; const mock = (() => { let store: { [key: string]: string } = {}; @@ -62,6 +62,7 @@ describe('AppStore', () => { 'fetchDevices', 'fetchSystemStatus', 'fetchCertificates', + 'deleteCertificate', ]); TestBed.configureTestingModule({ @@ -216,5 +217,20 @@ describe('AppStore', () => { appStore.getCertificates(); }); }); + + describe('deleteCertificate', () => { + it('should update store', done => { + mockService.deleteCertificate.and.returnValue(of(true)); + + appStore.updateCertificates([certificate, certificate2]); + + appStore.viewModel$.pipe(skip(1), take(1)).subscribe(store => { + expect(store.certificates).toEqual([certificate2]); + done(); + }); + + appStore.deleteCertificate('iot.bms.google.com'); + }); + }); }); }); diff --git a/modules/ui/src/app/app.store.ts b/modules/ui/src/app/app.store.ts index ee5c952fe..46d42bd3f 100644 --- a/modules/ui/src/app/app.store.ts +++ b/modules/ui/src/app/app.store.ts @@ -16,7 +16,7 @@ import { Injectable } from '@angular/core'; import { ComponentStore } from '@ngrx/component-store'; -import { tap } from 'rxjs/operators'; +import { tap, withLatestFrom } from 'rxjs/operators'; import { selectError, selectHasConnectionSettings, @@ -148,6 +148,26 @@ export class AppStore extends ComponentStore { }) ); }); + + deleteCertificate = this.effect(trigger$ => { + return trigger$.pipe( + exhaustMap((name: string) => { + return this.testRunService.deleteCertificate(name).pipe( + withLatestFrom(this.certificates$), + tap(([, current]) => { + this.removeCertificate(name, current); + }) + ); + }) + ); + }); + + private removeCertificate(name: string, current: Certificate[]) { + const certificates = current.filter( + certificate => certificate.name !== name + ); + this.updateCertificates(certificates); + } constructor( private store: Store, private testRunService: TestRunService diff --git a/modules/ui/src/app/mocks/certificate.mock.ts b/modules/ui/src/app/mocks/certificate.mock.ts index b2a38847b..f563d7d43 100644 --- a/modules/ui/src/app/mocks/certificate.mock.ts +++ b/modules/ui/src/app/mocks/certificate.mock.ts @@ -20,3 +20,9 @@ export const certificate = { organisation: 'Google, Inc.', expires: '2024-09-01T09:00:12Z', } as Certificate; + +export const certificate2 = { + name: 'sensor.bms.google.com', + organisation: 'Google, Inc.', + expires: '2024-09-01T09:00:12Z', +} as Certificate; diff --git a/modules/ui/src/app/pages/certificates/certificate-item/certificate-item.component.html b/modules/ui/src/app/pages/certificates/certificate-item/certificate-item.component.html index 2f1a4c778..d1a1b2189 100644 --- a/modules/ui/src/app/pages/certificates/certificate-item/certificate-item.component.html +++ b/modules/ui/src/app/pages/certificates/certificate-item/certificate-item.component.html @@ -13,7 +13,8 @@
diff --git a/modules/ui/src/app/pages/certificates/certificate-item/certificate-item.component.spec.ts b/modules/ui/src/app/pages/certificates/certificate-item/certificate-item.component.spec.ts index 7705605e3..b39876501 100644 --- a/modules/ui/src/app/pages/certificates/certificate-item/certificate-item.component.spec.ts +++ b/modules/ui/src/app/pages/certificates/certificate-item/certificate-item.component.spec.ts @@ -45,12 +45,24 @@ describe('CertificateItemComponent', () => { expect(date?.textContent?.trim()).toEqual('01 Sep 2024'); }); - it('should have delete button', () => { - const deleteButton = fixture.nativeElement.querySelector( - '#main .test-button' - ) as HTMLButtonElement; + describe('delete button', () => { + let deleteButton: HTMLButtonElement; + beforeEach(() => { + deleteButton = fixture.nativeElement.querySelector( + '.certificate-item-delete' + ) as HTMLButtonElement; + }); - expect(deleteButton).toBeDefined(); + it('should be present', () => { + expect(deleteButton).toBeDefined(); + }); + + it('should emit delete event on delete button clicked', () => { + const deleteSpy = spyOn(component.deleteButtonClicked, 'emit'); + deleteButton.click(); + + expect(deleteSpy).toHaveBeenCalledWith('iot.bms.google.com'); + }); }); }); }); diff --git a/modules/ui/src/app/pages/certificates/certificate-item/certificate-item.component.ts b/modules/ui/src/app/pages/certificates/certificate-item/certificate-item.component.ts index ccdbf43cc..9e653d111 100644 --- a/modules/ui/src/app/pages/certificates/certificate-item/certificate-item.component.ts +++ b/modules/ui/src/app/pages/certificates/certificate-item/certificate-item.component.ts @@ -1,4 +1,4 @@ -import { Component, Input } from '@angular/core'; +import { Component, EventEmitter, Input, Output } from '@angular/core'; import { Certificate } from '../../../model/certificate'; import { MatIcon } from '@angular/material/icon'; import { DatePipe } from '@angular/common'; @@ -13,4 +13,5 @@ import { MatButtonModule } from '@angular/material/button'; }) export class CertificateItemComponent { @Input() certificate!: Certificate; + @Output() deleteButtonClicked = new EventEmitter(); } diff --git a/modules/ui/src/app/pages/certificates/certificates.component.html b/modules/ui/src/app/pages/certificates/certificates.component.html index d61e2867f..0450a951c 100644 --- a/modules/ui/src/app/pages/certificates/certificates.component.html +++ b/modules/ui/src/app/pages/certificates/certificates.component.html @@ -31,7 +31,8 @@

Certificates

+ [certificate]="certificate" + (deleteButtonClicked)="deleteCertificate($event)">