From 9c4525518ef0ff7668334287a7993ba5f8bc7b19 Mon Sep 17 00:00:00 2001 From: Isaac Hill <71404865+isaachilly@users.noreply.github.com> Date: Wed, 20 May 2026 23:19:56 +0200 Subject: [PATCH 01/22] [OGUI-1216] Override browser zoom to scale log table only Intercept Ctrl+scroll and Ctrl+/- to zoom the log table independently of the browser. Zoom state is tracked in the model, with font size and row height both being derived in rem from a multiplier. Added a reset button in the toolbar. --- InfoLogger/public/Model.js | 73 +++++++++++++++++++++++ InfoLogger/public/app.css | 10 +++- InfoLogger/public/log/Log.js | 9 ++- InfoLogger/public/log/commandLogs.js | 12 +++- InfoLogger/public/log/tableLogsContent.js | 9 ++- InfoLogger/public/view.js | 40 ++++++++++++- 6 files changed, 140 insertions(+), 13 deletions(-) diff --git a/InfoLogger/public/Model.js b/InfoLogger/public/Model.js index 308633a6a..cec707743 100644 --- a/InfoLogger/public/Model.js +++ b/InfoLogger/public/Model.js @@ -81,6 +81,17 @@ export default class Model extends Observable { // Model can change very often we protect router with callRateLimiter // Router limit: 100 calls per 30 seconds max = 30ms, 2 FPS is enough (500ms) this.observe(callRateLimiter(this.updateRouteOnModelChange.bind(this), 500)); + + this.zoom = { + level: 1, // Default zoom multiplier, 1 = 100% + min: 0.5, + max: 4, + step: 0.1, // Adds/subtracts 10% to the level + baseFontSize: 0.7, // Default font size in rem at level=1, matches default CSS .logs-container font size + // Row height in rem is fontSize * rowHeightRatio; keep this in sync with CSS variable --row-height + rowHeightRatio: 1.3, + lastScrollTime: 0, // Throttle zoom on scroll event to avoid too many updates, especially on a trackpad + }; } /** @@ -389,4 +400,66 @@ export default class Model extends Observable { isSecureContext() { return window.isSecureContext; } + + /** + * Font size in rem units + * @returns {number} - font size in rem units + */ + get fontSize() { + return this.zoom.baseFontSize * this.zoom.level; + } + + /** + * Row height in rem units, computed with font size to keep the same ratio across zoom levels + * @returns {number} - row height in rem units + */ + get rowHeightRem() { + return this.fontSize * this.zoom.rowHeightRatio; + } + + /** + * Row height in pixels, used for the virtual scroll to know how many logs to render depending on the container size + * @returns {number} - row height in pixels + */ + get rowHeightPx() { + return this.rowHeightRem * parseFloat(getComputedStyle(document.documentElement).fontSize); + } + + /** + * Zoom in by increasing zoom level by step, with a maximum of zoom.max + */ + zoomIn() { + this.#setZoomLevel(Math.min(this.zoom.level + this.zoom.step, this.zoom.max)); + } + + /** + * Zoom out by decreasing zoom level by step, with a minimum of zoom.min + */ + zoomOut() { + this.#setZoomLevel(Math.max(this.zoom.level - this.zoom.step, this.zoom.min)); + } + + /** + * Reset zoom to base level of 1 + */ + resetZoom() { + this.#setZoomLevel(1); + } + + /** + * Set zoom level + * @param {number} level - zoom level to set, should be between zoom.min and zoom.max + */ + #setZoomLevel(level) { + // Keep zoom to 2 d.p. to avoid floating-point artifacts (for example 1.2000000000000002) + // This keeps CSS values stable and ensures users can reliably return to default zoom (1) + this.zoom.level = parseFloat(level.toFixed(2)); + const root = document.querySelector('.logs-container'); + if (root) { + // Keep CSS sizes to 3 d.p. to avoid floating-point artifacts (for example 1.0920000000000002rem) + root.style.setProperty('--log-font-size', `${this.fontSize.toFixed(3)}rem`); + root.style.setProperty('--row-height', `${this.rowHeightRem.toFixed(3)}rem`); + } + this.notify(); + } } diff --git a/InfoLogger/public/app.css b/InfoLogger/public/app.css index f3d4cba4e..e5a1bfd45 100644 --- a/InfoLogger/public/app.css +++ b/InfoLogger/public/app.css @@ -23,7 +23,11 @@ .table-filters { width: 100%; } /* logs div */ -.logs-container { cursor: default; } +.logs-container { + cursor: default; + --log-font-size: 0.7rem; /* default, overridden by JS zoom */ + --row-height: 0.91rem; /* default, overridden by JS zoom */ +} .logs-content { border-top: 1px solid #aaa; } /* logs tables */ @@ -35,12 +39,12 @@ .table-logs-content td {} .table-logs-header td, -.table-logs-content td { font-size: 0.7rem; } +.table-logs-content td { font-size: var(--log-font-size); } td, th { max-width: 0; /* allow ellipsis on tables */ vertical-align: top; } -.cell { line-height: 1rem; font-size: 1rem; padding: 0rem 0.2rem; font-weight: 100; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; line-height: 18px; /* must be sync with rowHeight constant in view */ } +.cell { line-height: var(--row-height); font-size: var(--log-font-size); padding: 0rem 0.2rem; font-weight: 100; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } .cell-bordered { border-left: 1px solid rgb(170, 170, 170); } .cell-xs { width: 2rem; } diff --git a/InfoLogger/public/log/Log.js b/InfoLogger/public/log/Log.js index a7f615841..12a5f3ff3 100644 --- a/InfoLogger/public/log/Log.js +++ b/InfoLogger/public/log/Log.js @@ -16,7 +16,6 @@ import { Observable, RemoteData } from '/js/src/index.js'; import LogFilter from '../logFilter/LogFilter.js'; import { MODE } from '../constants/mode.const.js'; import { TIME_MS } from '../common/Timezone.js'; -import { ROW_HEIGHT } from '../constants/visual.const.js'; /** * Model Log, encapsulate all log management and queries @@ -595,8 +594,12 @@ export default class Log extends Observable { */ listLogsInViewportOnly() { return this.list.slice( - Math.floor(this.scrollTop / ROW_HEIGHT), - Math.floor(this.scrollTop / ROW_HEIGHT) + Math.ceil(this.scrollHeight / ROW_HEIGHT) + 1, + Math.floor(this.scrollTop / this.rowHeight), + Math.floor(this.scrollTop / this.rowHeight) + Math.ceil(this.scrollHeight / this.rowHeight) + 1, ); } + + get rowHeight() { + return this.model.rowHeightPx; + } } diff --git a/InfoLogger/public/log/commandLogs.js b/InfoLogger/public/log/commandLogs.js index 231ff2f6f..83b6694ac 100644 --- a/InfoLogger/public/log/commandLogs.js +++ b/InfoLogger/public/log/commandLogs.js @@ -12,7 +12,13 @@ * or submit itself to any jurisdiction. */ -import { h, iconPerson, iconMediaPlay, iconMediaStop, iconDataTransferDownload } from '/js/src/index.js'; +import { h, + iconPerson, + iconMediaPlay, + iconMediaStop, + iconDataTransferDownload, + iconMagnifyingGlass, +} from '/js/src/index.js'; import { BUTTON } from '../constants/button-states.const.js'; import { MODE } from '../constants/mode.const.js'; import { setBrowserTabTitle } from '../common/utils.js'; @@ -58,6 +64,10 @@ export default (model) => [ title: 'Go to last log message (ALT + down arrow)', }, '↓'), downloadButtonGroup(model.log), + h('button.btn.flex-row', { + onclick: () => model.resetZoom(), + disabled: model.zoom.level === 1, + }, h('span', ['Reset ', iconMagnifyingGlass()])), ]; /** diff --git a/InfoLogger/public/log/tableLogsContent.js b/InfoLogger/public/log/tableLogsContent.js index 9c45c283b..190e54d88 100644 --- a/InfoLogger/public/log/tableLogsContent.js +++ b/InfoLogger/public/log/tableLogsContent.js @@ -16,7 +16,6 @@ import { h } from '/js/src/index.js'; import { severityClass } from './severityUtils.js'; import tableColGroup from './tableColGroup.js'; -import { ROW_HEIGHT } from './../constants/visual.const.js'; /** * Main content of ILG - simulates a big table scrolling. @@ -34,7 +33,7 @@ export default (model) => tableContainerHooks(model), h('div.tableLogsContentPlaceholder', { style: { - height: `${model.log.list.length * ROW_HEIGHT}px`, + height: `${model.log.list.length * model.log.rowHeight}px`, position: 'relative', }, }, [ @@ -55,7 +54,7 @@ export default (model) => const scrollStyling = (model) => ({ style: { position: 'absolute', - top: `${model.log.scrollTop - model.log.scrollTop % ROW_HEIGHT}px`, + top: `${model.log.scrollTop - model.log.scrollTop % model.log.rowHeight}px`, }, }); @@ -182,7 +181,7 @@ const autoscrollManager = (model, vnode) => { if (previousLastLogId !== currentLastLogId) { // scroll at maximum bottom possible - vnode.dom.scrollTo(0, ROW_HEIGHT * model.log.applicationLimit); + vnode.dom.scrollTo(0, model.log.rowHeight * model.log.applicationLimit); vnode.dom.dataset.lastLogId = currentLastLogId; } @@ -199,7 +198,7 @@ const autoscrollManager = (model, vnode) => { if (previousSelectedItemId !== currentSelectedItemId && model.log.autoScrollToItem) { // scroll to an index * height of row, centered const index = model.log.list.indexOf(model.log.item); - const positionRow = ROW_HEIGHT * index; + const positionRow = model.log.rowHeight * index; const halfView = model.log.scrollHeight / 2; vnode.dom.scrollTo(0, positionRow - halfView); } diff --git a/InfoLogger/public/view.js b/InfoLogger/public/view.js index 8f744afce..36717f3eb 100644 --- a/InfoLogger/public/view.js +++ b/InfoLogger/public/view.js @@ -32,7 +32,45 @@ import errorComponent from './common/errorComponent.js'; */ export default (model) => [ notification(model.notification), - h('.flex-column absolute-fill', [ + h('.flex-column absolute-fill', { + oncreate: (vnode) => { + const handleWheel = (e) => { + if (!e.ctrlKey) { + return; + } + e.preventDefault(); + const now = Date.now(); + if (now - model.zoom.lastScrollTime < 50) { + return; + } + model.zoom.lastScrollTime = now; + e.deltaY < 0 ? model.zoomIn() : model.zoomOut(); + }; + + const handleKeyDown = (e) => { + if (!e.ctrlKey) { + return; + } + // Support both '=' and '+' for zooming in, as some keyboards require Shift to type '+' + if (e.key === '=' || e.key === '+') { + e.preventDefault(); + model.zoomIn(); + } else if (e.key === '-') { + e.preventDefault(); + model.zoomOut(); + } + }; + + window.addEventListener('wheel', handleWheel, { passive: false }); + window.addEventListener('keydown', handleKeyDown); + + vnode.state.cleanup = () => { + window.removeEventListener('wheel', handleWheel); + window.removeEventListener('keydown', handleKeyDown); + }; + }, + onremove: (vnode) => vnode.state.cleanup(), + }, [ h('.shadow-level2', [ h('header.p1.flex-row.f7', [ h('', commandLogs(model)), From dcb30cfd214e5ac997fef23470f2b8f5714f3cd9 Mon Sep 17 00:00:00 2001 From: Isaac Hill <71404865+isaachilly@users.noreply.github.com> Date: Wed, 20 May 2026 23:37:42 +0200 Subject: [PATCH 02/22] [OGUI-1216] Add zoom feature test suite Tests cover: - Keyboard zoom - Mouse wheel zoom - Min/max clamping - Reset button - Preserve zoom ratio --- InfoLogger/test/mocha-index.js | 1 + InfoLogger/test/public/zoom.mocha.js | 215 +++++++++++++++++++++++++++ QualityControl/docker-compose.yml | 2 +- 3 files changed, 217 insertions(+), 1 deletion(-) create mode 100644 InfoLogger/test/public/zoom.mocha.js diff --git a/InfoLogger/test/mocha-index.js b/InfoLogger/test/mocha-index.js index 051a18aa1..6e4d1b23f 100644 --- a/InfoLogger/test/mocha-index.js +++ b/InfoLogger/test/mocha-index.js @@ -104,6 +104,7 @@ describe('InfoLogger', function() { require('./public/log-filter-actions-mocha'); require('./public/live-mode-mocha'); require('./public/query-mode-mocha'); + require('./public/zoom.mocha'); after(async () => { await browser.close(); diff --git a/InfoLogger/test/public/zoom.mocha.js b/InfoLogger/test/public/zoom.mocha.js new file mode 100644 index 000000000..f792ba094 --- /dev/null +++ b/InfoLogger/test/public/zoom.mocha.js @@ -0,0 +1,215 @@ +/** + * @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. +*/ + +const assert = require('assert'); +const test = require('../mocha-index'); + +describe('Zoom test-suite', async () => { + let page; + + before(async () => { + page = test.page; + }); + + it('should have default zoom level of 1 with font size 0.7rem and matching row height', async () => { + const result = await page.evaluate(() => { + const container = document.querySelector('.logs-container'); + const style = getComputedStyle(container); + return { + fontSize: style.getPropertyValue('--log-font-size').trim(), + rowHeight: style.getPropertyValue('--row-height').trim(), + zoomLevel: window.model.zoom.level, + }; + }); + + assert.strictEqual(result.zoomLevel, 1, 'default zoom level should be 1'); + assert.strictEqual(result.fontSize, '0.7rem', 'default font size should be 0.7rem'); + assert.strictEqual(result.rowHeight, '0.91rem', 'default row height should be 0.91rem'); + }); + + it('should zoom in with Ctrl++', async () => { + await page.keyboard.down('Control'); + await page.keyboard.press('Equal'); + await page.keyboard.up('Control'); + + const result = await page.evaluate(() => { + const container = document.querySelector('.logs-container'); + const style = getComputedStyle(container); + return { + zoomLevel: window.model.zoom.level, + fontSize: style.getPropertyValue('--log-font-size').trim(), + }; + }); + + assert.strictEqual(result.zoomLevel, 1.1, 'zoom level should increase by 0.1'); + assert.strictEqual(result.fontSize, '0.770rem', 'font size should scale with zoom'); + }); + + it('should zoom out with Ctrl+-', async () => { + await page.keyboard.down('Control'); + await page.keyboard.press('Minus'); + await page.keyboard.up('Control'); + + const result = await page.evaluate(() => ({ + zoomLevel: window.model.zoom.level, + })); + + assert.strictEqual(result.zoomLevel, 1, 'zoom level should decrease back to 1'); + }); + + it('should not zoom below minimum level', async () => { + for (let i = 0; i < 10; i++) { + await page.keyboard.down('Control'); + await page.keyboard.press('Minus'); + await page.keyboard.up('Control'); + } + + const result = await page.evaluate(() => ({ + zoomLevel: window.model.zoom.level, + min: window.model.zoom.min, + })); + + assert.strictEqual(result.zoomLevel, result.min, 'zoom should not go below minimum'); + }); + + it('should not zoom above maximum level', async () => { + await page.evaluate(() => window.model.resetZoom()); + for (let i = 0; i < 35; i++) { + await page.keyboard.down('Control'); + await page.keyboard.press('Equal'); + await page.keyboard.up('Control'); + } + + const result = await page.evaluate(() => ({ + zoomLevel: window.model.zoom.level, + max: window.model.zoom.max, + })); + + assert.strictEqual(result.zoomLevel, result.max, 'zoom should not go above maximum'); + }); + + it('should reset zoom to default level', async () => { + await page.evaluate(() => window.model.resetZoom()); + + const result = await page.evaluate(() => { + const container = document.querySelector('.logs-container'); + const style = getComputedStyle(container); + return { + zoomLevel: window.model.zoom.level, + fontSize: style.getPropertyValue('--log-font-size').trim(), + rowHeight: style.getPropertyValue('--row-height').trim(), + }; + }); + + assert.strictEqual(result.zoomLevel, 1, 'zoom level should reset to 1'); + assert.strictEqual(result.fontSize, '0.700rem', 'font size should reset to default'); + assert.strictEqual(result.rowHeight, '0.910rem', 'row height should reset to default'); + }); + + it('should maintain row height ratio relative to font size across zoom levels', async () => { + const ratios = []; + for (let i = 0; i < 5; i++) { + const result = await page.evaluate(() => { + const fontSize = window.model.fontSize; + const rowHeight = window.model.rowHeightRem; + return { ratio: rowHeight / fontSize }; + }); + ratios.push(Number(result.ratio.toFixed(2))); + await page.evaluate(() => window.model.zoomIn()); + } + + const allSame = ratios.every((r) => r === ratios[0]); + assert.ok(allSame, `row height / font size ratio should be constant, got: ${ratios.join(', ')}`); + + await page.evaluate(() => window.model.resetZoom()); + }); + + it('should zoom in with mouse wheel (Ctrl+scroll up)', async () => { + const container = await page.$('.logs-container'); + const box = await container.boundingBox(); + const x = box.x + box.width / 2; + const y = box.y + box.height / 2; + + await page.mouse.move(x, y); + await page.evaluate(() => { + window.model.resetZoom(); + window.model.zoom.lastScrollTime = 0; + }); + + await page.evaluate((cx, cy) => { + const event = new WheelEvent('wheel', { + deltaY: -100, + ctrlKey: true, + bubbles: true, + clientX: cx, + clientY: cy, + }); + document.querySelector('.flex-column.absolute-fill').dispatchEvent(event); + }, x, y); + + const afterZoomIn = await page.evaluate(() => window.model.zoom.level); + assert.strictEqual(afterZoomIn, 1.1, 'Ctrl+scroll up should zoom in'); + }); + + it('should zoom out with mouse wheel (Ctrl+scroll down)', async () => { + await page.evaluate(() => { window.model.zoom.lastScrollTime = 0; }); + + const container = await page.$('.logs-container'); + const box = await container.boundingBox(); + const x = box.x + box.width / 2; + const y = box.y + box.height / 2; + + await page.evaluate((cx, cy) => { + const event = new WheelEvent('wheel', { + deltaY: 100, + ctrlKey: true, + bubbles: true, + clientX: cx, + clientY: cy, + }); + document.querySelector('.flex-column.absolute-fill').dispatchEvent(event); + }, x, y); + + const afterZoomOut = await page.evaluate(() => window.model.zoom.level); + assert.strictEqual(afterZoomOut, 1, 'Ctrl+scroll down should zoom out'); + }); + + it('should have reset button disabled at default zoom', async () => { + await page.evaluate(() => window.model.resetZoom()); + await new Promise((resolve) => setTimeout(resolve, 200)); + + const isDisabled = await page.evaluate(() => { + const buttons = [...document.querySelectorAll('button.btn')]; + const resetBtn = buttons.find((b) => b.textContent.includes('Reset')); + return resetBtn ? resetBtn.disabled : null; + }); + + assert.strictEqual(isDisabled, true, 'reset button should be disabled at default zoom'); + }); + + it('should have reset button enabled when zoomed', async () => { + await page.evaluate(() => window.model.zoomIn()); + await new Promise((resolve) => setTimeout(resolve, 200)); + + const isDisabled = await page.evaluate(() => { + const buttons = [...document.querySelectorAll('button.btn')]; + const resetBtn = buttons.find((b) => b.textContent.includes('Reset')); + return resetBtn ? resetBtn.disabled : null; + }); + + assert.strictEqual(isDisabled, false, 'reset button should be enabled when zoomed'); + + await page.evaluate(() => window.model.resetZoom()); + }); +}); diff --git a/QualityControl/docker-compose.yml b/QualityControl/docker-compose.yml index f191ad11d..5828d8206 100644 --- a/QualityControl/docker-compose.yml +++ b/QualityControl/docker-compose.yml @@ -6,7 +6,7 @@ services: environment: MYSQL_ROOT_PASSWORD: "${MYSQL_ROOT_PASSWORD:-cern}" ports: - - "3306:3306" + - "3307:3306" volumes: - type: volume source: database-data From 616d55c56d49934d7e79dddbcdf0af2ebf3fd133 Mon Sep 17 00:00:00 2001 From: Isaac Hill <71404865+isaachilly@users.noreply.github.com> Date: Thu, 21 May 2026 08:06:14 +0200 Subject: [PATCH 03/22] [OGUI-1216] Fix failing CI/CD tests --- InfoLogger/test/public/zoom.mocha.js | 20 ++++++-------------- 1 file changed, 6 insertions(+), 14 deletions(-) diff --git a/InfoLogger/test/public/zoom.mocha.js b/InfoLogger/test/public/zoom.mocha.js index f792ba094..dccd83c98 100644 --- a/InfoLogger/test/public/zoom.mocha.js +++ b/InfoLogger/test/public/zoom.mocha.js @@ -187,28 +187,20 @@ describe('Zoom test-suite', async () => { it('should have reset button disabled at default zoom', async () => { await page.evaluate(() => window.model.resetZoom()); - await new Promise((resolve) => setTimeout(resolve, 200)); - - const isDisabled = await page.evaluate(() => { + await page.waitForFunction(() => { const buttons = [...document.querySelectorAll('button.btn')]; const resetBtn = buttons.find((b) => b.textContent.includes('Reset')); - return resetBtn ? resetBtn.disabled : null; - }); - - assert.strictEqual(isDisabled, true, 'reset button should be disabled at default zoom'); + return resetBtn && resetBtn.disabled === true; + }, {timeout: 2000}); }); it('should have reset button enabled when zoomed', async () => { await page.evaluate(() => window.model.zoomIn()); - await new Promise((resolve) => setTimeout(resolve, 200)); - - const isDisabled = await page.evaluate(() => { + await page.waitForFunction(() => { const buttons = [...document.querySelectorAll('button.btn')]; const resetBtn = buttons.find((b) => b.textContent.includes('Reset')); - return resetBtn ? resetBtn.disabled : null; - }); - - assert.strictEqual(isDisabled, false, 'reset button should be enabled when zoomed'); + return resetBtn && resetBtn.disabled === false; + }, {timeout: 2000}); await page.evaluate(() => window.model.resetZoom()); }); From 99304c84fbbcc0f0e0cd8c4e40e000f5c319ae04 Mon Sep 17 00:00:00 2001 From: Isaac Hill <71404865+isaachilly@users.noreply.github.com> Date: Thu, 21 May 2026 08:11:16 +0200 Subject: [PATCH 04/22] [OGUI-1216] Try again to fix CI/CD failing test --- InfoLogger/public/log/commandLogs.js | 1 + InfoLogger/test/public/zoom.mocha.js | 10 ++++------ 2 files changed, 5 insertions(+), 6 deletions(-) diff --git a/InfoLogger/public/log/commandLogs.js b/InfoLogger/public/log/commandLogs.js index 83b6694ac..13a6dc5a8 100644 --- a/InfoLogger/public/log/commandLogs.js +++ b/InfoLogger/public/log/commandLogs.js @@ -67,6 +67,7 @@ export default (model) => [ h('button.btn.flex-row', { onclick: () => model.resetZoom(), disabled: model.zoom.level === 1, + id: 'reset-zoom-button', }, h('span', ['Reset ', iconMagnifyingGlass()])), ]; diff --git a/InfoLogger/test/public/zoom.mocha.js b/InfoLogger/test/public/zoom.mocha.js index dccd83c98..341bcd849 100644 --- a/InfoLogger/test/public/zoom.mocha.js +++ b/InfoLogger/test/public/zoom.mocha.js @@ -188,19 +188,17 @@ describe('Zoom test-suite', async () => { it('should have reset button disabled at default zoom', async () => { await page.evaluate(() => window.model.resetZoom()); await page.waitForFunction(() => { - const buttons = [...document.querySelectorAll('button.btn')]; - const resetBtn = buttons.find((b) => b.textContent.includes('Reset')); + const resetBtn = document.querySelector('#reset-zoom-button'); return resetBtn && resetBtn.disabled === true; - }, {timeout: 2000}); + }, { timeout: 2000 }); }); it('should have reset button enabled when zoomed', async () => { await page.evaluate(() => window.model.zoomIn()); await page.waitForFunction(() => { - const buttons = [...document.querySelectorAll('button.btn')]; - const resetBtn = buttons.find((b) => b.textContent.includes('Reset')); + const resetBtn = document.querySelector('#reset-zoom-button'); return resetBtn && resetBtn.disabled === false; - }, {timeout: 2000}); + }, { timeout: 2000 }); await page.evaluate(() => window.model.resetZoom()); }); From 16a465131cd54c247360f2fd936a867dc1824228 Mon Sep 17 00:00:00 2001 From: Isaac Hill <71404865+isaachilly@users.noreply.github.com> Date: Thu, 21 May 2026 08:34:38 +0200 Subject: [PATCH 05/22] [OGUI-1216] Increase tiemout for failing CI/CD test --- InfoLogger/test/public/zoom.mocha.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/InfoLogger/test/public/zoom.mocha.js b/InfoLogger/test/public/zoom.mocha.js index 341bcd849..693224b83 100644 --- a/InfoLogger/test/public/zoom.mocha.js +++ b/InfoLogger/test/public/zoom.mocha.js @@ -190,7 +190,7 @@ describe('Zoom test-suite', async () => { await page.waitForFunction(() => { const resetBtn = document.querySelector('#reset-zoom-button'); return resetBtn && resetBtn.disabled === true; - }, { timeout: 2000 }); + }); }); it('should have reset button enabled when zoomed', async () => { @@ -198,7 +198,7 @@ describe('Zoom test-suite', async () => { await page.waitForFunction(() => { const resetBtn = document.querySelector('#reset-zoom-button'); return resetBtn && resetBtn.disabled === false; - }, { timeout: 2000 }); + }); await page.evaluate(() => window.model.resetZoom()); }); From 569df463a3857fdeb489a14b4a814e9334c89a31 Mon Sep 17 00:00:00 2001 From: Isaac Hill <71404865+isaachilly@users.noreply.github.com> Date: Thu, 21 May 2026 09:05:40 +0200 Subject: [PATCH 06/22] [OGUI-1216] Calling Zoom In via keyboard --- InfoLogger/test/public/zoom.mocha.js | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/InfoLogger/test/public/zoom.mocha.js b/InfoLogger/test/public/zoom.mocha.js index 693224b83..7ae2a4a17 100644 --- a/InfoLogger/test/public/zoom.mocha.js +++ b/InfoLogger/test/public/zoom.mocha.js @@ -194,7 +194,10 @@ describe('Zoom test-suite', async () => { }); it('should have reset button enabled when zoomed', async () => { - await page.evaluate(() => window.model.zoomIn()); + await page.keyboard.down('Control'); + await page.keyboard.press('Equal'); + await page.keyboard.up('Control'); + await page.waitForFunction(() => { const resetBtn = document.querySelector('#reset-zoom-button'); return resetBtn && resetBtn.disabled === false; From 80eba669a70a8a0564b8f82a3f938790bb8cb87f Mon Sep 17 00:00:00 2001 From: Isaac Hill <71404865+isaachilly@users.noreply.github.com> Date: Thu, 21 May 2026 09:19:23 +0200 Subject: [PATCH 07/22] [OGUI-1216] Revert to original, still unsure why failing --- InfoLogger/test/public/zoom.mocha.js | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/InfoLogger/test/public/zoom.mocha.js b/InfoLogger/test/public/zoom.mocha.js index 7ae2a4a17..2397cf261 100644 --- a/InfoLogger/test/public/zoom.mocha.js +++ b/InfoLogger/test/public/zoom.mocha.js @@ -194,9 +194,7 @@ describe('Zoom test-suite', async () => { }); it('should have reset button enabled when zoomed', async () => { - await page.keyboard.down('Control'); - await page.keyboard.press('Equal'); - await page.keyboard.up('Control'); + await page.evaluate(() => window.model.zoomIn()); await page.waitForFunction(() => { const resetBtn = document.querySelector('#reset-zoom-button'); From 259badf0c76524c04253989b1a1932ed64a4c387 Mon Sep 17 00:00:00 2001 From: Isaac Hill <71404865+isaachilly@users.noreply.github.com> Date: Thu, 21 May 2026 09:27:45 +0200 Subject: [PATCH 08/22] [OGUI-1216] Request the animation frame instead of polling --- InfoLogger/test/public/zoom.mocha.js | 15 +++++++-------- 1 file changed, 7 insertions(+), 8 deletions(-) diff --git a/InfoLogger/test/public/zoom.mocha.js b/InfoLogger/test/public/zoom.mocha.js index 2397cf261..cade13ad0 100644 --- a/InfoLogger/test/public/zoom.mocha.js +++ b/InfoLogger/test/public/zoom.mocha.js @@ -187,19 +187,18 @@ describe('Zoom test-suite', async () => { it('should have reset button disabled at default zoom', async () => { await page.evaluate(() => window.model.resetZoom()); - await page.waitForFunction(() => { - const resetBtn = document.querySelector('#reset-zoom-button'); - return resetBtn && resetBtn.disabled === true; - }); + await page.evaluate(() => new Promise(requestAnimationFrame)); + + const disabled = await page.evaluate(() => document.querySelector('#reset-zoom-button')?.disabled); + assert.strictEqual(disabled, true, 'reset button should be disabled at default zoom'); }); it('should have reset button enabled when zoomed', async () => { await page.evaluate(() => window.model.zoomIn()); + await page.evaluate(() => new Promise(requestAnimationFrame)); - await page.waitForFunction(() => { - const resetBtn = document.querySelector('#reset-zoom-button'); - return resetBtn && resetBtn.disabled === false; - }); + const disabled = await page.evaluate(() => document.querySelector('#reset-zoom-button')?.disabled); + assert.strictEqual(disabled, false, 'reset button should be enabled when zoomed'); await page.evaluate(() => window.model.resetZoom()); }); From e4549d57828e8c6c20117da81ab51e5314f29db2 Mon Sep 17 00:00:00 2001 From: Isaac Hill <71404865+isaachilly@users.noreply.github.com> Date: Thu, 21 May 2026 09:37:16 +0200 Subject: [PATCH 09/22] [OGUI-1216] Revert again --- InfoLogger/test/public/zoom.mocha.js | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/InfoLogger/test/public/zoom.mocha.js b/InfoLogger/test/public/zoom.mocha.js index cade13ad0..693224b83 100644 --- a/InfoLogger/test/public/zoom.mocha.js +++ b/InfoLogger/test/public/zoom.mocha.js @@ -187,18 +187,18 @@ describe('Zoom test-suite', async () => { it('should have reset button disabled at default zoom', async () => { await page.evaluate(() => window.model.resetZoom()); - await page.evaluate(() => new Promise(requestAnimationFrame)); - - const disabled = await page.evaluate(() => document.querySelector('#reset-zoom-button')?.disabled); - assert.strictEqual(disabled, true, 'reset button should be disabled at default zoom'); + await page.waitForFunction(() => { + const resetBtn = document.querySelector('#reset-zoom-button'); + return resetBtn && resetBtn.disabled === true; + }); }); it('should have reset button enabled when zoomed', async () => { await page.evaluate(() => window.model.zoomIn()); - await page.evaluate(() => new Promise(requestAnimationFrame)); - - const disabled = await page.evaluate(() => document.querySelector('#reset-zoom-button')?.disabled); - assert.strictEqual(disabled, false, 'reset button should be enabled when zoomed'); + await page.waitForFunction(() => { + const resetBtn = document.querySelector('#reset-zoom-button'); + return resetBtn && resetBtn.disabled === false; + }); await page.evaluate(() => window.model.resetZoom()); }); From f99a2860acf41c46846fb139654b8b5ae7765b00 Mon Sep 17 00:00:00 2001 From: Isaac Hill <71404865+isaachilly@users.noreply.github.com> Date: Fri, 22 May 2026 13:42:17 +0200 Subject: [PATCH 10/22] [OGUI-1216] Extract zoom into its own model Moves all state, properties and methods into own observable that bubbles up to Model. --- InfoLogger/public/Model.js | 75 ++-------------------------- InfoLogger/public/log/Zoom.js | 94 +++++++++++++++++++++++++++++++++++ 2 files changed, 97 insertions(+), 72 deletions(-) create mode 100644 InfoLogger/public/log/Zoom.js diff --git a/InfoLogger/public/Model.js b/InfoLogger/public/Model.js index cec707743..0e5fcd810 100644 --- a/InfoLogger/public/Model.js +++ b/InfoLogger/public/Model.js @@ -21,6 +21,7 @@ import { callRateLimiter, setBrowserTabTitle } from './common/utils.js'; import { ConfigurationService } from './services/ConfigurationService.js'; import { MODE } from './constants/mode.const.js'; import Log from './log/Log.js'; +import Zoom from './log/Zoom.js'; import Table from './table/Table.js'; import Timezone from './common/Timezone.js'; @@ -82,16 +83,8 @@ export default class Model extends Observable { // Router limit: 100 calls per 30 seconds max = 30ms, 2 FPS is enough (500ms) this.observe(callRateLimiter(this.updateRouteOnModelChange.bind(this), 500)); - this.zoom = { - level: 1, // Default zoom multiplier, 1 = 100% - min: 0.5, - max: 4, - step: 0.1, // Adds/subtracts 10% to the level - baseFontSize: 0.7, // Default font size in rem at level=1, matches default CSS .logs-container font size - // Row height in rem is fontSize * rowHeightRatio; keep this in sync with CSS variable --row-height - rowHeightRatio: 1.3, - lastScrollTime: 0, // Throttle zoom on scroll event to avoid too many updates, especially on a trackpad - }; + this.zoom = new Zoom(); + this.zoom.bubbleTo(this); } /** @@ -400,66 +393,4 @@ export default class Model extends Observable { isSecureContext() { return window.isSecureContext; } - - /** - * Font size in rem units - * @returns {number} - font size in rem units - */ - get fontSize() { - return this.zoom.baseFontSize * this.zoom.level; - } - - /** - * Row height in rem units, computed with font size to keep the same ratio across zoom levels - * @returns {number} - row height in rem units - */ - get rowHeightRem() { - return this.fontSize * this.zoom.rowHeightRatio; - } - - /** - * Row height in pixels, used for the virtual scroll to know how many logs to render depending on the container size - * @returns {number} - row height in pixels - */ - get rowHeightPx() { - return this.rowHeightRem * parseFloat(getComputedStyle(document.documentElement).fontSize); - } - - /** - * Zoom in by increasing zoom level by step, with a maximum of zoom.max - */ - zoomIn() { - this.#setZoomLevel(Math.min(this.zoom.level + this.zoom.step, this.zoom.max)); - } - - /** - * Zoom out by decreasing zoom level by step, with a minimum of zoom.min - */ - zoomOut() { - this.#setZoomLevel(Math.max(this.zoom.level - this.zoom.step, this.zoom.min)); - } - - /** - * Reset zoom to base level of 1 - */ - resetZoom() { - this.#setZoomLevel(1); - } - - /** - * Set zoom level - * @param {number} level - zoom level to set, should be between zoom.min and zoom.max - */ - #setZoomLevel(level) { - // Keep zoom to 2 d.p. to avoid floating-point artifacts (for example 1.2000000000000002) - // This keeps CSS values stable and ensures users can reliably return to default zoom (1) - this.zoom.level = parseFloat(level.toFixed(2)); - const root = document.querySelector('.logs-container'); - if (root) { - // Keep CSS sizes to 3 d.p. to avoid floating-point artifacts (for example 1.0920000000000002rem) - root.style.setProperty('--log-font-size', `${this.fontSize.toFixed(3)}rem`); - root.style.setProperty('--row-height', `${this.rowHeightRem.toFixed(3)}rem`); - } - this.notify(); - } } diff --git a/InfoLogger/public/log/Zoom.js b/InfoLogger/public/log/Zoom.js new file mode 100644 index 000000000..32c24a5ec --- /dev/null +++ b/InfoLogger/public/log/Zoom.js @@ -0,0 +1,94 @@ +/** + * @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 } from '/js/src/index.js'; + +/** + * Model for log table zoom, controls row font size and height scaling + */ +export default class Zoom extends Observable { + constructor() { + super(); + + this.level = 1; + this.min = 0.5; + this.max = 4; + this.step = 0.1; + this.baseFontSize = 0.7; + this.rowHeightRatio = 1.3; + this.lastScrollTime = 0; + } + + /** + * Font size in rem units + * @returns {number} - font size in rem units + */ + get fontSize() { + return this.baseFontSize * this.level; + } + + /** + * Row height in rem units, computed with font size to keep the same ratio across zoom levels + * @returns {number} - row height in rem units + */ + get rowHeightRem() { + return this.fontSize * this.rowHeightRatio; + } + + /** + * Row height in pixels, used for the virtual scroll to know how many logs to render depending on the container size + * @returns {number} - row height in pixels + */ + get rowHeightPx() { + return this.rowHeightRem * parseFloat(getComputedStyle(document.documentElement).fontSize); + } + + /** + * Zoom in by increasing zoom level by step, with a maximum of zoom.max + */ + zoomIn() { + this.#setZoomLevel(Math.min(this.level + this.step, this.max)); + } + + /** + * Zoom out by decreasing zoom level by step, with a minimum of zoom.min + */ + zoomOut() { + this.#setZoomLevel(Math.max(this.level - this.step, this.min)); + } + + /** + * Reset zoom to base level of 1 + */ + resetZoom() { + this.#setZoomLevel(1); + } + + /** + * Set zoom level + * @param {number} level - zoom level to set, should be between zoom.min and zoom.max + */ + #setZoomLevel(level) { + // Keep zoom to 2 d.p. to avoid floating-point artifacts (for example 1.2000000000000002) + // This keeps CSS values stable and ensures users can reliably return to default zoom (1) + this.level = parseFloat(level.toFixed(2)); + const root = document.querySelector('.logs-container'); + if (root) { + // Keep CSS sizes to 3 d.p. to avoid floating-point artifacts (for example 1.0920000000000002rem) + root.style.setProperty('--log-font-size', `${this.fontSize.toFixed(3)}rem`); + root.style.setProperty('--row-height', `${this.rowHeightRem.toFixed(3)}rem`); + } + this.notify(); + } +} From 0a97d6071fbf162cd264296f20545f80f8492e1e Mon Sep 17 00:00:00 2001 From: Isaac Hill <71404865+isaachilly@users.noreply.github.com> Date: Fri, 22 May 2026 13:44:53 +0200 Subject: [PATCH 11/22] [OGUI-1216] Update zoom calls to use new Zoom model --- InfoLogger/public/log/Log.js | 2 +- InfoLogger/public/log/commandLogs.js | 2 +- InfoLogger/public/view.js | 6 +++--- 3 files changed, 5 insertions(+), 5 deletions(-) diff --git a/InfoLogger/public/log/Log.js b/InfoLogger/public/log/Log.js index 12a5f3ff3..5469dcb69 100644 --- a/InfoLogger/public/log/Log.js +++ b/InfoLogger/public/log/Log.js @@ -600,6 +600,6 @@ export default class Log extends Observable { } get rowHeight() { - return this.model.rowHeightPx; + return this.model.zoom.rowHeightPx; } } diff --git a/InfoLogger/public/log/commandLogs.js b/InfoLogger/public/log/commandLogs.js index 13a6dc5a8..0f1e2c13b 100644 --- a/InfoLogger/public/log/commandLogs.js +++ b/InfoLogger/public/log/commandLogs.js @@ -65,7 +65,7 @@ export default (model) => [ }, '↓'), downloadButtonGroup(model.log), h('button.btn.flex-row', { - onclick: () => model.resetZoom(), + onclick: () => model.zoom.resetZoom(), disabled: model.zoom.level === 1, id: 'reset-zoom-button', }, h('span', ['Reset ', iconMagnifyingGlass()])), diff --git a/InfoLogger/public/view.js b/InfoLogger/public/view.js index 36717f3eb..8438e397c 100644 --- a/InfoLogger/public/view.js +++ b/InfoLogger/public/view.js @@ -44,7 +44,7 @@ export default (model) => [ return; } model.zoom.lastScrollTime = now; - e.deltaY < 0 ? model.zoomIn() : model.zoomOut(); + e.deltaY < 0 ? model.zoom.zoomIn() : model.zoom.zoomOut(); }; const handleKeyDown = (e) => { @@ -54,10 +54,10 @@ export default (model) => [ // Support both '=' and '+' for zooming in, as some keyboards require Shift to type '+' if (e.key === '=' || e.key === '+') { e.preventDefault(); - model.zoomIn(); + model.zoom.zoomIn(); } else if (e.key === '-') { e.preventDefault(); - model.zoomOut(); + model.zoom.zoomOut(); } }; From 6d12199683a4f1db30aba273da44f568c5d4db66 Mon Sep 17 00:00:00 2001 From: Isaac Hill <71404865+isaachilly@users.noreply.github.com> Date: Fri, 22 May 2026 13:47:34 +0200 Subject: [PATCH 12/22] [OGUI-1216] Add support for macOS cmd key --- InfoLogger/public/view.js | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/InfoLogger/public/view.js b/InfoLogger/public/view.js index 8438e397c..585af814b 100644 --- a/InfoLogger/public/view.js +++ b/InfoLogger/public/view.js @@ -35,7 +35,9 @@ export default (model) => [ h('.flex-column absolute-fill', { oncreate: (vnode) => { const handleWheel = (e) => { - if (!e.ctrlKey) { + // Only trigger zoom if Ctrl (or Cmd on Mac) is pressed + // Windows intercepts the Windows key events, so these do not reach the browser + if (!e.ctrlKey && !e.metaKey) { return; } e.preventDefault(); @@ -48,7 +50,7 @@ export default (model) => [ }; const handleKeyDown = (e) => { - if (!e.ctrlKey) { + if (!e.ctrlKey && !e.metaKey) { return; } // Support both '=' and '+' for zooming in, as some keyboards require Shift to type '+' From 114db03746b9dfc749110b28e18f1f2410271fd0 Mon Sep 17 00:00:00 2001 From: Isaac Hill <71404865+isaachilly@users.noreply.github.com> Date: Fri, 22 May 2026 13:49:09 +0200 Subject: [PATCH 13/22] [OGUI-1216] Reorganise zoom tests, add macOS Meta key tests and add reset button test --- InfoLogger/test/public/zoom.mocha.js | 389 +++++++++++++++++---------- 1 file changed, 246 insertions(+), 143 deletions(-) diff --git a/InfoLogger/test/public/zoom.mocha.js b/InfoLogger/test/public/zoom.mocha.js index 693224b83..52fd41ed7 100644 --- a/InfoLogger/test/public/zoom.mocha.js +++ b/InfoLogger/test/public/zoom.mocha.js @@ -10,196 +10,299 @@ * 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. -*/ + */ const assert = require('assert'); const test = require('../mocha-index'); describe('Zoom test-suite', async () => { - let page; + let page = null; before(async () => { - page = test.page; + ({ page } = test); }); - it('should have default zoom level of 1 with font size 0.7rem and matching row height', async () => { - const result = await page.evaluate(() => { - const container = document.querySelector('.logs-container'); - const style = getComputedStyle(container); - return { - fontSize: style.getPropertyValue('--log-font-size').trim(), - rowHeight: style.getPropertyValue('--row-height').trim(), - zoomLevel: window.model.zoom.level, - }; - }); + describe('Default state', () => { + it('should have default zoom level of 1 with font size 0.7rem and matching row height', async () => { + const result = await page.evaluate(() => { + const container = document.querySelector('.logs-container'); + const style = getComputedStyle(container); + return { + fontSize: style.getPropertyValue('--log-font-size').trim(), + rowHeight: style.getPropertyValue('--row-height').trim(), + zoomLevel: window.model.zoom.level, + }; + }); - assert.strictEqual(result.zoomLevel, 1, 'default zoom level should be 1'); - assert.strictEqual(result.fontSize, '0.7rem', 'default font size should be 0.7rem'); - assert.strictEqual(result.rowHeight, '0.91rem', 'default row height should be 0.91rem'); + assert.strictEqual(result.zoomLevel, 1, 'default zoom level should be 1'); + assert.strictEqual(result.fontSize, '0.7rem', 'default font size should be 0.7rem'); + assert.strictEqual(result.rowHeight, '0.91rem', 'default row height should be 0.91rem'); + }); }); - it('should zoom in with Ctrl++', async () => { - await page.keyboard.down('Control'); - await page.keyboard.press('Equal'); - await page.keyboard.up('Control'); + describe('Keyboard shortcuts', () => { + it('should zoom in with Ctrl+Plus (Windows/Linux)', async () => { + await page.keyboard.down('Control'); + await page.keyboard.press('Equal'); + await page.keyboard.up('Control'); + + const result = await page.evaluate(() => { + const container = document.querySelector('.logs-container'); + const style = getComputedStyle(container); + return { + zoomLevel: window.model.zoom.level, + fontSize: style.getPropertyValue('--log-font-size').trim(), + }; + }); - const result = await page.evaluate(() => { - const container = document.querySelector('.logs-container'); - const style = getComputedStyle(container); - return { - zoomLevel: window.model.zoom.level, - fontSize: style.getPropertyValue('--log-font-size').trim(), - }; + assert.strictEqual(result.zoomLevel, 1.1, 'zoom level should increase by 0.1'); + assert.strictEqual(result.fontSize, '0.770rem', 'font size should scale with zoom'); }); - assert.strictEqual(result.zoomLevel, 1.1, 'zoom level should increase by 0.1'); - assert.strictEqual(result.fontSize, '0.770rem', 'font size should scale with zoom'); - }); + it('should zoom out with Ctrl+Minus (Windows/Linux)', async () => { + await page.keyboard.down('Control'); + await page.keyboard.press('Minus'); + await page.keyboard.up('Control'); - it('should zoom out with Ctrl+-', async () => { - await page.keyboard.down('Control'); - await page.keyboard.press('Minus'); - await page.keyboard.up('Control'); + const result = await page.evaluate(() => ({ + zoomLevel: window.model.zoom.level, + })); - const result = await page.evaluate(() => ({ - zoomLevel: window.model.zoom.level, - })); + assert.strictEqual(result.zoomLevel, 1, 'zoom level should decrease back to 1'); + }); - assert.strictEqual(result.zoomLevel, 1, 'zoom level should decrease back to 1'); - }); + it('should zoom in with Meta+Plus (macOS)', async () => { + await page.evaluate(() => window.model.zoom.resetZoom()); + await page.keyboard.down('Meta'); + await page.keyboard.press('Equal'); + await page.keyboard.up('Meta'); - it('should not zoom below minimum level', async () => { - for (let i = 0; i < 10; i++) { - await page.keyboard.down('Control'); + const result = await page.evaluate(() => ({ + zoomLevel: window.model.zoom.level, + })); + + assert.strictEqual(result.zoomLevel, 1.1, 'Meta++ should zoom in'); + }); + + it('should zoom out with Meta+Minus (macOS)', async () => { + await page.keyboard.down('Meta'); await page.keyboard.press('Minus'); - await page.keyboard.up('Control'); - } + await page.keyboard.up('Meta'); - const result = await page.evaluate(() => ({ - zoomLevel: window.model.zoom.level, - min: window.model.zoom.min, - })); + const result = await page.evaluate(() => ({ + zoomLevel: window.model.zoom.level, + })); - assert.strictEqual(result.zoomLevel, result.min, 'zoom should not go below minimum'); + assert.strictEqual(result.zoomLevel, 1, 'Meta+- should zoom out'); + }); }); - it('should not zoom above maximum level', async () => { - await page.evaluate(() => window.model.resetZoom()); - for (let i = 0; i < 35; i++) { - await page.keyboard.down('Control'); - await page.keyboard.press('Equal'); - await page.keyboard.up('Control'); - } + describe('Mouse wheel', () => { + it('should zoom in with Ctrl+ScrollUp (Windows/Linux)', async () => { + const container = await page.$('.logs-container'); + const box = await container.boundingBox(); + const x = box.x + box.width / 2; + const y = box.y + box.height / 2; + + await page.mouse.move(x, y); + await page.evaluate(() => { + window.model.zoom.resetZoom(); + window.model.zoom.lastScrollTime = 0; + }); + + await page.evaluate((cx, cy) => { + const event = new WheelEvent('wheel', { + deltaY: -100, + ctrlKey: true, + bubbles: true, + clientX: cx, + clientY: cy, + }); + document.querySelector('.flex-column.absolute-fill').dispatchEvent(event); + }, x, y); + + const afterZoomIn = await page.evaluate(() => window.model.zoom.level); + assert.strictEqual(afterZoomIn, 1.1, 'Ctrl+scroll up should zoom in'); + }); + + it('should zoom out with Ctrl+ScrollDown (Windows/Linux)', async () => { + await page.evaluate(() => { + window.model.zoom.lastScrollTime = 0; + }); + + const container = await page.$('.logs-container'); + const box = await container.boundingBox(); + const x = box.x + box.width / 2; + const y = box.y + box.height / 2; + + await page.evaluate((cx, cy) => { + const event = new WheelEvent('wheel', { + deltaY: 100, + ctrlKey: true, + bubbles: true, + clientX: cx, + clientY: cy, + }); + document.querySelector('.flex-column.absolute-fill').dispatchEvent(event); + }, x, y); + + const afterZoomOut = await page.evaluate(() => window.model.zoom.level); + assert.strictEqual(afterZoomOut, 1, 'Ctrl+scroll down should zoom out'); + }); + + it('should zoom in with Meta+ScrollUp (macOS)', async () => { + const container = await page.$('.logs-container'); + const box = await container.boundingBox(); + const x = box.x + box.width / 2; + const y = box.y + box.height / 2; - const result = await page.evaluate(() => ({ - zoomLevel: window.model.zoom.level, - max: window.model.zoom.max, - })); + await page.mouse.move(x, y); + await page.evaluate(() => { + window.model.zoom.resetZoom(); + window.model.zoom.lastScrollTime = 0; + }); + + await page.evaluate((cx, cy) => { + const event = new WheelEvent('wheel', { + deltaY: -100, + metaKey: true, + bubbles: true, + clientX: cx, + clientY: cy, + }); + document.querySelector('.flex-column.absolute-fill').dispatchEvent(event); + }, x, y); + + const afterZoomIn = await page.evaluate(() => window.model.zoom.level); + assert.strictEqual(afterZoomIn, 1.1, 'Meta+scroll up should zoom in'); + }); + + it('should zoom out with Meta+ScrollDown (macOS)', async () => { + await page.evaluate(() => { + window.model.zoom.lastScrollTime = 0; + }); - assert.strictEqual(result.zoomLevel, result.max, 'zoom should not go above maximum'); + const container = await page.$('.logs-container'); + const box = await container.boundingBox(); + const x = box.x + box.width / 2; + const y = box.y + box.height / 2; + + await page.evaluate((cx, cy) => { + const event = new WheelEvent('wheel', { + deltaY: 100, + metaKey: true, + bubbles: true, + clientX: cx, + clientY: cy, + }); + document.querySelector('.flex-column.absolute-fill').dispatchEvent(event); + }, x, y); + + const afterZoomOut = await page.evaluate(() => window.model.zoom.level); + assert.strictEqual(afterZoomOut, 1, 'Meta+scroll down should zoom out'); + }); }); - it('should reset zoom to default level', async () => { - await page.evaluate(() => window.model.resetZoom()); + describe('Boundaries', () => { + it('should not zoom below minimum level', async () => { + for (let i = 0; i < 10; i++) { + await page.keyboard.down('Control'); + await page.keyboard.press('Minus'); + await page.keyboard.up('Control'); + } - const result = await page.evaluate(() => { - const container = document.querySelector('.logs-container'); - const style = getComputedStyle(container); - return { + const result = await page.evaluate(() => ({ zoomLevel: window.model.zoom.level, - fontSize: style.getPropertyValue('--log-font-size').trim(), - rowHeight: style.getPropertyValue('--row-height').trim(), - }; + min: window.model.zoom.min, + })); + + assert.strictEqual(result.zoomLevel, result.min, 'zoom should not go below minimum'); }); - assert.strictEqual(result.zoomLevel, 1, 'zoom level should reset to 1'); - assert.strictEqual(result.fontSize, '0.700rem', 'font size should reset to default'); - assert.strictEqual(result.rowHeight, '0.910rem', 'row height should reset to default'); + it('should not zoom above maximum level', async () => { + await page.evaluate(() => window.model.zoom.resetZoom()); + for (let i = 0; i < 35; i++) { + await page.keyboard.down('Control'); + await page.keyboard.press('Equal'); + await page.keyboard.up('Control'); + } + + const result = await page.evaluate(() => ({ + zoomLevel: window.model.zoom.level, + max: window.model.zoom.max, + })); + + assert.strictEqual(result.zoomLevel, result.max, 'zoom should not go above maximum'); + }); }); - it('should maintain row height ratio relative to font size across zoom levels', async () => { - const ratios = []; - for (let i = 0; i < 5; i++) { + describe('Reset', () => { + it('should reset zoom to default level', async () => { + await page.evaluate(() => window.model.zoom.resetZoom()); + const result = await page.evaluate(() => { - const fontSize = window.model.fontSize; - const rowHeight = window.model.rowHeightRem; - return { ratio: rowHeight / fontSize }; + const container = document.querySelector('.logs-container'); + const style = getComputedStyle(container); + return { + zoomLevel: window.model.zoom.level, + fontSize: style.getPropertyValue('--log-font-size').trim(), + rowHeight: style.getPropertyValue('--row-height').trim(), + }; }); - ratios.push(Number(result.ratio.toFixed(2))); - await page.evaluate(() => window.model.zoomIn()); - } - const allSame = ratios.every((r) => r === ratios[0]); - assert.ok(allSame, `row height / font size ratio should be constant, got: ${ratios.join(', ')}`); + assert.strictEqual(result.zoomLevel, 1, 'zoom level should reset to 1'); + assert.strictEqual(result.fontSize, '0.700rem', 'font size should reset to default'); + assert.strictEqual(result.rowHeight, '0.910rem', 'row height should reset to default'); + }); - await page.evaluate(() => window.model.resetZoom()); - }); + it('should have reset button disabled at default zoom', async () => { + await page.evaluate(() => window.model.zoom.resetZoom()); + await page.waitForFunction(() => { + const resetBtn = document.querySelector('#reset-zoom-button'); + return resetBtn && resetBtn.disabled === true; + }); + }); - it('should zoom in with mouse wheel (Ctrl+scroll up)', async () => { - const container = await page.$('.logs-container'); - const box = await container.boundingBox(); - const x = box.x + box.width / 2; - const y = box.y + box.height / 2; - - await page.mouse.move(x, y); - await page.evaluate(() => { - window.model.resetZoom(); - window.model.zoom.lastScrollTime = 0; - }); - - await page.evaluate((cx, cy) => { - const event = new WheelEvent('wheel', { - deltaY: -100, - ctrlKey: true, - bubbles: true, - clientX: cx, - clientY: cy, + it('should have reset button enabled when zoomed', async () => { + await page.evaluate(() => window.model.zoom.zoomIn()); + await page.waitForFunction(() => { + const resetBtn = document.querySelector('#reset-zoom-button'); + return resetBtn && resetBtn.disabled === false; }); - document.querySelector('.flex-column.absolute-fill').dispatchEvent(event); - }, x, y); + }); - const afterZoomIn = await page.evaluate(() => window.model.zoom.level); - assert.strictEqual(afterZoomIn, 1.1, 'Ctrl+scroll up should zoom in'); - }); + it('should reset zoom when reset button is clicked', async () => { + await page.evaluate(() => window.model.zoom.zoomIn()); - it('should zoom out with mouse wheel (Ctrl+scroll down)', async () => { - await page.evaluate(() => { window.model.zoom.lastScrollTime = 0; }); - - const container = await page.$('.logs-container'); - const box = await container.boundingBox(); - const x = box.x + box.width / 2; - const y = box.y + box.height / 2; - - await page.evaluate((cx, cy) => { - const event = new WheelEvent('wheel', { - deltaY: 100, - ctrlKey: true, - bubbles: true, - clientX: cx, - clientY: cy, - }); - document.querySelector('.flex-column.absolute-fill').dispatchEvent(event); - }, x, y); + await page.evaluate(() => document.querySelector('#reset-zoom-button').click()); - const afterZoomOut = await page.evaluate(() => window.model.zoom.level); - assert.strictEqual(afterZoomOut, 1, 'Ctrl+scroll down should zoom out'); - }); + const result = await page.evaluate(() => ({ + zoomLevel: window.model.zoom.level, + })); + + assert.strictEqual(result.zoomLevel, 1); - it('should have reset button disabled at default zoom', async () => { - await page.evaluate(() => window.model.resetZoom()); - await page.waitForFunction(() => { - const resetBtn = document.querySelector('#reset-zoom-button'); - return resetBtn && resetBtn.disabled === true; + await page.evaluate(() => window.model.zoom.resetZoom()); }); }); - it('should have reset button enabled when zoomed', async () => { - await page.evaluate(() => window.model.zoomIn()); - await page.waitForFunction(() => { - const resetBtn = document.querySelector('#reset-zoom-button'); - return resetBtn && resetBtn.disabled === false; + describe('Visual consistency', () => { + it('should maintain row height ratio relative to font size across zoom levels', async () => { + const ratios = []; + for (let i = 0; i < 5; i++) { + const result = await page.evaluate(() => { + const { fontSize } = window.model.zoom; + const { rowHeightRem: rowHeight } = window.model.zoom; + return { ratio: rowHeight / fontSize }; + }); + ratios.push(Number(result.ratio.toFixed(2))); + await page.evaluate(() => window.model.zoom.zoomIn()); + } + + const allSame = ratios.every((r) => r === ratios[0]); + assert.ok(allSame, `row height / font size ratio should be constant, got: ${ratios.join(', ')}`); + + await page.evaluate(() => window.model.zoom.resetZoom()); }); - - await page.evaluate(() => window.model.resetZoom()); }); }); From 422d40526ded2b1eee8544f50de7bcd4e52a279f Mon Sep 17 00:00:00 2001 From: Isaac Hill <71404865+isaachilly@users.noreply.github.com> Date: Fri, 22 May 2026 13:55:54 +0200 Subject: [PATCH 14/22] [OGUI-1216] Attempt to fix CI failing tests --- InfoLogger/test/public/zoom.mocha.js | 7 ++----- 1 file changed, 2 insertions(+), 5 deletions(-) diff --git a/InfoLogger/test/public/zoom.mocha.js b/InfoLogger/test/public/zoom.mocha.js index 52fd41ed7..4a23c605c 100644 --- a/InfoLogger/test/public/zoom.mocha.js +++ b/InfoLogger/test/public/zoom.mocha.js @@ -265,6 +265,7 @@ describe('Zoom test-suite', async () => { it('should have reset button enabled when zoomed', async () => { await page.evaluate(() => window.model.zoom.zoomIn()); + await page.waitForFunction(() => window.model.zoom.level === 1.1); await page.waitForFunction(() => { const resetBtn = document.querySelector('#reset-zoom-button'); return resetBtn && resetBtn.disabled === false; @@ -276,11 +277,7 @@ describe('Zoom test-suite', async () => { await page.evaluate(() => document.querySelector('#reset-zoom-button').click()); - const result = await page.evaluate(() => ({ - zoomLevel: window.model.zoom.level, - })); - - assert.strictEqual(result.zoomLevel, 1); + await page.waitForFunction(() => window.model.zoom.level === 1); await page.evaluate(() => window.model.zoom.resetZoom()); }); From 9cc691c13d637fe8a875a563c57c1f87f7db1dcd Mon Sep 17 00:00:00 2001 From: Isaac Hill <71404865+isaachilly@users.noreply.github.com> Date: Fri, 22 May 2026 15:52:08 +0200 Subject: [PATCH 15/22] [OGUI-1216] Move zoom handlers to Model Added a global wheel listener and handleWheel method to Model. --- InfoLogger/public/Model.js | 39 +++++++++++++++++++++++++++++++---- InfoLogger/public/view.js | 42 +------------------------------------- 2 files changed, 36 insertions(+), 45 deletions(-) diff --git a/InfoLogger/public/Model.js b/InfoLogger/public/Model.js index 8b3516f91..48987d06f 100644 --- a/InfoLogger/public/Model.js +++ b/InfoLogger/public/Model.js @@ -69,8 +69,9 @@ export default class Model extends Observable { this.router.bubbleTo(this); this.handleLocationChange(); // Init first page - // Setup keyboard dispatcher + // Setup keyboard and wheel dispatchers window.addEventListener('keydown', this.handleKeyboardDown.bind(this)); + window.addEventListener('wheel', this.handleWheel.bind(this), { passive: false }); // Setup WS connection this.ws = new WebSocketClient(); @@ -200,14 +201,44 @@ export default class Model extends Observable { return; } + /** + * Handles wheel events for zoom control + * @param {WheelEvent} e - wheel event + */ + handleWheel(e) { + // Only trigger zoom if Ctrl (or Cmd on Mac) is pressed + // Windows intercepts the Windows key events, so these do not reach the browser + if (!e.ctrlKey && !e.metaKey) { + return; + } + e.preventDefault(); + const now = Date.now(); + // throttle zoom to avoid too many events on fast scroll, especially on trackpads + if (now - this.zoom.lastScrollTime < 50) { + return; + } + this.zoom.lastScrollTime = now; + e.deltaY < 0 ? this.zoom.zoomIn() : this.zoom.zoomOut(); + } + /** * Delegates sub-model actions depending on incoming keyboard event * @param {Event} e - keyboard event */ handleKeyboardDown(e) { - // console.log( - // e.code, e.key,e.keyCode, e.metaKey, e.ctrlKey, e.altKey` - // ); + // Zoom shortcuts regardless of focus + if (e.ctrlKey || e.metaKey) { + if (e.key === '=' || e.key === '+') { + e.preventDefault(); + this.zoom.zoomIn(); + return; + } else if (e.key === '-') { + e.preventDefault(); + this.zoom.zoomOut(); + return; + } + } + const code = e.keyCode; // Enter diff --git a/InfoLogger/public/view.js b/InfoLogger/public/view.js index 91fe28746..35057213c 100644 --- a/InfoLogger/public/view.js +++ b/InfoLogger/public/view.js @@ -34,47 +34,7 @@ import { cellContextMenu } from './log/cellContextMenu.js'; export default (model) => [ notification(model.notification), cellContextMenu(model), - h('.flex-column absolute-fill', { - oncreate: (vnode) => { - const handleWheel = (e) => { - // Only trigger zoom if Ctrl (or Cmd on Mac) is pressed - // Windows intercepts the Windows key events, so these do not reach the browser - if (!e.ctrlKey && !e.metaKey) { - return; - } - e.preventDefault(); - const now = Date.now(); - if (now - model.zoom.lastScrollTime < 50) { - return; - } - model.zoom.lastScrollTime = now; - e.deltaY < 0 ? model.zoom.zoomIn() : model.zoom.zoomOut(); - }; - - const handleKeyDown = (e) => { - if (!e.ctrlKey && !e.metaKey) { - return; - } - // Support both '=' and '+' for zooming in, as some keyboards require Shift to type '+' - if (e.key === '=' || e.key === '+') { - e.preventDefault(); - model.zoom.zoomIn(); - } else if (e.key === '-') { - e.preventDefault(); - model.zoom.zoomOut(); - } - }; - - window.addEventListener('wheel', handleWheel, { passive: false }); - window.addEventListener('keydown', handleKeyDown); - - vnode.state.cleanup = () => { - window.removeEventListener('wheel', handleWheel); - window.removeEventListener('keydown', handleKeyDown); - }; - }, - onremove: (vnode) => vnode.state.cleanup(), - }, [ + h('.flex-column absolute-fill', [ h('.shadow-level2', [ h('header.p1.flex-row.f7', [ h('', commandLogs(model)), From 99286b200bc35a239f38c39e8c4f1420ea29eab2 Mon Sep 17 00:00:00 2001 From: Isaac Hill <71404865+isaachilly@users.noreply.github.com> Date: Fri, 22 May 2026 16:25:41 +0200 Subject: [PATCH 16/22] [OGUI-1216] Add zoom in/out buttons and tests MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace the single reset-zoom button with a .btn-group. Make space by taking away Reset text and not using icons. Adds new tests covering clicking +/− changes the zoom level, and that the +/- buttons become disabled at min/max and enabled at the default zoom. --- InfoLogger/public/log/commandLogs.js | 28 ++++++++++++++--- InfoLogger/test/public/zoom.mocha.js | 47 ++++++++++++++++++++++++++++ 2 files changed, 70 insertions(+), 5 deletions(-) diff --git a/InfoLogger/public/log/commandLogs.js b/InfoLogger/public/log/commandLogs.js index 0f1e2c13b..649cb11a7 100644 --- a/InfoLogger/public/log/commandLogs.js +++ b/InfoLogger/public/log/commandLogs.js @@ -18,6 +18,8 @@ import { h, iconMediaStop, iconDataTransferDownload, iconMagnifyingGlass, + iconPlus, + iconMinus, } from '/js/src/index.js'; import { BUTTON } from '../constants/button-states.const.js'; import { MODE } from '../constants/mode.const.js'; @@ -64,11 +66,27 @@ export default (model) => [ title: 'Go to last log message (ALT + down arrow)', }, '↓'), downloadButtonGroup(model.log), - h('button.btn.flex-row', { - onclick: () => model.zoom.resetZoom(), - disabled: model.zoom.level === 1, - id: 'reset-zoom-button', - }, h('span', ['Reset ', iconMagnifyingGlass()])), + h('.btn-group', [ + h('button.btn', { + onclick: () => model.zoom.zoomOut(), + disabled: model.zoom.level <= model.zoom.min, + id: 'zoom-out-button', + title: 'Zoom out (Ctrl/Cmd + -)', + // span makes the icons off center vertically, but just a div makes the height wrong + }, h('span', { style: 'font-size:0.8em;' }, iconMinus())), + h('button.btn', { + onclick: () => model.zoom.resetZoom(), + disabled: model.zoom.level === 1, + id: 'reset-zoom-button', + title: 'Reset zoom', + }, h('span', { style: 'font-size:0.9em;' }, iconMagnifyingGlass())), + h('button.btn', { + onclick: () => model.zoom.zoomIn(), + disabled: model.zoom.level >= model.zoom.max, + id: 'zoom-in-button', + title: 'Zoom in (Ctrl/Cmd + +)', + }, h('span', { style: 'font-size:0.8em;' }, iconPlus())), + ]), ]; /** diff --git a/InfoLogger/test/public/zoom.mocha.js b/InfoLogger/test/public/zoom.mocha.js index 4a23c605c..e6635cd5e 100644 --- a/InfoLogger/test/public/zoom.mocha.js +++ b/InfoLogger/test/public/zoom.mocha.js @@ -254,6 +254,12 @@ describe('Zoom test-suite', async () => { assert.strictEqual(result.fontSize, '0.700rem', 'font size should reset to default'); assert.strictEqual(result.rowHeight, '0.910rem', 'row height should reset to default'); }); + }); + + describe('Zoom buttons', () => { + beforeEach(async () => { + await page.evaluate(() => window.model.zoom.resetZoom()); + }); it('should have reset button disabled at default zoom', async () => { await page.evaluate(() => window.model.zoom.resetZoom()); @@ -281,6 +287,47 @@ describe('Zoom test-suite', async () => { await page.evaluate(() => window.model.zoom.resetZoom()); }); + + it('should enable both -/+ buttons at default zoom', async () => { + await page.waitForFunction(() => { + const zoomIn = document.querySelector('#zoom-in-button'); + const zoomOut = document.querySelector('#zoom-out-button'); + return zoomIn && !zoomIn.disabled && zoomOut && !zoomOut.disabled; + }); + }); + + it('should zoom in when + button is clicked', async () => { + await page.evaluate(() => document.querySelector('#zoom-in-button').click()); + const level = await page.evaluate(() => window.model.zoom.level); + assert.strictEqual(level, 1.1); + }); + + it('should zoom out when - button is clicked', async () => { + await page.evaluate(() => window.model.zoom.zoomIn()); + await page.evaluate(() => document.querySelector('#zoom-out-button').click()); + const level = await page.evaluate(() => window.model.zoom.level); + assert.strictEqual(level, 1); + }); + + it('should disable - button at minimum zoom', async () => { + await page.evaluate(() => { + window.model.zoom.level = window.model.zoom.min; + }); + await page.waitForFunction(() => { + const btn = document.querySelector('#zoom-out-button'); + return btn && btn.disabled === true; + }); + }); + + it('should disable + button at maximum zoom', async () => { + await page.evaluate(() => { + window.model.zoom.level = window.model.zoom.max; + }); + await page.waitForFunction(() => { + const btn = document.querySelector('#zoom-in-button'); + return btn && btn.disabled === true; + }); + }); }); describe('Visual consistency', () => { From 39c7b98a0b8f584b72e543313a0e25371a4e3f81 Mon Sep 17 00:00:00 2001 From: Isaac Hill <71404865+isaachilly@users.noreply.github.com> Date: Fri, 22 May 2026 16:32:34 +0200 Subject: [PATCH 17/22] [OGUI-1216] Extract zoom controls into zoomButtonGroup --- InfoLogger/public/log/commandLogs.js | 49 ++++++++++++++++------------ 1 file changed, 28 insertions(+), 21 deletions(-) diff --git a/InfoLogger/public/log/commandLogs.js b/InfoLogger/public/log/commandLogs.js index 649cb11a7..9fb9c8ebd 100644 --- a/InfoLogger/public/log/commandLogs.js +++ b/InfoLogger/public/log/commandLogs.js @@ -66,27 +66,7 @@ export default (model) => [ title: 'Go to last log message (ALT + down arrow)', }, '↓'), downloadButtonGroup(model.log), - h('.btn-group', [ - h('button.btn', { - onclick: () => model.zoom.zoomOut(), - disabled: model.zoom.level <= model.zoom.min, - id: 'zoom-out-button', - title: 'Zoom out (Ctrl/Cmd + -)', - // span makes the icons off center vertically, but just a div makes the height wrong - }, h('span', { style: 'font-size:0.8em;' }, iconMinus())), - h('button.btn', { - onclick: () => model.zoom.resetZoom(), - disabled: model.zoom.level === 1, - id: 'reset-zoom-button', - title: 'Reset zoom', - }, h('span', { style: 'font-size:0.9em;' }, iconMagnifyingGlass())), - h('button.btn', { - onclick: () => model.zoom.zoomIn(), - disabled: model.zoom.level >= model.zoom.max, - id: 'zoom-in-button', - title: 'Zoom in (Ctrl/Cmd + +)', - }, h('span', { style: 'font-size:0.8em;' }, iconPlus())), - ]), + zoomButtonGroup(model.zoom), ]; /** @@ -179,6 +159,33 @@ const downloadButtonGroup = (logModel) => ]), ]); +/** + * Group of buttons for controlling log table zoom level + * @param {Zoom} zoom - the zoom model + * @returns {vnode} - the view of the zoom button group + */ +const zoomButtonGroup = (zoom) => + h('.btn-group', [ + h('button.btn', { + onclick: () => zoom.zoomOut(), + disabled: zoom.level <= zoom.min, + id: 'zoom-out-button', + title: 'Zoom out (Ctrl/Cmd + -)', + }, h('span', { style: 'font-size:0.8em' }, iconMinus())), + h('button.btn', { + onclick: () => zoom.resetZoom(), + disabled: zoom.level === 1, + id: 'reset-zoom-button', + title: 'Reset zoom', + }, h('span', { style: 'font-size:0.9em' }, iconMagnifyingGlass())), + h('button.btn', { + onclick: () => zoom.zoomIn(), + disabled: zoom.level >= zoom.max, + id: 'zoom-in-button', + title: 'Zoom in (Ctrl/Cmd + +)', + }, h('span', { style: 'font-size:0.8em' }, iconPlus())), + ]); + /** * Live button final state depends on the following states * - services lookup From 48c48cd09b5408af11ddd011f5768de57e122ee5 Mon Sep 17 00:00:00 2001 From: Isaac Hill <71404865+isaachilly@users.noreply.github.com> Date: Fri, 22 May 2026 16:41:56 +0200 Subject: [PATCH 18/22] [OGUI-1216] Wait for zoom reset in beforeEach --- InfoLogger/test/public/zoom.mocha.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/InfoLogger/test/public/zoom.mocha.js b/InfoLogger/test/public/zoom.mocha.js index e6635cd5e..6a2a4a482 100644 --- a/InfoLogger/test/public/zoom.mocha.js +++ b/InfoLogger/test/public/zoom.mocha.js @@ -259,10 +259,10 @@ describe('Zoom test-suite', async () => { describe('Zoom buttons', () => { beforeEach(async () => { await page.evaluate(() => window.model.zoom.resetZoom()); + await page.waitForFunction(() => window.model.zoom.level === 1); }); it('should have reset button disabled at default zoom', async () => { - await page.evaluate(() => window.model.zoom.resetZoom()); await page.waitForFunction(() => { const resetBtn = document.querySelector('#reset-zoom-button'); return resetBtn && resetBtn.disabled === true; From 41ed25de9b498c6f2b237bbafdff492dc2d4b68d Mon Sep 17 00:00:00 2001 From: Isaac Hill <71404865+isaachilly@users.noreply.github.com> Date: Fri, 22 May 2026 17:21:53 +0200 Subject: [PATCH 19/22] [OGUI-1216] Fixes to help solve CI/CD issues Co-Authored-By: George R. <9214854+graduta@users.noreply.github.com> --- InfoLogger/test/public/zoom.mocha.js | 18 ++++++++++++++---- 1 file changed, 14 insertions(+), 4 deletions(-) diff --git a/InfoLogger/test/public/zoom.mocha.js b/InfoLogger/test/public/zoom.mocha.js index 6a2a4a482..b5f2f174b 100644 --- a/InfoLogger/test/public/zoom.mocha.js +++ b/InfoLogger/test/public/zoom.mocha.js @@ -19,7 +19,8 @@ describe('Zoom test-suite', async () => { let page = null; before(async () => { - ({ page } = test); + ({ helpers: { baseUrl }, page } = test); + await page.goto(baseUrl, { waitUntil: 'networkidle0' }); }); describe('Default state', () => { @@ -259,10 +260,14 @@ describe('Zoom test-suite', async () => { describe('Zoom buttons', () => { beforeEach(async () => { await page.evaluate(() => window.model.zoom.resetZoom()); - await page.waitForFunction(() => window.model.zoom.level === 1); + await page.waitForFunction(() => { + const resetBtn = document.querySelector('#reset-zoom-button'); + return resetBtn && resetBtn.disabled === true; + }); }); it('should have reset button disabled at default zoom', async () => { + await page.evaluate(() => window.model.zoom.resetZoom()); await page.waitForFunction(() => { const resetBtn = document.querySelector('#reset-zoom-button'); return resetBtn && resetBtn.disabled === true; @@ -280,12 +285,15 @@ describe('Zoom test-suite', async () => { it('should reset zoom when reset button is clicked', async () => { await page.evaluate(() => window.model.zoom.zoomIn()); + await page.waitForFunction(() => window.model.zoom.level === 1.1); + await page.waitForFunction(() => { + const resetBtn = document.querySelector('#reset-zoom-button'); + return resetBtn && resetBtn.disabled === false; + }); await page.evaluate(() => document.querySelector('#reset-zoom-button').click()); await page.waitForFunction(() => window.model.zoom.level === 1); - - await page.evaluate(() => window.model.zoom.resetZoom()); }); it('should enable both -/+ buttons at default zoom', async () => { @@ -312,6 +320,7 @@ describe('Zoom test-suite', async () => { it('should disable - button at minimum zoom', async () => { await page.evaluate(() => { window.model.zoom.level = window.model.zoom.min; + window.model.zoom.notify(); }); await page.waitForFunction(() => { const btn = document.querySelector('#zoom-out-button'); @@ -322,6 +331,7 @@ describe('Zoom test-suite', async () => { it('should disable + button at maximum zoom', async () => { await page.evaluate(() => { window.model.zoom.level = window.model.zoom.max; + window.model.zoom.notify(); }); await page.waitForFunction(() => { const btn = document.querySelector('#zoom-in-button'); From a7468acf00700af8047ea3a100536e122aca10f8 Mon Sep 17 00:00:00 2001 From: Isaac Hill <71404865+isaachilly@users.noreply.github.com> Date: Fri, 22 May 2026 17:22:23 +0200 Subject: [PATCH 20/22] [OGUI-1216] Make viewport for mocha ILG tests 15" Co-Authored-By: George R. <9214854+graduta@users.noreply.github.com> --- InfoLogger/test/mocha-index.js | 1 + 1 file changed, 1 insertion(+) diff --git a/InfoLogger/test/mocha-index.js b/InfoLogger/test/mocha-index.js index 17b8bf7d2..c31d8c1e2 100644 --- a/InfoLogger/test/mocha-index.js +++ b/InfoLogger/test/mocha-index.js @@ -70,6 +70,7 @@ describe('InfoLogger', function() { // Start browser to test UI browser = await puppeteer.launch({headless: 'new', args: ['--no-sandbox', '--disable-setuid-sandbox']}); page = await browser.newPage(); + await page.setViewport({ width: 1440, height: 900 }); // 15" screen equivalent // Export page and configurations for the other mocha files exports.page = page; From 03392589234f62e95ae1f0e5b62738b427ad44f6 Mon Sep 17 00:00:00 2001 From: Isaac Hill <71404865+isaachilly@users.noreply.github.com> Date: Fri, 22 May 2026 17:33:16 +0200 Subject: [PATCH 21/22] [OGUI-1216] Fix codeQL raised issue Co-Authored-By: George R. <9214854+graduta@users.noreply.github.com> --- InfoLogger/test/public/zoom.mocha.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/InfoLogger/test/public/zoom.mocha.js b/InfoLogger/test/public/zoom.mocha.js index b5f2f174b..5c86a52f5 100644 --- a/InfoLogger/test/public/zoom.mocha.js +++ b/InfoLogger/test/public/zoom.mocha.js @@ -19,8 +19,8 @@ describe('Zoom test-suite', async () => { let page = null; before(async () => { - ({ helpers: { baseUrl }, page } = test); - await page.goto(baseUrl, { waitUntil: 'networkidle0' }); + ({ page } = test); + await page.goto(test.helpers.baseUrl, { waitUntil: 'networkidle0' }); }); describe('Default state', () => { From d1a7283a14bd22fca1a06077c1dc2c4754533715 Mon Sep 17 00:00:00 2001 From: Isaac Hill <71404865+isaachilly@users.noreply.github.com> Date: Fri, 22 May 2026 17:40:34 +0200 Subject: [PATCH 22/22] [OGUI-1216] Remove QC erroneous commit Co-Authored-By: George R. <9214854+graduta@users.noreply.github.com> --- QualityControl/docker-compose.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/QualityControl/docker-compose.yml b/QualityControl/docker-compose.yml index 5828d8206..f191ad11d 100644 --- a/QualityControl/docker-compose.yml +++ b/QualityControl/docker-compose.yml @@ -6,7 +6,7 @@ services: environment: MYSQL_ROOT_PASSWORD: "${MYSQL_ROOT_PASSWORD:-cern}" ports: - - "3307:3306" + - "3306:3306" volumes: - type: volume source: database-data