diff --git a/src/main/java/de/tum/cit/aet/artemis/programming/service/BuildLogEntryService.java b/src/main/java/de/tum/cit/aet/artemis/programming/service/BuildLogEntryService.java index 1ee0f9f1470a..d1b016f7ba81 100644 --- a/src/main/java/de/tum/cit/aet/artemis/programming/service/BuildLogEntryService.java +++ b/src/main/java/de/tum/cit/aet/artemis/programming/service/BuildLogEntryService.java @@ -2,12 +2,15 @@ import static de.tum.cit.aet.artemis.core.config.Constants.PROFILE_CORE; +import java.io.BufferedReader; import java.io.IOException; +import java.io.InputStreamReader; import java.nio.charset.StandardCharsets; import java.nio.file.DirectoryStream; import java.nio.file.Files; import java.nio.file.Path; import java.time.ZonedDateTime; +import java.time.format.DateTimeParseException; import java.util.ArrayList; import java.util.Collections; import java.util.List; @@ -489,4 +492,53 @@ public boolean buildJobHasLogFile(String buildJobId, ProgrammingExercise program return Files.exists(logPath); } + /** + * Parses the build log entries from a given file and returns them as a list of {@link BuildLogDTO} objects. + * + *

+ * The method reads the file line by line and splits each line into a timestamp and a log message. + * The timestamp is expected to be separated from the log message by a tab character. + * If the timestamp cannot be parsed, the log message is appended to the previous entry. + *

+ * + * @param buildLog The {@link FileSystemResource} representing the build log file. + * @return A list of {@link BuildLogDTO} objects containing the parsed build log entries. + */ + public List parseBuildLogEntries(FileSystemResource buildLog) { + try { + List buildLogEntries = new ArrayList<>(); + try (BufferedReader reader = new BufferedReader(new InputStreamReader(buildLog.getInputStream()))) { + String line; + while ((line = reader.readLine()) != null) { + // split into timestamp and log message + int logMessageParts = 2; + String[] parts = line.split("\t", logMessageParts); + if (parts.length == logMessageParts) { + try { + ZonedDateTime time = ZonedDateTime.parse(parts[0]); + buildLogEntries.add(new BuildLogDTO(time, parts[1])); + } + catch (DateTimeParseException e) { + // If the time cannot be parsed, append the line to the last entry + if (!buildLogEntries.isEmpty()) { + BuildLogDTO lastEntry = buildLogEntries.getLast(); + buildLogEntries.set(buildLogEntries.size() - 1, new BuildLogDTO(lastEntry.time(), lastEntry.log() + "\n\t" + line)); + } + } + } + else { + // If the line does not contain a tab, add it to in a new entry + BuildLogDTO lastEntry = buildLogEntries.getLast(); + buildLogEntries.add(new BuildLogDTO(lastEntry.time(), line)); + } + } + } + return buildLogEntries; + } + catch (IOException e) { + log.error("Error occurred while trying to parse build log entries", e); + return new ArrayList<>(); + } + } + } diff --git a/src/main/java/de/tum/cit/aet/artemis/programming/web/localci/BuildLogResource.java b/src/main/java/de/tum/cit/aet/artemis/programming/web/localci/BuildLogResource.java index c8fd18e69fca..ca33dd04d34e 100644 --- a/src/main/java/de/tum/cit/aet/artemis/programming/web/localci/BuildLogResource.java +++ b/src/main/java/de/tum/cit/aet/artemis/programming/web/localci/BuildLogResource.java @@ -2,6 +2,8 @@ import static de.tum.cit.aet.artemis.core.config.Constants.PROFILE_LOCALCI; +import java.util.List; + import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.context.annotation.Profile; @@ -16,6 +18,7 @@ import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RestController; +import de.tum.cit.aet.artemis.buildagent.dto.BuildLogDTO; import de.tum.cit.aet.artemis.core.security.annotations.EnforceAtLeastEditor; import de.tum.cit.aet.artemis.programming.service.BuildLogEntryService; @@ -52,4 +55,25 @@ public ResponseEntity getBuildLogForBuildJob(@PathVariable String buil responseHeaders.setContentDispositionFormData("attachment", "build-" + buildJobId + ".log"); return new ResponseEntity<>(buildLog, responseHeaders, HttpStatus.OK); } + + /** + * GET /build-log/{buildJobId}/entries : get the build log entries for a given result + * + * @param buildJobId the id of the build job for which to retrieve the build log entries + * @return the ResponseEntity with status 200 (OK) and the build log entries in the body, or with status 404 (Not Found) if the build log entries could not be found + */ + @GetMapping("build-log/{buildJobId}/entries") + @EnforceAtLeastEditor + public ResponseEntity> getBuildLogEntriesForBuildJob(@PathVariable String buildJobId) { + FileSystemResource buildLog = buildLogEntryService.retrieveBuildLogsFromFileForBuildJob(buildJobId); + if (buildLog == null) { + return ResponseEntity.notFound().build(); + } + + var buildLogEntries = buildLogEntryService.parseBuildLogEntries(buildLog); + if (buildLogEntries == null || buildLogEntries.isEmpty()) { + return ResponseEntity.notFound().build(); + } + return ResponseEntity.ok(buildLogEntries); + } } diff --git a/src/main/webapp/app/entities/programming/build-log.model.ts b/src/main/webapp/app/entities/programming/build-log.model.ts index fd4135a005cd..18e951c737a6 100644 --- a/src/main/webapp/app/entities/programming/build-log.model.ts +++ b/src/main/webapp/app/entities/programming/build-log.model.ts @@ -8,6 +8,11 @@ export enum BuildLogType { OTHER = 'OTHER', } +export type BuildLogLines = { + time: any; + logLines: string[]; +}; + export type BuildLogEntry = { time: any; log: string; diff --git a/src/main/webapp/app/localci/build-queue/build-queue.component.html b/src/main/webapp/app/localci/build-queue/build-queue.component.html index a8f9a582cbf3..bc447c21135d 100644 --- a/src/main/webapp/app/localci/build-queue/build-queue.component.html +++ b/src/main/webapp/app/localci/build-queue/build-queue.component.html @@ -504,7 +504,7 @@

@@ -617,7 +617,7 @@

- + {{ finishedBuildJob.buildAgentAddress }} @@ -742,7 +742,7 @@

+ + + + + + diff --git a/src/main/webapp/app/localci/build-queue/build-queue.component.scss b/src/main/webapp/app/localci/build-queue/build-queue.component.scss index b67b0520fbb5..1989ed2fb6d9 100644 --- a/src/main/webapp/app/localci/build-queue/build-queue.component.scss +++ b/src/main/webapp/app/localci/build-queue/build-queue.component.scss @@ -34,3 +34,21 @@ .finish-jobs-column-strings { max-width: 180px; } + +.build-output { + height: inherit; + &__entry { + &-date { + width: 200px; + margin-right: 10px; + color: var(--secondary); + font-weight: normal; + float: left; + clear: left; + } + &-text { + margin-bottom: 0; + color: var(--body-color); + } + } +} diff --git a/src/main/webapp/app/localci/build-queue/build-queue.component.ts b/src/main/webapp/app/localci/build-queue/build-queue.component.ts index 79ca4931786c..45b337ba1bd1 100644 --- a/src/main/webapp/app/localci/build-queue/build-queue.component.ts +++ b/src/main/webapp/app/localci/build-queue/build-queue.component.ts @@ -19,6 +19,7 @@ import { NgbModal, NgbTypeahead } from '@ng-bootstrap/ng-bootstrap'; import { LocalStorageService } from 'ngx-webstorage'; import { Observable, OperatorFunction, Subject, Subscription, merge } from 'rxjs'; import { UI_RELOAD_TIME } from 'app/shared/constants/exercise-exam-constants'; +import { BuildLogEntry, BuildLogLines } from 'app/entities/programming/build-log.model'; export class FinishedBuildJobFilter { status?: string = undefined; @@ -159,6 +160,9 @@ export class BuildQueueComponent implements OnInit, OnDestroy { searchSubscription: Subscription; searchTerm?: string = undefined; + rawBuildLogs: BuildLogLines[] = []; + displayedBuildJobId?: string; + constructor( private route: ActivatedRoute, private websocketService: JhiWebsocketService, @@ -386,11 +390,33 @@ export class BuildQueueComponent implements OnInit, OnDestroy { /** * View the build logs of a specific build job - * @param resultId The id of the build job + * @param modal The modal to open + * @param buildJobId The id of the build job + */ + viewBuildLogs(modal: any, buildJobId: string | undefined): void { + if (buildJobId) { + this.openModal(modal, true); + this.displayedBuildJobId = buildJobId; + this.buildQueueService.getBuildJobLogs(buildJobId).subscribe({ + next: (buildLogs: BuildLogEntry[]) => { + this.rawBuildLogs = buildLogs.map((entry) => { + const logLines = entry.log ? entry.log.split('\n') : []; + return { time: entry.time, logLines: logLines }; + }); + }, + error: (res: HttpErrorResponse) => { + onError(this.alertService, res, false); + }, + }); + } + } + + /** + * Download the build logs of a specific build job */ - viewBuildLogs(resultId: string | undefined): void { - if (resultId) { - const url = `/api/build-log/${resultId}`; + downloadBuildLogs(): void { + if (this.displayedBuildJobId) { + const url = `/api/build-log/${this.displayedBuildJobId}`; window.open(url, '_blank'); } } @@ -443,8 +469,8 @@ export class BuildQueueComponent implements OnInit, OnDestroy { /** * Opens the modal. */ - open(content: any) { - this.modalService.open(content); + openModal(modal: any, fullscreen?: boolean, size?: 'sm' | 'lg' | 'xl', scrollable = true, keyboard = true) { + this.modalService.open(modal, { size, keyboard, scrollable, fullscreen }); } /** diff --git a/src/main/webapp/app/localci/build-queue/build-queue.service.ts b/src/main/webapp/app/localci/build-queue/build-queue.service.ts index fdc9bec66e2f..a004acb9eaa8 100644 --- a/src/main/webapp/app/localci/build-queue/build-queue.service.ts +++ b/src/main/webapp/app/localci/build-queue/build-queue.service.ts @@ -7,6 +7,7 @@ import { BuildJob, BuildJobStatistics, FinishedBuildJob, SpanType } from 'app/en import { createNestedRequestOption } from 'app/shared/util/request.util'; import { HttpResponse } from '@angular/common/http'; import { FinishedBuildJobFilter } from 'app/localci/build-queue/build-queue.component'; +import { BuildLogEntry } from 'app/entities/programming/build-log.model'; @Injectable({ providedIn: 'root' }) export class BuildQueueService { @@ -188,4 +189,16 @@ export class BuildQueueService { }), ); } + + /** + * Get all build jobs of a course in the queue + * @param buildJobId + */ + getBuildJobLogs(buildJobId: string): Observable { + return this.http.get(`${this.resourceUrl}/build-log/${buildJobId}/entries`).pipe( + catchError(() => { + return throwError(() => new Error('artemisApp.buildQueue.logs.errorFetchingLogs')); + }), + ); + } } diff --git a/src/main/webapp/app/shared/pipes/artemis-date.pipe.ts b/src/main/webapp/app/shared/pipes/artemis-date.pipe.ts index 36d88d6474f7..cd3749e9ede0 100644 --- a/src/main/webapp/app/shared/pipes/artemis-date.pipe.ts +++ b/src/main/webapp/app/shared/pipes/artemis-date.pipe.ts @@ -36,6 +36,7 @@ export class ArtemisDatePipe implements PipeTransform, OnDestroy { private showTime = true; private showSeconds = false; private showWeekday = false; + private showMilliSeconds = false; private static mobileDeviceSize = 768; constructor(private translateService: TranslateService) {} @@ -47,8 +48,9 @@ export class ArtemisDatePipe implements PipeTransform, OnDestroy { * @param seconds Should seconds be displayed? Defaults to false. * @param timeZone Explicit time zone that should be used instead of the local time zone. * @param weekday Should the weekday be displayed? Defaults to false. + * @param milliSeconds Should milliseconds be displayed? Defaults to false. */ - transform(dateTime: DateType, format: DateFormat = 'long', seconds = false, timeZone: string | undefined = undefined, weekday = false): string { + transform(dateTime: DateType, format: DateFormat = 'long', seconds = false, timeZone: string | undefined = undefined, weekday = false, milliSeconds = false): string { // Return empty string if given dateTime equals null or is not convertible to dayjs. if (!dateTime || !dayjs(dateTime).isValid()) { return ''; @@ -59,6 +61,7 @@ export class ArtemisDatePipe implements PipeTransform, OnDestroy { this.showTime = format !== 'short-date' && format !== 'long-date'; this.showSeconds = seconds; this.showWeekday = weekday; + this.showMilliSeconds = milliSeconds; // Evaluate the format length based on the current window width. this.formatLengthBasedOnWindowWidth(window.innerWidth); @@ -88,12 +91,12 @@ export class ArtemisDatePipe implements PipeTransform, OnDestroy { * @param format Format of the localized date time. Defaults to 'long'. * @param seconds Should seconds be displayed? Defaults to false. */ - static format(locale = 'en', format: DateFormat = 'long', seconds = false): string { + static format(locale = 'en', format: DateFormat = 'long', seconds = false, showMilliSeconds = false): string { const long = format === 'long' || format === 'long-date'; const showDate = format !== 'time'; const showTime = format !== 'short-date' && format !== 'long-date'; const dateFormat = ArtemisDatePipe.dateFormat(long, showDate, locale); - const timeFormat = ArtemisDatePipe.timeFormat(showTime, seconds); + const timeFormat = ArtemisDatePipe.timeFormat(showTime, seconds, showMilliSeconds); return dateFormat + (dateFormat && timeFormat ? ' ' : '') + timeFormat; } @@ -123,7 +126,7 @@ export class ArtemisDatePipe implements PipeTransform, OnDestroy { private format(): string { const dateFormat = ArtemisDatePipe.dateFormat(this.long, this.showDate, this.locale); - const timeFormat = ArtemisDatePipe.timeFormat(this.showTime, this.showSeconds); + const timeFormat = ArtemisDatePipe.timeFormat(this.showTime, this.showSeconds, this.showMilliSeconds); return dateFormat + (dateFormat && timeFormat ? ' ' : '') + timeFormat; } @@ -144,12 +147,14 @@ export class ArtemisDatePipe implements PipeTransform, OnDestroy { return format; } - private static timeFormat(showTime: boolean, showSeconds: boolean): string { + private static timeFormat(showTime: boolean, showSeconds: boolean, showMilliSeconds: boolean): string { if (!showTime) { return ''; } let format = 'HH:mm'; - if (showSeconds) { + if (showMilliSeconds) { + format = 'HH:mm:ss.SSS'; + } else if (showSeconds) { format = 'HH:mm:ss'; } return format; diff --git a/src/main/webapp/app/shared/util/global.utils.ts b/src/main/webapp/app/shared/util/global.utils.ts index 78948d8c3340..3261878a8ef4 100644 --- a/src/main/webapp/app/shared/util/global.utils.ts +++ b/src/main/webapp/app/shared/util/global.utils.ts @@ -77,8 +77,9 @@ export const matchRegexWithLineNumbers = (multiLineText: string, regex: RegExp): * Use alert service to show the error message from the error response * @param alertService the service used to show the exception messages to the user * @param error the error response that's status is used to determine the error message + * @param disableTranslation whether the error message should be translated */ -export const onError = (alertService: AlertService, error: HttpErrorResponse) => { +export const onError = (alertService: AlertService, error: HttpErrorResponse, disableTranslation: boolean = true) => { switch (error.status) { case 400: alertService.error('error.http.400'); @@ -100,7 +101,7 @@ export const onError = (alertService: AlertService, error: HttpErrorResponse) => alertService.addAlert({ type: AlertType.DANGER, message: error.message, - disableTranslation: true, + disableTranslation: disableTranslation, }); break; } diff --git a/src/main/webapp/i18n/de/buildQueue.json b/src/main/webapp/i18n/de/buildQueue.json index faa60fc5c108..7d56dc944003 100644 --- a/src/main/webapp/i18n/de/buildQueue.json +++ b/src/main/webapp/i18n/de/buildQueue.json @@ -74,6 +74,12 @@ "daySpan": "1 Tag", "weekSpan": "7 Tage", "monthSpan": "30 Tage" + }, + "logs": { + "title": "Build Logs für Job", + "download": "Herunterladen", + "noLogs": "Keine Logs verfügbar", + "errorFetchingLogs": "Fehler beim Abrufen von Build-Log-Einträgen für Build-Job" } } } diff --git a/src/main/webapp/i18n/en/buildQueue.json b/src/main/webapp/i18n/en/buildQueue.json index aa065f3617df..63a258d407d1 100644 --- a/src/main/webapp/i18n/en/buildQueue.json +++ b/src/main/webapp/i18n/en/buildQueue.json @@ -74,6 +74,12 @@ "daySpan": "1 day", "weekSpan": "7 days", "monthSpan": "30 days" + }, + "logs": { + "title": "Build Logs for Job", + "download": "Download", + "noLogs": "No logs available", + "errorFetchingLogs": "Failed to get build log entries for build job" } } } diff --git a/src/test/java/de/tum/cit/aet/artemis/programming/icl/LocalCIResourceIntegrationTest.java b/src/test/java/de/tum/cit/aet/artemis/programming/icl/LocalCIResourceIntegrationTest.java index 4a5219c6ba7b..eb03d08f5fd8 100644 --- a/src/test/java/de/tum/cit/aet/artemis/programming/icl/LocalCIResourceIntegrationTest.java +++ b/src/test/java/de/tum/cit/aet/artemis/programming/icl/LocalCIResourceIntegrationTest.java @@ -11,6 +11,7 @@ import java.time.ZonedDateTime; import java.time.temporal.ChronoUnit; import java.util.ArrayList; +import java.util.LinkedHashMap; import java.util.List; import org.junit.jupiter.api.AfterEach; @@ -346,6 +347,30 @@ void testGetBuildLogsForResult() throws Exception { } } + @Test + @WithMockUser(username = TEST_PREFIX + "instructor1", roles = "INSTRUCTOR") + void testGetBuildLogsEntriesForResult() throws Exception { + try { + buildJobRepository.save(finishedJobForLogs); + BuildLogDTO buildLogEntry = new BuildLogDTO(ZonedDateTime.now(), "Dummy log"); + buildLogEntryService.saveBuildLogsToFile(List.of(buildLogEntry), "6", programmingExercise); + var response = request.get("/api/build-log/6/entries", HttpStatus.OK, List.class); + + LinkedHashMap responseMap = ((LinkedHashMap) response.getFirst()); + String log = responseMap.get("log").toString(); + ZonedDateTime time = ZonedDateTime.parse(responseMap.get("time").toString()); + assertThat(response).hasSize(1); + assertThat(buildLogEntry.log()).isEqualTo(log); + assertThat(buildLogEntry.time()).isEqualTo(time); + + } + finally { + Path buildLogFile = Path.of("build-logs").resolve(programmingExercise.getCourseViaExerciseGroupOrCourseMember().getShortName()) + .resolve(programmingExercise.getShortName()).resolve("6.log"); + Files.deleteIfExists(buildLogFile); + } + } + @Test @WithMockUser(username = TEST_PREFIX + "admin", roles = "ADMIN") void testGetBuildJobStatistics() throws Exception { diff --git a/src/test/javascript/spec/component/localci/build-queue/build-queue.component.spec.ts b/src/test/javascript/spec/component/localci/build-queue/build-queue.component.spec.ts index b22a67ee3f15..2e51313e967c 100644 --- a/src/test/javascript/spec/component/localci/build-queue/build-queue.component.spec.ts +++ b/src/test/javascript/spec/component/localci/build-queue/build-queue.component.spec.ts @@ -19,6 +19,7 @@ import { LocalStorageService } from 'ngx-webstorage'; import { MockLocalStorageService } from '../../../helpers/mocks/service/mock-local-storage.service'; import { ArtemisSharedComponentModule } from 'app/shared/components/shared-component.module'; import { PieChartModule } from '@swimlane/ngx-charts'; +import { BuildLogEntry, BuildLogLines } from '../../../../../../main/webapp/app/entities/programming/build-log.model'; describe('BuildQueueComponent', () => { let component: BuildQueueComponent; @@ -40,6 +41,7 @@ describe('BuildQueueComponent', () => { getFinishedBuildJobs: jest.fn(), getBuildJobStatistics: jest.fn(), getBuildJobStatisticsForCourse: jest.fn(), + getBuildJobLogs: jest.fn(), }; const mockLocalStorageService = new MockLocalStorageService(); @@ -267,6 +269,17 @@ describe('BuildQueueComponent', () => { numberOfAppliedFilters: 0, }; + const buildLogEntries: BuildLogEntry[] = [ + { + time: dayjs('2024-01-01'), + log: 'log1', + }, + { + time: dayjs('2024-01-02'), + log: 'log2', + }, + ]; + beforeEach(waitForAsync(() => { mockActivatedRoute = { params: of({ courseId: testCourseId }) }; @@ -661,4 +674,24 @@ describe('BuildQueueComponent', () => { expect(component.finishedBuildJobFilter.areDatesValid).toBeFalsy(); expect(component.finishedBuildJobFilter.areDurationFiltersValid).toBeFalsy(); }); + + it('should download build logs', () => { + const buildJobId = '1'; + jest.spyOn(window, 'open').mockImplementation(); + + mockBuildQueueService.getBuildJobLogs = jest.fn().mockReturnValue(of(buildLogEntries)); + + const buildLogsMultiLines: BuildLogLines[] = buildLogEntries.map((entry) => { + return { time: entry.time, logLines: entry.log.split('\n') }; + }); + + component.viewBuildLogs(undefined, buildJobId); + + expect(mockBuildQueueService.getBuildJobLogs).toHaveBeenCalledWith(buildJobId); + expect(component.rawBuildLogs).toEqual(buildLogsMultiLines); + + component.downloadBuildLogs(); + + expect(window.open).toHaveBeenCalledWith(`/api/build-log/${component.displayedBuildJobId}`, '_blank'); + }); }); diff --git a/src/test/javascript/spec/component/localci/build-queue/build-queue.service.spec.ts b/src/test/javascript/spec/component/localci/build-queue/build-queue.service.spec.ts index 16a2d81d37ed..8f5a9af490f5 100644 --- a/src/test/javascript/spec/component/localci/build-queue/build-queue.service.spec.ts +++ b/src/test/javascript/spec/component/localci/build-queue/build-queue.service.spec.ts @@ -15,6 +15,7 @@ import { JobTimingInfo } from 'app/entities/job-timing-info.model'; import { BuildConfig } from 'app/entities/programming/build-config.model'; import { FinishedBuildJobFilter } from 'app/localci/build-queue/build-queue.component'; import { provideHttpClient } from '@angular/common/http'; +import { BuildLogEntry } from '../../../../../../main/webapp/app/entities/programming/build-log.model'; describe('BuildQueueService', () => { let service: BuildQueueService; @@ -32,6 +33,17 @@ describe('BuildQueueService', () => { filterOptions.buildStartDateFilterTo = dayjs('2024-01-02'); filterOptions.status = 'SUCCESSFUL'; + const buildLogEntries: BuildLogEntry[] = [ + { + time: dayjs('2024-01-01'), + log: 'log1', + }, + { + time: dayjs('2024-01-02'), + log: 'log2', + }, + ]; + const expectFilterParams = (req: TestRequest, filterOptions: FinishedBuildJobFilter) => { expect(req.request.params.get('buildAgentAddress')).toBe(filterOptions.buildAgentAddress); expect(req.request.params.get('buildDurationLower')).toBe(filterOptions.buildDurationFilterLowerBound?.toString()); @@ -589,6 +601,41 @@ describe('BuildQueueService', () => { expect(errorOccurred).toBeTrue(); })); + it('should return build log entries for a specific build job', () => { + const buildJobId = '1'; + const expectedResponse = buildLogEntries; + + service.getBuildJobLogs(buildJobId).subscribe((data) => { + expect(data).toEqual(expectedResponse); + }); + + const req = httpMock.expectOne(`${service.resourceUrl}/build-log/${buildJobId}/entries`); + expect(req.request.method).toBe('GET'); + req.flush(expectedResponse); + }); + + it('should handle errors when getting build log entries for a specific build job', fakeAsync(() => { + const buildJobId = '1'; + + let errorOccurred = false; + + service.getBuildJobLogs(buildJobId).subscribe({ + error: (err) => { + expect(err.message).toBe('artemisApp.buildQueue.logs.errorFetchingLogs'); + errorOccurred = true; + }, + }); + + const req = httpMock.expectOne(`${service.resourceUrl}/build-log/${buildJobId}/entries`); + expect(req.request.method).toBe('GET'); + + req.flush(null, { status: 500, statusText: 'Internal Server Error' }); + + tick(); + + expect(errorOccurred).toBeTrue(); + })); + afterEach(() => { httpMock.verify(); // Verify that there are no outstanding requests. });