diff --git a/InfoLogger/public/log/Log.js b/InfoLogger/public/log/Log.js index db9af7368..a92b2a224 100644 --- a/InfoLogger/public/log/Log.js +++ b/InfoLogger/public/log/Log.js @@ -547,15 +547,20 @@ export default class Log extends Observable { } /** - * Empty the list of all logs, reset stats, clear query mode request if any - * and close the inspector panel + * Empty the list of all logs, reset stats, clear query mode request if any, + * deselect the current log, close the inspector panel and re-enable auto-scroll in live mode */ empty() { this.list = []; + this.item = null; + this.autoScrollToItem = false; this.limitReached = null; this.model.inspectorEnabled = false; this.resetStats(); this.queryResult = RemoteData.notAsked(); + if (this.isLiveModeRunning()) { + this.setAutoScrollLive(true, false); + } this.notify(); } @@ -575,11 +580,19 @@ export default class Log extends Observable { } /** - * Enable auto-scroll for live mode and scroll to the bottom of the table + * Set auto-scroll for live mode and optionally notify the UI + * @param {boolean} enabled - whether auto-scroll should be enabled + * @param {boolean} notify - whether to notify the UI if there is a change */ - enableAutoScrollLiveAndNotify() { - this.autoScrollLive = true; - this.notify(); + setAutoScrollLive(enabled, notify = true) { + if (this.autoScrollLive === enabled) { + return; + } + this.autoScrollLive = enabled; + + if (notify) { + 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 bbcfa3d26..d820ce2d0 100644 --- a/InfoLogger/public/log/statusBar.js +++ b/InfoLogger/public/log/statusBar.js @@ -107,7 +107,7 @@ const applicationOptions = (model) => [ { id: 'status-bar-auto-scroll', title: 'Click here to re-enable autoscroll', - onclick: () => model.log.enableAutoScrollLiveAndNotify(), + onclick: () => model.log.setAutoScrollLive(true), }, [ iconWarning(), diff --git a/InfoLogger/public/log/tableLogsContent.js b/InfoLogger/public/log/tableLogsContent.js index b72db7a20..1552cdc70 100644 --- a/InfoLogger/public/log/tableLogsContent.js +++ b/InfoLogger/public/log/tableLogsContent.js @@ -198,35 +198,70 @@ const linkToWikiErrors = (errcode) => h('a', { const tableContainerHooks = (model) => ({ /** - * Hook. Listen to events needed for handling scrolling like window size change - * And set scroll change handler to internal state of dom element + * Hook. Listen to scroll and window size changes to keep the model's viewport in sync * @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) + * Current scroll position, cancels negative position due to Safari bounce scrolling + * @returns {number} scrollTop clamped to 0 */ - const onTableScroll = () => { - const container = vnode.dom; + const getScrollTop = () => Math.max(container.scrollTop, 0); + + /** + * Update the viewport size and scroll position in the model + * @param {number} scrollTop - current scroll position + */ + const updateViewport = (scrollTop = getScrollTop()) => { 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; - // setup window size listener - view needs redraw for smart scrolling - window.addEventListener('resize', onTableScroll); + /** + * Whether the viewport is at the bottom of the table, <=1 tolerates fractional heights + * @param {number} scrollTop - current scroll position + * @returns {boolean} true if at the bottom + */ + const isAtBottom = (scrollTop) => container.scrollHeight - scrollTop - container.clientHeight <= 1; + + /** + * Disable auto-scroll when the user scrolls up, then update the viewport. + * A shrinking table clamps scrollTop down but stays at the bottom, so it doesn't count. + * Needed as well as onTableWheel as onTableWheel doesn't cover all cases of user-initiated scrolls. + */ + const onTableScroll = () => { + const scrollTop = getScrollTop(); + if (scrollTop < model.log.scrollTop && !isAtBottom(scrollTop)) { + model.log.setAutoScrollLive(false, false); + } + updateViewport(scrollTop); + }; - // remember this function for later (destroy) - vnode.dom.onTableScroll = onTableScroll; + /** + * Disable auto-scroll as soon as the user wheels up. + * onTableScroll can miss them if an incoming log pushes the scroll to the bottom before the check runs. + * @param {WheelEvent} e - the wheel event + */ + const onTableWheel = (e) => { + if (e.deltaY < 0 && !e.ctrlKey && !e.metaKey && getScrollTop() > 0) { + model.log.setAutoScrollLive(false, false); + } + }; + + const listeners = new AbortController(); + const { signal } = listeners; + + container.addEventListener('scroll', onTableScroll, { signal }); + container.addEventListener('wheel', onTableWheel, { signal, passive: true }); + // setup window size listener - view needs redraw for smart scrolling + window.addEventListener('resize', () => updateViewport(), { signal }); + container.listeners = listeners; + model.log.dom.table = container; // call the function once on next frame when we know sizes - onTableScroll(); + updateViewport(); }, /** @@ -242,8 +277,7 @@ 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); + vnode.dom.listeners.abort(); }, }); 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..04236b914 --- /dev/null +++ b/InfoLogger/test/public/logs-table-mocha.js @@ -0,0 +1,174 @@ +/** + * @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'); + +/** + * Inserts and checks that the logs table is scrolled past the bottom. + * For Live mode inserting is much quicker than waiting for the table to naturally populate. + * @param {Page} page - puppeteer page + */ +const insertAndWaitForScrollPastBottom = async (page) => { + await injectLogs(page, Array.from({ length: 200 }, (_, i) => ({ + severity: 'I', + message: `info log ${i}`, + timestamp: Date.now() + i, + }))); + await waitForScrollPastBottom(page); +}; + +/** + * Waits for the logs table to be scrolled past the bottom. + * @param {Page} page - puppeteer page + */ +const waitForScrollPastBottom = async (page) => { + await page.waitForFunction(() => model.log.dom.table.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 + * @param {Page} page - puppeteer page + * @param {number} deltaY - wheel delta + */ +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); + await page.goto(baseUrl, { waitUntil: 'networkidle0' }); + }); + + describe('Autoscroll behavior', async () => { + describe('in live mode', async () => { + beforeEach(async () => { + await page.waitForSelector('#live-button:not([disabled])'); + await page.evaluate(() => { + model.log.liveStop('Query'); + model.zoom.resetZoom(); + model.log.empty(); + }); + await page.waitForFunction(() => model.log.dom.table.scrollTop === 0); + await page.click('#live-button'); + }); + + after(async () => { + await page.evaluate(() => model.log.liveStop('Query')); + await page.evaluate(() => model.zoom.resetZoom()); + }); + + it('should disable autoscroll when the user scrolls up', async () => { + await assertAutoScrollLive(page, true); + + await insertAndWaitForScrollPastBottom(page); + + await wheelOverTable(page, -100); + await waitForAnimationFrame(page, 2); + + await assertAutoScrollLive(page, false); + }); + + it('should not disable autoscroll when switching from a full query table to live mode', async () => { + await page.evaluate(() => model.log.liveStop('Query')); + + 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 waitForScrollPastBottom(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 assertAutoScrollLive(page, true); + + await insertAndWaitForScrollPastBottom(page); + + await page.click('#clear-button'); + await waitForAnimationFrame(page, 2); + + await assertAutoScrollLive(page, true); + }); + + it('should re-enable autoscroll when the log list is cleared', async () => { + await insertAndWaitForScrollPastBottom(page); + + await wheelOverTable(page, -100); + await waitForAnimationFrame(page, 2); + await assertAutoScrollLive(page, false); + + await page.click('#clear-button'); + await waitForAnimationFrame(page, 2); + + await assertAutoScrollLive(page, true); + }); + + it('should not disable autoscroll when zooming in and out', async () => { + await assertAutoScrollLive(page, true); + await insertAndWaitForScrollPastBottom(page); + + await page.click('#zoom-in-button'); + await waitForAnimationFrame(page, 2); + await assertAutoScrollLive(page, true); + + await page.click('#zoom-out-button'); + await waitForAnimationFrame(page, 2); + await assertAutoScrollLive(page, true); + }); + }); + }); +});