From fa667bcb54f473e21b00d2f50bdf894f9ced7ce5 Mon Sep 17 00:00:00 2001 From: hehoon <100522372+hehoon@users.noreply.github.com> Date: Mon, 5 Jan 2026 09:45:07 +0100 Subject: [PATCH 01/19] Notify user via native browser notification when a new run has started --- QualityControl/lib/QCModel.js | 5 +- QualityControl/lib/api.js | 2 +- QualityControl/lib/services/RunModeService.js | 14 ++- QualityControl/public/Model.js | 4 + .../public/common/enums/storageKeys.enum.js | 1 + QualityControl/public/common/header.js | 42 ++++++- .../model/NotificationRunStartModel.js | 104 ++++++++++++++++++ .../test/lib/services/RunModeService.test.js | 7 +- 8 files changed, 171 insertions(+), 8 deletions(-) create mode 100644 QualityControl/public/common/notifications/model/NotificationRunStartModel.js diff --git a/QualityControl/lib/QCModel.js b/QualityControl/lib/QCModel.js index aa5364af6..172be0002 100644 --- a/QualityControl/lib/QCModel.js +++ b/QualityControl/lib/QCModel.js @@ -53,10 +53,11 @@ const LOG_FACILITY = `${process.env.npm_config_log_label ?? 'qcg'}/model-setup`; /** * Model initialization for the QCG application + * @param {WebSocket} ws - web-ui websocket server implementation * @param {EventEmitter} eventEmitter - Event emitter instance for inter-service communication * @returns {Promise} Multiple services and controllers that are to be used by the QCG application */ -export const setupQcModel = async (eventEmitter) => { +export const setupQcModel = async (ws, eventEmitter) => { const logger = LogManager.getLogger(LOG_FACILITY); const __filename = fileURLToPath(import.meta.url); @@ -117,7 +118,7 @@ export const setupQcModel = async (eventEmitter) => { const bookkeepingService = new BookkeepingService(config.bookkeeping); const filterService = new FilterService(bookkeepingService, config); - const runModeService = new RunModeService(config.bookkeeping, bookkeepingService, ccdbService, eventEmitter); + const runModeService = new RunModeService(config.bookkeeping, bookkeepingService, ccdbService, eventEmitter, ws); const objectController = new ObjectController(qcObjectService, runModeService, qcdbDownloadService); const filterController = new FilterController(filterService, runModeService); diff --git a/QualityControl/lib/api.js b/QualityControl/lib/api.js index 3c02671cd..410c1a384 100644 --- a/QualityControl/lib/api.js +++ b/QualityControl/lib/api.js @@ -54,7 +54,7 @@ export const setup = async (http, ws, eventEmitter) => { objectGetByIdValidation, objectsGetValidation, objectGetContentsValidation, - } = await setupQcModel(eventEmitter); + } = await setupQcModel(ws, eventEmitter); statusService.ws = ws; http.get('/object/:id', objectGetByIdValidation, objectController.getObjectByIdHandler.bind(objectController)); diff --git a/QualityControl/lib/services/RunModeService.js b/QualityControl/lib/services/RunModeService.js index 4c773410a..5059ef788 100644 --- a/QualityControl/lib/services/RunModeService.js +++ b/QualityControl/lib/services/RunModeService.js @@ -12,7 +12,7 @@ * or submit itself to any jurisdiction. */ -import { LogManager } from '@aliceo2/web-ui'; +import { LogManager, WebSocketMessage } from '@aliceo2/web-ui'; import { EmitterKeys } from '../../common/library/enums/emitterKeys.enum.js'; import { Transition } from '../../common/library/enums/transition.enum.js'; import { RunStatus } from '../../common/library/runStatus.enum.js'; @@ -29,16 +29,19 @@ export class RunModeService { * @param {BookkeepingService} bookkeepingService - Used to check the status of a run. * @param {CcdbService} dataService - Used to fetch data from the CCDB. * @param {EventEmitter} eventEmitter - Event emitter to be used to emit events when new data is available + * @param {WebSocket} ws - web-ui websocket server implementation */ constructor( config, bookkeepingService, dataService, eventEmitter, + ws, ) { this._bookkeepingService = bookkeepingService; this._dataService = dataService; this._eventEmitter = eventEmitter; + this._ws = ws; this._ongoingRuns = new Map(); this._lastRunsRefresh = 0; @@ -134,6 +137,15 @@ export class RunModeService { } else if (transition === Transition.STOP_ACTIVITY) { this._ongoingRuns.delete(runNumber); } + + const wsMessage = new WebSocketMessage(); + wsMessage.command = EmitterKeys.RUN_TRACK; + wsMessage.payload = { + runNumber, + transition, + }; + + this._ws.broadcast(wsMessage); } /** diff --git a/QualityControl/public/Model.js b/QualityControl/public/Model.js index 4c8a238f8..4af4ea5f7 100644 --- a/QualityControl/public/Model.js +++ b/QualityControl/public/Model.js @@ -29,6 +29,7 @@ import AboutViewModel from './pages/aboutView/AboutViewModel.js'; import LayoutListModel from './pages/layoutListView/model/LayoutListModel.js'; import { RequestFields } from './common/RequestFields.enum.js'; import FilterModel from './common/filters/model/FilterModel.js'; +import NotificationRunStartModel from './common/notifications/model/NotificationRunStartModel.js'; /** * Represents the application's state and actions as a class @@ -85,6 +86,9 @@ export default class Model extends Observable { this.ws.addListener('authed', this.handleWSAuthed.bind(this)); this.ws.addListener('close', this.handleWSClose.bind(this)); + this.notificationRunStartModel = new NotificationRunStartModel(this); + this.notificationRunStartModel.bubbleTo(this); + this.initModel(); } diff --git a/QualityControl/public/common/enums/storageKeys.enum.js b/QualityControl/public/common/enums/storageKeys.enum.js index 54953dd0f..8f96f7af4 100644 --- a/QualityControl/public/common/enums/storageKeys.enum.js +++ b/QualityControl/public/common/enums/storageKeys.enum.js @@ -20,4 +20,5 @@ export const StorageKeysEnum = Object.freeze({ OBJECT_VIEW_LEFT_PANEL_WIDTH: 'object-view-left-panel-width', OBJECT_VIEW_INFO_VISIBILITY_SETTING: 'object-view-info-visibility-setting', + NOTIFICATION_START_RUN_SETTING: 'notification-start-run-setting', }); diff --git a/QualityControl/public/common/header.js b/QualityControl/public/common/header.js index d965a4e59..7dd2ac27b 100644 --- a/QualityControl/public/common/header.js +++ b/QualityControl/public/common/header.js @@ -12,7 +12,8 @@ * or submit itself to any jurisdiction. */ -import { h, iconPerson } from '/js/src/index.js'; +import { h, iconPerson, getBrowserNotificationPermission, + requestBrowserNotificationPermissions } from '/js/src/index.js'; import { spinner } from './spinner.js'; import layoutViewHeader from '../layout/view/header.js'; @@ -100,8 +101,12 @@ const commonHeader = (model) => h('.flex-row.items-center.w-25', [ * @param {Model} model - root model of the application * @returns {vnode} - virtual node element */ -const loginButton = (model) => - h('.dropdown', { +const loginButton = (model) => { + const browserNotificationPermission = getBrowserNotificationPermission(); + const notificationsAvailable = browserNotificationPermission && browserNotificationPermission !== 'denied'; + const runStartNotificationEnabled = model.notificationRunStartModel.getBrowserNotificationSetting(); + + return h('.dropdown', { title: 'Login', class: model.accountMenuEnabled ? 'dropdown-open' : '', }, [ h('button.btn', { onclick: () => model.toggleAccountMenu() }, iconPerson()), @@ -110,5 +115,36 @@ const loginButton = (model) => model.session.personid === 0 // Anonymous user has id 0 ? h('p.m3.gray-darker', 'This instance of the application does not require authentication.') : h('a.menu-item', { onclick: () => alert('Not implemented') }, 'Logout'), + h( + 'label.flex-row.g1.items-center.form-check-label', + { + style: `cursor: ${notificationsAvailable ? 'pointer' : 'not-allowed'};`, + }, + [ + h( + '.switch', + [ + h('input', { + onchange: async (event) => { + let permission = false; + if (event.target.checked) { + permission = await requestBrowserNotificationPermissions() === 'granted'; + } + model.notificationRunStartModel.setBrowserNotificationSetting(permission); + }, + type: 'checkbox', + checked: runStartNotificationEnabled, + }), + h(`span.slider.round.bg-${ + runStartNotificationEnabled ? 'primary' : 'gray' + }`, { + style: `cursor: ${notificationsAvailable ? 'pointer' : 'not-allowed'};`, + }), + ], + ), + 'Notify on run start', + ], + ), ]), ]); +}; diff --git a/QualityControl/public/common/notifications/model/NotificationRunStartModel.js b/QualityControl/public/common/notifications/model/NotificationRunStartModel.js new file mode 100644 index 000000000..64f0d6646 --- /dev/null +++ b/QualityControl/public/common/notifications/model/NotificationRunStartModel.js @@ -0,0 +1,104 @@ +/** + * @license + * Copyright 2019-2020 CERN and copyright holders of ALICE O2. + * See http://alice-o2.web.cern.ch/copyright for details of the copyright holders. + * All rights not expressly granted are reserved. + * + * This software is distributed under the terms of the GNU General Public + * License v3 (GPL Version 3), copied verbatim in the file "COPYING". + * + * In applying this license CERN does not waive the privileges and immunities + * granted to it by virtue of its status as an Intergovernmental Organization + * or submit itself to any jurisdiction. + */ + +import { Observable, BrowserStorage, showNativeBrowserNotification } from '/js/src/index.js'; +import { EmitterKeys } from '../../../../library/enums/emitterKeys.enum.js'; +import { StorageKeysEnum } from '../../enums/storageKeys.enum.js'; +import { Transition } from '../../../../library/enums/transition.enum.js'; + +/** + * Model responsible for handling browser notifications when a new run starts. + */ +export default class NotificationRunStartModel extends Observable { + /** + * Initialize with empty values + * @param {Model} model - root model of the application + */ + constructor(model) { + super(); + + this.model = model; + this._browserNotificationStorage = new BrowserStorage(StorageKeysEnum.NOTIFICATION_START_RUN_SETTING); + + this.model.ws.addListener('command', (message) => { + if (message.command === EmitterKeys.RUN_TRACK) { + this._handleWSRunTrack.bind(this, message.payload); + } + }); + } + + /** + * Returns whether browser notifications for run start events + * are enabled for the current user. + * @returns {boolean} `true` if notifications are enabled, `false` otherwise. + */ + getBrowserNotificationSetting() { + try { + return this._browserNotificationStorage.getLocalItem(this.model.session.personid.toString()) ?? false; + } catch { + this._browserNotificationStorage.removeLocalItem(this.model.session.personid.toString()); + return false; + } + } + + /** + * Persists the browser notification preference for the current user + * and notifies all observers. + * @param {boolean} enabled - Whether notifications should be enabled. + * @returns {undefined} + */ + setBrowserNotificationSetting(enabled) { + this._browserNotificationStorage.setLocalItem(this.model.session.personid.toString(), enabled); + this.notify(); + } + + /** + * Handles {@link EmitterKeys.RUN_TRACK} WebSocket events. + * A native browser notification is displayed only when: + * - The transition is {@link Transition.START_ACTIVITY} + * - The user has enabled notifications + * @param {object} payload - WebSocket payload. + * @param {number} payload.runNumber - Run number that started. + * @param {Transition} payload.transition - Transition type. + * @returns {undefined} + */ + async _handleWSRunTrack({ runNumber, transition }) { + if (transition !== Transition.START_ACTIVITY) { + return; + } + + if (!this.getBrowserNotificationSetting()) { + return; + } + + showNativeBrowserNotification({ + title: `RUN ${runNumber ?? 'unknown'} has started`, + onclick: () => { + const searchParams = new URLSearchParams(); + + const { params } = this.model.router; + params.RunNumber = runNumber; + + Object.entries(params).forEach(([key, value]) => { + searchParams.append(key, String(value)); + }); + + const query = searchParams.toString(); + if (query) { + this.model.router.go(query); + } + }, + }); + } +} diff --git a/QualityControl/test/lib/services/RunModeService.test.js b/QualityControl/test/lib/services/RunModeService.test.js index 5802005a4..89b859ade 100644 --- a/QualityControl/test/lib/services/RunModeService.test.js +++ b/QualityControl/test/lib/services/RunModeService.test.js @@ -28,6 +28,7 @@ export const runModeServiceTestSuite = async () => { let bookkeepingService = undefined; let dataService = undefined; const eventEmitter = new EventEmitter(); + let ws = undefined; beforeEach(() => { bookkeepingService = { @@ -38,8 +39,12 @@ export const runModeServiceTestSuite = async () => { getObjectsLatestVersionList: sinon.stub(), }; + ws = { + broadcast: sinon.stub(), + }; + const config = { refreshInterval: 60000 }; - runModeService = new RunModeService(config, bookkeepingService, dataService, eventEmitter); + runModeService = new RunModeService(config, bookkeepingService, dataService, eventEmitter, ws); }); suite('retrievePathsAndSetRunStatus', () => { test('should retrieve paths and cache them if run is ongoing', async () => { From 033aeb832a40ebebddca22dd785901fa3503f45e Mon Sep 17 00:00:00 2001 From: hehoon <100522372+hehoon@users.noreply.github.com> Date: Mon, 5 Jan 2026 12:42:55 +0100 Subject: [PATCH 02/19] Fix failing tests --- .../public/common/filters/runMode/runModeCheckbox.js | 1 + QualityControl/test/public/features/runMode.test.js | 8 ++++---- 2 files changed, 5 insertions(+), 4 deletions(-) diff --git a/QualityControl/public/common/filters/runMode/runModeCheckbox.js b/QualityControl/public/common/filters/runMode/runModeCheckbox.js index a4cdb48da..ac74d3556 100644 --- a/QualityControl/public/common/filters/runMode/runModeCheckbox.js +++ b/QualityControl/public/common/filters/runMode/runModeCheckbox.js @@ -34,6 +34,7 @@ export const runModeCheckbox = (filterModel, viewModel) => { 'label.flex-row.g1.items-center.form-check-label', { style: `cursor:${isAvailable ? 'pointer' : 'not-allowed'}`, + id: 'run-mode-switch', }, [ h( diff --git a/QualityControl/test/public/features/runMode.test.js b/QualityControl/test/public/features/runMode.test.js index 1c500f01b..36e9c5d8e 100644 --- a/QualityControl/test/public/features/runMode.test.js +++ b/QualityControl/test/public/features/runMode.test.js @@ -47,14 +47,14 @@ export const runModeTests = async (url, page, timeout = 5000, testParent) => { await page.evaluate(() => { window.model.filterModel.ONGOING_RUN_INTERVAL_MS = 12000000; }); - await page.locator('.form-check-label > .switch'); + await page.locator('#run-mode-switch > .switch'); const runsModeTitle = await page.evaluate(() => - document.querySelector('.form-check-label').textContent); + document.querySelector('#run-mode-switch').textContent); strictEqual(runsModeTitle, 'Run mode', 'The text displayed is not `Runs mode`'); }); await testParent.test('should activate run mode', { timeout }, async () => { - await page.locator('.form-check-label > .switch').click(); + await page.locator('#run-mode-switch > .switch').click(); await delay(500); expectCountRunStatusCalls ++; @@ -141,7 +141,7 @@ export const runModeTests = async (url, page, timeout = 5000, testParent) => { ok(isRunModeActive, 'Run mode should be active before disabling'); // Click the run mode checkbox to disable it - await page.locator('.form-check-label > .switch').click(); + await page.locator('#run-mode-switch > .switch').click(); await delay(100); // Verify run mode is now deactivated From ac82b0ee267f7003a5994ae476f6ffe7ecc1c516 Mon Sep 17 00:00:00 2001 From: hehoon <100522372+hehoon@users.noreply.github.com> Date: Mon, 5 Jan 2026 13:15:37 +0100 Subject: [PATCH 03/19] Add `getPageTargetModel` to `FilterModel` and rewrite `filterSpecific` in `header.js` --- .../public/common/filters/model/FilterModel.js | 15 +++++++++++++++ QualityControl/public/common/header.js | 15 +++++++++------ 2 files changed, 24 insertions(+), 6 deletions(-) diff --git a/QualityControl/public/common/filters/model/FilterModel.js b/QualityControl/public/common/filters/model/FilterModel.js index 69d02a196..4e3a7ef17 100644 --- a/QualityControl/public/common/filters/model/FilterModel.js +++ b/QualityControl/public/common/filters/model/FilterModel.js @@ -386,4 +386,19 @@ export default class FilterModel extends Observable { } return { refreshNeeded: true, data: null }; } + + /** + * Returns the target model based on the current page + * @returns {object} the specific object/view model for the page + */ + getPageTargetModel() { + const { page, layout, object, objectViewModel } = this.model; + + switch (page) { + case 'layoutShow': return layout; + case 'objectTree': return object; + case 'objectView': return objectViewModel; + default: return null; + } + } } diff --git a/QualityControl/public/common/header.js b/QualityControl/public/common/header.js index 7dd2ac27b..a42e85b6c 100644 --- a/QualityControl/public/common/header.js +++ b/QualityControl/public/common/header.js @@ -69,14 +69,17 @@ const headerSpecific = (model) => { * @returns {vnode} - virtual node element */ const filterSpecific = (model) => { - const { page, filterModel, layout, object, objectViewModel } = model; + const { page, filterModel, layout } = model; + if (page === 'layoutList' && layout.editEnabled) { + return null; + } - switch (page) { - case 'layoutShow': return !layout.editEnabled && filtersPanel(filterModel, layout); - case 'objectTree': return filtersPanel(filterModel, object); - case 'objectView': return filtersPanel(filterModel, objectViewModel); - default: return null; + const viewModel = filterModel.getPageTargetModel(); + if (!viewModel) { + return null; } + + return filtersPanel(filterModel, viewModel); }; /** From 591fb808bf7b8da133af93e3497f97d5565a75e2 Mon Sep 17 00:00:00 2001 From: hehoon <100522372+hehoon@users.noreply.github.com> Date: Mon, 5 Jan 2026 13:16:14 +0100 Subject: [PATCH 04/19] on notification click, the runmode for that runnumber should be enabled --- .../model/NotificationRunStartModel.js | 20 ++++++++----------- 1 file changed, 8 insertions(+), 12 deletions(-) diff --git a/QualityControl/public/common/notifications/model/NotificationRunStartModel.js b/QualityControl/public/common/notifications/model/NotificationRunStartModel.js index 64f0d6646..f8652a953 100644 --- a/QualityControl/public/common/notifications/model/NotificationRunStartModel.js +++ b/QualityControl/public/common/notifications/model/NotificationRunStartModel.js @@ -85,19 +85,15 @@ export default class NotificationRunStartModel extends Observable { showNativeBrowserNotification({ title: `RUN ${runNumber ?? 'unknown'} has started`, onclick: () => { - const searchParams = new URLSearchParams(); - - const { params } = this.model.router; - params.RunNumber = runNumber; - - Object.entries(params).forEach(([key, value]) => { - searchParams.append(key, String(value)); - }); - - const query = searchParams.toString(); - if (query) { - this.model.router.go(query); + const { isRunModeActivated } = this.model.filterModel; + if (!isRunModeActivated) { + const viewModel = this.model.filterModel.getPageTargetModel(); + if (viewModel) { + this.model.filterModel.activateRunsMode(viewModel); + } } + + this.model.filterModel.setFilterValue('RunNumber', runNumber?.toString(), true); }, }); } From 4abdbface639b8fd1dd1565bc3c5c154c9dfc6db Mon Sep 17 00:00:00 2001 From: hehoon <100522372+hehoon@users.noreply.github.com> Date: Mon, 5 Jan 2026 13:37:01 +0100 Subject: [PATCH 05/19] Backend tests --- .../test/lib/services/RunModeService.test.js | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/QualityControl/test/lib/services/RunModeService.test.js b/QualityControl/test/lib/services/RunModeService.test.js index 89b859ade..6b6e824c6 100644 --- a/QualityControl/test/lib/services/RunModeService.test.js +++ b/QualityControl/test/lib/services/RunModeService.test.js @@ -21,6 +21,7 @@ import { RunStatus } from '../../../common/library/runStatus.enum.js'; import { EmitterKeys } from '../../../common/library/enums/emitterKeys.enum.js'; import { Transition } from '../../../common/library/enums/transition.enum.js'; import { delayAndCheck } from '../../testUtils/delay.js'; +import { WebSocketMessage } from '@aliceo2/web-ui'; export const runModeServiceTestSuite = async () => { suite('RunModeService', () => { @@ -158,6 +159,23 @@ export const runModeServiceTestSuite = async () => { })); }); + test('should listen to events on RUN_TRACK and broadcast to websocket', async () => { + const runEvent = { runNumber: 1234, transition: Transition.START_ACTIVITY }; + runModeService._dataService.getObjectsLatestVersionList = sinon.stub().resolves([{ path: '/path/from/event' }]); + + eventEmitter.emit(EmitterKeys.RUN_TRACK, runEvent); + await delayAndCheck(() => runModeService._ongoingRuns.has(runEvent.runNumber), 500, 10); + + const wsMessage = new WebSocketMessage(); + wsMessage.command = EmitterKeys.RUN_TRACK; + wsMessage.payload = runEvent; + + ok( + ws.broadcast.calledOnceWith(wsMessage), + `Websocket broadcast should have message: ${JSON.stringify(wsMessage)}`, + ); + }); + test('should remove run from ongoing runs map on STOP_ACTIVITY event', async () => { const runEventStop = { runNumber: 5678, transition: Transition.STOP_ACTIVITY }; runModeService._ongoingRuns.set(runEventStop.runNumber, [{ path: '/some/path' }]); From caf1e3f1741c08afcc41f1587f2ed43f53ac8741 Mon Sep 17 00:00:00 2001 From: hehoon <100522372+hehoon@users.noreply.github.com> Date: Tue, 6 Jan 2026 13:57:59 +0100 Subject: [PATCH 06/19] Add tests --- .../public/components/profileHeader.test.js | 274 ++++++++++++++++++ QualityControl/test/test-index.js | 7 + 2 files changed, 281 insertions(+) create mode 100644 QualityControl/test/public/components/profileHeader.test.js diff --git a/QualityControl/test/public/components/profileHeader.test.js b/QualityControl/test/public/components/profileHeader.test.js new file mode 100644 index 000000000..bd83608f2 --- /dev/null +++ b/QualityControl/test/public/components/profileHeader.test.js @@ -0,0 +1,274 @@ +/** + * @license + * Copyright 2019-2020 CERN and copyright holders of ALICE O2. + * See http://alice-o2.web.cern.ch/copyright for details of the copyright holders. + * All rights not expressly granted are reserved. + * + * This software is distributed under the terms of the GNU General Public + * License v3 (GPL Version 3), copied verbatim in the file 'COPYING'. + * + * In applying this license CERN does not waive the privileges and immunities + * granted to it by virtue of its status as an Intergovernmental Organization + * or submit itself to any jurisdiction. + */ + +import { doesNotMatch, match, ok, strictEqual } from 'node:assert'; +import { delay } from '../../testUtils/delay.js'; +import { StorageKeysEnum } from '../../../public/common/enums/storageKeys.enum.js'; +import { getLocalStorageAsJson } from '../../testUtils/localStorage.js'; +import { Transition } from '../../../common/library/enums/transition.enum.js'; + +/** + * Performs a series of automated tests on the layoutList page using Puppeteer. + * @param {string} url - URL needed to open page for testing + * @param {object} page - Puppeteer page object + * @param {number} timeout - Timeout PER test; default 100 + * @param {object} testParent - Node.js test object which ensures subtests are being awaited + */ +export const profileHeaderTests = async (url, page, timeout = 1000, testParent) => { + await testParent.test('should load', { timeout }, async () => { + await page.goto(`${url}?page=layoutList`, { waitUntil: 'networkidle0' }); + const location = await page.evaluate(() => window.location); + + strictEqual(location.search, '?page=layoutList'); + }); + + await testParent.test('should have an account button', async () => { + const accountButtonExists = await page.evaluate(() => + document.querySelector('#qcg-header div[title="Login"] button.btn') !== null); + + ok(accountButtonExists); + }); + + await testParent.test('clicking the account button opens a dropdown', { timeout }, async () => { + const selector = '#qcg-header div[title="Login"]'; + const locator = '#qcg-header div[title="Login"] button.btn'; + let classNames = undefined; + + classNames = await page.evaluate((query) => document.querySelector(query).className, selector); + doesNotMatch(classNames, /\bdropdown-open\b/, 'Account dropdown should not be open before clicking'); + + await page.locator(locator).click(); + await delay(100); + classNames = await page.evaluate((query) => document.querySelector(query).className, selector); + match(classNames, /\bdropdown-open\b/, 'Account dropdown should be open after clicking'); + + await page.locator(locator).click(); + await delay(100); + classNames = await page.evaluate((query) => document.querySelector(query).className, selector); + doesNotMatch(classNames, /\bdropdown-open\b/, 'Account dropdown should be closed after clicking again'); + }); + + await testParent.test('toggling the "notify on run start" setting updates LocalStorage', { timeout }, async () => { + const locator = '#qcg-header div[title="Login"] .dropdown-menu .switch'; + const selector = `${locator} input[type="checkbox"]`; + const personId = await page.evaluate(() => window.model?.session?.personid?.toString()); + const localStorageKey = `${StorageKeysEnum.NOTIFICATION_START_RUN_SETTING}-${personId}`; + const context = page.browserContext(); + let switchValue = undefined; + let storageValue = undefined; + if (!personId) { + throw new Error('Could not resolve personId from the application model'); + } + + try { + // Grant notification permissions + await context.overridePermissions(url, ['notifications']); + + // Open the dropdown + await page.locator('#qcg-header div[title="Login"] button.btn').click(); + + switchValue = await page.evaluate((query) => document.querySelector(query).checked, selector); + storageValue = await getLocalStorageAsJson(page, localStorageKey); + strictEqual(switchValue, false, 'Setting "notify on run start" should be disabled'); + strictEqual(storageValue, null, 'Should not have a stored setting for "notify on run start"'); + + await page.locator(locator).click(); + await page.waitForFunction( + (query, expected) => document.querySelector(query).checked === expected, + {}, + selector, + true, + ); + switchValue = await page.evaluate((query) => document.querySelector(query).checked, selector); + storageValue = await getLocalStorageAsJson(page, localStorageKey); + strictEqual(switchValue, true, 'Setting "notify on run start" should be enabled'); + strictEqual(storageValue, true, 'Should have a stored value "true" for setting "notify on run start"'); + + await page.locator(locator).click(); + await page.waitForFunction( + (query, expected) => document.querySelector(query).checked === expected, + {}, + selector, + false, + ); + switchValue = await page.evaluate((query) => document.querySelector(query).checked, selector); + storageValue = await getLocalStorageAsJson(page, localStorageKey); + strictEqual(switchValue, false, 'Setting "notify on run start" should be disabled'); + strictEqual(storageValue, false, 'Should have a stored value "false" for setting "notify on run start"'); + } finally { + context.clearPermissionOverrides(); + } + }); + + await testParent.test('setting "notify on run start" should be loaded from LocalStorage', { timeout }, async () => { + const switchButtonLocator = '#qcg-header div[title="Login"] .dropdown-menu .switch'; + const checkboxSelector = `${switchButtonLocator} input[type="checkbox"]`; + const accountButtonLocator = '#qcg-header div[title="Login"] button.btn'; + // Resolve LocalStorage key dynamically based on personId + const personId = await page.evaluate(() => window.model?.session?.personid?.toString()); + const localStorageKey = `${StorageKeysEnum.NOTIFICATION_START_RUN_SETTING}-${personId}`; + const context = page.browserContext(); + if (!personId) { + throw new Error('Could not resolve personId from the application model'); + } + + /** + * @typedef {object} SwitchState + * @property {boolean} switchBefore - Switch checked state before page reload + * @property {JSON} storageBefore - LocalStorage value before page reload + * @property {boolean} switchAfter - Switch checked state after page reload + * @property {JSON} storageAfter - LocalStorage value after page reload + */ + + /** + * Reads the switch state and LocalStorage before and after a reload. + * @returns {Promise} Object containing switch and LocalStorage values before and after page reload. + */ + const readSwitchStateBeforeAndAfterReload = async () => { + // Read before reload + const switchBefore = await page.evaluate((query) => document.querySelector(query).checked, checkboxSelector); + const storageBefore = await getLocalStorageAsJson(page, localStorageKey); + + // Reload the page + await page.reload({ waitUntil: 'networkidle0' }); + + // Open the dropdown + await page.locator(accountButtonLocator).click(); + await page.waitForFunction(() => + /\bdropdown-open\b/.test(document.querySelector('#qcg-header div[title="Login"]').className)); + + // Read after reload + const switchAfter = await page.evaluate((query) => document.querySelector(query).checked, checkboxSelector); + const storageAfter = await getLocalStorageAsJson(page, localStorageKey); + + return { switchBefore, storageBefore, switchAfter, storageAfter }; + }; + + try { + // Grant notification permissions + await context.overridePermissions(url, ['notifications']); + + // Verify default disabled state persists across reload + const disabledState = await readSwitchStateBeforeAndAfterReload(); + strictEqual( + disabledState.switchBefore, + disabledState.switchAfter, + 'Switch state should persist when disabled', + ); + strictEqual( + disabledState.storageBefore, + disabledState.storageAfter, + 'LocalStorage value should persist when disabled', + ); + + // Enable the setting + await page.locator(switchButtonLocator).click(); + await page.waitForFunction( + (query) => document.querySelector(query).checked === true, + {}, + checkboxSelector, + ); + + // Verify enabled state persists across reload + const enabledState = await readSwitchStateBeforeAndAfterReload(); + strictEqual( + enabledState.switchBefore, + enabledState.switchAfter, + 'Switch state should persist when enabled', + ); + strictEqual( + enabledState.storageBefore, + enabledState.storageAfter, + 'LocalStorage value should persist when enabled', + ); + } finally { + context.clearPermissionOverrides(); + } + }); + + await testParent.test('should enable RunMode when native browser notification is clicked', { timeout }, async () => { + await page.goto(`${url}?page=objectTree`, { waitUntil: 'networkidle0' }); + const context = page.browserContext(); + + try { + // Grant notification permissions + await context.overridePermissions(url, ['notifications']); + + // Inject a runtime Notification mock + await page.evaluate(() => { + window.__originalNotification = window.Notification; + window.__lastNotification = null; + + class MockNotification { + /** + * `MockNotification` constructor + * @param {string} title = Notification title + * @param {object} options - Notification options + */ + constructor(title, options) { + this.title = title; + this.options = options; + + // Save instance so test can access it + window.__lastNotification = this; + } + + close() {} + + static permission = 'granted'; + + static requestPermission() { + return Promise.resolve('granted'); + } + } + + window.Notification = MockNotification; + }); + + // Trigger native browser notification by simulating websocket message + await page.evaluate( + (wsMessage) => window.model.notificationRunStartModel._handleWSRunTrack(wsMessage), + { runNumber: 1234, transition: Transition.START_ACTIVITY }, + ); + // `window.__lastNotification` is set by the mocked `Notification` + await page.waitForFunction(() => window.__lastNotification !== null); + + let selectedRun = await page.evaluate(() => document.querySelector('select#ongoingRunsFilter')?.value); + strictEqual(selectedRun, undefined, 'Should not have a run selected in RunMode before clicking the notification'); + + await page.evaluate(() => { + if (typeof window.__lastNotification.onclick === 'function') { + window.__lastNotification.onclick(); + } + }); + await page.waitForFunction(() => + document.querySelector('#run-mode-switch .switch input[type="checkbox"]')?.checked === true); + + selectedRun = await page.evaluate(() => document.querySelector('select#ongoingRunsFilter')?.value); + strictEqual( + selectedRun, + '1234', + 'Should have the newly started run selected in RunMode after clicking the notification', + ); + } finally { + context.clearPermissionOverrides(); + // Remove mocked Notification + await page.evaluate(() => { + if (window.__originalNotification) { + window.Notification = window.__originalNotification; + } + }); + } + }); +}; diff --git a/QualityControl/test/test-index.js b/QualityControl/test/test-index.js index 40412d607..90bd8f114 100644 --- a/QualityControl/test/test-index.js +++ b/QualityControl/test/test-index.js @@ -29,6 +29,7 @@ import { */ import { initialPageSetupTests } from './public/initialPageSetup.test.js'; +import { profileHeaderTests } from './public/components/profileHeader.test.js'; import { qcDrawingOptionsTests } from './public/components/qcDrawingOptions.test.js'; import { layoutListPageTests } from './public/pages/layout-list.test.js'; import { objectTreePageTests } from './public/pages/object-tree.test.js'; @@ -146,6 +147,12 @@ suite('All Tests - QCG', { timeout: FRONT_END_TIMEOUT + BACK_END_TIMEOUT }, asyn async (testParent) => await qcDrawingOptionsTests(url, page, FRONT_END_PER_TEST_TIMEOUT, testParent), ); + test( + 'should successfully import and run tests for profile in header', + { timeout: INITIAL_PAGE_SETUP_TIMEOUT }, + async (testParent) => await profileHeaderTests(url, page, FRONT_END_PER_TEST_TIMEOUT, testParent), + ); + test( 'should successfully run layoutList page tests', { timeout: LAYOUT_LIST_PAGE_TIMEOUT }, From 22d4ca0be4059dff5b1a7c9efb83088354452738 Mon Sep 17 00:00:00 2001 From: hehoon <100522372+hehoon@users.noreply.github.com> Date: Mon, 5 Jan 2026 09:45:07 +0100 Subject: [PATCH 07/19] Notify user via native browser notification when a new run has started --- QualityControl/lib/QCModel.js | 5 +- QualityControl/lib/api.js | 2 +- QualityControl/lib/services/RunModeService.js | 14 ++- QualityControl/public/Model.js | 4 + .../public/common/enums/storageKeys.enum.js | 1 + QualityControl/public/common/header.js | 42 ++++++- .../model/NotificationRunStartModel.js | 104 ++++++++++++++++++ .../test/lib/services/RunModeService.test.js | 7 +- 8 files changed, 171 insertions(+), 8 deletions(-) create mode 100644 QualityControl/public/common/notifications/model/NotificationRunStartModel.js diff --git a/QualityControl/lib/QCModel.js b/QualityControl/lib/QCModel.js index aa5364af6..172be0002 100644 --- a/QualityControl/lib/QCModel.js +++ b/QualityControl/lib/QCModel.js @@ -53,10 +53,11 @@ const LOG_FACILITY = `${process.env.npm_config_log_label ?? 'qcg'}/model-setup`; /** * Model initialization for the QCG application + * @param {WebSocket} ws - web-ui websocket server implementation * @param {EventEmitter} eventEmitter - Event emitter instance for inter-service communication * @returns {Promise} Multiple services and controllers that are to be used by the QCG application */ -export const setupQcModel = async (eventEmitter) => { +export const setupQcModel = async (ws, eventEmitter) => { const logger = LogManager.getLogger(LOG_FACILITY); const __filename = fileURLToPath(import.meta.url); @@ -117,7 +118,7 @@ export const setupQcModel = async (eventEmitter) => { const bookkeepingService = new BookkeepingService(config.bookkeeping); const filterService = new FilterService(bookkeepingService, config); - const runModeService = new RunModeService(config.bookkeeping, bookkeepingService, ccdbService, eventEmitter); + const runModeService = new RunModeService(config.bookkeeping, bookkeepingService, ccdbService, eventEmitter, ws); const objectController = new ObjectController(qcObjectService, runModeService, qcdbDownloadService); const filterController = new FilterController(filterService, runModeService); diff --git a/QualityControl/lib/api.js b/QualityControl/lib/api.js index 3c02671cd..410c1a384 100644 --- a/QualityControl/lib/api.js +++ b/QualityControl/lib/api.js @@ -54,7 +54,7 @@ export const setup = async (http, ws, eventEmitter) => { objectGetByIdValidation, objectsGetValidation, objectGetContentsValidation, - } = await setupQcModel(eventEmitter); + } = await setupQcModel(ws, eventEmitter); statusService.ws = ws; http.get('/object/:id', objectGetByIdValidation, objectController.getObjectByIdHandler.bind(objectController)); diff --git a/QualityControl/lib/services/RunModeService.js b/QualityControl/lib/services/RunModeService.js index 4c773410a..5059ef788 100644 --- a/QualityControl/lib/services/RunModeService.js +++ b/QualityControl/lib/services/RunModeService.js @@ -12,7 +12,7 @@ * or submit itself to any jurisdiction. */ -import { LogManager } from '@aliceo2/web-ui'; +import { LogManager, WebSocketMessage } from '@aliceo2/web-ui'; import { EmitterKeys } from '../../common/library/enums/emitterKeys.enum.js'; import { Transition } from '../../common/library/enums/transition.enum.js'; import { RunStatus } from '../../common/library/runStatus.enum.js'; @@ -29,16 +29,19 @@ export class RunModeService { * @param {BookkeepingService} bookkeepingService - Used to check the status of a run. * @param {CcdbService} dataService - Used to fetch data from the CCDB. * @param {EventEmitter} eventEmitter - Event emitter to be used to emit events when new data is available + * @param {WebSocket} ws - web-ui websocket server implementation */ constructor( config, bookkeepingService, dataService, eventEmitter, + ws, ) { this._bookkeepingService = bookkeepingService; this._dataService = dataService; this._eventEmitter = eventEmitter; + this._ws = ws; this._ongoingRuns = new Map(); this._lastRunsRefresh = 0; @@ -134,6 +137,15 @@ export class RunModeService { } else if (transition === Transition.STOP_ACTIVITY) { this._ongoingRuns.delete(runNumber); } + + const wsMessage = new WebSocketMessage(); + wsMessage.command = EmitterKeys.RUN_TRACK; + wsMessage.payload = { + runNumber, + transition, + }; + + this._ws.broadcast(wsMessage); } /** diff --git a/QualityControl/public/Model.js b/QualityControl/public/Model.js index d4022f362..b451cc747 100644 --- a/QualityControl/public/Model.js +++ b/QualityControl/public/Model.js @@ -29,6 +29,7 @@ import AboutViewModel from './pages/aboutView/AboutViewModel.js'; import LayoutListModel from './pages/layoutListView/model/LayoutListModel.js'; import { RequestFields } from './common/RequestFields.enum.js'; import FilterModel from './common/filters/model/FilterModel.js'; +import NotificationRunStartModel from './common/notifications/model/NotificationRunStartModel.js'; /** * Represents the application's state and actions as a class @@ -85,6 +86,9 @@ export default class Model extends Observable { this.ws.addListener('authed', this.handleWSAuthed.bind(this)); this.ws.addListener('close', this.handleWSClose.bind(this)); + this.notificationRunStartModel = new NotificationRunStartModel(this); + this.notificationRunStartModel.bubbleTo(this); + this.initModel(); } diff --git a/QualityControl/public/common/enums/storageKeys.enum.js b/QualityControl/public/common/enums/storageKeys.enum.js index 54953dd0f..8f96f7af4 100644 --- a/QualityControl/public/common/enums/storageKeys.enum.js +++ b/QualityControl/public/common/enums/storageKeys.enum.js @@ -20,4 +20,5 @@ export const StorageKeysEnum = Object.freeze({ OBJECT_VIEW_LEFT_PANEL_WIDTH: 'object-view-left-panel-width', OBJECT_VIEW_INFO_VISIBILITY_SETTING: 'object-view-info-visibility-setting', + NOTIFICATION_START_RUN_SETTING: 'notification-start-run-setting', }); diff --git a/QualityControl/public/common/header.js b/QualityControl/public/common/header.js index d965a4e59..7dd2ac27b 100644 --- a/QualityControl/public/common/header.js +++ b/QualityControl/public/common/header.js @@ -12,7 +12,8 @@ * or submit itself to any jurisdiction. */ -import { h, iconPerson } from '/js/src/index.js'; +import { h, iconPerson, getBrowserNotificationPermission, + requestBrowserNotificationPermissions } from '/js/src/index.js'; import { spinner } from './spinner.js'; import layoutViewHeader from '../layout/view/header.js'; @@ -100,8 +101,12 @@ const commonHeader = (model) => h('.flex-row.items-center.w-25', [ * @param {Model} model - root model of the application * @returns {vnode} - virtual node element */ -const loginButton = (model) => - h('.dropdown', { +const loginButton = (model) => { + const browserNotificationPermission = getBrowserNotificationPermission(); + const notificationsAvailable = browserNotificationPermission && browserNotificationPermission !== 'denied'; + const runStartNotificationEnabled = model.notificationRunStartModel.getBrowserNotificationSetting(); + + return h('.dropdown', { title: 'Login', class: model.accountMenuEnabled ? 'dropdown-open' : '', }, [ h('button.btn', { onclick: () => model.toggleAccountMenu() }, iconPerson()), @@ -110,5 +115,36 @@ const loginButton = (model) => model.session.personid === 0 // Anonymous user has id 0 ? h('p.m3.gray-darker', 'This instance of the application does not require authentication.') : h('a.menu-item', { onclick: () => alert('Not implemented') }, 'Logout'), + h( + 'label.flex-row.g1.items-center.form-check-label', + { + style: `cursor: ${notificationsAvailable ? 'pointer' : 'not-allowed'};`, + }, + [ + h( + '.switch', + [ + h('input', { + onchange: async (event) => { + let permission = false; + if (event.target.checked) { + permission = await requestBrowserNotificationPermissions() === 'granted'; + } + model.notificationRunStartModel.setBrowserNotificationSetting(permission); + }, + type: 'checkbox', + checked: runStartNotificationEnabled, + }), + h(`span.slider.round.bg-${ + runStartNotificationEnabled ? 'primary' : 'gray' + }`, { + style: `cursor: ${notificationsAvailable ? 'pointer' : 'not-allowed'};`, + }), + ], + ), + 'Notify on run start', + ], + ), ]), ]); +}; diff --git a/QualityControl/public/common/notifications/model/NotificationRunStartModel.js b/QualityControl/public/common/notifications/model/NotificationRunStartModel.js new file mode 100644 index 000000000..64f0d6646 --- /dev/null +++ b/QualityControl/public/common/notifications/model/NotificationRunStartModel.js @@ -0,0 +1,104 @@ +/** + * @license + * Copyright 2019-2020 CERN and copyright holders of ALICE O2. + * See http://alice-o2.web.cern.ch/copyright for details of the copyright holders. + * All rights not expressly granted are reserved. + * + * This software is distributed under the terms of the GNU General Public + * License v3 (GPL Version 3), copied verbatim in the file "COPYING". + * + * In applying this license CERN does not waive the privileges and immunities + * granted to it by virtue of its status as an Intergovernmental Organization + * or submit itself to any jurisdiction. + */ + +import { Observable, BrowserStorage, showNativeBrowserNotification } from '/js/src/index.js'; +import { EmitterKeys } from '../../../../library/enums/emitterKeys.enum.js'; +import { StorageKeysEnum } from '../../enums/storageKeys.enum.js'; +import { Transition } from '../../../../library/enums/transition.enum.js'; + +/** + * Model responsible for handling browser notifications when a new run starts. + */ +export default class NotificationRunStartModel extends Observable { + /** + * Initialize with empty values + * @param {Model} model - root model of the application + */ + constructor(model) { + super(); + + this.model = model; + this._browserNotificationStorage = new BrowserStorage(StorageKeysEnum.NOTIFICATION_START_RUN_SETTING); + + this.model.ws.addListener('command', (message) => { + if (message.command === EmitterKeys.RUN_TRACK) { + this._handleWSRunTrack.bind(this, message.payload); + } + }); + } + + /** + * Returns whether browser notifications for run start events + * are enabled for the current user. + * @returns {boolean} `true` if notifications are enabled, `false` otherwise. + */ + getBrowserNotificationSetting() { + try { + return this._browserNotificationStorage.getLocalItem(this.model.session.personid.toString()) ?? false; + } catch { + this._browserNotificationStorage.removeLocalItem(this.model.session.personid.toString()); + return false; + } + } + + /** + * Persists the browser notification preference for the current user + * and notifies all observers. + * @param {boolean} enabled - Whether notifications should be enabled. + * @returns {undefined} + */ + setBrowserNotificationSetting(enabled) { + this._browserNotificationStorage.setLocalItem(this.model.session.personid.toString(), enabled); + this.notify(); + } + + /** + * Handles {@link EmitterKeys.RUN_TRACK} WebSocket events. + * A native browser notification is displayed only when: + * - The transition is {@link Transition.START_ACTIVITY} + * - The user has enabled notifications + * @param {object} payload - WebSocket payload. + * @param {number} payload.runNumber - Run number that started. + * @param {Transition} payload.transition - Transition type. + * @returns {undefined} + */ + async _handleWSRunTrack({ runNumber, transition }) { + if (transition !== Transition.START_ACTIVITY) { + return; + } + + if (!this.getBrowserNotificationSetting()) { + return; + } + + showNativeBrowserNotification({ + title: `RUN ${runNumber ?? 'unknown'} has started`, + onclick: () => { + const searchParams = new URLSearchParams(); + + const { params } = this.model.router; + params.RunNumber = runNumber; + + Object.entries(params).forEach(([key, value]) => { + searchParams.append(key, String(value)); + }); + + const query = searchParams.toString(); + if (query) { + this.model.router.go(query); + } + }, + }); + } +} diff --git a/QualityControl/test/lib/services/RunModeService.test.js b/QualityControl/test/lib/services/RunModeService.test.js index 5802005a4..89b859ade 100644 --- a/QualityControl/test/lib/services/RunModeService.test.js +++ b/QualityControl/test/lib/services/RunModeService.test.js @@ -28,6 +28,7 @@ export const runModeServiceTestSuite = async () => { let bookkeepingService = undefined; let dataService = undefined; const eventEmitter = new EventEmitter(); + let ws = undefined; beforeEach(() => { bookkeepingService = { @@ -38,8 +39,12 @@ export const runModeServiceTestSuite = async () => { getObjectsLatestVersionList: sinon.stub(), }; + ws = { + broadcast: sinon.stub(), + }; + const config = { refreshInterval: 60000 }; - runModeService = new RunModeService(config, bookkeepingService, dataService, eventEmitter); + runModeService = new RunModeService(config, bookkeepingService, dataService, eventEmitter, ws); }); suite('retrievePathsAndSetRunStatus', () => { test('should retrieve paths and cache them if run is ongoing', async () => { From 669f350448f7aa5a29fa241398729fa3a621ad9c Mon Sep 17 00:00:00 2001 From: hehoon <100522372+hehoon@users.noreply.github.com> Date: Mon, 5 Jan 2026 12:42:55 +0100 Subject: [PATCH 08/19] Fix failing tests --- .../public/common/filters/runMode/runModeCheckbox.js | 1 + QualityControl/test/public/features/runMode.test.js | 8 ++++---- 2 files changed, 5 insertions(+), 4 deletions(-) diff --git a/QualityControl/public/common/filters/runMode/runModeCheckbox.js b/QualityControl/public/common/filters/runMode/runModeCheckbox.js index a4cdb48da..ac74d3556 100644 --- a/QualityControl/public/common/filters/runMode/runModeCheckbox.js +++ b/QualityControl/public/common/filters/runMode/runModeCheckbox.js @@ -34,6 +34,7 @@ export const runModeCheckbox = (filterModel, viewModel) => { 'label.flex-row.g1.items-center.form-check-label', { style: `cursor:${isAvailable ? 'pointer' : 'not-allowed'}`, + id: 'run-mode-switch', }, [ h( diff --git a/QualityControl/test/public/features/runMode.test.js b/QualityControl/test/public/features/runMode.test.js index 11443a65f..b01a23a34 100644 --- a/QualityControl/test/public/features/runMode.test.js +++ b/QualityControl/test/public/features/runMode.test.js @@ -47,14 +47,14 @@ export const runModeTests = async (url, page, timeout = 5000, testParent) => { await page.evaluate(() => { window.model.filterModel.ONGOING_RUN_INTERVAL_MS = 12000000; }); - await page.locator('.form-check-label > .switch'); + await page.locator('#run-mode-switch > .switch'); const runsModeTitle = await page.evaluate(() => - document.querySelector('.form-check-label').textContent); + document.querySelector('#run-mode-switch').textContent); strictEqual(runsModeTitle, 'Run mode', 'The text displayed is not `Runs mode`'); }); await testParent.test('should activate run mode', { timeout }, async () => { - await page.locator('.form-check-label > .switch').click(); + await page.locator('#run-mode-switch > .switch').click(); await delay(500); expectCountRunStatusCalls ++; @@ -142,7 +142,7 @@ export const runModeTests = async (url, page, timeout = 5000, testParent) => { ok(isRunModeActive, 'Run mode should be active before disabling'); // Click the run mode checkbox to disable it - await page.locator('.form-check-label > .switch').click(); + await page.locator('#run-mode-switch > .switch').click(); await delay(100); // Verify run mode is now deactivated From 05d4d6aec35ba3b739329f54e864982a210b8226 Mon Sep 17 00:00:00 2001 From: hehoon <100522372+hehoon@users.noreply.github.com> Date: Mon, 5 Jan 2026 13:15:37 +0100 Subject: [PATCH 09/19] Add `getPageTargetModel` to `FilterModel` and rewrite `filterSpecific` in `header.js` --- .../public/common/filters/model/FilterModel.js | 15 +++++++++++++++ QualityControl/public/common/header.js | 15 +++++++++------ 2 files changed, 24 insertions(+), 6 deletions(-) diff --git a/QualityControl/public/common/filters/model/FilterModel.js b/QualityControl/public/common/filters/model/FilterModel.js index d2f209732..2faf3e46e 100644 --- a/QualityControl/public/common/filters/model/FilterModel.js +++ b/QualityControl/public/common/filters/model/FilterModel.js @@ -440,4 +440,19 @@ export default class FilterModel extends Observable { } return { refreshNeeded: true, data: null }; } + + /** + * Returns the target model based on the current page + * @returns {object} the specific object/view model for the page + */ + getPageTargetModel() { + const { page, layout, object, objectViewModel } = this.model; + + switch (page) { + case 'layoutShow': return layout; + case 'objectTree': return object; + case 'objectView': return objectViewModel; + default: return null; + } + } } diff --git a/QualityControl/public/common/header.js b/QualityControl/public/common/header.js index 7dd2ac27b..a42e85b6c 100644 --- a/QualityControl/public/common/header.js +++ b/QualityControl/public/common/header.js @@ -69,14 +69,17 @@ const headerSpecific = (model) => { * @returns {vnode} - virtual node element */ const filterSpecific = (model) => { - const { page, filterModel, layout, object, objectViewModel } = model; + const { page, filterModel, layout } = model; + if (page === 'layoutList' && layout.editEnabled) { + return null; + } - switch (page) { - case 'layoutShow': return !layout.editEnabled && filtersPanel(filterModel, layout); - case 'objectTree': return filtersPanel(filterModel, object); - case 'objectView': return filtersPanel(filterModel, objectViewModel); - default: return null; + const viewModel = filterModel.getPageTargetModel(); + if (!viewModel) { + return null; } + + return filtersPanel(filterModel, viewModel); }; /** From f0ff74b765f94a79d37ee5d4d3fa0b83314df1c7 Mon Sep 17 00:00:00 2001 From: hehoon <100522372+hehoon@users.noreply.github.com> Date: Mon, 5 Jan 2026 13:16:14 +0100 Subject: [PATCH 10/19] on notification click, the runmode for that runnumber should be enabled --- .../model/NotificationRunStartModel.js | 20 ++++++++----------- 1 file changed, 8 insertions(+), 12 deletions(-) diff --git a/QualityControl/public/common/notifications/model/NotificationRunStartModel.js b/QualityControl/public/common/notifications/model/NotificationRunStartModel.js index 64f0d6646..f8652a953 100644 --- a/QualityControl/public/common/notifications/model/NotificationRunStartModel.js +++ b/QualityControl/public/common/notifications/model/NotificationRunStartModel.js @@ -85,19 +85,15 @@ export default class NotificationRunStartModel extends Observable { showNativeBrowserNotification({ title: `RUN ${runNumber ?? 'unknown'} has started`, onclick: () => { - const searchParams = new URLSearchParams(); - - const { params } = this.model.router; - params.RunNumber = runNumber; - - Object.entries(params).forEach(([key, value]) => { - searchParams.append(key, String(value)); - }); - - const query = searchParams.toString(); - if (query) { - this.model.router.go(query); + const { isRunModeActivated } = this.model.filterModel; + if (!isRunModeActivated) { + const viewModel = this.model.filterModel.getPageTargetModel(); + if (viewModel) { + this.model.filterModel.activateRunsMode(viewModel); + } } + + this.model.filterModel.setFilterValue('RunNumber', runNumber?.toString(), true); }, }); } From 34331ae5904101b0d316c739cb58fba23d45e15f Mon Sep 17 00:00:00 2001 From: hehoon <100522372+hehoon@users.noreply.github.com> Date: Mon, 5 Jan 2026 13:37:01 +0100 Subject: [PATCH 11/19] Backend tests --- .../test/lib/services/RunModeService.test.js | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/QualityControl/test/lib/services/RunModeService.test.js b/QualityControl/test/lib/services/RunModeService.test.js index 89b859ade..6b6e824c6 100644 --- a/QualityControl/test/lib/services/RunModeService.test.js +++ b/QualityControl/test/lib/services/RunModeService.test.js @@ -21,6 +21,7 @@ import { RunStatus } from '../../../common/library/runStatus.enum.js'; import { EmitterKeys } from '../../../common/library/enums/emitterKeys.enum.js'; import { Transition } from '../../../common/library/enums/transition.enum.js'; import { delayAndCheck } from '../../testUtils/delay.js'; +import { WebSocketMessage } from '@aliceo2/web-ui'; export const runModeServiceTestSuite = async () => { suite('RunModeService', () => { @@ -158,6 +159,23 @@ export const runModeServiceTestSuite = async () => { })); }); + test('should listen to events on RUN_TRACK and broadcast to websocket', async () => { + const runEvent = { runNumber: 1234, transition: Transition.START_ACTIVITY }; + runModeService._dataService.getObjectsLatestVersionList = sinon.stub().resolves([{ path: '/path/from/event' }]); + + eventEmitter.emit(EmitterKeys.RUN_TRACK, runEvent); + await delayAndCheck(() => runModeService._ongoingRuns.has(runEvent.runNumber), 500, 10); + + const wsMessage = new WebSocketMessage(); + wsMessage.command = EmitterKeys.RUN_TRACK; + wsMessage.payload = runEvent; + + ok( + ws.broadcast.calledOnceWith(wsMessage), + `Websocket broadcast should have message: ${JSON.stringify(wsMessage)}`, + ); + }); + test('should remove run from ongoing runs map on STOP_ACTIVITY event', async () => { const runEventStop = { runNumber: 5678, transition: Transition.STOP_ACTIVITY }; runModeService._ongoingRuns.set(runEventStop.runNumber, [{ path: '/some/path' }]); From db1b8f285822e7101eeb8a1eb8ce32b007365603 Mon Sep 17 00:00:00 2001 From: hehoon <100522372+hehoon@users.noreply.github.com> Date: Tue, 6 Jan 2026 13:57:59 +0100 Subject: [PATCH 12/19] Add tests --- .../public/components/profileHeader.test.js | 274 ++++++++++++++++++ QualityControl/test/test-index.js | 7 + 2 files changed, 281 insertions(+) create mode 100644 QualityControl/test/public/components/profileHeader.test.js diff --git a/QualityControl/test/public/components/profileHeader.test.js b/QualityControl/test/public/components/profileHeader.test.js new file mode 100644 index 000000000..bd83608f2 --- /dev/null +++ b/QualityControl/test/public/components/profileHeader.test.js @@ -0,0 +1,274 @@ +/** + * @license + * Copyright 2019-2020 CERN and copyright holders of ALICE O2. + * See http://alice-o2.web.cern.ch/copyright for details of the copyright holders. + * All rights not expressly granted are reserved. + * + * This software is distributed under the terms of the GNU General Public + * License v3 (GPL Version 3), copied verbatim in the file 'COPYING'. + * + * In applying this license CERN does not waive the privileges and immunities + * granted to it by virtue of its status as an Intergovernmental Organization + * or submit itself to any jurisdiction. + */ + +import { doesNotMatch, match, ok, strictEqual } from 'node:assert'; +import { delay } from '../../testUtils/delay.js'; +import { StorageKeysEnum } from '../../../public/common/enums/storageKeys.enum.js'; +import { getLocalStorageAsJson } from '../../testUtils/localStorage.js'; +import { Transition } from '../../../common/library/enums/transition.enum.js'; + +/** + * Performs a series of automated tests on the layoutList page using Puppeteer. + * @param {string} url - URL needed to open page for testing + * @param {object} page - Puppeteer page object + * @param {number} timeout - Timeout PER test; default 100 + * @param {object} testParent - Node.js test object which ensures subtests are being awaited + */ +export const profileHeaderTests = async (url, page, timeout = 1000, testParent) => { + await testParent.test('should load', { timeout }, async () => { + await page.goto(`${url}?page=layoutList`, { waitUntil: 'networkidle0' }); + const location = await page.evaluate(() => window.location); + + strictEqual(location.search, '?page=layoutList'); + }); + + await testParent.test('should have an account button', async () => { + const accountButtonExists = await page.evaluate(() => + document.querySelector('#qcg-header div[title="Login"] button.btn') !== null); + + ok(accountButtonExists); + }); + + await testParent.test('clicking the account button opens a dropdown', { timeout }, async () => { + const selector = '#qcg-header div[title="Login"]'; + const locator = '#qcg-header div[title="Login"] button.btn'; + let classNames = undefined; + + classNames = await page.evaluate((query) => document.querySelector(query).className, selector); + doesNotMatch(classNames, /\bdropdown-open\b/, 'Account dropdown should not be open before clicking'); + + await page.locator(locator).click(); + await delay(100); + classNames = await page.evaluate((query) => document.querySelector(query).className, selector); + match(classNames, /\bdropdown-open\b/, 'Account dropdown should be open after clicking'); + + await page.locator(locator).click(); + await delay(100); + classNames = await page.evaluate((query) => document.querySelector(query).className, selector); + doesNotMatch(classNames, /\bdropdown-open\b/, 'Account dropdown should be closed after clicking again'); + }); + + await testParent.test('toggling the "notify on run start" setting updates LocalStorage', { timeout }, async () => { + const locator = '#qcg-header div[title="Login"] .dropdown-menu .switch'; + const selector = `${locator} input[type="checkbox"]`; + const personId = await page.evaluate(() => window.model?.session?.personid?.toString()); + const localStorageKey = `${StorageKeysEnum.NOTIFICATION_START_RUN_SETTING}-${personId}`; + const context = page.browserContext(); + let switchValue = undefined; + let storageValue = undefined; + if (!personId) { + throw new Error('Could not resolve personId from the application model'); + } + + try { + // Grant notification permissions + await context.overridePermissions(url, ['notifications']); + + // Open the dropdown + await page.locator('#qcg-header div[title="Login"] button.btn').click(); + + switchValue = await page.evaluate((query) => document.querySelector(query).checked, selector); + storageValue = await getLocalStorageAsJson(page, localStorageKey); + strictEqual(switchValue, false, 'Setting "notify on run start" should be disabled'); + strictEqual(storageValue, null, 'Should not have a stored setting for "notify on run start"'); + + await page.locator(locator).click(); + await page.waitForFunction( + (query, expected) => document.querySelector(query).checked === expected, + {}, + selector, + true, + ); + switchValue = await page.evaluate((query) => document.querySelector(query).checked, selector); + storageValue = await getLocalStorageAsJson(page, localStorageKey); + strictEqual(switchValue, true, 'Setting "notify on run start" should be enabled'); + strictEqual(storageValue, true, 'Should have a stored value "true" for setting "notify on run start"'); + + await page.locator(locator).click(); + await page.waitForFunction( + (query, expected) => document.querySelector(query).checked === expected, + {}, + selector, + false, + ); + switchValue = await page.evaluate((query) => document.querySelector(query).checked, selector); + storageValue = await getLocalStorageAsJson(page, localStorageKey); + strictEqual(switchValue, false, 'Setting "notify on run start" should be disabled'); + strictEqual(storageValue, false, 'Should have a stored value "false" for setting "notify on run start"'); + } finally { + context.clearPermissionOverrides(); + } + }); + + await testParent.test('setting "notify on run start" should be loaded from LocalStorage', { timeout }, async () => { + const switchButtonLocator = '#qcg-header div[title="Login"] .dropdown-menu .switch'; + const checkboxSelector = `${switchButtonLocator} input[type="checkbox"]`; + const accountButtonLocator = '#qcg-header div[title="Login"] button.btn'; + // Resolve LocalStorage key dynamically based on personId + const personId = await page.evaluate(() => window.model?.session?.personid?.toString()); + const localStorageKey = `${StorageKeysEnum.NOTIFICATION_START_RUN_SETTING}-${personId}`; + const context = page.browserContext(); + if (!personId) { + throw new Error('Could not resolve personId from the application model'); + } + + /** + * @typedef {object} SwitchState + * @property {boolean} switchBefore - Switch checked state before page reload + * @property {JSON} storageBefore - LocalStorage value before page reload + * @property {boolean} switchAfter - Switch checked state after page reload + * @property {JSON} storageAfter - LocalStorage value after page reload + */ + + /** + * Reads the switch state and LocalStorage before and after a reload. + * @returns {Promise} Object containing switch and LocalStorage values before and after page reload. + */ + const readSwitchStateBeforeAndAfterReload = async () => { + // Read before reload + const switchBefore = await page.evaluate((query) => document.querySelector(query).checked, checkboxSelector); + const storageBefore = await getLocalStorageAsJson(page, localStorageKey); + + // Reload the page + await page.reload({ waitUntil: 'networkidle0' }); + + // Open the dropdown + await page.locator(accountButtonLocator).click(); + await page.waitForFunction(() => + /\bdropdown-open\b/.test(document.querySelector('#qcg-header div[title="Login"]').className)); + + // Read after reload + const switchAfter = await page.evaluate((query) => document.querySelector(query).checked, checkboxSelector); + const storageAfter = await getLocalStorageAsJson(page, localStorageKey); + + return { switchBefore, storageBefore, switchAfter, storageAfter }; + }; + + try { + // Grant notification permissions + await context.overridePermissions(url, ['notifications']); + + // Verify default disabled state persists across reload + const disabledState = await readSwitchStateBeforeAndAfterReload(); + strictEqual( + disabledState.switchBefore, + disabledState.switchAfter, + 'Switch state should persist when disabled', + ); + strictEqual( + disabledState.storageBefore, + disabledState.storageAfter, + 'LocalStorage value should persist when disabled', + ); + + // Enable the setting + await page.locator(switchButtonLocator).click(); + await page.waitForFunction( + (query) => document.querySelector(query).checked === true, + {}, + checkboxSelector, + ); + + // Verify enabled state persists across reload + const enabledState = await readSwitchStateBeforeAndAfterReload(); + strictEqual( + enabledState.switchBefore, + enabledState.switchAfter, + 'Switch state should persist when enabled', + ); + strictEqual( + enabledState.storageBefore, + enabledState.storageAfter, + 'LocalStorage value should persist when enabled', + ); + } finally { + context.clearPermissionOverrides(); + } + }); + + await testParent.test('should enable RunMode when native browser notification is clicked', { timeout }, async () => { + await page.goto(`${url}?page=objectTree`, { waitUntil: 'networkidle0' }); + const context = page.browserContext(); + + try { + // Grant notification permissions + await context.overridePermissions(url, ['notifications']); + + // Inject a runtime Notification mock + await page.evaluate(() => { + window.__originalNotification = window.Notification; + window.__lastNotification = null; + + class MockNotification { + /** + * `MockNotification` constructor + * @param {string} title = Notification title + * @param {object} options - Notification options + */ + constructor(title, options) { + this.title = title; + this.options = options; + + // Save instance so test can access it + window.__lastNotification = this; + } + + close() {} + + static permission = 'granted'; + + static requestPermission() { + return Promise.resolve('granted'); + } + } + + window.Notification = MockNotification; + }); + + // Trigger native browser notification by simulating websocket message + await page.evaluate( + (wsMessage) => window.model.notificationRunStartModel._handleWSRunTrack(wsMessage), + { runNumber: 1234, transition: Transition.START_ACTIVITY }, + ); + // `window.__lastNotification` is set by the mocked `Notification` + await page.waitForFunction(() => window.__lastNotification !== null); + + let selectedRun = await page.evaluate(() => document.querySelector('select#ongoingRunsFilter')?.value); + strictEqual(selectedRun, undefined, 'Should not have a run selected in RunMode before clicking the notification'); + + await page.evaluate(() => { + if (typeof window.__lastNotification.onclick === 'function') { + window.__lastNotification.onclick(); + } + }); + await page.waitForFunction(() => + document.querySelector('#run-mode-switch .switch input[type="checkbox"]')?.checked === true); + + selectedRun = await page.evaluate(() => document.querySelector('select#ongoingRunsFilter')?.value); + strictEqual( + selectedRun, + '1234', + 'Should have the newly started run selected in RunMode after clicking the notification', + ); + } finally { + context.clearPermissionOverrides(); + // Remove mocked Notification + await page.evaluate(() => { + if (window.__originalNotification) { + window.Notification = window.__originalNotification; + } + }); + } + }); +}; diff --git a/QualityControl/test/test-index.js b/QualityControl/test/test-index.js index 40412d607..90bd8f114 100644 --- a/QualityControl/test/test-index.js +++ b/QualityControl/test/test-index.js @@ -29,6 +29,7 @@ import { */ import { initialPageSetupTests } from './public/initialPageSetup.test.js'; +import { profileHeaderTests } from './public/components/profileHeader.test.js'; import { qcDrawingOptionsTests } from './public/components/qcDrawingOptions.test.js'; import { layoutListPageTests } from './public/pages/layout-list.test.js'; import { objectTreePageTests } from './public/pages/object-tree.test.js'; @@ -146,6 +147,12 @@ suite('All Tests - QCG', { timeout: FRONT_END_TIMEOUT + BACK_END_TIMEOUT }, asyn async (testParent) => await qcDrawingOptionsTests(url, page, FRONT_END_PER_TEST_TIMEOUT, testParent), ); + test( + 'should successfully import and run tests for profile in header', + { timeout: INITIAL_PAGE_SETUP_TIMEOUT }, + async (testParent) => await profileHeaderTests(url, page, FRONT_END_PER_TEST_TIMEOUT, testParent), + ); + test( 'should successfully run layoutList page tests', { timeout: LAYOUT_LIST_PAGE_TIMEOUT }, From af5709930472f7cb8a7dbffc4e44d12e780b2136 Mon Sep 17 00:00:00 2001 From: hehoon <100522372+hehoon@users.noreply.github.com> Date: Wed, 7 Jan 2026 15:33:35 +0100 Subject: [PATCH 13/19] Use the `BrowserNotificationPermission` enum in `header.js` --- QualityControl/public/common/header.js | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/QualityControl/public/common/header.js b/QualityControl/public/common/header.js index a42e85b6c..644aafc98 100644 --- a/QualityControl/public/common/header.js +++ b/QualityControl/public/common/header.js @@ -13,7 +13,7 @@ */ import { h, iconPerson, getBrowserNotificationPermission, - requestBrowserNotificationPermissions } from '/js/src/index.js'; + requestBrowserNotificationPermissions, BrowserNotificationPermission } from '/js/src/index.js'; import { spinner } from './spinner.js'; import layoutViewHeader from '../layout/view/header.js'; @@ -106,7 +106,8 @@ const commonHeader = (model) => h('.flex-row.items-center.w-25', [ */ const loginButton = (model) => { const browserNotificationPermission = getBrowserNotificationPermission(); - const notificationsAvailable = browserNotificationPermission && browserNotificationPermission !== 'denied'; + const notificationsAvailable = browserNotificationPermission + && browserNotificationPermission !== BrowserNotificationPermission.DENIED; const runStartNotificationEnabled = model.notificationRunStartModel.getBrowserNotificationSetting(); return h('.dropdown', { @@ -129,11 +130,12 @@ const loginButton = (model) => { [ h('input', { onchange: async (event) => { - let permission = false; + let permissionGranted = false; if (event.target.checked) { - permission = await requestBrowserNotificationPermissions() === 'granted'; + const permission = await requestBrowserNotificationPermissions(); + permissionGranted = permission === BrowserNotificationPermission.GRANTED; } - model.notificationRunStartModel.setBrowserNotificationSetting(permission); + model.notificationRunStartModel.setBrowserNotificationSetting(permissionGranted); }, type: 'checkbox', checked: runStartNotificationEnabled, From 3ba33629de6b0eea2b88f3f68a28998cf09b891d Mon Sep 17 00:00:00 2001 From: hehoon <100522372+hehoon@users.noreply.github.com> Date: Wed, 7 Jan 2026 17:18:14 +0100 Subject: [PATCH 14/19] Do not show filters when editing the layout on the layout show page --- QualityControl/public/common/header.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/QualityControl/public/common/header.js b/QualityControl/public/common/header.js index 644aafc98..1667a4148 100644 --- a/QualityControl/public/common/header.js +++ b/QualityControl/public/common/header.js @@ -70,7 +70,7 @@ const headerSpecific = (model) => { */ const filterSpecific = (model) => { const { page, filterModel, layout } = model; - if (page === 'layoutList' && layout.editEnabled) { + if (page === 'layoutShow' && layout.editEnabled) { return null; } From 3f6a5f92fa90911b0176d825f7a6cb80c8f8e726 Mon Sep 17 00:00:00 2001 From: hehoon <100522372+hehoon@users.noreply.github.com> Date: Thu, 8 Jan 2026 12:20:15 +0100 Subject: [PATCH 15/19] Update test queries --- .../public/components/profileHeader.test.js | 19 +++++++++---------- QualityControl/test/test-index.js | 2 ++ 2 files changed, 11 insertions(+), 10 deletions(-) diff --git a/QualityControl/test/public/components/profileHeader.test.js b/QualityControl/test/public/components/profileHeader.test.js index bd83608f2..8c05e9524 100644 --- a/QualityControl/test/public/components/profileHeader.test.js +++ b/QualityControl/test/public/components/profileHeader.test.js @@ -35,17 +35,16 @@ export const profileHeaderTests = async (url, page, timeout = 1000, testParent) await testParent.test('should have an account button', async () => { const accountButtonExists = await page.evaluate(() => - document.querySelector('#qcg-header div[title="Login"] button.btn') !== null); + document.querySelector('header div[title="Login"] button.btn') !== null); ok(accountButtonExists); }); await testParent.test('clicking the account button opens a dropdown', { timeout }, async () => { - const selector = '#qcg-header div[title="Login"]'; - const locator = '#qcg-header div[title="Login"] button.btn'; - let classNames = undefined; + const selector = 'header div[title="Login"]'; + const locator = 'header div[title="Login"] button.btn'; - classNames = await page.evaluate((query) => document.querySelector(query).className, selector); + let classNames = await page.evaluate((query) => document.querySelector(query).className, selector); doesNotMatch(classNames, /\bdropdown-open\b/, 'Account dropdown should not be open before clicking'); await page.locator(locator).click(); @@ -60,7 +59,7 @@ export const profileHeaderTests = async (url, page, timeout = 1000, testParent) }); await testParent.test('toggling the "notify on run start" setting updates LocalStorage', { timeout }, async () => { - const locator = '#qcg-header div[title="Login"] .dropdown-menu .switch'; + const locator = 'header div[title="Login"] .dropdown-menu .switch'; const selector = `${locator} input[type="checkbox"]`; const personId = await page.evaluate(() => window.model?.session?.personid?.toString()); const localStorageKey = `${StorageKeysEnum.NOTIFICATION_START_RUN_SETTING}-${personId}`; @@ -76,7 +75,7 @@ export const profileHeaderTests = async (url, page, timeout = 1000, testParent) await context.overridePermissions(url, ['notifications']); // Open the dropdown - await page.locator('#qcg-header div[title="Login"] button.btn').click(); + await page.locator('header div[title="Login"] button.btn').click(); switchValue = await page.evaluate((query) => document.querySelector(query).checked, selector); storageValue = await getLocalStorageAsJson(page, localStorageKey); @@ -112,9 +111,9 @@ export const profileHeaderTests = async (url, page, timeout = 1000, testParent) }); await testParent.test('setting "notify on run start" should be loaded from LocalStorage', { timeout }, async () => { - const switchButtonLocator = '#qcg-header div[title="Login"] .dropdown-menu .switch'; + const switchButtonLocator = 'header div[title="Login"] .dropdown-menu .switch'; const checkboxSelector = `${switchButtonLocator} input[type="checkbox"]`; - const accountButtonLocator = '#qcg-header div[title="Login"] button.btn'; + const accountButtonLocator = 'header div[title="Login"] button.btn'; // Resolve LocalStorage key dynamically based on personId const personId = await page.evaluate(() => window.model?.session?.personid?.toString()); const localStorageKey = `${StorageKeysEnum.NOTIFICATION_START_RUN_SETTING}-${personId}`; @@ -146,7 +145,7 @@ export const profileHeaderTests = async (url, page, timeout = 1000, testParent) // Open the dropdown await page.locator(accountButtonLocator).click(); await page.waitForFunction(() => - /\bdropdown-open\b/.test(document.querySelector('#qcg-header div[title="Login"]').className)); + /\bdropdown-open\b/.test(document.querySelector('header div[title="Login"]').className)); // Read after reload const switchAfter = await page.evaluate((query) => document.querySelector(query).checked, checkboxSelector); diff --git a/QualityControl/test/test-index.js b/QualityControl/test/test-index.js index e78b29d5e..ca24cd33f 100644 --- a/QualityControl/test/test-index.js +++ b/QualityControl/test/test-index.js @@ -104,6 +104,7 @@ const FRONT_END_PER_TEST_TIMEOUT = 5000; // each front-end test is allowed this // remaining tests are based on the number of individual tests in each suite const INITIAL_PAGE_SETUP_TIMEOUT = FRONT_END_PER_TEST_TIMEOUT * 5; +const PAGE_HEADER_COMPONENT_TIMEOUT = FRONT_END_PER_TEST_TIMEOUT * 6; const QC_DRAWING_OPTIONS_TIMEOUT = FRONT_END_PER_TEST_TIMEOUT * 13; const LAYOUT_LIST_PAGE_TIMEOUT = FRONT_END_PER_TEST_TIMEOUT * 17; const OBJECT_TREE_PAGE_TIMEOUT = FRONT_END_PER_TEST_TIMEOUT * 20; @@ -115,6 +116,7 @@ const FILTER_TEST_TIMEOUT = FRONT_END_PER_TEST_TIMEOUT * 26; const RUN_MODE_TEST_TIMEOUT = FRONT_END_PER_TEST_TIMEOUT * 10; const FRONT_END_TIMEOUT = INITIAL_PAGE_SETUP_TIMEOUT + + PAGE_HEADER_COMPONENT_TIMEOUT + QC_DRAWING_OPTIONS_TIMEOUT + LAYOUT_LIST_PAGE_TIMEOUT + OBJECT_TREE_PAGE_TIMEOUT From 286b31c27c465d36e6bce8eb0c4a9a0c85ea312e Mon Sep 17 00:00:00 2001 From: hehoon <100522372+hehoon@users.noreply.github.com> Date: Fri, 9 Jan 2026 22:33:04 +0100 Subject: [PATCH 16/19] Address PR feedback --- QualityControl/lib/services/RunModeService.js | 20 +++-- QualityControl/public/common/header.js | 79 ++++++++++--------- .../model/NotificationRunStartModel.js | 29 ++++--- 3 files changed, 65 insertions(+), 63 deletions(-) diff --git a/QualityControl/lib/services/RunModeService.js b/QualityControl/lib/services/RunModeService.js index 5059ef788..533847e34 100644 --- a/QualityControl/lib/services/RunModeService.js +++ b/QualityControl/lib/services/RunModeService.js @@ -36,12 +36,12 @@ export class RunModeService { bookkeepingService, dataService, eventEmitter, - ws, + webSocketService, ) { this._bookkeepingService = bookkeepingService; this._dataService = dataService; this._eventEmitter = eventEmitter; - this._ws = ws; + this._webSocketService = webSocketService; this._ongoingRuns = new Map(); this._lastRunsRefresh = 0; @@ -134,18 +134,16 @@ export class RunModeService { this._logger.errorMessage(`Error fetching initial paths for run ${runNumber}: ${error.message || error}`); } this._ongoingRuns.set(runNumber, rawPaths); + + const wsMessage = new WebSocketMessage(); + wsMessage.command = `${EmitterKeys.RUN_TRACK}:${Transition.START_ACTIVITY}`; + wsMessage.payload = { + runNumber, + }; + this._webSocketService.broadcast(wsMessage); } else if (transition === Transition.STOP_ACTIVITY) { this._ongoingRuns.delete(runNumber); } - - const wsMessage = new WebSocketMessage(); - wsMessage.command = EmitterKeys.RUN_TRACK; - wsMessage.payload = { - runNumber, - transition, - }; - - this._ws.broadcast(wsMessage); } /** diff --git a/QualityControl/public/common/header.js b/QualityControl/public/common/header.js index eca59b5ff..5f4a1ae3e 100644 --- a/QualityControl/public/common/header.js +++ b/QualityControl/public/common/header.js @@ -12,7 +12,7 @@ * or submit itself to any jurisdiction. */ -import { h, iconPerson, getBrowserNotificationPermission, +import { h, iconPerson, getBrowserNotificationPermission, areBrowserNotificationsGranted, requestBrowserNotificationPermissions, BrowserNotificationPermission } from '/js/src/index.js'; import { spinner } from './spinner.js'; @@ -104,13 +104,8 @@ const commonHeader = (model) => h('.flex-row.items-center.w-25', [ * @param {Model} model - root model of the application * @returns {vnode} - virtual node element */ -const loginButton = (model) => { - const browserNotificationPermission = getBrowserNotificationPermission(); - const notificationsAvailable = browserNotificationPermission - && browserNotificationPermission !== BrowserNotificationPermission.DENIED; - const runStartNotificationEnabled = model.notificationRunStartModel.getBrowserNotificationSetting(); - - return h('.dropdown', { +const loginButton = (model) => + h('.dropdown', { title: 'Login', class: model.accountMenuEnabled ? 'dropdown-open' : '', }, [ h('button.btn', { onclick: () => model.toggleAccountMenu() }, iconPerson()), @@ -119,37 +114,43 @@ const loginButton = (model) => { model.session.personid === 0 // Anonymous user has id 0 ? h('p.m3.gray-darker', 'This instance of the application does not require authentication.') : h('a.menu-item', { onclick: () => alert('Not implemented') }, 'Logout'), - h( - 'label.flex-row.g1.items-center.form-check-label', - { - style: `cursor: ${notificationsAvailable ? 'pointer' : 'not-allowed'};`, - }, - [ - h( - '.switch', - [ - h('input', { - onchange: async (event) => { - let permissionGranted = false; - if (event.target.checked) { - const permission = await requestBrowserNotificationPermissions(); - permissionGranted = permission === BrowserNotificationPermission.GRANTED; - } - model.notificationRunStartModel.setBrowserNotificationSetting(permissionGranted); - }, - type: 'checkbox', - checked: runStartNotificationEnabled, - }), - h(`span.slider.round.bg-${ - runStartNotificationEnabled ? 'primary' : 'gray' - }`, { - style: `cursor: ${notificationsAvailable ? 'pointer' : 'not-allowed'};`, - }), - ], - ), - 'Notify on run start', - ], - ), + notifyOnRunStartSettingComponent(model.notificationRunStartModel), ]), ]); + +/** + * Builds the toggle and its functionality of the "notify on run start" setting + * @param {NotificationRunStartModel} notificationRunStartModel - the notification run start model + * @returns {vnode} - virtual node element + */ +const notifyOnRunStartSettingComponent = (notificationRunStartModel) => { + const browserNotificationPermission = getBrowserNotificationPermission(); + const notificationsAvailable = browserNotificationPermission + && browserNotificationPermission !== BrowserNotificationPermission.DENIED; + const runStartNotificationEnabled = notificationRunStartModel.getBrowserNotificationSetting(); + + return h( + 'label.flex-row.g1.items-center.form-check-label', + { style: `cursor: ${notificationsAvailable ? 'pointer' : 'not-allowed'};` }, + [ + h('.switch', [ + h('input', { + onchange: async (event) => { + let permissionGranted = false; + if (event.target.checked) { + await requestBrowserNotificationPermissions(); + permissionGranted = areBrowserNotificationsGranted(); + } + notificationRunStartModel.setBrowserNotificationSetting(permissionGranted); + }, + type: 'checkbox', + checked: runStartNotificationEnabled, + }), + h(`span.slider.round.bg-${runStartNotificationEnabled ? 'primary' : 'gray'}`, { + style: `cursor: ${notificationsAvailable ? 'pointer' : 'not-allowed'};`, + }), + ]), + 'Notify on run start', + ], + ); }; diff --git a/QualityControl/public/common/notifications/model/NotificationRunStartModel.js b/QualityControl/public/common/notifications/model/NotificationRunStartModel.js index f8652a953..310ff8006 100644 --- a/QualityControl/public/common/notifications/model/NotificationRunStartModel.js +++ b/QualityControl/public/common/notifications/model/NotificationRunStartModel.js @@ -16,6 +16,7 @@ import { Observable, BrowserStorage, showNativeBrowserNotification } from '/js/s import { EmitterKeys } from '../../../../library/enums/emitterKeys.enum.js'; import { StorageKeysEnum } from '../../enums/storageKeys.enum.js'; import { Transition } from '../../../../library/enums/transition.enum.js'; +import {areBrowserNotificationsGranted} from "@aliceo2/web-ui/Frontend/js/src/index.js"; /** * Model responsible for handling browser notifications when a new run starts. @@ -43,9 +44,12 @@ export default class NotificationRunStartModel extends Observable { * are enabled for the current user. * @returns {boolean} `true` if notifications are enabled, `false` otherwise. */ - getBrowserNotificationSetting() { + async getBrowserNotificationSetting() { try { - return this._browserNotificationStorage.getLocalItem(this.model.session.personid.toString()) ?? false; + if (this._browserNotificationStorage.getLocalItem(this.model.session.personid.toString())) { + return areBrowserNotificationsGranted(); + } + return false; } catch { this._browserNotificationStorage.removeLocalItem(this.model.session.personid.toString()); return false; @@ -70,14 +74,9 @@ export default class NotificationRunStartModel extends Observable { * - The user has enabled notifications * @param {object} payload - WebSocket payload. * @param {number} payload.runNumber - Run number that started. - * @param {Transition} payload.transition - Transition type. * @returns {undefined} */ - async _handleWSRunTrack({ runNumber, transition }) { - if (transition !== Transition.START_ACTIVITY) { - return; - } - + async _handleWSRunTrack({ runNumber }) { if (!this.getBrowserNotificationSetting()) { return; } @@ -85,15 +84,19 @@ export default class NotificationRunStartModel extends Observable { showNativeBrowserNotification({ title: `RUN ${runNumber ?? 'unknown'} has started`, onclick: () => { + // On notification click we always navigate to the `objectTree` page. + // Additionally, we view the run using the given `runNumber`. + this.model.router.go(`?page=objectTree&RunNumber=${runNumber}`); + + // If RunMode is not activated, we should enable it const { isRunModeActivated } = this.model.filterModel; if (!isRunModeActivated) { - const viewModel = this.model.filterModel.getPageTargetModel(); - if (viewModel) { - this.model.filterModel.activateRunsMode(viewModel); - } + this.model.filterModel.activateRunsMode(this.model.filterModel.getPageTargetModel()); } - this.model.filterModel.setFilterValue('RunNumber', runNumber?.toString(), true); + // We select the given `runNumber` in RunMode. + // We do not have to set the parameter in the URL, as this is already achieved on navigation. + this.model.filterModel.setFilterValue('RunNumber', runNumber?.toString()); }, }); } From 5d82e3debec94741b0b9ea1368b1130b5866c131 Mon Sep 17 00:00:00 2001 From: hehoon <100522372+hehoon@users.noreply.github.com> Date: Fri, 9 Jan 2026 22:40:27 +0100 Subject: [PATCH 17/19] Remove unnecessary `async` from `getBrowserNotificationSetting` --- .../common/notifications/model/NotificationRunStartModel.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/QualityControl/public/common/notifications/model/NotificationRunStartModel.js b/QualityControl/public/common/notifications/model/NotificationRunStartModel.js index 310ff8006..6f559e3ed 100644 --- a/QualityControl/public/common/notifications/model/NotificationRunStartModel.js +++ b/QualityControl/public/common/notifications/model/NotificationRunStartModel.js @@ -44,7 +44,7 @@ export default class NotificationRunStartModel extends Observable { * are enabled for the current user. * @returns {boolean} `true` if notifications are enabled, `false` otherwise. */ - async getBrowserNotificationSetting() { + getBrowserNotificationSetting() { try { if (this._browserNotificationStorage.getLocalItem(this.model.session.personid.toString())) { return areBrowserNotificationsGranted(); From d1f0aa2977ab0cb1af7767fab2c816ecdfc38278 Mon Sep 17 00:00:00 2001 From: hehoon <100522372+hehoon@users.noreply.github.com> Date: Fri, 9 Jan 2026 22:46:23 +0100 Subject: [PATCH 18/19] Fix eslint errors --- .../common/notifications/model/NotificationRunStartModel.js | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/QualityControl/public/common/notifications/model/NotificationRunStartModel.js b/QualityControl/public/common/notifications/model/NotificationRunStartModel.js index 6f559e3ed..363f53a0b 100644 --- a/QualityControl/public/common/notifications/model/NotificationRunStartModel.js +++ b/QualityControl/public/common/notifications/model/NotificationRunStartModel.js @@ -12,11 +12,10 @@ * or submit itself to any jurisdiction. */ -import { Observable, BrowserStorage, showNativeBrowserNotification } from '/js/src/index.js'; +import { Observable, BrowserStorage, showNativeBrowserNotification, + areBrowserNotificationsGranted } from '/js/src/index.js'; import { EmitterKeys } from '../../../../library/enums/emitterKeys.enum.js'; import { StorageKeysEnum } from '../../enums/storageKeys.enum.js'; -import { Transition } from '../../../../library/enums/transition.enum.js'; -import {areBrowserNotificationsGranted} from "@aliceo2/web-ui/Frontend/js/src/index.js"; /** * Model responsible for handling browser notifications when a new run starts. From 6076a270bbe94147f75817bb25ffaba06dfad9e1 Mon Sep 17 00:00:00 2001 From: hehoon <100522372+hehoon@users.noreply.github.com> Date: Fri, 9 Jan 2026 22:46:58 +0100 Subject: [PATCH 19/19] Fix docs after attribute name change --- QualityControl/lib/services/RunModeService.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/QualityControl/lib/services/RunModeService.js b/QualityControl/lib/services/RunModeService.js index 533847e34..ea73b558b 100644 --- a/QualityControl/lib/services/RunModeService.js +++ b/QualityControl/lib/services/RunModeService.js @@ -29,7 +29,7 @@ export class RunModeService { * @param {BookkeepingService} bookkeepingService - Used to check the status of a run. * @param {CcdbService} dataService - Used to fetch data from the CCDB. * @param {EventEmitter} eventEmitter - Event emitter to be used to emit events when new data is available - * @param {WebSocket} ws - web-ui websocket server implementation + * @param {WebSocket} webSocketService - web-ui websocket server implementation */ constructor( config,