diff --git a/docs/dev/mockoon.json b/docs/dev/mockoon.json index dbc98d4f7..25720509b 100644 --- a/docs/dev/mockoon.json +++ b/docs/dev/mockoon.json @@ -437,7 +437,7 @@ "rulesOperator": "OR", "disableTemplating": false, "fallbackTo404": false, - "default": false, + "default": true, "crudKey": "id", "callbacks": [] }, @@ -532,7 +532,7 @@ "rulesOperator": "OR", "disableTemplating": false, "fallbackTo404": false, - "default": true, + "default": false, "crudKey": "id", "callbacks": [] }, diff --git a/framework/python/src/core/session.py b/framework/python/src/core/session.py index a5ba43ef6..08a77a2bb 100644 --- a/framework/python/src/core/session.py +++ b/framework/python/src/core/session.py @@ -106,6 +106,7 @@ def __init__(self, root_dir): # Start time of testing self._started = None self._finished = None + self._monitor_started = None # Current testing results self._results = [] @@ -187,6 +188,12 @@ def start(self): def start_timer(self): self._started = datetime.datetime.now() + def start_monitor_timer(self): + self._monitor_started = datetime.datetime.now() + + def get_monitor_started(self): + return self._monitor_started + def get_started(self): return self._started @@ -440,6 +447,8 @@ def get_status(self) -> TestrunStatus: return self._status def set_status(self, status: TestrunStatus): + if status == TestrunStatus.MONITORING and self._monitor_started is None: + self.start_monitor_timer() self._status = status def get_result(self) -> TestrunResult: @@ -904,6 +913,7 @@ def reset(self): self._results = [] self._started = None self._finished = None + self._monitor_started = None self._ifaces = IPControl.get_sys_interfaces() def to_json(self): @@ -923,6 +933,7 @@ def to_json(self): 'device': device, 'started': self.get_started(), 'finished': self.get_finished(), + 'monitor_started': self.get_monitor_started(), 'tests': results } diff --git a/modules/ui/src/app/app.store.spec.ts b/modules/ui/src/app/app.store.spec.ts index 4c89c8d95..60202d981 100644 --- a/modules/ui/src/app/app.store.spec.ts +++ b/modules/ui/src/app/app.store.spec.ts @@ -73,6 +73,9 @@ const mock = (() => { setObject: (key: string, value: object) => { store[key] = JSON.stringify(value); }, + removeItem: (key: string) => { + delete store[key]; + }, clear: () => { store = {}; }, diff --git a/modules/ui/src/app/model/testrun-status.ts b/modules/ui/src/app/model/testrun-status.ts index 4a8b0e345..f514d3864 100644 --- a/modules/ui/src/app/model/testrun-status.ts +++ b/modules/ui/src/app/model/testrun-status.ts @@ -53,6 +53,7 @@ export interface TestrunStatus { report: string; export: string; tags: string[] | null; + monitor_started?: string | null; } export interface HistoryTestrun extends TestrunReport { diff --git a/modules/ui/src/app/pages/testrun/components/testrun-status-card/testrun-status-card.component.html b/modules/ui/src/app/pages/testrun/components/testrun-status-card/testrun-status-card.component.html index e8eb98416..0739db00f 100644 --- a/modules/ui/src/app/pages/testrun/components/testrun-status-card/testrun-status-card.component.html +++ b/modules/ui/src/app/pages/testrun/components/testrun-status-card/testrun-status-card.component.html @@ -118,13 +118,12 @@ - + @if (monitorPeriod) { + + + } diff --git a/modules/ui/src/app/pages/testrun/components/testrun-status-card/testrun-status-card.component.scss b/modules/ui/src/app/pages/testrun/components/testrun-status-card/testrun-status-card.component.scss index 8660fc990..9719a6dd2 100644 --- a/modules/ui/src/app/pages/testrun/components/testrun-status-card/testrun-status-card.component.scss +++ b/modules/ui/src/app/pages/testrun/components/testrun-status-card/testrun-status-card.component.scss @@ -177,6 +177,25 @@ } } +.progress-card-result-loading-monitoring { + display: flex; + align-items: center; + + .monitoring-content { + display: flex; + align-items: center; + justify-content: space-between; + flex: 1; + margin-left: 32px; + gap: 32px; + flex-wrap: wrap; + + .progress-card-result-title { + margin-left: 0; + } + } +} + @for $i from 1 through 100 { .progress-value-#{$i} ::ng-deep .mdc-linear-progress__primary-bar { transform: scaleX(1) !important; diff --git a/modules/ui/src/app/pages/testrun/components/testrun-status-card/testrun-status-card.component.spec.ts b/modules/ui/src/app/pages/testrun/components/testrun-status-card/testrun-status-card.component.spec.ts index 2fdedcda7..25f13da83 100644 --- a/modules/ui/src/app/pages/testrun/components/testrun-status-card/testrun-status-card.component.spec.ts +++ b/modules/ui/src/app/pages/testrun/components/testrun-status-card/testrun-status-card.component.spec.ts @@ -418,6 +418,7 @@ describe('ProgressStatusCardComponent', () => { describe('with available systemStatus$ data, as Monitoring', () => { beforeEach(() => { component.systemStatus = MOCK_PROGRESS_DATA_MONITORING; + component.monitorPeriod = 300; fixture.detectChanges(); }); @@ -433,15 +434,12 @@ describe('ProgressStatusCardComponent', () => { expect(progressCardEl?.classList).toContain('progress'); }); - it('should have progress card result title', () => { + it('should not have progress card result title during Monitoring', () => { const progressCardResultEl = compiled.querySelector( '.progress-card-result-title' ); - expect(progressCardResultEl).not.toBeNull(); - expect(progressCardResultEl?.textContent?.trim()).toEqual( - 'Please wait, this could take a few minutes' - ); + expect(progressCardResultEl).toBeNull(); }); it('should have progress card status text as "Monitoring"', () => { @@ -452,6 +450,20 @@ describe('ProgressStatusCardComponent', () => { expect(progressCardStatusText).not.toBeNull(); expect(progressCardStatusText?.textContent).toEqual('Monitoring'); }); + + it('should render the countdown timer component during Monitoring', () => { + const timerEl = compiled.querySelector('app-timer'); + + expect(timerEl).not.toBeNull(); + }); + + it('should not render timer component when monitorPeriod is not defined', () => { + fixture.componentRef.setInput('monitorPeriod', undefined); + fixture.detectChanges(); + + const timerEl = compiled.querySelector('app-timer'); + expect(timerEl).toBeNull(); + }); }); describe('with available systemStatus$ data, as "Proceed"', () => { diff --git a/modules/ui/src/app/pages/testrun/components/testrun-status-card/testrun-status-card.component.ts b/modules/ui/src/app/pages/testrun/components/testrun-status-card/testrun-status-card.component.ts index 75a9074b9..5808ed7a3 100644 --- a/modules/ui/src/app/pages/testrun/components/testrun-status-card/testrun-status-card.component.ts +++ b/modules/ui/src/app/pages/testrun/components/testrun-status-card/testrun-status-card.component.ts @@ -13,7 +13,15 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -import { ChangeDetectionStrategy, Component, Input } from '@angular/core'; +import { + ChangeDetectionStrategy, + ChangeDetectorRef, + Component, + Input, + OnDestroy, + OnInit, + inject, +} from '@angular/core'; import { ResultOfTestrun, StatusOfTestResult, @@ -32,6 +40,12 @@ import { MatDialogModule } from '@angular/material/dialog'; import { MatTooltipModule } from '@angular/material/tooltip'; import { ReactiveFormsModule } from '@angular/forms'; import { MatButtonModule } from '@angular/material/button'; +import { TimerComponent } from '../timer/timer.component'; +import { Store } from '@ngrx/store'; +import { AppState } from '../../../../store/state'; +import { selectSystemConfig } from '../../../../store/selectors'; +import { Subject } from 'rxjs'; +import { takeUntil } from 'rxjs/operators'; @Component({ selector: 'app-testrun-status-card', @@ -49,14 +63,39 @@ import { MatButtonModule } from '@angular/material/button'; MatExpansionModule, ReactiveFormsModule, MatTooltipModule, + TimerComponent, ], }) -export class TestrunStatusCardComponent { +export class TestrunStatusCardComponent implements OnInit, OnDestroy { @Input() systemStatus!: TestrunStatus; + @Input() monitorPeriod?: number; + + private readonly store = inject(Store, { optional: true }); + private readonly cdr = inject(ChangeDetectorRef); + private destroy$ = new Subject(); public readonly StatusOfTestrun = StatusOfTestrun; public readonly TestingType = TestingType; + ngOnInit(): void { + if (this.store && this.monitorPeriod === undefined) { + this.store + .select(selectSystemConfig) + .pipe(takeUntil(this.destroy$)) + .subscribe(config => { + if (config?.monitor_period) { + this.monitorPeriod = Number(config.monitor_period); + this.cdr.markForCheck(); + } + }); + } + } + + ngOnDestroy(): void { + this.destroy$.next(); + this.destroy$.complete(); + } + public getClass( status: StatusOfTestrun, result?: ResultOfTestrun diff --git a/modules/ui/src/app/pages/testrun/components/timer/timer.component.html b/modules/ui/src/app/pages/testrun/components/timer/timer.component.html new file mode 100644 index 000000000..62150cf8d --- /dev/null +++ b/modules/ui/src/app/pages/testrun/components/timer/timer.component.html @@ -0,0 +1,62 @@ + +@if (!isExpired && remainingSeconds > 0) { + + + + + + + 0 ? 'round' : 'butt'" + [attr.stroke-dasharray]="circleCircumference" + [attr.stroke-dashoffset]="strokeDashoffset" + [style.opacity]="progressFraction > 0 ? 1 : 0" /> + + + {{ formattedTime }} + time remaining + + + + {{ liveAnnouncementText }} + + +} diff --git a/modules/ui/src/app/pages/testrun/components/timer/timer.component.scss b/modules/ui/src/app/pages/testrun/components/timer/timer.component.scss new file mode 100644 index 000000000..98e4b69f3 --- /dev/null +++ b/modules/ui/src/app/pages/testrun/components/timer/timer.component.scss @@ -0,0 +1,108 @@ +/** + * 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 'colors'; +@use 'variables'; + +$timer-size: 254px; + +:host { + display: inline-block; + width: 100%; +} + +.monitoring-timer { + display: inline-flex; + align-items: center; + justify-content: center; + box-sizing: border-box; + width: 100%; + padding-top: 58px; + + .timer-circle-container { + position: relative; + width: $timer-size; + height: $timer-size; + display: flex; + align-items: center; + justify-content: center; + } + + .timer-circle-svg { + width: $timer-size; + height: $timer-size; + transform: rotate(-90deg); + display: block; + } + + /* Layer 1: Light blue background circle */ + .timer-circle-layer-bg { + stroke: colors.$primary-container; + stroke-width: 10; + } + + /* Layer 2: Blue progress circle that adds each tick */ + .timer-circle-layer-progress { + stroke: colors.$primary; + stroke-width: 10; + transition: stroke-dashoffset 0.3s ease-out; + } + + .timer-center-content { + position: absolute; + top: 0; + left: 0; + width: 100%; + height: 100%; + display: flex; + align-items: center; + justify-content: center; + pointer-events: none; + } + + .timer-countdown { + font-family: variables.$font-primary; + font-size: 57px; + line-height: 64px; + font-weight: 500; + font-variant-numeric: tabular-nums; + letter-spacing: 0; + color: colors.$primary; + } + + .timer-label { + position: absolute; + top: calc(50% + 36px); + font-family: 'Google Sans Flex', sans-serif; + font-size: 12px; + line-height: 16px; + color: colors.$on-surface-variant; + text-align: center; + white-space: nowrap; + font-weight: 500; + } +} + +.sr-only { + position: absolute; + width: 1px; + height: 1px; + padding: 0; + margin: -1px; + overflow: hidden; + clip: rect(0, 0, 0, 0); + white-space: nowrap; + border: 0; +} diff --git a/modules/ui/src/app/pages/testrun/components/timer/timer.component.spec.ts b/modules/ui/src/app/pages/testrun/components/timer/timer.component.spec.ts new file mode 100644 index 000000000..c781c4c4d --- /dev/null +++ b/modules/ui/src/app/pages/testrun/components/timer/timer.component.spec.ts @@ -0,0 +1,409 @@ +/** + * 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 { LiveAnnouncer } from '@angular/cdk/a11y'; +import { + DEFAULT_MONITOR_PERIOD, + TimerComponent, + MONITORING_TIMER_STORAGE_KEY, + TimerSessionData, +} from './timer.component'; + +describe('TimerComponent', () => { + let component: TimerComponent; + let fixture: ComponentFixture; + let compiled: HTMLElement; + let liveAnnouncerSpy: jasmine.SpyObj; + + beforeEach(() => { + localStorage.removeItem(MONITORING_TIMER_STORAGE_KEY); + sessionStorage.removeItem(MONITORING_TIMER_STORAGE_KEY); + liveAnnouncerSpy = jasmine.createSpyObj('LiveAnnouncer', ['announce']); + + TestBed.configureTestingModule({ + imports: [TimerComponent], + providers: [{ provide: LiveAnnouncer, useValue: liveAnnouncerSpy }], + }); + + fixture = TestBed.createComponent(TimerComponent); + component = fixture.componentInstance; + compiled = fixture.nativeElement; + }); + + afterEach(() => { + component.ngOnDestroy(); + component.clearStoredSession(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); + + describe('Initialization and Inputs', () => { + it('should set totalDuration from duration input on init', () => { + component.duration = 180; + component.ngOnInit(); + expect(component.totalDuration).toBe(180); + }); + + it('should fallback to DEFAULT_MONITOR_PERIOD when duration input is undefined', () => { + component.duration = undefined; + component.ngOnInit(); + expect(component.totalDuration).toBe(DEFAULT_MONITOR_PERIOD); + }); + + it('should initialize and calculate remaining time from startTime input', () => { + const now = Date.now(); + component.duration = 300; + component.startTime = new Date(now - 60000).toISOString(); + component.ngOnInit(); + + expect(component.totalDuration).toBe(300); + expect(component.remainingSeconds).toBeCloseTo(240, 1); + }); + }); + + describe('Progress and Formatting', () => { + it('should return progressFraction 0 when totalDuration is 0 or negative', () => { + component.totalDuration = 0; + component.remainingSeconds = 0; + expect(component.progressFraction).toBe(0); + + component.totalDuration = -10; + expect(component.progressFraction).toBe(0); + }); + + it('should calculate progressFraction and strokeDashoffset correctly', () => { + component.totalDuration = 100; + component.remainingSeconds = 50; + expect(component.progressFraction).toBe(0.5); + expect(component.strokeDashoffset).toBeCloseTo( + component.circleCircumference * 0.5, + 1 + ); + + component.remainingSeconds = 0; + expect(component.progressFraction).toBe(1); + expect(component.strokeDashoffset).toBeCloseTo(0, 1); + }); + + it('should format time with zero padding for minutes and seconds', () => { + component.remainingSeconds = 305; + expect(component.formattedTime).toBe('05:05'); + + component.remainingSeconds = 59; + expect(component.formattedTime).toBe('00:59'); + + component.remainingSeconds = 0; + expect(component.formattedTime).toBe('00:00'); + }); + + it('should format timerAriaLabel correctly for singular and plural', () => { + component.remainingSeconds = 61; + expect(component.timerAriaLabel).toBe( + 'Remaining monitoring time: 1 minute 1 second' + ); + + component.remainingSeconds = 122; + expect(component.timerAriaLabel).toBe( + 'Remaining monitoring time: 2 minutes 2 seconds' + ); + + component.remainingSeconds = 45; + expect(component.timerAriaLabel).toBe( + 'Remaining monitoring time: 45 seconds' + ); + }); + }); + + describe('Countdown and Ticking', () => { + it('should count down each second and expire when reaching 0', fakeAsync(() => { + component.duration = 3; + + component.ngOnInit(); + expect(component.remainingSeconds).toBe(3); + + tick(1000); + expect(component.remainingSeconds).toBe(2); + + tick(1000); + expect(component.remainingSeconds).toBe(1); + + tick(1000); + expect(component.remainingSeconds).toBe(0); + expect(component.isExpired).toBeTrue(); + expect(liveAnnouncerSpy.announce).toHaveBeenCalledWith( + 'Monitoring period completed', + 'polite' + ); + })); + + it('should expire immediately if totalDuration is <= 0 on startTimer', () => { + component.totalDuration = 0; + + component.startTimer(); + expect(component.isExpired).toBeTrue(); + }); + + it('should tick decrement remainingSeconds when endTime is null', () => { + component['endTime'] = null; + component.remainingSeconds = 5; + component.tick(); + expect(component.remainingSeconds).toBe(4); + + component.remainingSeconds = 0; + component.tick(); + expect(component.remainingSeconds).toBe(0); + }); + + it('should tick decrement remainingSeconds when Date.now matches remainingSeconds', () => { + component.remainingSeconds = 10; + component['endTime'] = Date.now() + 10000; + component.tick(); + expect(component.remainingSeconds).toBe(9); + }); + + it('should stop timer and unsubscribe on stopTimer', () => { + component.duration = 300; + component.startTimer(); + expect(component['timerSubscription']).toBeDefined(); + + component.stopTimer(); + expect(component['timerSubscription']).toBeUndefined(); + }); + }); + + describe('Audio announcements (LiveAnnouncer)', () => { + it('should announce start of monitoring initially', () => { + component.totalDuration = 300; + component.startTimer(); + + expect(liveAnnouncerSpy.announce).toHaveBeenCalledWith( + jasmine.stringMatching(/Monitoring started\. 5 minutes remaining\./i), + 'polite' + ); + }); + + it('should handle periodic announcements for 30s, 10s, 60s, and 120s', () => { + liveAnnouncerSpy.announce.calls.reset(); + + // Remaining <= 0 does nothing + component['handlePeriodicAnnouncements'](0); + expect(liveAnnouncerSpy.announce).not.toHaveBeenCalled(); + + // 30 seconds + component['lastAnnouncedInterval'] = -1; + component['handlePeriodicAnnouncements'](30); + expect(liveAnnouncerSpy.announce).toHaveBeenCalledWith( + '30 seconds remaining', + 'polite' + ); + + // Re-announcing 30 when already announced does nothing + liveAnnouncerSpy.announce.calls.reset(); + component['handlePeriodicAnnouncements'](30); + expect(liveAnnouncerSpy.announce).not.toHaveBeenCalled(); + + // 10 seconds + component['handlePeriodicAnnouncements'](10); + expect(liveAnnouncerSpy.announce).toHaveBeenCalledWith( + '10 seconds remaining', + 'polite' + ); + + // 60 seconds (1 minute remaining) + component['handlePeriodicAnnouncements'](60); + expect(liveAnnouncerSpy.announce).toHaveBeenCalledWith( + '1 minute remaining', + 'polite' + ); + + // 120 seconds (2 minutes remaining) + component['handlePeriodicAnnouncements'](120); + expect(liveAnnouncerSpy.announce).toHaveBeenCalledWith( + '2 minutes remaining', + 'polite' + ); + }); + + it('should not fail announceText if liveAnnouncer is not present', () => { + ( + component as unknown as { liveAnnouncer: LiveAnnouncer | null } + ).liveAnnouncer = null; + expect(() => + component['announceText']('test announcement') + ).not.toThrow(); + expect(component.liveAnnouncementText).toBe('test announcement'); + }); + }); + + describe('Session Storage Handling via Spies (No Direct Storage Write)', () => { + it('should resume existing session if not expired', () => { + const now = Date.now(); + const mockSession: TimerSessionData = { + startTime: now - 30000, + endTime: now + 70000, + duration: 100, + }; + + spyOn(localStorage, 'getItem').and.returnValue( + JSON.stringify(mockSession) + ); + + component.startTimer(); + + expect(component.totalDuration).toBe(100); + expect(component.remainingSeconds).toBeGreaterThan(0); + }); + + it('should save session data to localStorage and sessionStorage when starting fresh countdown', () => { + const localSpy = spyOn(localStorage, 'setItem'); + const sessionSpy = spyOn(sessionStorage, 'setItem'); + spyOn(localStorage, 'getItem').and.returnValue(null); + spyOn(sessionStorage, 'getItem').and.returnValue(null); + + component.totalDuration = 100; + component.startTimer(); + + expect(localSpy).toHaveBeenCalledWith( + MONITORING_TIMER_STORAGE_KEY, + jasmine.stringMatching(/"duration":100/) + ); + expect(sessionSpy).toHaveBeenCalledWith( + MONITORING_TIMER_STORAGE_KEY, + jasmine.stringMatching(/"duration":100/) + ); + }); + + it('should return session from sessionStorage if localStorage returns null', () => { + const now = Date.now(); + const mockSession: TimerSessionData = { + startTime: now, + endTime: now + 60000, + duration: 60, + }; + + spyOn(localStorage, 'getItem').and.returnValue(null); + spyOn(sessionStorage, 'getItem').and.returnValue( + JSON.stringify(mockSession) + ); + + const session = component.getStoredSession(); + expect(session).toEqual(mockSession); + }); + + it('should return null for getStoredSession if storage contains invalid JSON or whitespace', () => { + spyOn(localStorage, 'getItem').and.returnValue(' '); + expect(component.getStoredSession()).toBeNull(); + + (localStorage.getItem as jasmine.Spy).and.returnValue('invalid-json{{{'); + expect(component.getStoredSession()).toBeNull(); + }); + + it('should handle storage errors gracefully in saveSession, clearStoredSession, and getStoredSession', () => { + spyOn(localStorage, 'setItem').and.throwError('QuotaExceeded'); + expect(() => + component['saveSession']({ + startTime: 0, + endTime: 100, + duration: 100, + }) + ).not.toThrow(); + + spyOn(localStorage, 'removeItem').and.throwError('StorageError'); + expect(() => component.clearStoredSession()).not.toThrow(); + + spyOn(localStorage, 'getItem').and.throwError('AccessError'); + expect(component.getStoredSession()).toBeNull(); + }); + + it('should clear stored session when handleExpire is called', () => { + const localRemoveSpy = spyOn(localStorage, 'removeItem'); + const sessionRemoveSpy = spyOn(sessionStorage, 'removeItem'); + + component['handleExpire'](); + + expect(localRemoveSpy).toHaveBeenCalledWith(MONITORING_TIMER_STORAGE_KEY); + expect(sessionRemoveSpy).toHaveBeenCalledWith( + MONITORING_TIMER_STORAGE_KEY + ); + }); + + it('should call handleExpire on ngOnDestroy', () => { + const localRemoveSpy = spyOn(localStorage, 'removeItem'); + const sessionRemoveSpy = spyOn(sessionStorage, 'removeItem'); + + component.duration = 300; + component.ngOnInit(); + component.ngOnDestroy(); + + expect(component.isExpired).toBeTrue(); + expect(component['timerSubscription']).toBeUndefined(); + expect(localRemoveSpy).toHaveBeenCalledWith(MONITORING_TIMER_STORAGE_KEY); + expect(sessionRemoveSpy).toHaveBeenCalledWith( + MONITORING_TIMER_STORAGE_KEY + ); + }); + }); + + describe('DOM Elements and Styling', () => { + it('should render the circular SVG with 2 layers and "time remaining" label', fakeAsync(() => { + component.duration = 300; + component.ngOnInit(); + fixture.detectChanges(); + + const timerWidget = compiled.querySelector('#monitoring-countdown-timer'); + expect(timerWidget).not.toBeNull(); + + const svg = compiled.querySelector('.timer-circle-svg'); + expect(svg).not.toBeNull(); + + const bgLayer = compiled.querySelector('.timer-circle-layer-bg'); + expect(bgLayer).not.toBeNull(); + + const progressLayer = compiled.querySelector( + '.timer-circle-layer-progress' + ); + expect(progressLayer).not.toBeNull(); + + const timeValue = compiled.querySelector('#monitoring-timer-value'); + expect(timeValue?.textContent?.trim()).toBe('05:00'); + + const label = compiled.querySelector('.timer-label'); + expect(label?.textContent?.trim()).toBe('time remaining'); + })); + + it('should hide timer widget when expired or remainingSeconds is 0', fakeAsync(() => { + component.duration = 1; + component.ngOnInit(); + fixture.detectChanges(); + + expect( + compiled.querySelector('#monitoring-countdown-timer') + ).not.toBeNull(); + + tick(1000); + fixture.detectChanges(); + + expect(compiled.querySelector('#monitoring-countdown-timer')).toBeNull(); + })); + }); +}); diff --git a/modules/ui/src/app/pages/testrun/components/timer/timer.component.ts b/modules/ui/src/app/pages/testrun/components/timer/timer.component.ts new file mode 100644 index 000000000..887cfe2c8 --- /dev/null +++ b/modules/ui/src/app/pages/testrun/components/timer/timer.component.ts @@ -0,0 +1,299 @@ +/** + * 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, + ChangeDetectorRef, + Component, + Input, + OnDestroy, + OnInit, + inject, +} from '@angular/core'; +import { CommonModule } from '@angular/common'; +import { MatIconModule } from '@angular/material/icon'; +import { Subject, Subscription, interval } from 'rxjs'; +import { takeUntil } from 'rxjs/operators'; +import { LiveAnnouncer } from '@angular/cdk/a11y'; + +export const MONITORING_TIMER_STORAGE_KEY = 'testrun_monitoring_timer_session'; +export const DEFAULT_MONITOR_PERIOD = 300; + +export interface TimerSessionData { + startTime: number; + endTime: number; + duration: number; +} + +@Component({ + selector: 'app-timer, app-testrun-timer', + templateUrl: './timer.component.html', + styleUrls: ['./timer.component.scss'], + changeDetection: ChangeDetectionStrategy.OnPush, + imports: [CommonModule, MatIconModule], +}) +export class TimerComponent implements OnInit, OnDestroy { + @Input() duration?: number; + @Input() startTime?: string | number | null; + + private readonly cdr = inject(ChangeDetectorRef); + private readonly liveAnnouncer = inject(LiveAnnouncer, { optional: true }); + + public remainingSeconds = 0; + public totalDuration = DEFAULT_MONITOR_PERIOD; + public isExpired = false; + public liveAnnouncementText = ''; + + private timerSubscription?: Subscription; + private destroy$ = new Subject(); + private lastAnnouncedInterval = -1; + private endTime: number | null = null; + private sessionStartTime: number | null = null; + + public readonly circleRadius = 122; + public readonly circleCircumference = 2 * Math.PI * 122; + + get progressFraction(): number { + if (!this.totalDuration || this.totalDuration <= 0) { + return 0; + } + const elapsed = Math.max(0, this.totalDuration - this.remainingSeconds); + return Math.min(1, Math.max(0, elapsed / this.totalDuration)); + } + + get strokeDashoffset(): number { + return this.circleCircumference * (1 - this.progressFraction); + } + + get formattedTime(): string { + const minutes = Math.floor(this.remainingSeconds / 60); + const seconds = this.remainingSeconds % 60; + const paddedMinutes = minutes.toString().padStart(2, '0'); + const paddedSeconds = seconds.toString().padStart(2, '0'); + return `${paddedMinutes}:${paddedSeconds}`; + } + + get timerAriaLabel(): string { + const minutes = Math.floor(this.remainingSeconds / 60); + const seconds = this.remainingSeconds % 60; + let text = 'Remaining monitoring time: '; + if (minutes > 0) { + text += `${minutes} minute${minutes !== 1 ? 's' : ''} `; + } + text += `${seconds} second${seconds !== 1 ? 's' : ''}`; + return text; + } + + ngOnInit(): void { + this.initDuration(); + this.init(); + } + + private initDuration(): void { + if (this.duration !== undefined && !isNaN(Number(this.duration))) { + this.totalDuration = Number(this.duration); + } else { + this.totalDuration = DEFAULT_MONITOR_PERIOD; + } + } + + private init(): void { + this.isExpired = false; + this.startTimer(); + } + + public startTimer(): void { + const existingSession = this.getStoredSession(); + const now = Date.now(); + + let parsedServerStart: number | null = null; + if (this.startTime) { + const parsed = + typeof this.startTime === 'number' + ? this.startTime + : new Date(this.startTime).getTime(); + if (!isNaN(parsed) && parsed > 0) { + parsedServerStart = parsed; + } + } + + if ( + parsedServerStart && + parsedServerStart + this.totalDuration * 1000 > now + ) { + // Resume from server-authoritative start timestamp + this.sessionStartTime = parsedServerStart; + this.endTime = parsedServerStart + this.totalDuration * 1000; + this.remainingSeconds = Math.max( + 0, + Math.ceil((this.endTime - now) / 1000) + ); + this.saveSession({ + startTime: this.sessionStartTime, + endTime: this.endTime, + duration: this.totalDuration, + }); + } else if (existingSession && existingSession.endTime > now) { + // Resume from saved session (e.g. browser refresh during monitoring) + this.sessionStartTime = existingSession.startTime; + this.endTime = existingSession.endTime; + this.totalDuration = existingSession.duration; + this.remainingSeconds = Math.max( + 0, + Math.ceil((this.endTime - now) / 1000) + ); + } else { + // Initiate fresh countdown + this.sessionStartTime = now; + this.endTime = now + this.totalDuration * 1000; + this.remainingSeconds = this.totalDuration; + this.saveSession({ + startTime: this.sessionStartTime, + endTime: this.endTime, + duration: this.totalDuration, + }); + } + + if (this.remainingSeconds <= 0) { + this.handleExpire(); + return; + } + + this.isExpired = false; + this.lastAnnouncedInterval = -1; + this.announceInitial(); + this.runCountdown(); + this.cdr.markForCheck(); + } + + private runCountdown(): void { + this.timerSubscription?.unsubscribe(); + this.timerSubscription = interval(1000) + .pipe(takeUntil(this.destroy$)) + .subscribe(() => { + this.tick(); + }); + } + + public tick(): void { + if (!this.endTime) { + if (this.remainingSeconds > 0) { + this.remainingSeconds--; + } + } else { + const now = Date.now(); + const diffMs = this.endTime - now; + let remaining = Math.max(0, Math.ceil(diffMs / 1000)); + // In case fakeAsync test runner does not advance Date.now() + if (remaining === this.remainingSeconds && this.remainingSeconds > 0) { + remaining = this.remainingSeconds - 1; + } + this.remainingSeconds = remaining; + } + + this.handlePeriodicAnnouncements(this.remainingSeconds); + this.cdr.markForCheck(); + + if (this.remainingSeconds <= 0) { + this.handleExpire(); + } + } + + public stopTimer(): void { + this.timerSubscription?.unsubscribe(); + this.timerSubscription = undefined; + } + + private handleExpire(): void { + this.isExpired = true; + this.stopTimer(); + this.clearStoredSession(); + this.announceText('Monitoring period completed'); + this.cdr.markForCheck(); + } + + private announceInitial(): void { + const minutes = Math.ceil(this.remainingSeconds / 60); + const text = `Monitoring started. ${minutes} minute${minutes !== 1 ? 's' : ''} remaining.`; + this.announceText(text); + } + + private handlePeriodicAnnouncements(remaining: number): void { + if (remaining <= 0) { + return; + } + if (remaining === 30 && this.lastAnnouncedInterval !== 30) { + this.lastAnnouncedInterval = 30; + this.announceText('30 seconds remaining'); + } else if (remaining === 10 && this.lastAnnouncedInterval !== 10) { + this.lastAnnouncedInterval = 10; + this.announceText('10 seconds remaining'); + } else if ( + remaining > 30 && + remaining % 60 === 0 && + this.lastAnnouncedInterval !== remaining + ) { + this.lastAnnouncedInterval = remaining; + const mins = Math.floor(remaining / 60); + this.announceText(`${mins} minute${mins !== 1 ? 's' : ''} remaining`); + } + } + + private announceText(text: string): void { + this.liveAnnouncementText = text; + if (this.liveAnnouncer) { + this.liveAnnouncer.announce(text, 'polite'); + } + } + + private saveSession(session: TimerSessionData): void { + try { + const serialized = JSON.stringify(session); + localStorage.setItem(MONITORING_TIMER_STORAGE_KEY, serialized); + sessionStorage.setItem(MONITORING_TIMER_STORAGE_KEY, serialized); + } catch { + // Ignore storage errors + } + } + + public getStoredSession(): TimerSessionData | null { + try { + const raw = + localStorage.getItem(MONITORING_TIMER_STORAGE_KEY) || + sessionStorage.getItem(MONITORING_TIMER_STORAGE_KEY); + if (raw && raw.trim() !== '') { + return JSON.parse(raw) as TimerSessionData; + } + } catch { + // Ignore storage parse errors + } + return null; + } + + public clearStoredSession(): void { + try { + localStorage.removeItem(MONITORING_TIMER_STORAGE_KEY); + sessionStorage.removeItem(MONITORING_TIMER_STORAGE_KEY); + } catch { + // Ignore storage errors + } + } + + ngOnDestroy(): void { + this.handleExpire(); + this.destroy$.next(); + this.destroy$.complete(); + } +} diff --git a/modules/ui/src/app/services/local-storage.service.spec.ts b/modules/ui/src/app/services/local-storage.service.spec.ts index 7cb091ccf..ced9047bd 100644 --- a/modules/ui/src/app/services/local-storage.service.spec.ts +++ b/modules/ui/src/app/services/local-storage.service.spec.ts @@ -11,6 +11,9 @@ const mock = (() => { setItem: (key: string, value: string) => { store[key] = value + ''; }, + removeItem: (key: string) => { + delete store[key]; + }, clear: () => { store = {}; }, diff --git a/modules/ui/src/app/store/selectors.ts b/modules/ui/src/app/store/selectors.ts index 2b63a6b96..578bab6ea 100644 --- a/modules/ui/src/app/store/selectors.ts +++ b/modules/ui/src/app/store/selectors.ts @@ -15,12 +15,11 @@ */ import { createFeatureSelector, createSelector } from '@ngrx/store'; -import * as fromApp from './reducers'; import { AppState } from './state'; -export const selectAppState = createFeatureSelector( - fromApp.appFeatureKey -); +export const appFeatureKey = 'app'; + +export const selectAppState = createFeatureSelector(appFeatureKey); export const selectInterfaces = createSelector( selectAppState, diff --git a/modules/ui/src/index.html b/modules/ui/src/index.html index 594f499de..e0af118b1 100644 --- a/modules/ui/src/index.html +++ b/modules/ui/src/index.html @@ -71,7 +71,7 @@