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..ea73b558b 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} webSocketService - web-ui websocket server implementation */ constructor( config, bookkeepingService, dataService, eventEmitter, + webSocketService, ) { this._bookkeepingService = bookkeepingService; this._dataService = dataService; this._eventEmitter = eventEmitter; + this._webSocketService = webSocketService; this._ongoingRuns = new Map(); this._lastRunsRefresh = 0; @@ -131,6 +134,13 @@ 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); } diff --git a/QualityControl/public/Model.js b/QualityControl/public/Model.js index 203a6e58b..e30163478 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 2c6f0c10f..b90f45425 100644 --- a/QualityControl/public/common/enums/storageKeys.enum.js +++ b/QualityControl/public/common/enums/storageKeys.enum.js @@ -20,5 +20,6 @@ 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', OBJECT_TREE_OPEN_NODES: 'object-tree-open-nodes', }); 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/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/public/common/header.js b/QualityControl/public/common/header.js index ce4746f95..5f4a1ae3e 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, areBrowserNotificationsGranted, + requestBrowserNotificationPermissions, BrowserNotificationPermission } from '/js/src/index.js'; import { spinner } from './spinner.js'; import layoutViewHeader from '../layout/view/header.js'; @@ -68,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 === 'layoutShow' && 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); }; /** @@ -110,5 +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'), + 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 new file mode 100644 index 000000000..363f53a0b --- /dev/null +++ b/QualityControl/public/common/notifications/model/NotificationRunStartModel.js @@ -0,0 +1,102 @@ +/** + * @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, + areBrowserNotificationsGranted } from '/js/src/index.js'; +import { EmitterKeys } from '../../../../library/enums/emitterKeys.enum.js'; +import { StorageKeysEnum } from '../../enums/storageKeys.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 { + if (this._browserNotificationStorage.getLocalItem(this.model.session.personid.toString())) { + return areBrowserNotificationsGranted(); + } + return 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. + * @returns {undefined} + */ + async _handleWSRunTrack({ runNumber }) { + if (!this.getBrowserNotificationSetting()) { + return; + } + + 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) { + this.model.filterModel.activateRunsMode(this.model.filterModel.getPageTargetModel()); + } + + // 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()); + }, + }); + } +} diff --git a/QualityControl/test/lib/services/RunModeService.test.js b/QualityControl/test/lib/services/RunModeService.test.js index 5802005a4..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', () => { @@ -28,6 +29,7 @@ export const runModeServiceTestSuite = async () => { let bookkeepingService = undefined; let dataService = undefined; const eventEmitter = new EventEmitter(); + let ws = undefined; beforeEach(() => { bookkeepingService = { @@ -38,8 +40,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 () => { @@ -153,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' }]); diff --git a/QualityControl/test/public/components/profileHeader.test.js b/QualityControl/test/public/components/profileHeader.test.js new file mode 100644 index 000000000..8c05e9524 --- /dev/null +++ b/QualityControl/test/public/components/profileHeader.test.js @@ -0,0 +1,273 @@ +/** + * @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('header div[title="Login"] button.btn') !== null); + + ok(accountButtonExists); + }); + + await testParent.test('clicking the account button opens a dropdown', { timeout }, async () => { + const selector = 'header div[title="Login"]'; + const locator = 'header div[title="Login"] button.btn'; + + 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(); + 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 = '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('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 = 'header div[title="Login"] .dropdown-menu .switch'; + const checkboxSelector = `${switchButtonLocator} input[type="checkbox"]`; + 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}`; + 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('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/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 diff --git a/QualityControl/test/test-index.js b/QualityControl/test/test-index.js index 75514eb4b..ca24cd33f 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'; @@ -103,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; @@ -114,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 @@ -151,6 +154,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 },