diff --git a/InfoLogger/public/Model.js b/InfoLogger/public/Model.js index 48987d06f..bf1cba5e6 100644 --- a/InfoLogger/public/Model.js +++ b/InfoLogger/public/Model.js @@ -387,7 +387,7 @@ export default class Model extends Observable { * do it silently to avoid infinite loop */ updateRouteOnModelChange() { - this.router.go(`?q=${JSON.stringify(this.log.filter.toObject())}`, true, true); + this.router.go(`?q=${encodeURIComponent(JSON.stringify(this.log.filter.toObject()))}`, true, true); } /** diff --git a/InfoLogger/public/log/commandLogs.js b/InfoLogger/public/log/commandLogs.js index 06ca8932f..e0fa613b1 100644 --- a/InfoLogger/public/log/commandLogs.js +++ b/InfoLogger/public/log/commandLogs.js @@ -20,6 +20,7 @@ import { h, iconMagnifyingGlass, iconPlus, iconMinus, + iconShare, } from '/js/src/index.js'; import { BUTTON } from '../constants/button-states.const.js'; import { MODE } from '../constants/mode.const.js'; @@ -67,6 +68,7 @@ export const commandLogs = (model) => [ ]), h('', downloadButtonGroup(model.log)), h('', zoomButtonGroup(model.zoom)), + h('', shareButton(model)), ]; /** @@ -242,6 +244,31 @@ const zoomButtonGroup = (zoom) => }, h('span', { style: 'font-size:0.8em' }, iconPlus())), ]); +const shareButton = (model) => + h('button.btn', { + onclick: () => copyLinkToShareCurrentView(model), + id: 'share-button', + title: 'Copy shareable link of current filters', + }, h('span', { style: 'font-size:0.9em' }, iconShare())); + +const copyLinkToShareCurrentView = (model) => { + if (!navigator.clipboard?.writeText) { + model.notification.show('Clipboard API is not available in this browser.', 'danger', 2000); + return; + } + navigator.clipboard.writeText(window.location.href) + .then(() => { + model.notification.show( + 'Shareable link copied to clipboard.', + 'success', + 2000, + ); + }) + .catch(() => { + model.notification.show('Failed to copy shareable link to clipboard.', 'danger', 2000); + }); +}; + /** * Method to toggle states of the buttons(Query/Live) depending on the mode the tool is running on * @param {Model} model - root model of the application diff --git a/InfoLogger/public/logFilter/LogFilter.js b/InfoLogger/public/logFilter/LogFilter.js index 29c45b0cc..73b2e8162 100644 --- a/InfoLogger/public/logFilter/LogFilter.js +++ b/InfoLogger/public/logFilter/LogFilter.js @@ -140,9 +140,6 @@ export default class LogFilter extends Observable { // remote empty inputs if (!criterias[field][operator]) { delete criterias[field][operator]; - } else if (operator === 'match' || operator === 'exclude') { - // encode potential breaking characters and escape double quotes as are used by browser by default - criterias[field][operator] = encodeURIComponent(criterias[field][operator].replace(/["]+/g, '\\"')); } // remove empty fields diff --git a/InfoLogger/test/mocha-index.js b/InfoLogger/test/mocha-index.js index 273b58edc..c11205f31 100644 --- a/InfoLogger/test/mocha-index.js +++ b/InfoLogger/test/mocha-index.js @@ -114,6 +114,7 @@ describe('InfoLogger', function () { require('./public/status-bar-mocha'); require('./public/zoom.mocha'); require('./public/log-context-menu-mocha'); + require('./public/share-mocha.js'); after(async () => { await browser.close(); diff --git a/InfoLogger/test/public/log-filter-actions-mocha.js b/InfoLogger/test/public/log-filter-actions-mocha.js index cab6a8768..d00a52d2c 100644 --- a/InfoLogger/test/public/log-filter-actions-mocha.js +++ b/InfoLogger/test/public/log-filter-actions-mocha.js @@ -105,7 +105,8 @@ describe('Filter actions test-suite', async () => { it('should update filters based on profile when passed in the URI', async () => { // for now check if the filters are reset once the profile is passed - const expectedParams = '?q={%22severity%22:{%22in%22:%22I%20W%20E%20F%22},%22level%22:{%22max%22:1}}'; + const expectedParams = + '?q=%7B%22severity%22%3A%7B%22in%22%3A%22I%20W%20E%20F%22%7D%2C%22level%22%3A%7B%22max%22%3A1%7D%7D'; const searchParams = await page.evaluate(() => { const params = { profile: 'physicist' }; @@ -123,7 +124,8 @@ describe('Filter actions test-suite', async () => { it('should reset filters and show warning message when profile and filters are passed', async () => { // wait until the previous notification is hidden await page.waitForFunction('window.model.notification.state === \'hidden\''); - const expectedParams = '?q={%22severity%22:{%22in%22:%22I%20W%20E%20F%22},%22level%22:{%22max%22:1}}'; + const expectedParams = + '?q=%7B%22severity%22%3A%7B%22in%22%3A%22I%20W%20E%20F%22%7D%2C%22level%22%3A%7B%22max%22%3A1%7D%7D'; const searchParams = await page.evaluate(() => { const params = { profile: 'physicist', q: '"severity":{"in":"I W E F"}}' }; window.model.parseLocation(params); @@ -148,7 +150,7 @@ describe('Filter actions test-suite', async () => { }; }); - assert.strictEqual(decodeURI(locationAndNotification.search), expectedDefaultParams); + assert.strictEqual(decodeURIComponent(locationAndNotification.search), expectedDefaultParams); assert.strictEqual(locationAndNotification.notification.type, 'danger'); // CI/CD runs on Chromium so this assertion is based on Chromium's JSON engine's error message assert.strictEqual( @@ -159,7 +161,8 @@ describe('Filter actions test-suite', async () => { it('should update URI with new encoded "match" criteria', async () => { const decodedParams = '?q={"hostname":{"match":"\\"%ald_qdip01%"},"severity":{"in":"I W E F"}}'; - const expectedParams = '?q={%22hostname%22:{%22match%22:%22%5C%22%25ald_qdip01%25%22},%22severity%22:{%22in%22:%22I%20W%20E%20F%22}}'; + const expectedParams = '?q=%7B%22hostname%22%3A%7B%22match%22%3A%22%5C%22%25ald_qdip01%25%22%7D' + + '%2C%22severity%22%3A%7B%22in%22%3A%22I%20W%20E%20F%22%7D%7D'; const searchParams = await page.evaluate(() => { window.model.log.filter.setCriteria('hostname', 'match', '"%ald_qdip01%'); window.model.updateRouteOnModelChange(); @@ -167,12 +170,13 @@ describe('Filter actions test-suite', async () => { }); assert.deepStrictEqual(searchParams, expectedParams); - assert.deepStrictEqual(decodeURI(searchParams), decodedParams); + assert.deepStrictEqual(decodeURIComponent(searchParams), decodedParams); }); it('should update URI with new encoded "exclude" criteria', async () => { const decodedParams = '?q={"hostname":{"exclude":"\\"%ald_qdip01%"},"severity":{"in":"I W E F"}}'; - const expectedParams = '?q={%22hostname%22:{%22exclude%22:%22%5C%22%25ald_qdip01%25%22},%22severity%22:{%22in%22:%22I%20W%20E%20F%22}}'; + const expectedParams = '?q=%7B%22hostname%22%3A%7B%22exclude%22%3A%22%5C%22%25ald_qdip01%25%22%7D' + + '%2C%22severity%22%3A%7B%22in%22%3A%22I%20W%20E%20F%22%7D%7D'; const searchParams = await page.evaluate(() => { window.model.log.filter.resetCriteria(); window.model.log.filter.setCriteria('hostname', 'exclude', '"%ald_qdip01%'); @@ -181,7 +185,7 @@ describe('Filter actions test-suite', async () => { }); assert.deepStrictEqual(searchParams, expectedParams); - assert.deepStrictEqual(decodeURI(searchParams), decodedParams); + assert.deepStrictEqual(decodeURIComponent(searchParams), decodedParams); }); it('should parse dates in format DD/MM/YY', async () => { @@ -249,30 +253,6 @@ describe('Filter actions test-suite', async () => { assert.deepStrictEqual($in, ['I', 'W', 'E', 'F']); }); - it('should encode special characters correctly into the URL', async () => { - const pidMatch = await page.evaluate(() => { - window.model.log.filter.setCriteria('pid', 'match', 'a+b c %d #anchor & = héllo wörld 日本語'); - return window.model.log.filter.criterias.pid.$match; - }); - - assert.strictEqual(pidMatch, 'a+b c %d #anchor & = héllo wörld 日本語'); - - const searchParams = await page.evaluate(() => { - window.model.updateRouteOnModelChange(); - return window.location.search; - }); - - assert.ok(searchParams.includes('a%2Bb%20c%20%25d%20%23anchor%20%26%20%3D%20h%C3%A9llo%20w%C3%B6rld%20%E6%97%A5%E6%9C%AC%E8%AA%9E')); - }); - - it('should decode special characters correctly from the URL', async () => { - await page.goto(`${baseUrl}?q={%22pid%22:{%22match%22:%22a%2Bb%20c%20%25d%20%23anchor%20%26%20%3D%20h%C3%A9llo%20w%C3%B6rld%20%E6%97%A5%E6%9C%AC%E8%AA%9E%22}}`, { waitUntil: 'networkidle0' }); - - const pidMatch = await page.evaluate(() => window.model.log.filter.criterias.pid.$match); - - assert.strictEqual(pidMatch, 'a+b c %d #anchor & = héllo wörld 日本語'); - }); - it('should reset filters and set them again', async () => { const criterias = await page.evaluate(() => { window.model.log.filter.resetCriteria(); @@ -650,4 +630,58 @@ describe('Filter actions test-suite', async () => { assert.strictEqual(limit, 1000000); }); }); + + describe('Filter round-trip through the URL', async () => { + /** + * Sets message match criteria, then reloads the page on the URL the model produced for it + * @param {string} value - the raw filter value to round-trip + * @returns {Promise} the value held by the model after the reload + */ + const roundTrip = async (value) => { + await page.evaluate((raw) => { + window.model.log.filter.setCriteria('message', 'match', raw); + window.model.updateRouteOnModelChange(); + }, value); + + const url = await page.evaluate(() => window.location.href); + await page.goto(url, { waitUntil: 'networkidle0' }); + await page.waitForFunction(() => window.model?.log?.filter); + + return await page.evaluate(() => window.model.log.filter.criterias.message.match); + }; + + it('should preserve consecutive double quotes', async () => { + // /["]+/g collapsed a run of quotes into a single escaped one, so "" came back as " + assert.strictEqual(await roundTrip('a""b'), 'a""b'); + }); + + it('should preserve a backslash that forms a valid JSON escape', async () => { + // C:\temp used to reach JSON.parse unescaped and come back as C:emp + assert.strictEqual(await roundTrip('C:\\temp'), 'C:\\temp'); + }); + + it('should preserve a backslash that does not form a valid JSON escape', async () => { + // C:\xyz used to throw, resetting every filter + assert.strictEqual(await roundTrip('C:\\xyz'), 'C:\\xyz'); + }); + + it('should preserve a multi-line message filter', async () => { + assert.strictEqual(await roundTrip('first\nsecond'), 'first\nsecond'); + }); + + it('should preserve a value containing URL-significant characters', async () => { + assert.strictEqual(await roundTrip('a&b#c=d?e %20 a+b c %d #anchor & = héllo wörld 日本語'), + 'a&b#c=d?e %20 a+b c %d #anchor & = héllo wörld 日本語' + ); + }); + + it('should store the value unencoded in the model', async () => { + const stored = await page.evaluate(() => { + window.model.log.filter.setCriteria('message', 'match', 'foo bar'); + return window.model.log.filter.toObject().message.match; + }); + + assert.strictEqual(stored, 'foo bar'); + }); + }); }); diff --git a/InfoLogger/test/public/share-mocha.js b/InfoLogger/test/public/share-mocha.js new file mode 100644 index 000000000..dbf905640 --- /dev/null +++ b/InfoLogger/test/public/share-mocha.js @@ -0,0 +1,113 @@ +/** + * @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 SHARE_BUTTON = '#share-button'; +const NOTIFICATION = '.notification-content'; + +/** + * Wait for the notification to be displayed with the expected type and return its message + * @param {Page} page - puppeteer page + * @param {string} type - one of primary/success/warning/danger + * @returns {Promise} - the notification message + */ +const getNotification = async (page, type) => { + await page.waitForSelector(`${NOTIFICATION}.bg-${type}.notification-open`); + return (await page.$eval(NOTIFICATION, (el) => el.textContent)).trim(); +}; + +describe('Share button test-suite', () => { + let page = null; + + before(async () => { + ({ page } = test); + await page.goto(test.helpers.baseUrl, { waitUntil: 'networkidle0' }); + await page.waitForSelector(SHARE_BUTTON); + }); + + /* + * Only one notification is displayed at a time and it keeps its type class once hidden; + * dismiss it so the next test does not match the previous one's notification. + */ + afterEach(async () => { + if (await page.$(`${NOTIFICATION}.notification-open`)) { + await page.click(NOTIFICATION); + await page.waitForSelector(`${NOTIFICATION}.notification-close`); + } + }); + + it('should copy the URL of the current page', async () => { + await page.goto( + `${test.helpers.baseUrl}?q={"severity":{"in":"E F"}}`, + { waitUntil: 'networkidle0' }, + ); + await page.waitForSelector(SHARE_BUTTON); + + await page.evaluate(() => { + window.__copiedValue = ''; + Object.defineProperty(navigator, 'clipboard', { + value: { + writeText: (value) => { + window.__copiedValue = value; + return Promise.resolve(); + }, + }, + configurable: true, + }); + }); + + await page.click(SHARE_BUTTON); + + await getNotification(page, 'success'); + + const { copied, href } = await page.evaluate(() => ({ + copied: window.__copiedValue, + href: window.location.href, + })); + + // What is copied is exactly what the address bar holds, so only one URL ever exists + assert.strictEqual(copied, href); + + // That URL is fully percent-encoded and round-trips back to the original filter + assert.ok(!new URL(copied).search.includes('{'), 'query parameter must be percent-encoded'); + assert.strictEqual(new URL(copied).searchParams.get('q'), '{"severity":{"in":"E F"}}'); + }); + + it('should show a danger notification if the clipboard API is not available', async () => { + await page.evaluate(() => { + Object.defineProperty(navigator, 'clipboard', { value: undefined, configurable: true }); + }); + await page.click(SHARE_BUTTON); + + const notificationText = await getNotification(page, 'danger'); + assert.strictEqual(notificationText, 'Clipboard API is not available in this browser.'); + }); + + it('should show a danger notification if the clipboard API fails', async () => { + await page.evaluate(() => { + Object.defineProperty(navigator, 'clipboard', { + value: { + writeText: () => Promise.reject(new Error('Random Error')), + }, + configurable: true, + }); + }); + await page.click(SHARE_BUTTON); + + const notificationText = await getNotification(page, 'danger'); + assert.strictEqual(notificationText, 'Failed to copy shareable link to clipboard.'); + }); +});