diff --git a/modules/ui/src/app/app.component.html b/modules/ui/src/app/app.component.html
index a89ee0b51..8b3a8f7dc 100644
--- a/modules/ui/src/app/app.component.html
+++ b/modules/ui/src/app/app.component.html
@@ -87,6 +87,9 @@
Testrun
(click)="openGeneralSettings(true)">
tune
+
+
diff --git a/modules/ui/src/app/app.component.spec.ts b/modules/ui/src/app/app.component.spec.ts
index f69b80548..859cc7d64 100644
--- a/modules/ui/src/app/app.component.spec.ts
+++ b/modules/ui/src/app/app.component.spec.ts
@@ -143,6 +143,7 @@ describe('AppComponent', () => {
AppComponent,
FakeGeneralSettingsComponent,
FakeSpinnerComponent,
+ FakeShutdownAppComponent,
FakeVersionComponent,
],
});
@@ -657,6 +658,14 @@ class FakeGeneralSettingsComponent {
})
class FakeSpinnerComponent {}
+@Component({
+ selector: 'app-shutdown-app',
+ template: '',
+})
+class FakeShutdownAppComponent {
+ @Input() disable!: boolean;
+}
+
@Component({
selector: 'app-version',
template: '',
diff --git a/modules/ui/src/app/app.component.ts b/modules/ui/src/app/app.component.ts
index e1791764b..21605e630 100644
--- a/modules/ui/src/app/app.component.ts
+++ b/modules/ui/src/app/app.component.ts
@@ -178,4 +178,12 @@ export class AppComponent implements OnInit {
consentShown() {
this.appStore.setContent();
}
+
+ testrunInProgress(status?: string): boolean {
+ return (
+ status === StatusOfTestrun.InProgress ||
+ status === StatusOfTestrun.WaitingForDevice ||
+ status === StatusOfTestrun.Monitoring
+ );
+ }
}
diff --git a/modules/ui/src/app/app.module.ts b/modules/ui/src/app/app.module.ts
index 42ade0ddd..753aaadc4 100644
--- a/modules/ui/src/app/app.module.ts
+++ b/modules/ui/src/app/app.module.ts
@@ -44,6 +44,8 @@ import { EffectsModule } from '@ngrx/effects';
import { AppEffects } from './store/effects';
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';
@NgModule({
declarations: [AppComponent, GeneralSettingsComponent],
@@ -71,8 +73,10 @@ import { SettingsDropdownComponent } from './pages/settings/components/settings-
EffectsModule.forRoot([AppEffects]),
CdkTrapFocus,
SettingsDropdownComponent,
+ ShutdownAppComponent,
],
providers: [
+ WindowProvider,
{
provide: HTTP_INTERCEPTORS,
useClass: ErrorInterceptor,
diff --git a/modules/ui/src/app/components/shutdown-app-modal/shutdown-app-modal.component.html b/modules/ui/src/app/components/shutdown-app-modal/shutdown-app-modal.component.html
new file mode 100644
index 000000000..0614dfc28
--- /dev/null
+++ b/modules/ui/src/app/components/shutdown-app-modal/shutdown-app-modal.component.html
@@ -0,0 +1,38 @@
+
+{{ data.title }}
+
+ {{ data.content }}
+
+
+
+
+
+
diff --git a/modules/ui/src/app/components/shutdown-app-modal/shutdown-app-modal.component.scss b/modules/ui/src/app/components/shutdown-app-modal/shutdown-app-modal.component.scss
new file mode 100644
index 000000000..85ddce69d
--- /dev/null
+++ b/modules/ui/src/app/components/shutdown-app-modal/shutdown-app-modal.component.scss
@@ -0,0 +1,44 @@
+/**
+ * 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 '../../../theming/colors';
+
+:host {
+ display: grid;
+ overflow: hidden;
+ width: 570px;
+ box-sizing: border-box;
+ padding: 24px 16px 8px 24px;
+ gap: 10px;
+}
+
+.modal-title {
+ color: $grey-900;
+ font-size: 18px;
+ line-height: 24px;
+}
+
+.modal-content {
+ font-family: Roboto, sans-serif;
+ font-size: 14px;
+ line-height: 20px;
+ letter-spacing: 0.2px;
+ color: $grey-800;
+}
+
+.modal-actions {
+ padding: 0;
+ min-height: 30px;
+}
diff --git a/modules/ui/src/app/components/shutdown-app-modal/shutdown-app-modal.component.spec.ts b/modules/ui/src/app/components/shutdown-app-modal/shutdown-app-modal.component.spec.ts
new file mode 100644
index 000000000..d7cd5abb6
--- /dev/null
+++ b/modules/ui/src/app/components/shutdown-app-modal/shutdown-app-modal.component.spec.ts
@@ -0,0 +1,100 @@
+/**
+ * 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 { ShutdownAppModalComponent } from './shutdown-app-modal.component';
+import {
+ MAT_DIALOG_DATA,
+ MatDialogModule,
+ MatDialogRef,
+} from '@angular/material/dialog';
+import { MatButtonModule } from '@angular/material/button';
+import { of } from 'rxjs';
+import { BrowserAnimationsModule } from '@angular/platform-browser/animations';
+
+describe('ShutdownAppModalComponent', () => {
+ let component: ShutdownAppModalComponent;
+ let fixture: ComponentFixture;
+ let compiled: HTMLElement;
+
+ beforeEach(async () => {
+ await TestBed.configureTestingModule({
+ imports: [
+ ShutdownAppModalComponent,
+ MatDialogModule,
+ MatButtonModule,
+ BrowserAnimationsModule,
+ ],
+ providers: [
+ {
+ provide: MatDialogRef,
+ useValue: {
+ keydownEvents: () => of(new KeyboardEvent('keydown', { code: '' })),
+ close: () => ({}),
+ },
+ },
+ { provide: MAT_DIALOG_DATA, useValue: {} },
+ ],
+ }).compileComponents();
+
+ fixture = TestBed.createComponent(ShutdownAppModalComponent);
+ component = fixture.componentInstance;
+ component.data = {
+ title: 'title',
+ content: 'content',
+ };
+ compiled = fixture.nativeElement as HTMLElement;
+ fixture.detectChanges();
+ });
+
+ it('should create', () => {
+ expect(component).toBeTruthy();
+ });
+
+ it('should has title and content', () => {
+ const title = compiled.querySelector('.modal-title') as HTMLElement;
+ const content = compiled.querySelector('.modal-content') as HTMLElement;
+
+ expect(title.innerHTML.trim()).toEqual('title');
+ expect(content.innerHTML.trim()).toEqual('content');
+ });
+
+ it('should close dialog on click "cancel" button', () => {
+ const closeSpy = spyOn(component.dialogRef, 'close');
+ const closeButton = compiled.querySelector(
+ '.cancel-button'
+ ) as HTMLButtonElement;
+
+ closeButton.click();
+
+ expect(closeSpy).toHaveBeenCalledWith();
+
+ closeSpy.calls.reset();
+ });
+
+ it('should close dialog with true on click "confirm" button', () => {
+ const closeSpy = spyOn(component.dialogRef, 'close');
+ const confirmButton = compiled.querySelector(
+ '.confirm-button'
+ ) as HTMLButtonElement;
+
+ confirmButton.click();
+
+ expect(closeSpy).toHaveBeenCalledWith(true);
+
+ closeSpy.calls.reset();
+ });
+});
diff --git a/modules/ui/src/app/components/shutdown-app-modal/shutdown-app-modal.component.ts b/modules/ui/src/app/components/shutdown-app-modal/shutdown-app-modal.component.ts
new file mode 100644
index 000000000..af42aabe9
--- /dev/null
+++ b/modules/ui/src/app/components/shutdown-app-modal/shutdown-app-modal.component.ts
@@ -0,0 +1,53 @@
+/**
+ * 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 { ChangeDetectionStrategy, Component, Inject } from '@angular/core';
+import { EscapableDialogComponent } from '../escapable-dialog/escapable-dialog.component';
+import {
+ MAT_DIALOG_DATA,
+ MatDialogModule,
+ MatDialogRef,
+} from '@angular/material/dialog';
+import { MatButtonModule } from '@angular/material/button';
+
+interface DialogData {
+ title?: string;
+ content?: string;
+}
+
+@Component({
+ selector: 'app-shutdown-app-modal',
+ templateUrl: './shutdown-app-modal.component.html',
+ styleUrl: './shutdown-app-modal.component.scss',
+ standalone: true,
+ imports: [MatDialogModule, MatButtonModule],
+ changeDetection: ChangeDetectionStrategy.OnPush,
+})
+export class ShutdownAppModalComponent extends EscapableDialogComponent {
+ constructor(
+ public override dialogRef: MatDialogRef,
+ @Inject(MAT_DIALOG_DATA) public data: DialogData
+ ) {
+ super(dialogRef);
+ }
+
+ confirm() {
+ this.dialogRef.close(true);
+ }
+
+ cancel() {
+ this.dialogRef.close();
+ }
+}
diff --git a/modules/ui/src/app/components/shutdown-app/shutdown-app.component.html b/modules/ui/src/app/components/shutdown-app/shutdown-app.component.html
new file mode 100644
index 000000000..a13bd5118
--- /dev/null
+++ b/modules/ui/src/app/components/shutdown-app/shutdown-app.component.html
@@ -0,0 +1,23 @@
+
+
diff --git a/modules/ui/src/app/components/shutdown-app/shutdown-app.component.scss b/modules/ui/src/app/components/shutdown-app/shutdown-app.component.scss
new file mode 100644
index 000000000..a424bf842
--- /dev/null
+++ b/modules/ui/src/app/components/shutdown-app/shutdown-app.component.scss
@@ -0,0 +1,28 @@
+/**
+ * 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.
+ */
+:host {
+ ::ng-deep.mat-mdc-icon-button .mat-mdc-button-persistent-ripple {
+ border-radius: inherit;
+ }
+}
+.shutdown-button {
+ border-radius: 20px;
+ padding: 0;
+ box-sizing: border-box;
+ height: 34px;
+ margin: 11px 2px 11px 0;
+ line-height: 50%;
+}
diff --git a/modules/ui/src/app/components/shutdown-app/shutdown-app.component.spec.ts b/modules/ui/src/app/components/shutdown-app/shutdown-app.component.spec.ts
new file mode 100644
index 000000000..522234f5e
--- /dev/null
+++ b/modules/ui/src/app/components/shutdown-app/shutdown-app.component.spec.ts
@@ -0,0 +1,112 @@
+/**
+ * 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,
+ fakeAsync,
+ TestBed,
+ tick,
+} from '@angular/core/testing';
+
+import { ShutdownAppComponent } from './shutdown-app.component';
+import { MatDialogModule, MatDialogRef } from '@angular/material/dialog';
+import { TestRunService } from '../../services/test-run.service';
+import SpyObj = jasmine.SpyObj;
+import { of } from 'rxjs';
+import { ShutdownAppModalComponent } from '../shutdown-app-modal/shutdown-app-modal.component';
+import { BrowserAnimationsModule } from '@angular/platform-browser/animations';
+import { WINDOW } from '../../providers/window.provider';
+
+describe('ShutdownAppComponent', () => {
+ let component: ShutdownAppComponent;
+ let compiled: HTMLElement;
+ let fixture: ComponentFixture;
+ let mockService: SpyObj;
+
+ const windowMock = {
+ location: {
+ reload: jasmine.createSpy('reload'),
+ },
+ };
+
+ beforeEach(async () => {
+ mockService = jasmine.createSpyObj(['shutdownTestrun']);
+
+ await TestBed.configureTestingModule({
+ imports: [ShutdownAppComponent, MatDialogModule, BrowserAnimationsModule],
+ providers: [
+ { provide: TestRunService, useValue: mockService },
+ {
+ provide: MatDialogRef,
+ useValue: {
+ close: () => ({}),
+ },
+ },
+ { provide: WINDOW, useValue: windowMock },
+ ],
+ }).compileComponents();
+
+ fixture = TestBed.createComponent(ShutdownAppComponent);
+ component = fixture.componentInstance;
+ compiled = fixture.nativeElement as HTMLElement;
+ });
+
+ it('should create', () => {
+ expect(component).toBeTruthy();
+ });
+
+ it('#openShutdownModal should call service shutdownTestrun after close', fakeAsync(() => {
+ mockService.shutdownTestrun.and.returnValue(of(false));
+ spyOn(component.dialog, 'open').and.returnValue({
+ afterClosed: () => of(true),
+ } as MatDialogRef);
+ tick();
+
+ component.openShutdownModal();
+
+ expect(mockService.shutdownTestrun).toHaveBeenCalled();
+ }));
+
+ it('#openShutdownModal should reload window after shutdownTestrun', fakeAsync(() => {
+ mockService.shutdownTestrun.and.returnValue(of(true));
+ spyOn(component.dialog, 'open').and.returnValue({
+ afterClosed: () => of(true),
+ } as MatDialogRef);
+ tick();
+
+ component.openShutdownModal();
+
+ expect(windowMock.location.reload).toHaveBeenCalled();
+ }));
+
+ it('shutdown button should be enable', () => {
+ const shutdownButton = compiled.querySelector(
+ '.shutdown-button'
+ ) as HTMLButtonElement;
+
+ expect(shutdownButton.disabled).toBeFalse();
+ });
+
+ it('shutdown button should be disable', () => {
+ component.disable = true;
+ fixture.detectChanges();
+
+ const shutdownButton = compiled.querySelector(
+ '.shutdown-button'
+ ) as HTMLButtonElement;
+
+ expect(shutdownButton.disabled).toBeTrue();
+ });
+});
diff --git a/modules/ui/src/app/components/shutdown-app/shutdown-app.component.ts b/modules/ui/src/app/components/shutdown-app/shutdown-app.component.ts
new file mode 100644
index 000000000..99a9b06d0
--- /dev/null
+++ b/modules/ui/src/app/components/shutdown-app/shutdown-app.component.ts
@@ -0,0 +1,92 @@
+/**
+ * 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 {
+ ChangeDetectionStrategy,
+ Component,
+ Inject,
+ Input,
+ OnDestroy,
+} from '@angular/core';
+import { CommonModule } from '@angular/common';
+import { MatButtonModule } from '@angular/material/button';
+import { MatIcon } from '@angular/material/icon';
+import { MatDialog } from '@angular/material/dialog';
+import { ShutdownAppModalComponent } from '../shutdown-app-modal/shutdown-app-modal.component';
+import { Subject, takeUntil } from 'rxjs';
+import { TestRunService } from '../../services/test-run.service';
+import { WINDOW } from '../../providers/window.provider';
+
+@Component({
+ selector: 'app-shutdown-app',
+ standalone: true,
+ imports: [CommonModule, MatButtonModule, MatIcon],
+ templateUrl: './shutdown-app.component.html',
+ styleUrl: './shutdown-app.component.scss',
+ changeDetection: ChangeDetectionStrategy.OnPush,
+})
+export class ShutdownAppComponent implements OnDestroy {
+ @Input() disable: boolean = false;
+
+ private destroy$: Subject = new Subject();
+ constructor(
+ public dialog: MatDialog,
+ private testRunService: TestRunService,
+ @Inject(WINDOW) private window: Window
+ ) {}
+
+ openShutdownModal() {
+ const dialogRef = this.dialog.open(ShutdownAppModalComponent, {
+ ariaLabel: 'Shutdown Testrun',
+ data: {
+ title: 'Shutdown Testrun',
+ content: 'Do you want to stop Testrun?',
+ },
+ autoFocus: true,
+ hasBackdrop: true,
+ disableClose: true,
+ panelClass: 'shutdown-app-dialog',
+ });
+
+ dialogRef
+ ?.afterClosed()
+ .pipe(takeUntil(this.destroy$))
+ .subscribe(shutdownApp => {
+ if (shutdownApp) {
+ this.shutdownApp();
+ }
+ });
+ }
+
+ private shutdownApp(): void {
+ this.testRunService
+ .shutdownTestrun()
+ .pipe(takeUntil(this.destroy$))
+ .subscribe(success => {
+ if (success) {
+ this.reloadPage();
+ }
+ });
+ }
+
+ private reloadPage(): void {
+ this.window.location.reload();
+ }
+
+ ngOnDestroy() {
+ this.destroy$.next(true);
+ this.destroy$.unsubscribe();
+ }
+}
diff --git a/modules/ui/src/app/providers/window.provider.ts b/modules/ui/src/app/providers/window.provider.ts
new file mode 100644
index 000000000..3902e6eb9
--- /dev/null
+++ b/modules/ui/src/app/providers/window.provider.ts
@@ -0,0 +1,27 @@
+import { FactoryProvider, InjectionToken } from '@angular/core';
+
+/**
+ * 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 const WINDOW = new InjectionToken('window');
+
+export const WindowProvider: FactoryProvider = {
+ provide: WINDOW,
+ useFactory: getWindow,
+};
+
+export function getWindow(): Window {
+ return window;
+}
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 6283b87ac..2068c2b80 100644
--- a/modules/ui/src/app/services/test-run.service.spec.ts
+++ b/modules/ui/src/app/services/test-run.service.spec.ts
@@ -200,6 +200,19 @@ describe('TestRunService', () => {
req.flush({});
});
+ it('#shutdownTestrun should have necessary request data', () => {
+ const apiUrl = 'http://localhost:8000/system/shutdown';
+
+ service.shutdownTestrun().subscribe(res => {
+ expect(res).toEqual(true);
+ });
+
+ const req = httpTestingController.expectOne(apiUrl);
+ expect(req.request.method).toBe('POST');
+ expect(req.request.body).toEqual({});
+ req.flush({});
+ });
+
describe('#startTestRun', () => {
it('should have necessary request data', () => {
const apiUrl = 'http://localhost:8000/system/start';
diff --git a/modules/ui/src/app/services/test-run.service.ts b/modules/ui/src/app/services/test-run.service.ts
index f47293b1d..72f578571 100644
--- a/modules/ui/src/app/services/test-run.service.ts
+++ b/modules/ui/src/app/services/test-run.service.ts
@@ -135,6 +135,12 @@ export class TestRunService {
.pipe(map(() => true));
}
+ shutdownTestrun(): Observable {
+ return this.http
+ .post<{ success: string }>(`${API_URL}/system/shutdown`, {})
+ .pipe(map(() => true));
+ }
+
getTestModules(): TestModule[] {
return this.testModules;
}
diff --git a/modules/ui/src/theming/colors.scss b/modules/ui/src/theming/colors.scss
index db007cf16..a54b7e9ab 100644
--- a/modules/ui/src/theming/colors.scss
+++ b/modules/ui/src/theming/colors.scss
@@ -23,6 +23,7 @@ $color-background-grey: #f8f9fa;
$dark-grey: #444746;
$grey-700: #5f6368;
$grey-800: #3c4043;
+$grey-900: #202124;
$light-grey: #bdc1c6;
$lighter-grey: #dadce0;
$green-50: #e6f4ea;