diff --git a/InfoLogger/public/Model.js b/InfoLogger/public/Model.js index 48987d06f..50f27ab21 100644 --- a/InfoLogger/public/Model.js +++ b/InfoLogger/public/Model.js @@ -277,20 +277,41 @@ export default class Model extends Observable { break; case 38: // top e.preventDefault(); // avoid scroll - this.log.previousItem(); + if (e.shiftKey) { + if (!this.log.selection.isDragging) { + this.log.selection.begin(this.log.selection.focus); + } + this.log.selection.extendTo(this.log.selection.focus - 1); + } else { + this.log.previousItem(); + } break; case 40: // bottom if (e.altKey) { this.log.goToLastItem(); + } else if (e.shiftKey) { + if (!this.log.selection.isDragging) { + this.log.selection.begin(this.log.selection.focus); + } + this.log.selection.extendTo(this.log.selection.focus + 1); } else { this.log.nextItem(); } e.preventDefault(); // avoid scroll break; case 67: - if ((e.metaKey || e.ctrlKey) && window.getSelection().toString() === '' && this.isSecureContext()) { - navigator.clipboard.writeText(this.log.displayedItemFieldsToString()); - this.notification.show('Message has been successfully copied to clipboard', 'success', 1500); + if ((e.metaKey || e.ctrlKey) && this.isSecureContext()) { + e.preventDefault(); + if (this.log.selection.isActive) { + navigator.clipboard.writeText(this.log.selectedItemsFieldsToString()); + this.notification.show( + this.log.selection.isCollapsed + ? 'Selected log has been successfully copied to clipboard' + : 'Selected logs have been successfully copied to clipboard', + 'success', + 1500, + ); + } } break; } diff --git a/InfoLogger/public/app.css b/InfoLogger/public/app.css index b3decfe96..c3c119938 100644 --- a/InfoLogger/public/app.css +++ b/InfoLogger/public/app.css @@ -60,6 +60,12 @@ th { max-width: 0; /* allow ellipsis on tables */ vertical-align: top; } overflow: hidden; text-overflow: ellipsis; } +.cell::selection { + /* Disabling select disables autoscroll when selecting off the page. + The app then has to handle this scrolling manually which is out of scope for this ticket. + So instead we just make it invisible */ + background-color: transparent; +} .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 91aae4be4..d059916a1 100644 --- a/InfoLogger/public/log/Log.js +++ b/InfoLogger/public/log/Log.js @@ -15,6 +15,7 @@ import { Observable, RemoteData } from '/js/src/index.js'; import LogFilter from '../logFilter/LogFilter.js'; import ContextMenu from './ContextMenu.js'; +import LogSelection from './LogSelection.js'; import { MODE } from '../constants/mode.const.js'; import { TIME_MS } from '../common/Timezone.js'; import { jsonPost } from '../common/jsonPost.js'; @@ -52,7 +53,7 @@ export default class Log extends Observable { this.queryAbortController = null; this.list = []; - this.item = null; + this.selection = new LogSelection(this); this.autoScrollToItem = false; // go to an item this.autoScrollLive = false; // go at bottom on Live mode this.activeMode = MODE.QUERY; @@ -79,6 +80,15 @@ export default class Log extends Observable { this.contextMenu.bubbleTo(this); } + /** + * Current log of the selection, the one shown by the inspector and moved by the keyboard. + * It is the `focus` of `selection`: a single selected log is a collapsed selection. + * @returns {object|null} - the current log, null if nothing is selected + */ + get item() { + return this.selection.focus !== null ? this.list[this.selection.focus] ?? null : null; + } + /** * Method to return if the current mode is Query * @returns {boolean} - is it query mode @@ -160,12 +170,12 @@ export default class Log extends Observable { } /** - * Set current `item`, if the reference is contained - * in `list`, it is also considered as selected in the list. + * Set current `item`, collapsing the selection on it. A log which is not part of `list` + * empties the selection. * @param {object} item - log to be set as current item */ - setItem(item) { - this.item = item; + set item(item) { + this.selection.collapseTo(this.list.indexOf(item)); this.autoScrollToItem = false; this.notify(); } @@ -180,6 +190,7 @@ export default class Log extends Observable { setLimit(limit) { if (limit < this.limit) { this.resetStats(); + this.selection.shiftBy(this.list.length - limit); this.list.splice(0, this.list.length - limit); this.list.forEach((log) => this.addStats(log)); } @@ -203,7 +214,7 @@ export default class Log extends Observable { return; } - this.item = this.list.find((item) => item.severity === 'E' || item.severity === 'F'); + this.selection.collapseTo(this.list.findIndex((item) => item.severity === 'E' || item.severity === 'F')); this.autoScrollToItem = true; this.autoScrollLive = false; @@ -227,11 +238,11 @@ export default class Log extends Observable { return; } - const currentIndex = this.list.indexOf(this.item); + const currentIndex = this.selection.focus; // find previous one, if any for (let i = currentIndex - 1; i >= 0; i--) { if (this.list[i].severity === 'E' || this.list[i].severity === 'F') { - this.item = this.list[i]; + this.selection.collapseTo(i); this.autoScrollToItem = true; this.autoScrollLive = false; this.notify(); @@ -257,11 +268,11 @@ export default class Log extends Observable { return; } - const currentIndex = this.list.indexOf(this.item); + const currentIndex = this.selection.focus; // find next one, if any for (let i = currentIndex + 1; i < this.list.length; i++) { if (this.list[i].severity === 'E' || this.list[i].severity === 'F') { - this.item = this.list[i]; + this.selection.collapseTo(i); this.autoScrollToItem = true; this.autoScrollLive = false; this.notify(); @@ -283,7 +294,7 @@ export default class Log extends Observable { for (let i = this.list.length - 1; i >= 0; --i) { const item = this.list[i]; if (item.severity === 'E' || item.severity === 'F') { - this.item = item; + this.selection.collapseTo(i); break; } } @@ -297,14 +308,14 @@ export default class Log extends Observable { * Select previous `item` after current `item` or first of `list` */ previousItem() { - this.goToItem(Math.max(this.list.indexOf(this.item) - 1, 0)); + this.goToItem(Math.max(this.currentIndex - 1, 0)); } /** * Select next `item` after current `item` or first of `list` */ nextItem() { - this.goToItem(Math.min(this.list.indexOf(this.item) + 1, this.list.length - 1)); + this.goToItem(Math.min(this.currentIndex + 1, this.list.length - 1)); } /** @@ -323,11 +334,20 @@ export default class Log extends Observable { return; } - this.item = this.list[index]; + this.selection.collapseTo(index); this.autoScrollToItem = true; this.notify(); } + /** + * Index of the current `item`, -1 if there is none, so that moving from no selection + * lands on the first or the last log of the list + * @returns {number} - index in `list` of the current item + */ + get currentIndex() { + return this.selection.focus ?? -1; + } + /** * Method to execute a query with the current filters configuration via button click or "Enter" keypress on filters. * (thus, check of DB status still needed) @@ -373,6 +393,7 @@ export default class Log extends Observable { }); this.resetStats(); this.queryResult = RemoteData.success(result); + this.selection.clear(); this.list = result.rows; this.list.forEach((log) => this.addStats(log)); this.goToLastItem(); @@ -535,6 +556,7 @@ export default class Log extends Observable { * and close the inspector panel */ empty() { + this.selection.clear(); this.list = []; this.limitReached = null; this.model.inspectorEnabled = false; @@ -553,6 +575,7 @@ export default class Log extends Observable { this.list.push(log); if (this.list.length > this.limit) { this.addStats(this.list[0], -1); + this.selection.shiftBy(this.list.length - this.limit); this.list.splice(0, this.list.length - this.limit); } this.notify(); @@ -607,11 +630,14 @@ export default class Log extends Observable { } /** - * Method which will create a table alike string with the elements displayed in the table of the current item - * @returns {string} - string with the elements of the current item + * Method which will create a table alike string with the elements displayed in the table of the selected items + * @returns {string} - string with the elements of the selected items */ - displayedItemFieldsToString() { - const message = this.getLogAsTableRowString(this.item); + selectedItemsFieldsToString() { + let message = ''; + this.selection.items.forEach((item) => { + message += `${this.getLogAsTableRowString(item)}\n`; + }); return message; } @@ -656,11 +682,20 @@ export default class Log extends Observable { */ listLogsInViewportOnly() { return this.list.slice( - Math.floor(this.scrollTop / this.rowHeight), - Math.floor(this.scrollTop / this.rowHeight) + Math.ceil(this.scrollHeight / this.rowHeight) + 1, + this.firstLogIndexInViewport, + this.firstLogIndexInViewport + Math.ceil(this.scrollHeight / this.rowHeight) + 1, ); } + /** + * Index in `list` of the first log returned by `listLogsInViewportOnly`, ie. the index the + * rendered rows are numbered from + * @returns {number} - index of the first log drawn + */ + get firstLogIndexInViewport() { + return Math.floor(this.scrollTop / this.rowHeight); + } + get rowHeight() { return this.model.zoom.rowHeightPx; } diff --git a/InfoLogger/public/log/LogSelection.js b/InfoLogger/public/log/LogSelection.js new file mode 100644 index 000000000..c27a4fe5c --- /dev/null +++ b/InfoLogger/public/log/LogSelection.js @@ -0,0 +1,173 @@ +/** + * @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. + */ + +/** + * Logs selected by dragging over or clicking the rows of the main table. + * + * The selection is kept as two indexes in `Log.list`: + * * `anchor` - the row on which the drag started + * * `focus` - the row the pointer is currently on + * + * Storing indexes instead of log references keeps makes the selection independent of the + * virtual scrolling. + * + * Inspired by https://developer.mozilla.org/en-US/docs/Web/API/Selection + */ +export default class LogSelection { + /** + * Initialize with nothing selected + * @param {Log} log - log model owning this selection + */ + constructor(log) { + this.log = log; + + this.anchor = null; + this.focus = null; + + this.isDragging = false; // a mouse button is held down on the table + this.hasDragged = false; // the current/last drag went over more than the anchor row + } + + /** + * Whether at least one log is selected + * @returns {boolean} - true if at least one log is selected + */ + get isActive() { + return this.anchor !== null && this.focus !== null; + } + + /** + * Whether the selection is at most one log is selected + * As in the DOM Selection API, an empty selection is collapsed. + * @returns {boolean} - true if at most one log is selected + */ + get isCollapsed() { + return !this.isActive || this.anchor === this.focus; + } + + /** + * Index of the first selected log, null if nothing is selected + * @returns {number|null} - index in `Log.list` + */ + get from() { + return this.isActive ? Math.min(this.anchor, this.focus) : null; + } + + /** + * Index of the last selected log, null if nothing is selected + * @returns {number|null} - index in `Log.list` + */ + get to() { + return this.isActive ? Math.max(this.anchor, this.focus) : null; + } + + /** + * Logs currently selected, in the order they are displayed + * @returns {Array} - selected logs, empty if nothing is selected + */ + get items() { + return this.isActive ? this.log.list.slice(this.from, this.to + 1) : []; + } + + /** + * Whether the log at the given index is part of the selection + * @param {number} index - index in `Log.list` + * @returns {boolean} - true if the log is selected + */ + has(index) { + return this.isActive && index >= this.from && index <= this.to; + } + + /** + * Start a drag on the given row, which becomes the only selected log + * @param {number} index - index in `Log.list` of the row the drag starts on + */ + begin(index) { + this.collapseTo(index); + this.isDragging = true; + this.hasDragged = false; + } + + /** + * Extend the on-going drag to the given row + * A drag only becomes a selection once it leaves the row it started on, so that a simple + * click leaves the selection collapsed on the pressed row. + * @param {number} index - index in `Log.list` of the row under the pointer + */ + extendTo(index) { + if (!this.isDragging || index === this.focus) { + return; + } + this.focus = index; + this.hasDragged = true; + this.log.notify(); + } + + /** + * End the on-going drag, the selected range is kept + */ + end() { + this.isDragging = false; + } + + /** + * Whether the click being handled is the end of a drag, in which case it should not be + * treated as a single log selection. Reading it consumes the flag. + * @returns {boolean} - true if a drag just ended + */ + consumeDrag() { + const { hasDragged } = this; + this.hasDragged = false; + return hasDragged; + } + + /** + * Drop the selection + */ + clear() { + this.anchor = null; + this.focus = null; + this.hasDragged = false; + } + + /** + * Keep the selection on the same logs after logs were removed from the head of the list + * in live mode. The selection is dropped if it scrolled out entirely. + * @param {number} count - number of logs removed from the beginning of `Log.list` + */ + shiftBy(count) { + if (!this.isActive) { + return; + } + if (this.to - count < 0) { + this.clear(); + return; + } + this.anchor = Math.max(this.anchor - count, 0); + this.focus = Math.max(this.focus - count, 0); + } + + /** + * Collapse the selection on a single log. An index outside of the list drops the selection. + * @param {number} index - index in `Log.list` of the log to select + */ + collapseTo(index) { + if (index === null || index < 0 || index >= this.log.list.length) { + this.clear(); + return; + } + this.anchor = index; + this.focus = index; + } +} diff --git a/InfoLogger/public/log/tableLogsContent.js b/InfoLogger/public/log/tableLogsContent.js index e0a207a48..fcf89013a 100644 --- a/InfoLogger/public/log/tableLogsContent.js +++ b/InfoLogger/public/log/tableLogsContent.js @@ -41,7 +41,10 @@ export default (model) => 'table.table-logs-content', scrollStyling(model), tableColGroup(model), - h('tbody', [model.log.listLogsInViewportOnly(model).map((row) => tableLogLine(model, row))]), + h('tbody', [ + model.log.listLogsInViewportOnly() + .map((row, i) => tableLogLine(model, row, model.log.firstLogIndexInViewport + i)), + ]), ), ]), ); @@ -62,17 +65,31 @@ const scrollStyling = (model) => ({ * Creates a line of log with tag and its columns if enabled. * @param {Model} model - root model of the application * @param {Log} row - a row of this table is a raw log + * @param {number} index - index of the log in `Log.list`, used for range selection * @returns {vnode} - the log build as a table row */ -const tableLogLine = (model, row) => { +const tableLogLine = (model, row, index) => { const { log, table } = model; return h('tr.row-hover', { - className: log.item === row ? 'row-selected' : '', - onclick: () => log.setItem(row), + className: log.selection.has(index) ? 'row-selected' : '', + onclick: () => onRowClick(log, row), ondblclick: () => model.toggleInspector(), + onmousedown: () => log.selection.begin(index), + onmousemove: () => log.selection.extendTo(index), }, tableRows(model, table.colsHeader, row)); }; +/** + * Select a single log, unless the click is the end of a drag which already selected a range + * @param {Log} log - log model of the application + * @param {object} row - log of the clicked row + */ +const onRowClick = (log, row) => { + if (!log.selection.consumeDrag()) { + log.item = row; + } +}; + /** * Resolves the required data to send to the context menu based on the cell's field and content. * @param {Model} model - root model of the application @@ -105,7 +122,7 @@ const resolveContextMenuData = (model, field, content) => { */ const cellWithContextMenu = (model, row, field, content, extraClasses = '', extraAttrs = {}) => { const openContextMenu = (e) => { - model.log.setItem(row); + model.log.item = row; const data = resolveContextMenuData(model, field, content); if (data) { e.preventDefault(); @@ -216,14 +233,23 @@ const tableContainerHooks = (model) => ({ model.log.setScrollTop(scrollTop, height); }; + /** + * A drag can end anywhere, not only over a row of the table + */ + const onMouseUp = () => { + model.log.selection.end(); + }; + // call the function when scrolling is updated vnode.dom.addEventListener('scroll', onTableScroll); + window.addEventListener('mouseup', onMouseUp); model.log.dom.table = vnode.dom; // setup window size listener - view needs redraw for smart scrolling window.addEventListener('resize', onTableScroll); - // remember this function for later (destroy) + // remember these functions for later (destroy) vnode.dom.onTableScroll = onTableScroll; + vnode.dom.onMouseUp = onMouseUp; // call the function once on next frame when we know sizes onTableScroll(); @@ -244,6 +270,7 @@ const tableContainerHooks = (model) => ({ ondestroy(vnode) { vnode.dom.removeEventListener('scroll', vnode.dom.onTableScroll); window.removeEventListener('resize', vnode.dom.onTableScroll); + window.removeEventListener('mouseup', vnode.dom.onMouseUp); }, }); @@ -278,8 +305,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 = model.log.rowHeight * index; + const positionRow = model.log.rowHeight * model.log.currentIndex; const halfView = model.log.scrollHeight / 2; vnode.dom.scrollTo(0, positionRow - halfView); } diff --git a/InfoLogger/test/public/log-context-menu-mocha.js b/InfoLogger/test/public/log-context-menu-mocha.js index ed2ba06e6..ba9d75709 100644 --- a/InfoLogger/test/public/log-context-menu-mocha.js +++ b/InfoLogger/test/public/log-context-menu-mocha.js @@ -158,7 +158,7 @@ describe('Cell Context Menu', async () => { it('should select the row on right-click', async () => { await page.evaluate(() => { - window.model.log.setItem(null); + window.model.log.item = null; window.model.notify(); }); @@ -722,7 +722,7 @@ describe('Cell Context Menu', async () => { beforeEach(async () => { await page.evaluate(() => { window.model.log.contextMenu.hide(); - window.model.log.setItem(null); + window.model.log.item = null; window.model.notify(); }); }); diff --git a/InfoLogger/test/public/query-mode-mocha.js b/InfoLogger/test/public/query-mode-mocha.js index f15ea3b11..b5982b784 100644 --- a/InfoLogger/test/public/query-mode-mocha.js +++ b/InfoLogger/test/public/query-mode-mocha.js @@ -49,13 +49,13 @@ const TEXT_FILTER_FIELD_BY_OPERATOR = { const setupQueryTestState = (page) => page.evaluate(() => { window.confirm = () => true; - window.model.frameworkInfo = { + model.frameworkInfo = { isSuccess: () => true, payload: { mysql: { status: { ok: true } } }, match: ({ Success }) => Success({ mysql: { status: { ok: true } } }), }; - window.model.log.filter.resetCriteria(); - window.model.log.empty(); + model.log.filter.resetCriteria(); + model.log.empty(); }); /** @@ -69,9 +69,9 @@ const startAndCancelQuery = (page) => window.fetch = (_url, { signal } = {}) => new Promise((_, reject) => { signal?.addEventListener('abort', () => reject(new DOMException('AbortError', 'AbortError'))); }); - const queryPromise = window.model.log.query(); + const queryPromise = model.log.query(); await new Promise((resolve) => setTimeout(resolve, 50)); - window.model.log.cancelQuery(); + model.log.cancelQuery(); await queryPromise; }); @@ -99,22 +99,22 @@ const runQueryWithMocks = (page, { confirmReturn, textFilterOperator }) => }; // Mock the frameworkInfo to make the query method think the query service is available in its check - window.model.frameworkInfo = { + model.frameworkInfo = { isSuccess: () => true, payload: { mysql: { status: { ok: true } } }, match: ({ Success }) => Success({ mysql: { status: { ok: true } } }), }; // Default state of filters includes no text filters - window.model.log.filter.resetCriteria(); + model.log.filter.resetCriteria(); if (textFilterOperator) { - window.model.log.filter.setCriteria( + model.log.filter.setCriteria( textFilterFieldByOperator[textFilterOperator], textFilterOperator, textFilterValueByOperator[textFilterOperator], ); } - await window.model.log.query(); + await model.log.query(); return { confirmCalls, postCalls }; }, { @@ -124,6 +124,34 @@ const runQueryWithMocks = (page, { confirmReturn, textFilterOperator }) => textFilterFieldByOperator: TEXT_FILTER_FIELD_BY_OPERATOR, }); +/** + * Waits until the log at the given index of `Log.list` is one of the rows currently rendered by the + * virtual scrolling. + * @param {Page} page - puppeteer page + * @param {number} index - index in `Log.list` + * @returns {Promise} - resolves once the row is in the DOM + */ +const waitForRowsRendered = (page, index) => + page.waitForFunction((index) => { + const rows = document.querySelectorAll('.table-logs-content tbody tr'); + const first = model.log.firstLogIndexInViewport; + return rows.length > 0 && index >= first && index < first + rows.length; + }, { timeout: 5000 }, index); + +/** + * Centre of the rendered row of the log at the given index of `Log.list`, in viewport coordinates. + * @param {Page} page - puppeteer page + * @param {number} index - index in `Log.list`, must be rendered (see `waitForRowsRendered`) + * @returns {Promise<{x: number, y: number}>} - point to move the mouse to + */ +const rowBoxOfLogAtIndex = (page, index) => + page.evaluate((index) => { + const rows = document.querySelectorAll('.table-logs-content tbody tr'); + const row = rows[index - model.log.firstLogIndexInViewport]; + const { x, y, width, height } = row.getBoundingClientRect(); + return { x: x + width / 2, y: y + height / 2 }; + }, index); + describe('Query Mode test-suite', async () => { let page; @@ -140,35 +168,102 @@ describe('Query Mode test-suite', async () => { } }); - it('should copy multiple rows in the correct format', async () => { - await injectLogs(page, [ - { severity: 'I', message: 'info log', timestamp: Date.now() }, - { severity: 'E', message: 'error log', timestamp: Date.now() }, - { severity: 'W', message: 'warning log', timestamp: Date.now() }, - ]); - await waitForTextInElement(page, '.table-logs-content tbody tr:first-child', 'info log'); - - // select the first two rows entirely, as a user dragging across them would - const copied = await page.evaluate(() => { - const rows = document.querySelectorAll('.table-logs-content tbody tr'); - const range = document.createRange(); - range.setStartBefore(rows[0].querySelector('td:first-child')); - range.setEndAfter(rows[1].querySelector('td:last-child')); - - const selection = window.getSelection(); - selection.removeAllRanges(); - selection.addRange(range); - - // what the browser puts on the clipboard as text/plain for this selection - return selection.toString(); + describe('selection copy', () => { + const rowCount = 100; + + before(async () => { + await page.evaluate(() => { + window.__copiedValue = undefined; + Object.defineProperty(navigator, 'clipboard', { + value: { + writeText: (value) => { + window.__copiedValue = value; + }, + }, + configurable: true, + }); + }); + + const logsToInject = Array.from({ length: rowCount }, (_, i) => ({ + severity: 'I', + message: `info log ${i}`, + timestamp: Date.now(), + })); + + await injectLogs( + page, + logsToInject, + ); + }); + + beforeEach(async () => { + // previous tests may have left the table scrolled down or an input focused, start from a known state + await page.evaluate(() => { + document.activeElement?.blur(); + model.log.selection.clear(); // remember clears the selection not the log list + model.log.dom.table.scrollTo(0, 0); + }); + + await waitForRowsRendered(page, 0); + await waitForTextInElement(page, '.table-logs-content tbody tr:first-child', 'info log 0', 5000); }); - const lines = copied.split('\n').filter((line) => line.trim() !== ''); + it('should be in the correct initial state', async () => { + const selection = await page.evaluate(() => { + const { selection } = model.log; + const { anchor, focus, from, to, items, isActive, isCollapsed } = selection; + return { anchor, focus, from, to, items, isActive, isCollapsed }; + }); - assert.strictEqual(lines.length, 2, `selection should be one line per row, got:\n${copied}`); - assert.ok(lines[0].includes('info log'), 'first line should hold the first row message'); - assert.ok(lines[1].includes('error log'), 'second line should hold the second row message'); - assert.ok(!copied.includes('⋮'), 'the context menu hint should not be part of the copied text'); + assert.strictEqual(selection.anchor, null, 'selection.anchor should be null'); + assert.strictEqual(selection.focus, null, 'selection.focus should be null'); + assert.strictEqual(selection.from, null, 'selection.from should be null'); + assert.strictEqual(selection.to, null, 'selection.to should be null'); + assert.deepStrictEqual(selection.items, [], 'selection.items should be empty'); + assert.ok(!selection.isActive, 'selection should be inactive'); + assert.ok(selection.isCollapsed, 'selection should be collapsed'); + }); + + it('should copy multiple rows in the correct format', async () => { + // press on the first row and press down + const firstRowBox = await rowBoxOfLogAtIndex(page, 0); + await page.mouse.move(firstRowBox.x, firstRowBox.y); + await page.mouse.down(); + + // scroll the last log into view + // as only ~30 rows are rendered at a time, this tests that the selection survives + // the rows it started on being recycled by the virtual scrolling + await page.evaluate(() => { + const { log } = model; + log.dom.table.scrollTo(0, log.rowHeight * log.list.length); + }); + await waitForRowsRendered(page, rowCount - 1); + + const lastRowBox = await rowBoxOfLogAtIndex(page, rowCount - 1); + await page.mouse.move(lastRowBox.x, lastRowBox.y, { steps: 10 }); + await page.mouse.up(); + + // copy the selection to the clipboard + await page.keyboard.down('Control'); + await page.keyboard.press('KeyC'); + await page.keyboard.up('Control'); + + const copied = await page.evaluate(() => window.__copiedValue); + assert.ok(copied, 'copied text should not be empty'); + const lines = copied.split('\n').filter((line) => line.trim() !== ''); + + assert.strictEqual(lines.length, rowCount, `expected ${rowCount} lines copied, got ${lines.length}`); + // each row should be in the csv format of the table + for (let i = 0; i < rowCount; i++) { + const { date, time } = await page.evaluate((i) => { + const date = model.timezone.format(model.log.list[i].timestamp, 'date'); + const time = model.timezone.format(model.log.list[i].timestamp, model.log.timeFormat); + return { date, time }; + }, i); + const expected = `I, info log ${i}, ${time}, ${date}`; + assert.ok(lines[i].startsWith(expected), `line ${i} should start with "${expected}", got "${lines[i]}"`); + } + }); }); describe('no-text-filter confirmation dialog', () => {