diff --git a/InfoLogger/public/log/Log.js b/InfoLogger/public/log/Log.js index 03608b52c..a98171394 100644 --- a/InfoLogger/public/log/Log.js +++ b/InfoLogger/public/log/Log.js @@ -575,11 +575,12 @@ export default class Log extends Observable { } /** - * Enable or disable auto-scroll for live mode, a checkbox is used to control it + * Enable auto-scroll and focus the log table */ - toggleAutoScroll() { - this.autoScrollLive = !this.autoScrollLive; + enableAutoScrollLive() { + this.autoScrollLive = true; this.dom.table.focus(); + this.dom.table.scrollTo(0, this.dom.table.scrollHeight); this.notify(); } diff --git a/InfoLogger/public/log/commandLogs.js b/InfoLogger/public/log/commandLogs.js index 1b10e0d54..840ecec00 100644 --- a/InfoLogger/public/log/commandLogs.js +++ b/InfoLogger/public/log/commandLogs.js @@ -42,7 +42,7 @@ const LIVE_BUTTON_TYPES_BY_MODE = { export const commandLogs = (model) => [ userActionsDropdown(model), h('', interactionModesGroupButton(model)), - h('', h('button.btn', { onclick: () => model.log.empty(), style: 'font-weight: bold' }, 'Clear')), + h('', h('button.btn', { id: 'clear-button', onclick: () => model.log.empty(), style: 'font-weight: bold' }, 'Clear')), h('.btn-group', [ h('button.btn', { disabled: !model.log.list.length, diff --git a/InfoLogger/public/log/statusBar.js b/InfoLogger/public/log/statusBar.js index 79e08f66f..423788132 100644 --- a/InfoLogger/public/log/statusBar.js +++ b/InfoLogger/public/log/statusBar.js @@ -13,6 +13,7 @@ */ import { h } from '/js/src/index.js'; +import { MODE } from '../constants/mode.const.js'; /** * Bottom bar, showing status of the log's list and its details, @@ -96,11 +97,23 @@ const applicationMessage = (model) => model.log.list.length > model.log.applicat * @returns {vnode} - the view of the application options */ const applicationOptions = (model) => [ - h('label.d-inline', { title: 'Scroll down in live mode on new log incoming' }, h('input', { - type: 'checkbox', - checked: model.log.autoScrollLive, - onchange: () => model.log.toggleAutoScroll(), - }), ' Autoscroll'), + model.log.activeMode === MODE.LIVE.RUNNING ? h( + 'label.d-inline', + { + title: model.log.autoScrollLive + ? 'Autoscroll is active' + : 'Click here / Scroll down to the bottom of the screen to reactivate', + }, + h( + 'span', + { + id: 'status-bar-auto-scroll', + className: model.log.autoScrollLive ? 'success' : 'warning actionable-icon', + onclick: model.log.autoScrollLive ? undefined : () => model.log.enableAutoScrollLive(), + }, + model.log.autoScrollLive ? 'Autoscroll Active' : '⚠️ Autoscroll Inactive', + ), + ) : null, h('span.mh1'), h('label.d-inline', { title: 'Show details of selected log' }, h('input', { type: 'checkbox', diff --git a/InfoLogger/public/log/tableLogsContent.js b/InfoLogger/public/log/tableLogsContent.js index 2250078ca..bb2675a56 100644 --- a/InfoLogger/public/log/tableLogsContent.js +++ b/InfoLogger/public/log/tableLogsContent.js @@ -203,30 +203,130 @@ const tableContainerHooks = (model) => ({ * @param {vnode} vnode - the vnode of the element */ oncreate(vnode) { + const container = vnode.dom; + /** - * THis handler allow to notify model of element scrolling change (.tableLogsContent) + * Update the viewport size and scroll position in the model */ - const onTableScroll = () => { - const container = vnode.dom; + const updateViewport = () => { const { height } = container.getBoundingClientRect(); const scrollTop = Math.max(container.scrollTop, 0); // cancel negative position due to Safari bounce scrolling - if (container.scrollTop < model.log.scrollTop) { - model.log.autoScrollLive = false; // stop auto-scrolling if user scrolls up - } model.log.setScrollTop(scrollTop, height); }; - // call the function when scrolling is updated - vnode.dom.addEventListener('scroll', onTableScroll); - model.log.dom.table = vnode.dom; + /** + * Whether the viewport is at the bottom of the table, <=1 tolerates fractional heights (zoom / HiDPI) + * @returns {boolean} true if at the bottom + */ + const isAtBottom = () => container.scrollHeight - Math.max(container.scrollTop, 0) - container.clientHeight <= 1; + + /** + * Record the direction of a user scroll; scrolling up disables auto-scroll straight away. + * Scroll events alone cannot tell a user scroll from a layout change (zoom, new logs, table emptied), + * so the user's intent is taken from their input instead. + * @param {boolean} isUp - true if the user scrolls towards older logs + */ + const onUserScroll = (isUp) => { + container.isUserScrollingDown = !isUp; + if (isUp && model.log.autoScrollLive && container.scrollTop > 0) { + model.log.autoScrollLive = false; + model.notify(); + } + }; + + /** + * Resume auto-scroll when the user scrolls to the bottom in live mode. + * Called from the input itself rather than waiting for the scroll to land, as a smooth scroll + * animation targets the bottom at the time of the input and falls short if logs arrive meanwhile. + */ + const resumeAutoScroll = () => { + if (model.log.isLiveModeRunning() && !model.log.autoScrollLive) { + model.log.autoScrollLive = true; + model.notify(); + } + }; + + /** + * Mouse wheel and touchpad scrolling + * @param {WheelEvent} e - wheel event + */ + const onWheel = (e) => { + // ctrl/cmd + wheel is zoom, handled by Model + if (e.ctrlKey || e.metaKey) { + return; + } + onUserScroll(e.deltaY < 0); + const maxScrollTop = container.scrollHeight - container.clientHeight; + if (e.deltaY > 0 && container.scrollTop + e.deltaY >= maxScrollTop - 1) { + resumeAutoScroll(); + } + }; + + /** + * Keyboard scrolling, arrow keys are handled by Model to move the selected log + * @param {KeyboardEvent} e - keyboard event + */ + const onKeyDown = (e) => { + if (e.target.tagName.toLowerCase() === 'input') { + return; + } + if (e.key === 'PageUp' || e.key === 'Home') { + onUserScroll(true); + } else if (e.key === 'End') { + onUserScroll(false); + resumeAutoScroll(); + } else if (e.key === 'PageDown') { + onUserScroll(false); + } + }; + + /** + * A pointer pressed on the container itself rather than a row is on the scrollbar + * @param {PointerEvent} e - pointer event + */ + const onPointerDown = (e) => { + container.isDraggingScrollbar = e.target === container; + }; + + /** + * End of a scrollbar drag + */ + const onPointerUp = () => { + container.isDraggingScrollbar = false; + }; + + /** + * Re-enable auto-scroll when the user reaches the bottom, then update the viewport. + * Disabling is done by the user input handlers above. + */ + const onTableScroll = () => { + // programmatic scroll is set when jumping to a selected log (e.g. the error navigation buttons) + if (container.isProgrammaticScroll) { + container.isProgrammaticScroll = false; + } else if (model.log.isLiveModeRunning()) { + if (container.isDraggingScrollbar) { + model.log.autoScrollLive = isAtBottom(); + } else if (container.isUserScrollingDown && isAtBottom()) { + model.log.autoScrollLive = true; + } + } + updateViewport(); + }; + + container.addEventListener('scroll', onTableScroll); + container.addEventListener('wheel', onWheel, { passive: true }); + container.addEventListener('pointerdown', onPointerDown); + window.addEventListener('pointerup', onPointerUp); + window.addEventListener('keydown', onKeyDown); + model.log.dom.table = container; // setup window size listener - view needs redraw for smart scrolling - window.addEventListener('resize', onTableScroll); + window.addEventListener('resize', updateViewport); - // remember this function for later (destroy) - vnode.dom.onTableScroll = onTableScroll; + // remember these functions for later (destroy) + Object.assign(container, { onTableScroll, onWheel, onPointerDown, onPointerUp, onKeyDown, updateViewport }); // call the function once on next frame when we know sizes - onTableScroll(); + updateViewport(); }, /** @@ -242,8 +342,13 @@ const tableContainerHooks = (model) => ({ * @param {vnode} vnode - the vnode of the element */ ondestroy(vnode) { - vnode.dom.removeEventListener('scroll', vnode.dom.onTableScroll); - window.removeEventListener('resize', vnode.dom.onTableScroll); + const container = vnode.dom; + container.removeEventListener('scroll', container.onTableScroll); + container.removeEventListener('wheel', container.onWheel); + container.removeEventListener('pointerdown', container.onPointerDown); + window.removeEventListener('pointerup', container.onPointerUp); + window.removeEventListener('keydown', container.onKeyDown); + window.removeEventListener('resize', container.updateViewport); }, }); @@ -281,7 +386,12 @@ const autoscrollManager = (model, vnode) => { const index = model.log.list.indexOf(model.log.item); const positionRow = model.log.rowHeight * index; const halfView = model.log.scrollHeight / 2; + const previousScrollTop = vnode.dom.scrollTop; vnode.dom.scrollTo(0, positionRow - halfView); + // scrollTo updates scrollTop synchronously; the event fires later and only if the position moved + if (vnode.dom.scrollTop !== previousScrollTop) { + vnode.dom.isProgrammaticScroll = true; + } } // Save the fact that we changed `item` diff --git a/InfoLogger/test/mocha-index.js b/InfoLogger/test/mocha-index.js index b8e50c737..2ee746510 100644 --- a/InfoLogger/test/mocha-index.js +++ b/InfoLogger/test/mocha-index.js @@ -115,6 +115,7 @@ describe('InfoLogger', function () { require('./public/status-bar-mocha'); require('./public/zoom.mocha'); require('./public/log-context-menu-mocha'); + require('./public/logs-table-mocha'); after(async () => { await browser.close(); diff --git a/InfoLogger/test/public/live-mode-mocha.js b/InfoLogger/test/public/live-mode-mocha.js index 271786f8e..7664dce16 100644 --- a/InfoLogger/test/public/live-mode-mocha.js +++ b/InfoLogger/test/public/live-mode-mocha.js @@ -24,6 +24,10 @@ describe('Live Mode test-suite', async () => { ({ helpers: { baseUrl }, page } = test); }); + after(async () => { + await page.evaluate(() => model.log.liveStop('Query')); + }); + it('should successfully go to homepage with predefined filters', async () => { await page.goto(baseUrl, { waitUntil: 'networkidle0' }); const location = await page.evaluate(() => window.location); diff --git a/InfoLogger/test/public/log-context-menu-mocha.js b/InfoLogger/test/public/log-context-menu-mocha.js index ed2ba06e6..d194b37d3 100644 --- a/InfoLogger/test/public/log-context-menu-mocha.js +++ b/InfoLogger/test/public/log-context-menu-mocha.js @@ -91,6 +91,10 @@ describe('Cell Context Menu', async () => { }); }); + after(async () => { + await page.evaluate(() => model.log.contextMenu.hide()); + }); + describe('Menu visibility', async () => { it('should show context menu on right-click', async () => { await page.evaluate(() => { diff --git a/InfoLogger/test/public/logs-table-mocha.js b/InfoLogger/test/public/logs-table-mocha.js new file mode 100644 index 000000000..01e016fcc --- /dev/null +++ b/InfoLogger/test/public/logs-table-mocha.js @@ -0,0 +1,162 @@ +/** + * @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'); +const { injectLogs } = require('../utils/utils'); + +/** + * Fills the logs table with 200 logs and scrolls to the bottom to test autoscroll behavior. + * @param {Page} page - puppeteer page + */ +const fillTableAndScrollToBottom = async (page) => { + // ensure table has more rows than fit on the screen + await injectLogs(page, Array.from({ length: 200 }, (_, i) => ({ + severity: 'I', + message: `info log ${i}`, + timestamp: Date.now() + i, + }))); + + await page.evaluate(() => model.log.goToLastItem()); + await page.waitForFunction(() => model.log.scrollTop > 0, { timeout: 5000 }); +}; + +/** + * Asserts that autoScrollLive is the expected value. + * @param {Page} page - puppeteer page + * @param {boolean} expected - expected value of autoScrollLive + */ +const assertAutoScrollLive = async (page, expected) => { + assert.strictEqual(await page.evaluate(() => model.log.autoScrollLive), expected); +}; + +/** + * Scrolls the logs table with a real mouse wheel event, as autoscroll reacts to user input rather than scroll position + * @param {Page} page - puppeteer page + * @param {number} deltaY - wheel delta, negative scrolls up + */ +const wheelOverTable = async (page, deltaY) => { + const box = await (await page.$('.tableLogsContent')).boundingBox(); + await page.mouse.move(box.x + box.width / 2, box.y + box.height / 2); + await page.mouse.wheel({ deltaY }); +}; + +/** + * Allows waiting for a specified number of animation frames + * Useful when dealing with scroll and layout changes that require dealing with what occurs in each animation frame. + * @param {Page} page - puppeteer page + * @param {*} frames - number of frames to wait for + * @returns {Promise} resolves after the specified number of animation frames + */ +const waitForAnimationFrame = (page, frames = 1) => + page.evaluate((frames) => new Promise((resolve) => { + const step = () => { + if (frames <= 0) { + resolve(); + } else { + frames--; + requestAnimationFrame(step); + } + }; + step(); + }), frames); + +describe('Logs Table test-suite', async () => { + let page = null; + let baseUrl = null; + + before(async () => { + ({ helpers: { baseUrl }, page } = test); + }); + + beforeEach(async () => { + await page.goto(baseUrl, { waitUntil: 'networkidle0' }); + }); + + after(async () => { + await page.evaluate(() => model.log.liveStop('Query')); + }); + + describe('Autoscroll behavior', async () => { + describe('in live mode', async () => { + beforeEach(async () => { + await page.evaluate(() => model.log.liveStop('Query')); + }); + + it('should disable autoscroll when the user scrolls up', async () => { + await page.click('#live-button'); + await assertAutoScrollLive(page, true); + + // wait until live logs overflow the table and autoscroll has moved it down + await page.waitForFunction(() => model.log.scrollTop > 0, { timeout: 5000 }); + + await wheelOverTable(page, -100); + await waitForAnimationFrame(page, 2); + + await assertAutoScrollLive(page, false); + }); + + it('should re-enable autoscroll when the user scrolls back to the bottom', async () => { + await page.click('#live-button'); + await page.waitForFunction(() => model.log.scrollTop > 0, { timeout: 5000 }); + + await wheelOverTable(page, -100); + await waitForAnimationFrame(page, 2); + await assertAutoScrollLive(page, false); + + await wheelOverTable(page, 100000); + await waitForAnimationFrame(page, 2); + await page.waitForFunction(() => model.log.autoScrollLive === true, { timeout: 5000 }); + }); + + it('should not disable autoscroll when switching from a full query table to live mode', async () => { + await fillTableAndScrollToBottom(page); + + await page.click('#live-button'); + await waitForAnimationFrame(page, 2); + + await assertAutoScrollLive(page, true); + }); + + it('should not disable autoscroll when the log list is cleared', async () => { + await page.click('#live-button'); + await assertAutoScrollLive(page, true); + + // wait until live logs overflow the table and autoscroll has moved it down + await page.waitForFunction(() => model.log.scrollTop > 0, { timeout: 5000 }); + + await page.click('#clear-button'); + await waitForAnimationFrame(page, 2); + + await assertAutoScrollLive(page, true); + }); + + it('should not disable autoscroll when zooming in', async () => { + await page.click('#live-button'); + await assertAutoScrollLive(page, true); + await page.waitForFunction(() => model.log.scrollTop > 0, { timeout: 5000 }); + + await page.evaluate(() => new Promise((resolve) => { + // queue a scroll event as the autoscroll jump does (1px still counts as the bottom)... + model.log.dom.table.scrollTop -= 1; + // ...and zoom before that event is handled, as with a fast zoom click + model.zoom.zoomIn(); + requestAnimationFrame(() => requestAnimationFrame(resolve)); + })); + + await assertAutoScrollLive(page, true); + }); + }); + }); +}); diff --git a/InfoLogger/test/public/status-bar-mocha.js b/InfoLogger/test/public/status-bar-mocha.js index 3c0d46609..6795f3b0f 100644 --- a/InfoLogger/test/public/status-bar-mocha.js +++ b/InfoLogger/test/public/status-bar-mocha.js @@ -35,7 +35,7 @@ async function getSeverityCounts(page) { } describe('Status Bar test-suite', async () => { - const AUTOSCROLL_SELECTOR = '#status-bar-application-options label[title*="Scroll down"] input'; + const AUTOSCROLL_SELECTOR = '#status-bar-auto-scroll'; const INSPECTOR_SELECTOR = '#status-bar-application-options label[title*="Show details"] input'; const SQL_QUERY = 'SELECT * FROM `messages` WHERE `timestamp`>=? AND `severity` IN (?) ' + 'ORDER BY `TIMESTAMP` LIMIT 100000'; @@ -47,6 +47,10 @@ describe('Status Bar test-suite', async () => { await page.goto(test.helpers.baseUrl, { waitUntil: 'networkidle0' }); }); + after(async () => { + await page.evaluate(() => model.log.liveStop('Query')); + }); + it('should be visible on the page', async () => { const statusBar = await page.$('#status-bar'); assert.ok(statusBar, 'status bar not found'); @@ -319,14 +323,71 @@ describe('Status Bar test-suite', async () => { }); describe('Application Options', () => { - it('should toggle autoscroll when its checkbox is clicked', async () => { - await page.click(AUTOSCROLL_SELECTOR); + describe('Autoscroll Option', () => { + before(async () => { + await page.waitForSelector('#live-button:not([disabled])'); + }); - const checkedAfter = await page.$eval(AUTOSCROLL_SELECTOR, (el) => el.checked); - const autoScrollModelValue = await page.evaluate(() => window.model.log.autoScrollLive); + it('should not display autoscroll option when in query mode', async () => { + const autoscrollOption = await page.$(AUTOSCROLL_SELECTOR); + assert.strictEqual(autoscrollOption, null); + }); - assert.strictEqual(autoScrollModelValue, checkedAfter); - assert.strictEqual(checkedAfter, true); + it('should display autoscroll option when in live running mode', async () => { + // running mode + await page.click('#live-button'); + + const autoscrollOption = await page.waitForSelector(AUTOSCROLL_SELECTOR); + assert.notStrictEqual(autoscrollOption, null); + }); + + it('should not display autoscroll option when in live paused mode', async () => { + await page.click('#live-button'); + + await page.waitForFunction( + (selector) => !document.querySelector(selector), + { timeout: 2000 }, + AUTOSCROLL_SELECTOR, + ); + }); + + it('should show autoscroll as active when enabled in live mode', async () => { + await page.click('#live-button'); + await page.waitForSelector(AUTOSCROLL_SELECTOR); + + const message = 'Autoscroll Active'; + await page.waitForFunction( + (selector, text) => document.querySelector(selector)?.textContent.includes(text), + {}, + AUTOSCROLL_SELECTOR, + message, + ); + }); + + it('should show autoscroll as inactive when disabled in live mode', async () => { + await page.waitForSelector(AUTOSCROLL_SELECTOR); + await page.evaluate(() => { + model.log.autoScrollLive = false; + model.notify(); + }); + const message = '⚠️ Autoscroll Inactive'; + await page.waitForFunction( + (selector, text) => document.querySelector(selector)?.textContent.includes(text), + {}, + AUTOSCROLL_SELECTOR, + message, + ); + }); + + it('should re-enable autoscroll when the inactive badge is clicked', async () => { + await page.click(AUTOSCROLL_SELECTOR); + await page.waitForFunction( + (selector) => document.querySelector(selector)?.textContent.includes('Autoscroll Active'), + {}, + AUTOSCROLL_SELECTOR, + ); + assert.strictEqual(await page.evaluate(() => model.log.autoScrollLive), true); + }); }); it('should toggle inspector when its checkbox is clicked', async () => {