Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion InfoLogger/public/Model.js
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}

/**
Expand Down
27 changes: 27 additions & 0 deletions InfoLogger/public/log/commandLogs.js
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -67,6 +68,7 @@ export const commandLogs = (model) => [
]),
h('', downloadButtonGroup(model.log)),
h('', zoomButtonGroup(model.zoom)),
h('', shareButton(model)),
];

/**
Expand Down Expand Up @@ -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
Expand Down
3 changes: 0 additions & 3 deletions InfoLogger/public/logFilter/LogFilter.js
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions InfoLogger/test/mocha-index.js
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down
96 changes: 65 additions & 31 deletions InfoLogger/test/public/log-filter-actions-mocha.js
Original file line number Diff line number Diff line change
Expand Up @@ -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' };
Expand All @@ -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);
Expand All @@ -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(
Expand All @@ -159,20 +161,22 @@ 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();
return window.location.search;
});

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%');
Expand All @@ -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 () => {
Expand Down Expand Up @@ -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();
Expand Down Expand Up @@ -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<string>} 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:<tab>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');
});
});
});
113 changes: 113 additions & 0 deletions InfoLogger/test/public/share-mocha.js
Original file line number Diff line number Diff line change
@@ -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<string>} - 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.');
});
});