Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -69,4 +69,17 @@ describe('EscapableDialogComponent', () => {

closeSpy.calls.reset();
});

it('should not close dialog on escape when escape is disabled', () => {
const closeSpy = spyOn(component.dialogRef, 'close');
component.disableEscape = true;

keyboardEvent.next(new KeyboardEvent('keydown', { code: 'Escape' }));
fixture.detectChanges();

expect(closeSpy).not.toHaveBeenCalled();

closeSpy.calls.reset();
component.disableEscape = false;
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -26,14 +26,17 @@ import { MatDialogRef } from '@angular/material/dialog';
})
export class EscapableDialogComponent {
dialogRef = inject<MatDialogRef<EscapableDialogComponent>>(MatDialogRef);
disableEscape = false;

constructor() {
const dialogRef = this.dialogRef;

this.dialogRef
.keydownEvents()
.pipe(
filter((e: KeyboardEvent) => e.code === 'Escape'),
filter(
(e: KeyboardEvent) => e.code === 'Escape' && !this.disableEscape
),
take(1)
)
.subscribe(() => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -25,23 +25,25 @@
{{ data.content }}
</p>

<mat-dialog-actions align="end" class="simple-dialog-actions">
<button
cdkFocusInitial
aria-label="Cancel"
(click)="cancel()"
class="cancel-button"
color="primary"
mat-button
type="button">
Cancel
</button>
<button
(click)="confirm()"
class="confirm-button"
color="primary"
mat-button
type="button">
{{ data.confirmName ? data.confirmName : 'Confirm' }}
</button>
</mat-dialog-actions>
@if (!data.isPersistent) {
<mat-dialog-actions align="end" class="simple-dialog-actions">
<button
cdkFocusInitial
aria-label="Cancel"
(click)="cancel()"
class="cancel-button"
color="primary"
mat-button
type="button">
Cancel
</button>
<button
(click)="confirm()"
class="confirm-button"
color="primary"
mat-button
type="button">
{{ data.confirmName ? data.confirmName : 'Confirm' }}
</button>
</mat-dialog-actions>
}
Original file line number Diff line number Diff line change
Expand Up @@ -105,4 +105,54 @@ describe('DeleteFormComponent', () => {

closeSpy.calls.reset();
});

describe('persistent mode', () => {
beforeEach(() => {
component.data = {
icon: 'error',
title: 'Connection Error',
content: 'content',
isPersistent: true,
};
component.disableEscape = true;
fixture.detectChanges();
});

it('should not render cancel and confirm buttons', () => {
expect(compiled.querySelector('.cancel-button')).toBeNull();
expect(compiled.querySelector('.confirm-button')).toBeNull();
expect(compiled.querySelector('mat-dialog-actions')).toBeNull();
});

it('should still render title and content', () => {
expect(
compiled.querySelector('.simple-dialog-title')?.textContent?.trim()
).toBe('Connection Error');
expect(
compiled.querySelector('.simple-dialog-content')?.textContent?.trim()
).toBe('content');
});
});

it('should set disableEscape from persistent dialog data', () => {
TestBed.resetTestingModule();
TestBed.configureTestingModule({
imports: [SimpleDialogComponent, MatDialogModule, MatButtonModule],
providers: [
{
provide: MatDialogRef,
useValue: {
keydownEvents: () => of(new KeyboardEvent('keydown', { code: '' })),
close: () => ({}),
afterOpened: () => of(void 0),
beforeClosed: () => of(void 0),
},
},
{ provide: MAT_DIALOG_DATA, useValue: { isPersistent: true } },
],
});
const persistentFixture = TestBed.createComponent(SimpleDialogComponent);

expect(persistentFixture.componentInstance.disableEscape).toBeTrue();
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ interface DialogData {
title?: string;
content?: string;
confirmName?: string;
isPersistent?: boolean;
}

@Component({
Expand Down Expand Up @@ -58,6 +59,7 @@ export class SimpleDialogComponent extends ComponentWithAnnouncement(
super(dialogRef, data.title, liveAnnouncer, focusService);
this.dialogRef = dialogRef;
this.data = data;
this.disableEscape = !!data?.isPersistent;
this.liveAnnouncer = liveAnnouncer;
this.focusService = focusService;
}
Expand Down
60 changes: 60 additions & 0 deletions modules/ui/src/app/interceptors/error.interceptor.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import { TestBed } from '@angular/core/testing';

import { ErrorInterceptor } from './error.interceptor';
import { NotificationService } from '../services/notification.service';
import { SYSTEM_START } from '../services/test-run.service';
import {
HttpErrorResponse,
HttpHandler,
Expand Down Expand Up @@ -129,4 +130,63 @@ describe('ErrorInterceptor', () => {
}
);
});

describe('SYSTEM_START request', () => {
const requestMock = new HttpRequest('POST', SYSTEM_START, {});

it('should not notify via snackbar when Testrun is unreachable', done => {
const next: HttpHandler = {
handle: () => {
return throwError(new HttpErrorResponse({ status: 0 }));
},
};

interceptor.intercept(requestMock, next).subscribe(
() => ({}),
() => {
expect(notificationServiceMock.notify).not.toHaveBeenCalled();
done();
}
);
});

it('should not notify via snackbar when the request times out', done => {
const next: HttpHandler = {
handle: () => {
return of(new HttpResponse()).pipe(delay(2000));
},
};

interceptor.intercept(requestMock, next, 1000).subscribe(
() => ({}),
() => {
expect(notificationServiceMock.notify).not.toHaveBeenCalled();
done();
}
);
});

it('should still notify about backend validation errors', done => {
const next: HttpHandler = {
handle: () => {
return throwError(
new HttpErrorResponse({
error: { error: 'Device configuration is not complete' },
status: 400,
})
);
},
};

interceptor.intercept(requestMock, next).subscribe(
() => ({}),
() => {
expect(notificationServiceMock.notify).toHaveBeenCalledWith(
'Device configuration is not complete'
);
done();
}
);
});
});
});
26 changes: 25 additions & 1 deletion modules/ui/src/app/interceptors/error.interceptor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,13 +30,31 @@ import {
} from 'rxjs';
import { NotificationService } from '../services/notification.service';

import { SYSTEM_STOP, EXPORT } from '../services/test-run.service';
import {
SYSTEM_START,
SYSTEM_STOP,
EXPORT,
} from '../services/test-run.service';
import { finalize } from 'rxjs/operators';

const DEFAULT_TIMEOUT_MS = 5000;

const SYSTEM_STOP_TIMEOUT_MS = 60 * 1000;
const EXPORT_ZIP_TIMEOUT_MS = 60 * 1000;

/**
* True when the request never got a response from Testrun: either the request
* timed out or the backend was unreachable (HTTP status 0).
*/
export function isConnectionFailure(
error: HttpErrorResponse | TimeoutError | unknown
): boolean {
return (
error instanceof TimeoutError ||
(error instanceof HttpErrorResponse && error.status === 0)
);
}

@Injectable()
export class ErrorInterceptor implements HttpInterceptor {
private notificationService = inject(NotificationService);
Expand All @@ -60,6 +78,12 @@ export class ErrorInterceptor implements HttpInterceptor {
return next.handle(request).pipe(
timeout(timeoutValue),
catchError((error: HttpErrorResponse | TimeoutError) => {
// Connection-level failures of the start command (timeout / backend
// unreachable) are surfaced by the testrun initiate form as a blocking
// error dialog, so no snackbar must be shown for them.
if (request.url.includes(SYSTEM_START) && isConnectionFailure(error)) {
return throwError(error);
}
if (error instanceof TimeoutError) {
this.notificationService.notify(
'Testrun is not responding. Please try again in a moment.'
Expand Down
18 changes: 18 additions & 0 deletions modules/ui/src/app/interceptors/loading.interceptor.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ import { LoadingInterceptor } from './loading.interceptor';
import { of } from 'rxjs/internal/observable/of';
import { HttpHandler, HttpRequest, HttpResponse } from '@angular/common/http';
import { LoaderService } from '../services/loader.service';
import { SYSTEM_START } from '../services/test-run.service';

describe('LoadingInterceptor', () => {
const loaderServiceMock: jasmine.SpyObj<LoaderService> = jasmine.createSpyObj(
Expand Down Expand Up @@ -87,4 +88,21 @@ describe('LoadingInterceptor', () => {

expect(loaderServiceMock.setLoading).toHaveBeenCalledWith(false);
});

it('should bypass loader for SYSTEM_START request', done => {
const interceptor: LoadingInterceptor = TestBed.inject(LoadingInterceptor);

const next: HttpHandler = {
handle: () => {
return of(new HttpResponse());
},
};

const requestMock = new HttpRequest('POST', SYSTEM_START, {});

interceptor.intercept(requestMock, next).subscribe(() => {
expect(loaderServiceMock.setLoading).not.toHaveBeenCalled();
done();
});
});
});
4 changes: 4 additions & 0 deletions modules/ui/src/app/interceptors/loading.interceptor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ import {
import { Observable } from 'rxjs';
import { finalize } from 'rxjs/operators';
import { LoaderService } from '../services/loader.service';
import { SYSTEM_START } from '../services/test-run.service';

@Injectable()
export class LoadingInterceptor implements HttpInterceptor {
Expand All @@ -35,6 +36,9 @@ export class LoadingInterceptor implements HttpInterceptor {
request: HttpRequest<unknown>,
next: HttpHandler
): Observable<HttpEvent<unknown>> {
if (request.url.includes(SYSTEM_START)) {
return next.handle(request);
}
this.totalRequests++;
this.zone.run(() => {
this.loadingService.setLoading(true);
Expand Down
Loading