From d139fc60747f766202da5010446db93abc395343 Mon Sep 17 00:00:00 2001 From: kurilova Date: Mon, 7 Sep 2026 09:30:52 +0000 Subject: [PATCH 1/3] Receive monitoring time --- framework/python/src/core/session.py | 11 +++++ modules/ui/src/app/model/testrun-status.ts | 1 + .../testrun-status-card.component.html | 5 ++- .../components/timer/timer.component.spec.ts | 35 ++++++++++++++- .../components/timer/timer.component.ts | 44 ++++++++++++++++--- testing/unit/framework/session_test.py | 20 +++++++++ 6 files changed, 107 insertions(+), 9 deletions(-) diff --git a/framework/python/src/core/session.py b/framework/python/src/core/session.py index e911e2ccb..532c961eb 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/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 331547fbf..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 @@ -119,7 +119,10 @@ @if (monitorPeriod) { - + + } 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 index 876efd589..adfcd4549 100644 --- 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 @@ -21,6 +21,7 @@ import { } from '@angular/core/testing'; import { LiveAnnouncer } from '@angular/cdk/a11y'; import { + DEFAULT_MONITOR_PERIOD, TimerComponent, MONITORING_TIMER_STORAGE_KEY, TimerSessionData, @@ -33,6 +34,8 @@ describe('TimerComponent', () => { let liveAnnouncerSpy: jasmine.SpyObj; beforeEach(() => { + localStorage.removeItem(MONITORING_TIMER_STORAGE_KEY); + sessionStorage.removeItem(MONITORING_TIMER_STORAGE_KEY); liveAnnouncerSpy = jasmine.createSpyObj('LiveAnnouncer', ['announce']); TestBed.configureTestingModule({ @@ -47,6 +50,7 @@ describe('TimerComponent', () => { afterEach(() => { component.ngOnDestroy(); + component.clearStoredSession(); }); it('should create', () => { @@ -60,10 +64,20 @@ describe('TimerComponent', () => { expect(component.totalDuration).toBe(180); }); - it('should set totalDuration to NaN when duration input is undefined', () => { + it('should fallback to DEFAULT_MONITOR_PERIOD when duration input is undefined', () => { component.duration = undefined; component.ngOnInit(); - expect(component.totalDuration).toBeNaN(); + 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); }); }); @@ -332,6 +346,23 @@ describe('TimerComponent', () => { MONITORING_TIMER_STORAGE_KEY ); }); + + it('should stop timer without clearing session storage on ngOnDestroy', () => { + const localRemoveSpy = spyOn(localStorage, 'removeItem'); + const sessionRemoveSpy = spyOn(sessionStorage, 'removeItem'); + + component.duration = 300; + component.ngOnInit(); + component.ngOnDestroy(); + + expect(component['timerSubscription']).toBeUndefined(); + expect(localRemoveSpy).not.toHaveBeenCalledWith( + MONITORING_TIMER_STORAGE_KEY + ); + expect(sessionRemoveSpy).not.toHaveBeenCalledWith( + MONITORING_TIMER_STORAGE_KEY + ); + }); }); describe('DOM Elements and Styling', () => { 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 index 0249d7b8d..887cfe2c8 100644 --- a/modules/ui/src/app/pages/testrun/components/timer/timer.component.ts +++ b/modules/ui/src/app/pages/testrun/components/timer/timer.component.ts @@ -46,6 +46,7 @@ export interface TimerSessionData { }) 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 }); @@ -59,7 +60,7 @@ export class TimerComponent implements OnInit, OnDestroy { private destroy$ = new Subject(); private lastAnnouncedInterval = -1; private endTime: number | null = null; - private startTime: number | null = null; + private sessionStartTime: number | null = null; public readonly circleRadius = 122; public readonly circleCircumference = 2 * Math.PI * 122; @@ -101,7 +102,11 @@ export class TimerComponent implements OnInit, OnDestroy { } private initDuration(): void { - this.totalDuration = Number(this.duration); + if (this.duration !== undefined && !isNaN(Number(this.duration))) { + this.totalDuration = Number(this.duration); + } else { + this.totalDuration = DEFAULT_MONITOR_PERIOD; + } } private init(): void { @@ -113,9 +118,36 @@ export class TimerComponent implements OnInit, OnDestroy { const existingSession = this.getStoredSession(); const now = Date.now(); - if (existingSession && existingSession.endTime > 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.startTime = existingSession.startTime; + this.sessionStartTime = existingSession.startTime; this.endTime = existingSession.endTime; this.totalDuration = existingSession.duration; this.remainingSeconds = Math.max( @@ -124,11 +156,11 @@ export class TimerComponent implements OnInit, OnDestroy { ); } else { // Initiate fresh countdown - this.startTime = now; + this.sessionStartTime = now; this.endTime = now + this.totalDuration * 1000; this.remainingSeconds = this.totalDuration; this.saveSession({ - startTime: this.startTime, + startTime: this.sessionStartTime, endTime: this.endTime, duration: this.totalDuration, }); diff --git a/testing/unit/framework/session_test.py b/testing/unit/framework/session_test.py index a858cb047..f0acbb032 100644 --- a/testing/unit/framework/session_test.py +++ b/testing/unit/framework/session_test.py @@ -403,3 +403,23 @@ def test_detect_network_adapters_change( # Verify that session_instance updated its local _ifaces state assert session_instance.get_ifaces() == {"eth0": "up", "wlan0": "down"} + +def test_monitor_timer_operations( + session_instance: session.TestrunSession, # pylint: disable=W0621 +): + """Test starting, getting, and resetting monitor timer.""" + assert session_instance.get_monitor_started() is None + session_instance.start_monitor_timer() + assert session_instance.get_monitor_started() is not None + + # Test setting status to MONITORING automatically starts timer if None + session_instance.reset() + assert session_instance.get_monitor_started() is None + session_instance.set_status(session.TestrunStatus.MONITORING) + assert session_instance.get_monitor_started() is not None + + # Verify to_json includes monitor_started + json_data = session_instance.to_json() + assert "monitor_started" in json_data + assert json_data["monitor_started"] == session_instance.get_monitor_started() + From 7732e958fadb5a92346bcc0466285bce89fce69b Mon Sep 17 00:00:00 2001 From: kurilova Date: Mon, 7 Sep 2026 12:33:15 +0000 Subject: [PATCH 2/3] Fix ui tests --- .../testrun/components/timer/timer.component.spec.ts | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) 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 index adfcd4549..c781c4c4d 100644 --- 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 @@ -347,7 +347,7 @@ describe('TimerComponent', () => { ); }); - it('should stop timer without clearing session storage on ngOnDestroy', () => { + it('should call handleExpire on ngOnDestroy', () => { const localRemoveSpy = spyOn(localStorage, 'removeItem'); const sessionRemoveSpy = spyOn(sessionStorage, 'removeItem'); @@ -355,11 +355,10 @@ describe('TimerComponent', () => { component.ngOnInit(); component.ngOnDestroy(); + expect(component.isExpired).toBeTrue(); expect(component['timerSubscription']).toBeUndefined(); - expect(localRemoveSpy).not.toHaveBeenCalledWith( - MONITORING_TIMER_STORAGE_KEY - ); - expect(sessionRemoveSpy).not.toHaveBeenCalledWith( + expect(localRemoveSpy).toHaveBeenCalledWith(MONITORING_TIMER_STORAGE_KEY); + expect(sessionRemoveSpy).toHaveBeenCalledWith( MONITORING_TIMER_STORAGE_KEY ); }); From 13dc2801ad7af020226a4c4761bff693c15b8abd Mon Sep 17 00:00:00 2001 From: Sofia Kurilova Date: Fri, 28 Aug 2026 09:06:46 +0000 Subject: [PATCH 3/3] Implement the counter on the FE (#1635) * Adds timer * Fix lint * Fix tests --- docs/dev/mockoon.json | 4 +- modules/ui/src/app/app.store.spec.ts | 3 + .../testrun-status-card.component.scss | 19 +++ .../testrun-status-card.component.spec.ts | 22 +++- .../testrun-status-card.component.ts | 43 ++++++- .../components/timer/timer.component.html | 62 ++++++++++ .../components/timer/timer.component.scss | 108 ++++++++++++++++++ .../services/local-storage.service.spec.ts | 3 + modules/ui/src/app/store/selectors.ts | 7 +- modules/ui/src/index.html | 2 +- 10 files changed, 259 insertions(+), 14 deletions(-) create mode 100644 modules/ui/src/app/pages/testrun/components/timer/timer.component.html create mode 100644 modules/ui/src/app/pages/testrun/components/timer/timer.component.scss 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/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/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) { +
+
+ +
+ + +
+
+ + {{ 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/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 @@