diff --git a/QualityControl/common/library/enums/Status/integratedServices.enum.js b/QualityControl/common/library/enums/Status/integratedServices.enum.js index 64acc6354..b5c55b93f 100644 --- a/QualityControl/common/library/enums/Status/integratedServices.enum.js +++ b/QualityControl/common/library/enums/Status/integratedServices.enum.js @@ -21,4 +21,5 @@ export const IntegratedServices = Object.freeze({ QCG: 'qcg', QC: 'qc', CCDB: 'ccdb', + KAFKA: 'kafka', }); diff --git a/QualityControl/common/library/enums/Status/serviceStatus.enum.js b/QualityControl/common/library/enums/Status/serviceStatus.enum.js index 37db33c0b..60def5304 100644 --- a/QualityControl/common/library/enums/Status/serviceStatus.enum.js +++ b/QualityControl/common/library/enums/Status/serviceStatus.enum.js @@ -20,6 +20,7 @@ export const ServiceStatus = Object.freeze({ NOT_ASKED: 'NOT_ASKED', LOADING: 'LOADING', - SUCCESS: 'SUCCESS', ERROR: 'ERROR', + SUCCESS: 'SUCCESS', + NOT_CONFIGURED: 'NOT_CONFIGURED', }); diff --git a/QualityControl/lib/QCModel.js b/QualityControl/lib/QCModel.js index 12b621456..c91c9288a 100644 --- a/QualityControl/lib/QCModel.js +++ b/QualityControl/lib/QCModel.js @@ -79,6 +79,16 @@ export const setupQcModel = async (ws, eventEmitter) => { logger.warnMessage('No database configuration found, skipping database initialization'); } + const layoutRepository = new LayoutRepository(jsonFileService); + const userRepository = new UserRepository(jsonFileService); + const chartRepository = new ChartRepository(jsonFileService); + + const userController = new UserController(userRepository); + const layoutController = new LayoutController(layoutRepository); + + const statusService = new StatusService({ version: packageJSON?.version ?? '-' }, { qc: config.qc ?? {} }); + const statusController = new StatusController(statusService); + if (config?.kafka?.enabled) { try { const validConfig = await KafkaConfigDto.validateAsync(config.kafka); @@ -90,22 +100,13 @@ export const setupQcModel = async (ws, eventEmitter) => { logLevel: logLevel.NOTHING, }); const aliEcsSynchronizer = new AliEcsSynchronizer(kafkaClient, consumerGroups, eventEmitter); + statusService.aliEcsSynchronizer = aliEcsSynchronizer; aliEcsSynchronizer.start(); } catch (error) { logger.errorMessage(`Kafka initialization/connection failed: ${error.message}`); } } - const layoutRepository = new LayoutRepository(jsonFileService); - const userRepository = new UserRepository(jsonFileService); - const chartRepository = new ChartRepository(jsonFileService); - - const userController = new UserController(userRepository); - const layoutController = new LayoutController(layoutRepository); - - const statusService = new StatusService({ version: packageJSON?.version ?? '-' }, { qc: config.qc ?? {} }); - const statusController = new StatusController(statusService); - const qcdbDownloadService = new QcdbDownloadService(config.ccdb); const ccdbService = CcdbService.setup(config.ccdb); diff --git a/QualityControl/lib/services/Status.service.js b/QualityControl/lib/services/Status.service.js index c6d6e33a9..a2d75bf0a 100644 --- a/QualityControl/lib/services/Status.service.js +++ b/QualityControl/lib/services/Status.service.js @@ -17,6 +17,7 @@ import { exec } from 'node:child_process'; import { LogManager } from '@aliceo2/web-ui'; import { IntegratedServices } from './../../common/library/enums/Status/integratedServices.enum.js'; +import { ServiceStatus } from '../../common/library/enums/Status/serviceStatus.enum.js'; const QC_VERSION_EXEC_COMMAND = 'yum info o2-QualityControl | awk \'/Version/ {print $3}\''; const execPromise = promisify(exec); @@ -43,6 +44,11 @@ export class StatusService { */ this._ws = undefined; + /** + * @type {?AliEcsSynchronizer} + */ + this._aliEcsSynchronizer = undefined; + this._packageInfo = packageInfo; this._config = config; } @@ -64,6 +70,9 @@ export class StatusService { case IntegratedServices.CCDB: result = await this.retrieveDataServiceStatus(); break; + case IntegratedServices.KAFKA: + result = this.retrieveKafkaServiceStatus(); + break; } return result; } @@ -75,7 +84,7 @@ export class StatusService { retrieveOwnStatus() { return { name: 'QCG', - status: { ok: true }, + status: { ok: true, category: ServiceStatus.SUCCESS }, version: this._packageInfo?.version ?? '', extras: { clients: this._ws?.server?.clients?.size ?? -1, @@ -88,15 +97,16 @@ export class StatusService { * @returns {string} - version of QC deployed on the system */ async retrieveQcVersion() { - let status = { ok: true }; + let status = { ok: false, category: ServiceStatus.NOT_CONFIGURED }; let version = 'Not part of an FLP deployment'; if (this._config.qc?.enabled) { try { const { stdout } = await execPromise(QC_VERSION_EXEC_COMMAND, { timeout: 6000 }); version = stdout.trim(); + status = { ok: true, category: ServiceStatus.SUCCESS }; } catch (error) { - status = { ok: false, message: error.message || error }; + status = { ok: false, category: ServiceStatus.ERROR, message: error.message || error }; this._logger.errorMessage(error, { level: 99, system: 'GUI', facility: 'qcg/status-service' }); } } @@ -109,15 +119,38 @@ export class StatusService { * @returns {Promise<{object}>} - status of the data service */ async retrieveDataServiceStatus() { - let status = { ok: true }; - let version = ''; + const statusPackage = { name: 'CCDB', version: '', extras: {} }; try { const { version: dataServiceVersion } = await this._dataService.getVersion(); - version = dataServiceVersion; + return { + ...statusPackage, + status: { ok: true, category: ServiceStatus.SUCCESS }, + version: dataServiceVersion, + }; } catch (err) { - status = { ok: false, message: err.message || err }; + return { + ...statusPackage, + status: { ok: false, category: ServiceStatus.ERROR, message: err.message || err }, + }; } - return { name: 'CCDB', status, version, extras: {} }; + } + + /** + * Retrieve the kafka service status response + * @returns {object} - status of the kafka service + */ + retrieveKafkaServiceStatus() { + const status = this._aliEcsSynchronizer?.status; + return { + name: IntegratedServices.KAFKA, + status: { + ok: status === ServiceStatus.SUCCESS, + category: status ?? ServiceStatus.NOT_CONFIGURED, + }, + extras: { + ...this._aliEcsSynchronizer?.extraInfo ?? {}, + }, + }; } /* @@ -141,4 +174,13 @@ export class StatusService { set ws(ws) { this._ws = ws; } + + /** + * Set instance of `AliEcsSynchronizer` + * @param {AliEcsSynchronizer} aliEcsSynchronizer - instance of the `AliEcsSynchronizer` + * @returns {void} + */ + set aliEcsSynchronizer(aliEcsSynchronizer) { + this._aliEcsSynchronizer = aliEcsSynchronizer; + } } diff --git a/QualityControl/lib/services/external/AliEcsSynchronizer.js b/QualityControl/lib/services/external/AliEcsSynchronizer.js index bb2b0d902..ed59a8ffd 100644 --- a/QualityControl/lib/services/external/AliEcsSynchronizer.js +++ b/QualityControl/lib/services/external/AliEcsSynchronizer.js @@ -13,6 +13,7 @@ import { AliEcsEventMessagesConsumer, LogManager } from '@aliceo2/web-ui'; import { EmitterKeys } from './../../../common/library/enums/emitterKeys.enum.js'; +import { ServiceStatus } from '../../../common/library/enums/Status/serviceStatus.enum.js'; const LOG_FACILITY = `${process.env.npm_config_log_label ?? 'qcg'}/ecs-synchronizer`; const RUN_TOPICS = ['aliecs.run']; @@ -38,18 +39,32 @@ export class AliEcsSynchronizer { RUN_TOPICS, ); this._ecsRunConsumer.onMessageReceived(this._onRunMessage.bind(this)); + + this._status = ServiceStatus.NOT_ASKED; + this._extraInfo = {}; } /** * Start the synchronization process and listen to events from various topics via their consumers - * @returns {void} + * @returns {Promise} */ - start() { + async start() { this._logger.infoMessage('Starting to consume AliECS messages for topics:'); - this._ecsRunConsumer - .start() - .catch((error) => - this._logger.errorMessage(`Error when starting ECS run consumer: ${error.message}\n${error.stack}`)); + this._status = ServiceStatus.ERROR; + this._extraInfo = { + // KafkaConsumer is currently not supporting "active" status checking [OGUI-1872] + message: 'Kafka is configured but the service has not started yet', + }; + try { + await this._ecsRunConsumer.start(); + this._status = ServiceStatus.SUCCESS; + } catch (error) { + this._logger.errorMessage(`Error when starting ECS run consumer: ${error.message}\n${error.stack}`); + this._status = ServiceStatus.ERROR; + this._extraInfo = { + message: error.message, + }; + } } /** @@ -75,4 +90,20 @@ export class AliEcsSynchronizer { }); } } + + /** + * Returns the current kafka service status + * @returns {ServiceStatus} - The kafka service status + */ + get status() { + return this._status; + } + + /** + * Returns extra information about the current kafka service + * @returns {object} - The extra information of the kafka service + */ + get extraInfo() { + return this._extraInfo; + } } diff --git a/QualityControl/public/Model.js b/QualityControl/public/Model.js index e30163478..3ed927105 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 { IntegratedServices } from '../library/enums/Status/integratedServices.enum.js'; import NotificationRunStartModel from './common/notifications/model/NotificationRunStartModel.js'; /** @@ -119,6 +120,12 @@ export default class Model extends Observable { height: 10, }; + // For active run monitoring, the kafka service must be available. + // If we do not yet know the kafka service status, we should request it from the backend + if (!this.aboutViewModel.findService(IntegratedServices.KAFKA)) { + this.aboutViewModel.retrieveIndividualServiceStatus(IntegratedServices.KAFKA); + } + /* * Init first page */ diff --git a/QualityControl/public/common/filters/filterViews.js b/QualityControl/public/common/filters/filterViews.js index 486968a5d..9d5f1f04a 100644 --- a/QualityControl/public/common/filters/filterViews.js +++ b/QualityControl/public/common/filters/filterViews.js @@ -21,7 +21,7 @@ import { } from './filter.js'; import { FilterType } from './filterTypes.js'; import { filtersConfig, runModeFilterConfig } from './filtersConfig.js'; -import { runModeCheckbox } from './runMode/runModeCheckbox.js'; +import { runModeComponent } from './runMode/runModeCheckbox.js'; import { cleanRunInformationPanel, detectorsQualitiesPanel, @@ -92,15 +92,15 @@ export function filtersPanel(filterModel, viewModel) { ONGOING_RUN_INTERVAL_MS: refreshRate, runInformation, } = filterModel; + if (!isVisible) { + return null; + } const { fetchOngoingRuns } = filterService; const onInputCallback = setFilterValue.bind(filterModel); const onChangeCallback = setFilterValue.bind(filterModel); const onFocusCallback = fetchOngoingRuns.bind(filterService); const onEnterCallback = () => filterModel.triggerFilter(viewModel); const clearFilterCallback = clearFiltersAndTrigger.bind(filterModel, viewModel); - if (!isVisible) { - return null; - } const filtersList = isRunModeActivated ? runModeFilterConfig(filterService) : filtersConfig(filterService); @@ -110,7 +110,7 @@ export function filtersPanel(filterModel, viewModel) { '.w-100.flex-column.p2.g2.justify-center#filterElement', [ h('.flex-row.g2.justify-center.items-center', [ - runModeCheckbox(filterModel, viewModel), + runModeComponent(filterModel, viewModel), !isRunModeActivated && [triggerFiltersButton(onEnterCallback, filterModel), clearFiltersButton(clearFilterCallback)], ...filtersList.map((filter) => diff --git a/QualityControl/public/common/filters/runMode/runModeCheckbox.js b/QualityControl/public/common/filters/runMode/runModeCheckbox.js index ac74d3556..9208e282c 100644 --- a/QualityControl/public/common/filters/runMode/runModeCheckbox.js +++ b/QualityControl/public/common/filters/runMode/runModeCheckbox.js @@ -11,7 +11,43 @@ * or submit itself to any jurisdiction. */ -import { h } from '/js/src/index.js'; +import { h, iconWarning, switchCase } from '/js/src/index.js'; +import { IntegratedServices } from '../../../../library/enums/Status/integratedServices.enum.js'; +import { ServiceStatus } from '../../../../library/enums/Status/serviceStatus.enum.js'; +import { spinner } from '../../spinner.js'; + +/** + * This component determines whether the Run Mode toggle should be displayed + * based on the availability and configuration state of the Kafka integrated service. + * Behavior by service state: + * - Loading: Displays a spinner while checking whether Run Mode is configured. + * - Failure: Displays an error box with a warning icon and the failure message returned by the service. + * - Success: + * - {@link ServiceStatus.SUCCESS}: Renders the Run Mode checkbox component. + * - {@link ServiceStatus.NOT_CONFIGURED}: Renders nothing (Run Mode is intentionally unavailable). + * - Any other state: Displays a generic error box instructing the user to contact an administrator. + * - Other: Unsupported or irrelevant state. + * @param {object} filterModel - The filter model containing the aboutViewModel used to locate integrated services. + * @param {object} viewModel - The view model associated with the current view. + * @returns {vnode|null} A vnode representing the RunMode switch or kafka state, or `null` if Kafka is not configured. + */ +export const runModeComponent = (filterModel, viewModel) => + filterModel.model.aboutViewModel.findService(IntegratedServices.KAFKA)?.match({ + Loading: () => spinner(2, 'Checking if RunMode is configured'), + Failure: (payload) => h('.error-box.danger.flex-column.justify-center.f6.text-center', { id: 'run-mode-failure' }, [ + h('span.error-icon', { title: 'RunMode is unavailable. Please contact administrator.' }, iconWarning()), + h('span', payload.status.message), + ]), + Success: (payload) => + switchCase( + payload.status.category, + { + [ServiceStatus.SUCCESS]: () => runModeCheckbox(filterModel, viewModel), + }, + () => {}, + )(), + Other: () => {}, + }); /** * Render a run mode switch diff --git a/QualityControl/public/pages/aboutView/AboutViewModel.js b/QualityControl/public/pages/aboutView/AboutViewModel.js index db24bbacf..80b4cfc36 100644 --- a/QualityControl/public/pages/aboutView/AboutViewModel.js +++ b/QualityControl/public/pages/aboutView/AboutViewModel.js @@ -62,11 +62,10 @@ export default class AboutViewModel extends BaseViewModel { if (!ok) { this.services[ServiceStatus.ERROR][service] = RemoteData.failure({ name: service, - status: { ok: false, message: result.message }, + status: { ok: false, category: ServiceStatus.ERROR, message: result.message }, }); } else { - const { status: { ok } } = result; - const category = ok ? ServiceStatus.SUCCESS : ServiceStatus.ERROR; + const { status: { category } } = result; this.services[category][service] = RemoteData.success(result); } this.notify(); @@ -74,4 +73,20 @@ export default class AboutViewModel extends BaseViewModel { this.model.notification.show(`Error fetching data for ${service}: ${error.message}`, 'danger', 2000); } } + + /** + * Iterates through all known {@link ServiceStatus} values and returns the + * first matching service found. This assumes that a given service can exist + * in at most one {@link ServiceStatus} at a time. + * @param {string} service - The service identifier to look up + * @returns {RemoteData|undefined} - The service instance under any `ServiceStatus`, or `undefined` if not found. + */ + findService(service) { + for (const status of Object.values(ServiceStatus)) { + if (this.services[status][service]) { + return this.services[status][service]; + } + } + return undefined; + } } diff --git a/QualityControl/public/pages/aboutView/AboutViewPage.js b/QualityControl/public/pages/aboutView/AboutViewPage.js index f10a9e556..b443b6379 100644 --- a/QualityControl/public/pages/aboutView/AboutViewPage.js +++ b/QualityControl/public/pages/aboutView/AboutViewPage.js @@ -12,27 +12,17 @@ * or submit itself to any jurisdiction. */ -import { ServiceStatus } from '../../../library/enums/Status/serviceStatus.enum.js'; -import { servicesLoadingPanel } from './components/servicesLoadingPanel.js'; -import { servicesResolvedPanel } from './components/servicesResolvedPanel.js'; import { h } from '/js/src/index.js'; +import { servicePanel } from './components/servicePanel.js'; /** * Shows a page to view framework information * @param {AboutViewModel} aboutViewModel - root model of the application * @returns {vnode} - virtual node element */ -export default (aboutViewModel) => { - const { services } = aboutViewModel; - return [ - h( - '.flex-column.flex-grow.p2.text-center', - { key: 'about-view-page' }, - [ - servicesLoadingPanel(services[ServiceStatus.LOADING]), - servicesResolvedPanel(services[ServiceStatus.ERROR], 'error'), - servicesResolvedPanel(services[ServiceStatus.SUCCESS], 'success'), - ], - ), - ]; -}; +export default (aboutViewModel) => + h( + '.flex-column.flex-grow.p2', + { key: 'about-view-page' }, + Object.entries(aboutViewModel.services).map(([serviceStatus, service]) => servicePanel(serviceStatus, service)), + ); diff --git a/QualityControl/public/pages/aboutView/components/serviceCard.js b/QualityControl/public/pages/aboutView/components/serviceCard.js index b7b9860fe..3ab876d84 100644 --- a/QualityControl/public/pages/aboutView/components/serviceCard.js +++ b/QualityControl/public/pages/aboutView/components/serviceCard.js @@ -13,6 +13,7 @@ */ import { h } from '/js/src/index.js'; +import { ServiceStatus } from '../../../../library/enums/Status/serviceStatus.enum.js'; /** * Builds a card for an integrated service @@ -21,18 +22,17 @@ import { h } from '/js/src/index.js'; */ export const serviceCard = (serviceData) => { const { name, status, version, extras = {} } = serviceData || {}; - const { ok, message = null } = status || {}; - const isDown = !ok; + const { category, message = null } = status || {}; const showExtras = Object.keys(extras).length > 0; const extrasToDisplay = JSON.parse(JSON.stringify(extras)); - const titleClass = ok ? '' : 'bg-danger white'; + const titleClass = category === ServiceStatus.ERROR ? 'bg-danger white' : ''; return h('.w-33.flex-column', { id: name }, [ h('.panel-title.p2.flex-row', { class: titleClass }, [ h('h4', name), version && h('i.text-right.flex-grow', { style: 'justify-content: flex-end' }, version), ]), h('.panel.flex-column.g2', [ - isDown && serviceRow('Error', message), + message && serviceRow('Error', message), showExtras && h( '.flex-column.g2', @@ -44,7 +44,6 @@ export const serviceCard = (serviceData) => { h('div', { style: 'flex: 1; text-align: left;' }, value.toString()), ])), ), - ]), ]); }; diff --git a/QualityControl/public/pages/aboutView/components/servicePanel.js b/QualityControl/public/pages/aboutView/components/servicePanel.js new file mode 100644 index 000000000..de695d827 --- /dev/null +++ b/QualityControl/public/pages/aboutView/components/servicePanel.js @@ -0,0 +1,33 @@ +/** + * @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 { ServiceStatus } from '../../../../library/enums/Status/serviceStatus.enum.js'; +import { servicesLoadingPanel } from './servicesLoadingPanel.js'; +import { servicesResolvedPanel } from './servicesResolvedPanel.js'; + +/** + * Build a reusable panel to display a wrapped list of service panels with their respective information + * @param {ServiceStatus} serviceStatus - Category of the service to be displayed + * @param {Record} servicesRecord - Category of the services to be displayed + * @returns {vnode|null} - A virtual node representing the resolved panel + */ +export const servicePanel = (serviceStatus, servicesRecord) => { + const serviceData = Object.values(servicesRecord); + if (!serviceData.length) { + return null; + } + return serviceStatus === ServiceStatus.LOADING + ? servicesLoadingPanel(Object.keys(servicesRecord)) + : servicesResolvedPanel(serviceStatus, serviceData); +}; diff --git a/QualityControl/public/pages/aboutView/components/servicesLoadingPanel.js b/QualityControl/public/pages/aboutView/components/servicesLoadingPanel.js index c41e7c7c0..516402738 100644 --- a/QualityControl/public/pages/aboutView/components/servicesLoadingPanel.js +++ b/QualityControl/public/pages/aboutView/components/servicesLoadingPanel.js @@ -14,19 +14,15 @@ import { spinner } from '../../../common/spinner.js'; import { h } from '/js/src/index.js'; +import { ServiceStatus } from '../../../../library/enums/Status/serviceStatus.enum.js'; /** * Build a reusable panel which displays a list of names of service that are currently waiting for their status - * @param {object} services - Object containing service names as keys and their status + * @param {string[]} serviceNames - Object containing service names as keys and their status * @returns {vnode} - A virtual node representing the loading panel */ -export const servicesLoadingPanel = (services) => { - if (Object.keys(services).length > 0) { - const namesAsString = Object.keys(services).join(', '); - return h('.w-100.flex-row.items-center.p2.shadow-level1', [ - spinner(2), - h('.ph2', `Loading status for: ${namesAsString.toUpperCase()}`), - ]); - } - return null; -}; +export const servicesLoadingPanel = (serviceNames) => + h('.w-100.flex-row.items-center.p2.shadow-level1', { id: `service-status-${ServiceStatus.LOADING.toLowerCase()}` }, [ + spinner(2), + h('.ph2', `Loading status for: ${serviceNames.join(', ').toUpperCase()}`), + ]); diff --git a/QualityControl/public/pages/aboutView/components/servicesResolvedPanel.js b/QualityControl/public/pages/aboutView/components/servicesResolvedPanel.js index 223059f54..54d08a3b3 100644 --- a/QualityControl/public/pages/aboutView/components/servicesResolvedPanel.js +++ b/QualityControl/public/pages/aboutView/components/servicesResolvedPanel.js @@ -13,28 +13,30 @@ */ import { serviceCard } from './serviceCard.js'; -import { h } from '/js/src/index.js'; +import { h, switchCase } from '/js/src/index.js'; +import { ServiceStatus } from '../../../../library/enums/Status/serviceStatus.enum.js'; /** * Build a reusable panel to display a wrapped list of service panels with their respective information - * @param {Map} servicesMap - Map of services with their respective information - * @param {string} category - Category of the services to be displayed + * @param {ServiceStatus} serviceStatus - Category of the service to be displayed + * @param {RemoteData[]} serviceData - Information of services * @returns {vnode} - A virtual node representing the resolved panel */ -export const servicesResolvedPanel = (servicesMap, category) => { - const services = Object.values(servicesMap); - if (services.length > 0) { - const label = `Services that are in ${category.toLocaleUpperCase()} state`; - const classes = category === 'error' ? 'danger' : category ?? ''; - return h( - '.w-100.flex-column.p2.shadow-level1', - h('h4', { class: classes }, label), - h('.flex-wrap.g1', [ - services - .sort(({ payload: { name: nameA } }, { payload: { name: nameB } }) => nameA > nameB ? 1 : -1) - .map(({ payload }) => serviceCard(payload)), - ]), - ); - } - return null; +export const servicesResolvedPanel = (serviceStatus, serviceData) => { + const label = `Services that are in ${serviceStatus.toLocaleUpperCase()} state`; + const classes = switchCase(serviceStatus, { + [ServiceStatus.ERROR]: 'danger', + [ServiceStatus.SUCCESS]: 'success', + [ServiceStatus.NOT_ASKED]: 'gray-darker', + [ServiceStatus.NOT_CONFIGURED]: 'gray-darker', + }, ''); + + return h('.w-100.flex-column.p2.shadow-level1', { id: `service-status-${serviceStatus.toLowerCase()}` }, [ + h('h4', { class: classes }, label), + h(`.flex-wrap.g1.${classes}`, [ + serviceData + .sort(({ payload: { name: nameA } }, { payload: { name: nameB } }) => nameA > nameB ? 1 : -1) + .map(({ payload }) => serviceCard(payload)), + ]), + ]); }; diff --git a/QualityControl/test/lib/services/StatusService.test.js b/QualityControl/test/lib/services/StatusService.test.js index 0bec75015..49124a79e 100644 --- a/QualityControl/test/lib/services/StatusService.test.js +++ b/QualityControl/test/lib/services/StatusService.test.js @@ -17,6 +17,7 @@ import { deepStrictEqual } from 'node:assert'; import { suite, test, before } from 'node:test'; import { StatusService } from './../../../lib/services/Status.service.js'; +import { ServiceStatus } from '../../../common/library/enums/Status/serviceStatus.enum.js'; export const statusServiceTestSuite = async () => { suite('`retrieveDataServiceStatus()` tests', () => { @@ -31,7 +32,12 @@ export const statusServiceTestSuite = async () => { const result = await statusService.retrieveDataServiceStatus(); deepStrictEqual( result, - { extras: {}, name: 'CCDB', status: { ok: false, message: 'Service is currently unavailable' }, version: '' }, + { + name: 'CCDB', + status: { ok: false, category: ServiceStatus.ERROR, message: 'Service is currently unavailable' }, + version: '', + extras: {}, + }, ); }); test('should successfully return status ok if data connector passed checks', async () => { @@ -39,7 +45,12 @@ export const statusServiceTestSuite = async () => { getVersion: stub().resolves({ version: '0.0.1' }), }; const response = await statusService.retrieveDataServiceStatus(); - deepStrictEqual(response, { name: 'CCDB', status: { ok: true }, version: '0.0.1', extras: {} }); + deepStrictEqual(response, { + name: 'CCDB', + status: { ok: true, category: ServiceStatus.SUCCESS }, + version: '0.0.1', + extras: {}, + }); }); }); @@ -47,17 +58,39 @@ export const statusServiceTestSuite = async () => { test('should successfully build an object with framework information from all used sources', async () => { const statusService = new StatusService(); statusService.dataService = { getVersion: stub().resolves({ version: '0.0.1-beta' }) }; + statusService.aliEcsSynchronizer = { status: ServiceStatus.SUCCESS }; const statusInfo = await Promise.all([ statusService.retrieveServiceStatus('qcg'), statusService.retrieveServiceStatus('qc'), statusService.retrieveServiceStatus('ccdb'), + statusService.retrieveServiceStatus('kafka'), ]); const expectedResults = [ - { name: 'QCG', version: '', status: { ok: true }, extras: { clients: -1 } }, - { name: 'QC', status: { ok: true }, version: 'Not part of an FLP deployment', extras: {} }, - { name: 'CCDB', status: { ok: true }, version: '0.0.1-beta', extras: {} }, + { + name: 'QCG', + version: '', + status: { ok: true, category: ServiceStatus.SUCCESS }, + extras: { clients: -1 }, + }, + { + name: 'QC', + status: { ok: false, category: ServiceStatus.NOT_CONFIGURED }, + version: 'Not part of an FLP deployment', + extras: {}, + }, + { + name: 'CCDB', + status: { ok: true, category: ServiceStatus.SUCCESS }, + version: '0.0.1-beta', + extras: {}, + }, + { + name: 'kafka', + status: { ok: true, category: ServiceStatus.SUCCESS }, + extras: {}, + }, ]; deepStrictEqual(statusInfo, expectedResults); @@ -67,7 +100,12 @@ export const statusServiceTestSuite = async () => { test('should return message that is not part of an FLP deployment', async () => { const statusService = new StatusService(); const response = await statusService.retrieveQcVersion(); - const result = { name: 'QC', status: { ok: true }, version: 'Not part of an FLP deployment', extras: {} }; + const result = { + name: 'QC', + status: { ok: false, category: ServiceStatus.NOT_CONFIGURED }, + version: 'Not part of an FLP deployment', + extras: {}, + }; deepStrictEqual(response, result); }); }); @@ -80,7 +118,7 @@ export const statusServiceTestSuite = async () => { deepStrictEqual(result, { name: 'QCG', - status: { ok: true }, + status: { ok: true, category: ServiceStatus.SUCCESS }, version: '0.0.1', extras: { clients: -1, @@ -94,10 +132,71 @@ export const statusServiceTestSuite = async () => { deepStrictEqual(result, { name: 'QCG', - status: { ok: true }, + status: { ok: true, category: ServiceStatus.SUCCESS }, version: '', extras: { clients: -1 }, }); }); }); + + suite('`retrieveKafkaServiceStatus()` tests', () => { + test('marks Kafka service as healthy when synchronizer reports SUCCESS', async () => { + const statusService = new StatusService(); + statusService.aliEcsSynchronizer = { status: ServiceStatus.SUCCESS }; + const result = statusService.retrieveKafkaServiceStatus(); + + deepStrictEqual(result, { + name: 'kafka', + status: { ok: true, category: ServiceStatus.SUCCESS }, + extras: {}, + }); + }); + + test('marks Kafka service as idle when synchronizer has not been queried', async () => { + const statusService = new StatusService(); + statusService.aliEcsSynchronizer = { status: ServiceStatus.NOT_ASKED }; + const result = statusService.retrieveKafkaServiceStatus(); + + deepStrictEqual(result, { + name: 'kafka', + status: { ok: false, category: ServiceStatus.NOT_ASKED }, + extras: {}, + }); + }); + + test('marks Kafka service as unhealthy when synchronizer reports an error', async () => { + const statusService = new StatusService(); + statusService.aliEcsSynchronizer = { status: ServiceStatus.ERROR, extraInfo: { message: 'test error' } }; + const result = statusService.retrieveKafkaServiceStatus(); + + deepStrictEqual(result, { + name: 'kafka', + status: { ok: false, category: ServiceStatus.ERROR }, + extras: { message: 'test error' }, + }); + }); + + test('marks Kafka service as initializing while synchronizer is loading', async () => { + const statusService = new StatusService(); + statusService.aliEcsSynchronizer = { status: ServiceStatus.LOADING }; + const result = statusService.retrieveKafkaServiceStatus(); + + deepStrictEqual(result, { + name: 'kafka', + status: { ok: false, category: ServiceStatus.LOADING }, + extras: {}, + }); + }); + + test('marks Kafka service as not configured when no synchronizer is present', async () => { + const statusService = new StatusService(); + const result = statusService.retrieveKafkaServiceStatus(); + + deepStrictEqual(result, { + name: 'kafka', + status: { ok: false, category: ServiceStatus.NOT_CONFIGURED }, + extras: {}, + }); + }); + }); }; diff --git a/QualityControl/test/public/components/profileHeader.test.js b/QualityControl/test/public/components/profileHeader.test.js index 8c05e9524..e269b9e94 100644 --- a/QualityControl/test/public/components/profileHeader.test.js +++ b/QualityControl/test/public/components/profileHeader.test.js @@ -16,7 +16,9 @@ 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'; +import { IntegratedServices } from '../../../common/library/enums/Status/integratedServices.enum.js'; +import { ServiceStatus } from '../../../common/library/enums/Status/serviceStatus.enum.js'; +import { integratedServiceInterceptor } from '../../testUtils/interceptors/integratedServiceInterceptor.js'; /** * Performs a series of automated tests on the layoutList page using Puppeteer. @@ -196,8 +198,32 @@ export const profileHeaderTests = async (url, page, timeout = 1000, testParent) } }); - await testParent.test('should enable RunMode when native browser notification is clicked', { timeout }, async () => { - await page.goto(`${url}?page=objectTree`, { waitUntil: 'networkidle0' }); + await testParent.test('should enable RunMode when browser notification is clicked', { timeout }, async () => { + const RUN_NUMBER = 1234; + + /* + * Kafka must be enabled for the browser notification feature to function correctly. + * We intercept the request and return a SUCCESS state of the kafka service. + */ + const requestHandler = (request) => + integratedServiceInterceptor(request, IntegratedServices.KAFKA, ServiceStatus.SUCCESS); + + try { + // Enable interception and attach the handler + await page.setRequestInterception(true); + page.on('request', requestHandler); + + await page.reload({ waitUntil: 'networkidle0' }); + } finally { + // Cleanup: remove listener and disable interception + page.off('request', requestHandler); + await page.setRequestInterception(false); + } + + /* + * To be able to receive a native browser notification we forcefully enable these permissions. + * After which we call the method triggered by websocket messages to push a fake notification. + */ const context = page.browserContext(); try { @@ -238,7 +264,7 @@ export const profileHeaderTests = async (url, page, timeout = 1000, testParent) // Trigger native browser notification by simulating websocket message await page.evaluate( (wsMessage) => window.model.notificationRunStartModel._handleWSRunTrack(wsMessage), - { runNumber: 1234, transition: Transition.START_ACTIVITY }, + { runNumber: RUN_NUMBER }, ); // `window.__lastNotification` is set by the mocked `Notification` await page.waitForFunction(() => window.__lastNotification !== null); @@ -251,13 +277,17 @@ export const profileHeaderTests = async (url, page, timeout = 1000, testParent) window.__lastNotification.onclick(); } }); + await page.waitForNetworkIdle(); await page.waitForFunction(() => document.querySelector('#run-mode-switch .switch input[type="checkbox"]')?.checked === true); + const location = await page.evaluate(() => window.location); + strictEqual(location.search, `?page=objectTree&RunNumber=${RUN_NUMBER}`); + selectedRun = await page.evaluate(() => document.querySelector('select#ongoingRunsFilter')?.value); strictEqual( selectedRun, - '1234', + RUN_NUMBER.toString(10), 'Should have the newly started run selected in RunMode after clicking the notification', ); } finally { diff --git a/QualityControl/test/public/features/filterTest.test.js b/QualityControl/test/public/features/filterTest.test.js index 129c98391..aae0f0552 100644 --- a/QualityControl/test/public/features/filterTest.test.js +++ b/QualityControl/test/public/features/filterTest.test.js @@ -14,6 +14,7 @@ import { strictEqual, ok } from 'node:assert'; import { delay } from '../../testUtils/delay.js'; import { RunStatus } from '../../../common/library/runStatus.enum.js'; +import {interceptRequest} from "../../testUtils/requestInterceptor.js"; export const filterTests = async (url, page, timeout = 5000, testParent) => { await testParent.test('filter should persist between pages', { timeout }, async () => { @@ -55,26 +56,19 @@ export const filterTests = async (url, page, timeout = 5000, testParent) => { 'should display detector qualities when filtering by run number if it has any', { timeout }, async () => { - const requestHandler = async (interceptedRequest) => { - const url = interceptedRequest.url(); - - if (url.includes('/api/filter/run-status/0')) { - // Mock the response - await interceptedRequest.respond({ - status: 200, - contentType: 'application/json', - body: JSON.stringify({ - detectorsQualities: [ - { id: 1, name: 'DETECTOR_GOOD_1', quality: 'good' }, - { id: 1, name: 'DETECTOR_GOOD_2', quality: 'good' }, - { id: 2, name: 'DETECTOR_BAD', quality: 'bad' }, - ], - }), - }); - } else { - interceptedRequest.continue(); - } - }; + const requestHandler = (request) => interceptRequest(request, /\/api\/filter\/run-status\/0/, (req) => { + req.respond({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ + detectorsQualities: [ + { id: 1, name: 'DETECTOR_GOOD_1', quality: 'good' }, + { id: 1, name: 'DETECTOR_GOOD_2', quality: 'good' }, + { id: 2, name: 'DETECTOR_BAD', quality: 'bad' }, + ], + }), + }); + }); try { // Enable interception and attach the handler @@ -110,22 +104,15 @@ export const filterTests = async (url, page, timeout = 5000, testParent) => { 'should not display detector qualities if the run has none when filtering by run number', { timeout }, async () => { - const requestHandler = async (interceptedRequest) => { - const url = interceptedRequest.url(); - - if (url.includes('/api/filter/run-status/0')) { - // Mock the response - await interceptedRequest.respond({ - status: 200, - contentType: 'application/json', - body: JSON.stringify({ - detectorsQualities: [], - }), - }); - } else { - interceptedRequest.continue(); - } - }; + const requestHandler = (request) => interceptRequest(request, /\/api\/filter\/run-status\/0/, (req) => { + req.respond({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ + detectorsQualities: [], + }), + }); + }); try { // Enable interception and attach the handler @@ -160,22 +147,15 @@ export const filterTests = async (url, page, timeout = 5000, testParent) => { }); await testParent.test('filtering by run number should display the run information', { timeout }, async () => { - const requestHandler = async (interceptedRequest) => { - const url = interceptedRequest.url(); - - if (url.includes('/api/filter/run-status/0')) { - // Mock the response - await interceptedRequest.respond({ - status: 200, - contentType: 'application/json', - body: JSON.stringify({ - runStatus: RunStatus.UNKNOWN, - }), - }); - } else { - interceptedRequest.continue(); - } - }; + const requestHandler = (request) => interceptRequest(request, /\/api\/filter\/run-status\/0/, (req) => { + req.respond({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ + runStatus: RunStatus.UNKNOWN, + }), + }); + }); try { // Enable interception and attach the handler @@ -189,9 +169,6 @@ export const filterTests = async (url, page, timeout = 5000, testParent) => { // Filtering by run number should display the run information const runInformation = await page.waitForSelector('#header-run-information', { visible: true, timeout: 1000 }); ok(runInformation, 'Run information should exists on the page'); - } catch (error) { - // Test failed - ok(false, error.message); } finally { // Cleanup: remove listener and disable interception page.off('request', requestHandler); @@ -201,18 +178,21 @@ export const filterTests = async (url, page, timeout = 5000, testParent) => { await testParent.test('should list all objects when clearing filters', { timeout }, async () => { await page.locator('#triggerFilterButton').click(); - await delay(100); //Navigate to object tree ///html/body/div[1]/div/nav/a[2] + await page.waitForSelector('nav > a:nth-child(3)', { timeout: 1000 }).catch(() => { /* Ignore timeout error */ }); await page.locator('nav > a:nth-child(3)').click(); //With filter applied + await page.waitForFunction(() => window.model.object.list.length === 1, { timeout: 1000 }) + .catch(() => { /* Ignore timeout error */ }); let objectList = await page.evaluate(() => window.model.object.list); strictEqual(objectList.length, 1); //Clear filters await page.locator('#clearFilterButton').click(); - await delay(100); + await page.waitForFunction(() => window.model.object.list.length === 3, { timeout: 1000 }) + .catch(() => { /* Ignore timeout error */ }); objectList = await page.evaluate(() => window.model.object.list); strictEqual(objectList.length, 3); }); diff --git a/QualityControl/test/public/features/runMode.test.js b/QualityControl/test/public/features/runMode.test.js index b01a23a34..6bc71744e 100644 --- a/QualityControl/test/public/features/runMode.test.js +++ b/QualityControl/test/public/features/runMode.test.js @@ -14,6 +14,9 @@ import { strictEqual, ok } from 'node:assert'; import { delay } from '../../testUtils/delay.js'; +import { IntegratedServices } from '../../../common/library/enums/Status/integratedServices.enum.js'; +import { ServiceStatus } from '../../../common/library/enums/Status/serviceStatus.enum.js'; +import { integratedServiceInterceptor } from '../../testUtils/interceptors/integratedServiceInterceptor.js'; // If using nock for HTTP mocking (uncomment if available) // import nock from 'nock'; @@ -37,20 +40,121 @@ export const runModeTests = async (url, page, timeout = 5000, testParent) => { } }); - await testParent.test('should have a switch to enable run mode', { timeout }, async () => { - await page.goto( - `${url}?page=objectTree`, - { waitUntil: 'networkidle0' }, - ); - await delay(100); - // Prevent the 'get run status' from re-triggering mid test - await page.evaluate(() => { - window.model.filterModel.ONGOING_RUN_INTERVAL_MS = 12000000; + await testParent.test('when kafka service is not configured the run mode toggle should be hidden', { timeout }, async () => { + const requestHandler = (request) => integratedServiceInterceptor(request, IntegratedServices.KAFKA, ServiceStatus.NOT_CONFIGURED); + + try { + // Enable interception and attach the handler + await page.setRequestInterception(true); + page.on('request', requestHandler); + + await page.goto( + `${url}?page=objectTree`, + { waitUntil: 'networkidle0' }, + ); + await delay(100); + // Prevent the 'get run status' from re-triggering mid test + await page.evaluate(() => { + window.model.filterModel.ONGOING_RUN_INTERVAL_MS = 12000000; + }); + + const runsModeToggleNoExist = await page.evaluate(() => document.querySelector('#run-mode-switch') === null); + ok(runsModeToggleNoExist, 'The RunMode switch should not be displayed'); + + const runsModeErrorNoExist = await page.evaluate(() => document.querySelector('#run-mode-failure') === null); + ok(runsModeErrorNoExist, 'The RunMode switch should not be displayed'); + } finally { + // Cleanup: remove listener and disable interception + page.off('request', requestHandler); + await page.setRequestInterception(false); + } + }); + + await testParent.test('when kafka service is unavailable nothing should be displayed (rely on about page)', { timeout }, async () => { + const requestHandler = (request) => integratedServiceInterceptor(request, IntegratedServices.KAFKA, ServiceStatus.ERROR, { + message: 'test error', }); - await page.locator('#run-mode-switch > .switch'); - const runsModeTitle = await page.evaluate(() => - document.querySelector('#run-mode-switch').textContent); - strictEqual(runsModeTitle, 'Run mode', 'The text displayed is not `Runs mode`'); + + try { + // Enable interception and attach the handler + await page.setRequestInterception(true); + page.on('request', requestHandler); + + await page.goto( + `${url}?page=objectTree`, + { waitUntil: 'networkidle0' }, + ); + await delay(100); + // Prevent the 'get run status' from re-triggering mid test + await page.evaluate(() => { + window.model.filterModel.ONGOING_RUN_INTERVAL_MS = 12000000; + }); + + const runsModeNoExist = await page.evaluate(() => document.querySelector('#run-mode-switch') === null); + ok(runsModeNoExist, 'The RunMode switch should not be displayed'); + } finally { + // Cleanup: remove listener and disable interception + page.off('request', requestHandler); + await page.setRequestInterception(false); + } + }); + + await testParent.test('should have a switch to enable run mode when kafka service is available', { timeout }, async () => { + // The kafka service is required for run mode to be available + const requestHandler = (request) => integratedServiceInterceptor(request, IntegratedServices.KAFKA, ServiceStatus.SUCCESS); + + try { + // Enable interception and attach the handler + await page.setRequestInterception(true); + page.on('request', requestHandler); + + await page.goto( + `${url}?page=objectTree`, + { waitUntil: 'networkidle0' }, + ); + await delay(100); + // Prevent the 'get run status' from re-triggering mid test + await page.evaluate(() => { + window.model.filterModel.ONGOING_RUN_INTERVAL_MS = 12000000; + }); + + await page.locator('#run-mode-switch > .switch'); + const runsModeTitle = await page.evaluate(() => document.querySelector('#run-mode-switch')?.textContent); + strictEqual(runsModeTitle, 'Run mode', 'The text displayed is not `Run mode`'); + } finally { + // Cleanup: remove listener and disable interception + page.off('request', requestHandler); + await page.setRequestInterception(false); + } + }); + + await testParent.test('should have a switch to enable run mode', { timeout }, async () => { + // The kafka service is required for run mode to be available + const requestHandler = (request) => integratedServiceInterceptor(request, IntegratedServices.KAFKA, ServiceStatus.SUCCESS); + + try { + // Enable interception and attach the handler + await page.setRequestInterception(true); + page.on('request', requestHandler); + + await page.goto( + `${url}?page=objectTree`, + { waitUntil: 'networkidle0' }, + ); + await delay(100); + // Prevent the 'get run status' from re-triggering mid test + await page.evaluate(() => { + window.model.filterModel.ONGOING_RUN_INTERVAL_MS = 12000000; + }); + await page.locator('#run-mode-switch > .switch'); + const runsModeTitle = await page.evaluate(() => + document.querySelector('#run-mode-switch')?.textContent); + strictEqual(runsModeTitle, 'Run mode', 'The text displayed is not `Run mode`'); + } finally { + // Cleanup: remove listener and disable interception + page.off('request', requestHandler); + await page.setRequestInterception(false); + } }); await testParent.test('should activate run mode', { timeout }, async () => { diff --git a/QualityControl/test/public/pages/about-page.test.js b/QualityControl/test/public/pages/about-page.test.js index 2fb102596..4f415c0ac 100644 --- a/QualityControl/test/public/pages/about-page.test.js +++ b/QualityControl/test/public/pages/about-page.test.js @@ -11,7 +11,8 @@ * or submit itself to any jurisdiction. */ -import { strictEqual } from 'node:assert'; +import { ok, strictEqual } from 'node:assert'; +import { IntegratedServices } from '../../../common/library/enums/Status/integratedServices.enum.js'; import { ServiceStatus } from '../../../common/library/enums/Status/serviceStatus.enum.js'; const ABOUT_PAGE_PARAM = '?page=about'; @@ -25,6 +26,101 @@ export const aboutPageTests = async (url, page, timeout = 5000, testParent) => { await testServiceStatus(testParent, page, 'qcg', timeout); await testServiceStatus(testParent, page, 'qc', timeout); await testServiceStatus(testParent, page, 'ccdb', timeout); + await testServiceStatus(testParent, page, 'kafka', timeout); + + await testParent.test('should display message when service errored', { timeout }, async () => { + const ERROR_MESSAGE = 'Service Error'; + + const requestHandler = (interceptedRequest) => { + const url = interceptedRequest.url(); + + if (url.includes(`/api/status/${IntegratedServices.KAFKA}`)) { + interceptedRequest.respond({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ + name: IntegratedServices.KAFKA, + status: { + ok: false, + category: ServiceStatus.ERROR, + }, + extras: { + message: ERROR_MESSAGE, + }, + }), + }); + } else { + interceptedRequest.continue(); + } + }; + + try { + // Enable interception and attach the handler + await page.setRequestInterception(true); + page.on('request', requestHandler); + + await page.reload({ waitUntil: 'networkidle0' }); + + const extras = await page.evaluate( + (serviceStatus, serviceName) => { + const query = `#service-status-${serviceStatus} #${serviceName} .panel .flex-row div:nth-child(2)`; + return Array.from(document.querySelectorAll(query)).map((element) => element.textContent); + }, + ServiceStatus.ERROR.toLowerCase(), + IntegratedServices.KAFKA, + ); + + ok(extras.includes(ERROR_MESSAGE), `errored service should contain message '${ERROR_MESSAGE}'`); + } finally { + // Cleanup: remove listener and disable interception + page.off('request', requestHandler); + await page.setRequestInterception(false); + } + }); + + await testParent.test('should have "NOT CONFIGURED" status when service is not configured', { timeout }, async () => { + const requestHandler = (interceptedRequest) => { + const url = interceptedRequest.url(); + + if (url.includes(`/api/status/${IntegratedServices.KAFKA}`)) { + interceptedRequest.respond({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ + name: IntegratedServices.KAFKA, + status: { + ok: false, + category: ServiceStatus.NOT_CONFIGURED, + }, + extras: {}, + }), + }); + } else { + interceptedRequest.continue(); + } + }; + + try { + // Enable interception and attach the handler + await page.setRequestInterception(true); + page.on('request', requestHandler); + + await page.reload({ waitUntil: 'networkidle0' }); + + const exists = await page.evaluate( + (serviceStatus, serviceName) => + document.querySelector(`#service-status-${serviceStatus} #${serviceName}`) !== null, + ServiceStatus.NOT_CONFIGURED.toLowerCase(), + IntegratedServices.KAFKA, + ); + + ok(exists, `Service '${IntegratedServices.KAFKA}' should have status '${ServiceStatus.NOT_CONFIGURED}'`); + } finally { + // Cleanup: remove listener and disable interception + page.off('request', requestHandler); + await page.setRequestInterception(false); + } + }); }; const testServiceStatus = async (testParent, page, serviceName, timeout = 5000) => { @@ -34,10 +130,8 @@ const testServiceStatus = async (testParent, page, serviceName, timeout = 5000) { timeout }, async () => { const kind = await page.evaluate( - (service, serviceStatus) => - window.model.aboutViewModel.services[serviceStatus.SUCCESS][service].kind, + (service) => window.model.aboutViewModel.findService(service)?.kind, serviceName, - ServiceStatus, ); strictEqual(kind, 'Success'); diff --git a/QualityControl/test/test-index.js b/QualityControl/test/test-index.js index 393d00184..2068f8788 100644 --- a/QualityControl/test/test-index.js +++ b/QualityControl/test/test-index.js @@ -114,7 +114,7 @@ const OBJECT_TREE_PAGE_TIMEOUT = FRONT_END_PER_TEST_TIMEOUT * 20; const OBJECT_VIEW_FROM_OBJECT_TREE_PAGE_TIMEOUT = FRONT_END_PER_TEST_TIMEOUT * 5; const OBJECT_VIEW_FROM_LAYOUT_SHOW_PAGE_TIMEOUT = FRONT_END_PER_TEST_TIMEOUT * 17; const LAYOUT_SHOW_PAGE_TIMEOUT = FRONT_END_PER_TEST_TIMEOUT * 23; -const ABOUT_VIEW_PAGE_TIMEOUT = FRONT_END_PER_TEST_TIMEOUT * 4; +const ABOUT_VIEW_PAGE_TIMEOUT = FRONT_END_PER_TEST_TIMEOUT * 6; const FILTER_TEST_TIMEOUT = FRONT_END_PER_TEST_TIMEOUT * 26; const RUN_MODE_TEST_TIMEOUT = FRONT_END_PER_TEST_TIMEOUT * 10; diff --git a/QualityControl/test/testUtils/interceptors/integratedServiceInterceptor.js b/QualityControl/test/testUtils/interceptors/integratedServiceInterceptor.js new file mode 100644 index 000000000..98ed8d784 --- /dev/null +++ b/QualityControl/test/testUtils/interceptors/integratedServiceInterceptor.js @@ -0,0 +1,44 @@ +/** + * @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 { ServiceStatus } from '../../../common/library/enums/Status/serviceStatus.enum.js'; +import { interceptRequest } from '../requestInterceptor.js'; + +/** + * An interceptor services statuses of integrated services. + * @param {import('puppeteer').HTTPRequest} request - The puppeteer request + * @param {IntegratedServices} integratedService - The {@link IntegratedServices} to intercept + * @param {ServiceStatus} serviceStatus - The {@link ServiceStatus} to apply for the integrated service + * @param {object} extras - Optional extras object. + * @returns {undefined} + */ +export const integratedServiceInterceptor = ( + request, + integratedService, + serviceStatus = ServiceStatus.SUCCESS, + extras = {}, +) => interceptRequest(request, new RegExp(`/api/status/${integratedService}`), async (req) => { + await req.respond({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ + name: integratedService, + status: { + ok: serviceStatus === ServiceStatus.SUCCESS, + category: serviceStatus, + }, + extras, + }), + }); +}); diff --git a/QualityControl/test/testUtils/requestInterceptor.js b/QualityControl/test/testUtils/requestInterceptor.js new file mode 100644 index 000000000..f328bfd61 --- /dev/null +++ b/QualityControl/test/testUtils/requestInterceptor.js @@ -0,0 +1,44 @@ +/** + * @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. + */ + +/** + * Intercepts and conditionally handles HTTP requests based on URL pattern matching. + * This function evaluates whether a request's URL matches a specified regular expression pattern. + * If the pattern matches, it delegates request handling to a provided callback function. + * Otherwise, it allows the request to continue normally without intervention. + * @param {import('puppeteer').HTTPRequest} request - The HTTP request object to be intercepted. + * @param {RegExp} pathRegex - A regular expression pattern used to match against the request URL. + * @param {(request: import('puppeteer').HTTPRequest) => Promise} callback - A callback function, + * invoked when the URL matches the pattern. Receives the request object as its parameter and is responsible + * for handling the request (e.g., abort, respond, or continue). + * @returns {void} + * @example + * // Block all image requests + * await page.setRequestInterception(true); + * page.on('request', (request) => + * interceptRequest(request, /\.(jpg|jpeg|png|gif)$/i, (req) => req.abort()) + * ); + */ +export const interceptRequest = async (request, pathRegex, callback) => { + if (request.isInterceptResolutionHandled()) { + // The interception has already been handled. + return; + } + + if (pathRegex.test(request.url())) { + await callback(request); + } else { + await request.continue(); + } +};