From b1a46c4fe9b3b96c736600f83dd90daf151f6f5c Mon Sep 17 00:00:00 2001 From: Isaac Hill <71404865+isaachilly@users.noreply.github.com> Date: Fri, 21 Aug 2026 18:19:06 +0200 Subject: [PATCH 01/12] Add share button for command log filters Add a new share action to the toolbar that copies a URL for the current view (path + active query params) to the clipboard and displays a notification on success/error. --- InfoLogger/public/log/commandLogs.js | 30 ++++++++++++++++++++++++++++ 1 file changed, 30 insertions(+) diff --git a/InfoLogger/public/log/commandLogs.js b/InfoLogger/public/log/commandLogs.js index 06ca8932f..e62863248 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,34 @@ 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; + } + const currentUrl = new URL(window.location.href); + const queryParams = new URLSearchParams(currentUrl.search); + const shareableLink = `${currentUrl.origin}${currentUrl.pathname}?${queryParams.toString()}`; + navigator.clipboard.writeText(shareableLink) + .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 From e08b6d8d7d8896a500902e3128fd9a83279b1043 Mon Sep 17 00:00:00 2001 From: Isaac Hill <71404865+isaachilly@users.noreply.github.com> Date: Fri, 21 Aug 2026 18:21:33 +0200 Subject: [PATCH 02/12] Add share button browser tests Add new tests to verify successful clipboard copying and correct error handling when the Clipboard API is missing or fails. --- InfoLogger/test/mocha-index.js | 1 + InfoLogger/test/public/share-mocha.js | 130 ++++++++++++++++++++++++++ 2 files changed, 131 insertions(+) create mode 100644 InfoLogger/test/public/share-mocha.js 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/share-mocha.js b/InfoLogger/test/public/share-mocha.js new file mode 100644 index 000000000..e5ed8167e --- /dev/null +++ b/InfoLogger/test/public/share-mocha.js @@ -0,0 +1,130 @@ +/** + * @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'; + +/** + * Install a fake clipboard which stores the written value in `window.__copiedValue` + * @param {Page} page - puppeteer page + * @returns {Promise} - resolves once the mock is installed + */ +const mockClipboard = (page) => page.evaluate(() => { + window.__copiedValue = ''; + Object.defineProperty(navigator, 'clipboard', { + value: { + writeText: (value) => { + window.__copiedValue = value; + return Promise.resolve(); + }, + }, + configurable: true, + }); +}); + +/** + * 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 a shareable link to the clipboard', async () => { + await mockClipboard(page); + await page.click(SHARE_BUTTON); + + const notificationText = await getNotification(page, 'success'); + assert.strictEqual(notificationText, 'Shareable link copied to clipboard.'); + + const { copied, expected } = await page.evaluate(() => { + const url = new URL(window.location.href); + return { + copied: window.__copiedValue, + expected: `${url.origin}${url.pathname}?${new URLSearchParams(url.search).toString()}`, + }; + }); + assert.strictEqual(copied, expected); + }); + + 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.'); + }); + + it('should copy a URL that is shareable and contains the current filters', async () => { + await page.goto( + `${test.helpers.baseUrl}?q={"severity":{"in":"E F"}}`, + { waitUntil: 'networkidle0' }, + ); + await page.waitForSelector(SHARE_BUTTON); + await mockClipboard(page); + await page.click(SHARE_BUTTON); + + await getNotification(page, 'success'); + + const copied = await page.evaluate(() => window.__copiedValue); + assert.ok(copied.startsWith(test.helpers.baseUrl.replace(/\/$/, ''))); + assert.strictEqual( + decodeURIComponent(new URL(copied).searchParams.get('q')), + '{"severity":{"in":"E F"}}', + ); + }); +}); From 218c0295bc95132207268eeccb08e962d08d1aaa Mon Sep 17 00:00:00 2001 From: Isaac Hill <71404865+isaachilly@users.noreply.github.com> Date: Mon, 24 Aug 2026 18:45:15 +0200 Subject: [PATCH 03/12] Simplify URL sharing with encodeURIComponent Replace manual per-field encoding in LogFilter with a single encodeURIComponent call on the full JSON query string in the router. --- InfoLogger/public/Model.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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); } /** From f3b8e24aac5b84f3c22a278564a430a1b306ad1f Mon Sep 17 00:00:00 2001 From: Isaac Hill <71404865+isaachilly@users.noreply.github.com> Date: Mon, 24 Aug 2026 18:50:56 +0200 Subject: [PATCH 04/12] Copy exact page URL in share action The share button now copies `window.location.href` directly instead of rebuilding the URL from parsed query params. This ensures the copied link exactly matches what the user sees in the address bar. Tests updated to validate exact URL copying, proper percent-encoding/round-trip of filters, and remove redundant test. --- InfoLogger/public/log/commandLogs.js | 5 +- InfoLogger/test/public/share-mocha.js | 81 +++++++++++---------------- 2 files changed, 33 insertions(+), 53 deletions(-) diff --git a/InfoLogger/public/log/commandLogs.js b/InfoLogger/public/log/commandLogs.js index e62863248..e0fa613b1 100644 --- a/InfoLogger/public/log/commandLogs.js +++ b/InfoLogger/public/log/commandLogs.js @@ -256,10 +256,7 @@ const copyLinkToShareCurrentView = (model) => { model.notification.show('Clipboard API is not available in this browser.', 'danger', 2000); return; } - const currentUrl = new URL(window.location.href); - const queryParams = new URLSearchParams(currentUrl.search); - const shareableLink = `${currentUrl.origin}${currentUrl.pathname}?${queryParams.toString()}`; - navigator.clipboard.writeText(shareableLink) + navigator.clipboard.writeText(window.location.href) .then(() => { model.notification.show( 'Shareable link copied to clipboard.', diff --git a/InfoLogger/test/public/share-mocha.js b/InfoLogger/test/public/share-mocha.js index e5ed8167e..dbf905640 100644 --- a/InfoLogger/test/public/share-mocha.js +++ b/InfoLogger/test/public/share-mocha.js @@ -18,24 +18,6 @@ const test = require('../mocha-index'); const SHARE_BUTTON = '#share-button'; const NOTIFICATION = '.notification-content'; -/** - * Install a fake clipboard which stores the written value in `window.__copiedValue` - * @param {Page} page - puppeteer page - * @returns {Promise} - resolves once the mock is installed - */ -const mockClipboard = (page) => page.evaluate(() => { - window.__copiedValue = ''; - Object.defineProperty(navigator, 'clipboard', { - value: { - writeText: (value) => { - window.__copiedValue = value; - return Promise.resolve(); - }, - }, - configurable: true, - }); -}); - /** * Wait for the notification to be displayed with the expected type and return its message * @param {Page} page - puppeteer page @@ -67,21 +49,41 @@ describe('Share button test-suite', () => { } }); - it('should copy a shareable link to the clipboard', async () => { - await mockClipboard(page); + 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); - const notificationText = await getNotification(page, 'success'); - assert.strictEqual(notificationText, 'Shareable link copied to clipboard.'); + await getNotification(page, 'success'); - const { copied, expected } = await page.evaluate(() => { - const url = new URL(window.location.href); - return { - copied: window.__copiedValue, - expected: `${url.origin}${url.pathname}?${new URLSearchParams(url.search).toString()}`, - }; - }); - assert.strictEqual(copied, expected); + 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 () => { @@ -108,23 +110,4 @@ describe('Share button test-suite', () => { const notificationText = await getNotification(page, 'danger'); assert.strictEqual(notificationText, 'Failed to copy shareable link to clipboard.'); }); - - it('should copy a URL that is shareable and contains the current filters', async () => { - await page.goto( - `${test.helpers.baseUrl}?q={"severity":{"in":"E F"}}`, - { waitUntil: 'networkidle0' }, - ); - await page.waitForSelector(SHARE_BUTTON); - await mockClipboard(page); - await page.click(SHARE_BUTTON); - - await getNotification(page, 'success'); - - const copied = await page.evaluate(() => window.__copiedValue); - assert.ok(copied.startsWith(test.helpers.baseUrl.replace(/\/$/, ''))); - assert.strictEqual( - decodeURIComponent(new URL(copied).searchParams.get('q')), - '{"severity":{"in":"E F"}}', - ); - }); }); From e90703e08aabae192daf6dd0b2ec0a7dbf61cbcc Mon Sep 17 00:00:00 2001 From: Isaac Hill <71404865+isaachilly@users.noreply.github.com> Date: Mon, 24 Aug 2026 19:56:49 +0200 Subject: [PATCH 05/12] Fix quote and backslash encoding bug The `+` matched a run of consecutive quotes and collapsed it to a single escaped quote/ A backslash was encoded to `%5C` so JSON.Stringify saw nothing to escape. URLSearchParams then decoded it back to `\` and JSON.parse read it together with the character after it so something like `C:\temp` would be returned as `C:emp`. Both follow escaping before encoding, whilst JSON.parse runs only following the router's URLSearchParams decoding, unsymmetrical. Now that we encode the whole `q` parameter, this per-value pass is redundant and actively harmful, so it is removed. --- InfoLogger/public/logFilter/LogFilter.js | 3 --- 1 file changed, 3 deletions(-) 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 From d5e24d8f07f22eb35f7fb0e6db819d09217776d2 Mon Sep 17 00:00:00 2001 From: Isaac Hill <71404865+isaachilly@users.noreply.github.com> Date: Mon, 24 Aug 2026 19:58:08 +0200 Subject: [PATCH 06/12] Fix URL encoding in filter tests Update expected URL params to use fully percent-encoded form. --- .../test/public/log-filter-actions-mocha.js | 18 +++++++++++------- 1 file changed, 11 insertions(+), 7 deletions(-) diff --git a/InfoLogger/test/public/log-filter-actions-mocha.js b/InfoLogger/test/public/log-filter-actions-mocha.js index cab6a8768..a89afdc51 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 () => { From d78261443a64789a2a74d53c73117f395ac8f2a9 Mon Sep 17 00:00:00 2001 From: Isaac Hill <71404865+isaachilly@users.noreply.github.com> Date: Mon, 24 Aug 2026 20:10:49 +0200 Subject: [PATCH 07/12] Add URL filter round-trip regression tests Extend the InfoLogger filter action suite with a dedicated URL round-trip section. The new tests verify message filter values survive reloads across tricky cases (double quotes, valid/invalid backslash escapes, multiline input, and URL-special characters), and assert the model keeps unencoded values internally. --- .../test/public/log-filter-actions-mocha.js | 78 +++++++++++++------ 1 file changed, 54 insertions(+), 24 deletions(-) diff --git a/InfoLogger/test/public/log-filter-actions-mocha.js b/InfoLogger/test/public/log-filter-actions-mocha.js index a89afdc51..d00a52d2c 100644 --- a/InfoLogger/test/public/log-filter-actions-mocha.js +++ b/InfoLogger/test/public/log-filter-actions-mocha.js @@ -253,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(); @@ -654,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'); + }); + }); }); From 30c6a1ae8ab3ddbc934c0d0d2af6d6c57b7925ef Mon Sep 17 00:00:00 2001 From: Isaac Hill <71404865+isaachilly@users.noreply.github.com> Date: Tue, 25 Aug 2026 14:52:41 +0200 Subject: [PATCH 08/12] Fix shared log links to use current filters Extract query-string generation into `Model.buildQueryString()` and reuse it for route updates. Update the share action to build the URL from the model state instead of `window.location.href`, avoiding stale links when the address bar lags behind debounced filter changes. --- InfoLogger/public/Model.js | 10 +++++++++- InfoLogger/public/log/commandLogs.js | 15 ++++++++++++++- 2 files changed, 23 insertions(+), 2 deletions(-) diff --git a/InfoLogger/public/Model.js b/InfoLogger/public/Model.js index bf1cba5e6..0fc231250 100644 --- a/InfoLogger/public/Model.js +++ b/InfoLogger/public/Model.js @@ -382,12 +382,20 @@ export default class Model extends Observable { } } + /** + * Builds the query string representation of the filter criteria + * @returns {string} The query string representation of the filter criteria + */ + buildQueryString() { + return `?q=${encodeURIComponent(JSON.stringify(this.log.filter.toObject()))}`; + } + /** * When model change (filters), update address bar with the filter * do it silently to avoid infinite loop */ updateRouteOnModelChange() { - this.router.go(`?q=${encodeURIComponent(JSON.stringify(this.log.filter.toObject()))}`, true, true); + this.router.go(this.buildQueryString(), true, true); } /** diff --git a/InfoLogger/public/log/commandLogs.js b/InfoLogger/public/log/commandLogs.js index e0fa613b1..1bfd4ea49 100644 --- a/InfoLogger/public/log/commandLogs.js +++ b/InfoLogger/public/log/commandLogs.js @@ -244,6 +244,11 @@ const zoomButtonGroup = (zoom) => }, h('span', { style: 'font-size:0.8em' }, iconPlus())), ]); +/** + * Button that copies a shareable link of the current filters to the clipboard + * @param {Model} model - root model of the application + * @returns {vnode} - the view of the share button + */ const shareButton = (model) => h('button.btn', { onclick: () => copyLinkToShareCurrentView(model), @@ -251,12 +256,20 @@ const shareButton = (model) => title: 'Copy shareable link of current filters', }, h('span', { style: 'font-size:0.9em' }, iconShare())); +/** + * Copies the URL reproducing the current filters to the clipboard and notifies the user of the outcome + * @param {Model} model - root model of the application + * @returns {void} + */ 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) + + // Built from the model, not location.href, which is debounced and may lag + const shareableLink = window.location.origin + window.location.pathname + model.buildQueryString(); + navigator.clipboard.writeText(shareableLink) .then(() => { model.notification.show( 'Shareable link copied to clipboard.', From 2abb13fb941f48bbb5b294352ff108ae0e87b01e Mon Sep 17 00:00:00 2001 From: Isaac Hill <71404865+isaachilly@users.noreply.github.com> Date: Tue, 25 Aug 2026 14:54:02 +0200 Subject: [PATCH 09/12] Use model-built URLs in InfoLogger tests Update the InfoLogger public tests to compare and reload against URLs built from `window.model.buildQueryString()` instead of `window.location.href`. This makes the assertions match the app's own URL generation logic. --- InfoLogger/test/public/log-filter-actions-mocha.js | 2 +- InfoLogger/test/public/share-mocha.js | 8 ++++---- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/InfoLogger/test/public/log-filter-actions-mocha.js b/InfoLogger/test/public/log-filter-actions-mocha.js index d00a52d2c..6b1a9956c 100644 --- a/InfoLogger/test/public/log-filter-actions-mocha.js +++ b/InfoLogger/test/public/log-filter-actions-mocha.js @@ -643,7 +643,7 @@ describe('Filter actions test-suite', async () => { window.model.updateRouteOnModelChange(); }, value); - const url = await page.evaluate(() => window.location.href); + const url = await page.evaluate(() => window.location.origin + window.location.pathname + window.model.buildQueryString()); await page.goto(url, { waitUntil: 'networkidle0' }); await page.waitForFunction(() => window.model?.log?.filter); diff --git a/InfoLogger/test/public/share-mocha.js b/InfoLogger/test/public/share-mocha.js index dbf905640..c4df58e52 100644 --- a/InfoLogger/test/public/share-mocha.js +++ b/InfoLogger/test/public/share-mocha.js @@ -73,13 +73,13 @@ describe('Share button test-suite', () => { await getNotification(page, 'success'); - const { copied, href } = await page.evaluate(() => ({ + const { copied, url } = await page.evaluate(() => ({ copied: window.__copiedValue, - href: window.location.href, + url: window.location.origin + window.location.pathname + window.model.buildQueryString() })); - // What is copied is exactly what the address bar holds, so only one URL ever exists - assert.strictEqual(copied, href); + // What is copied is exactly what the model builds, whose encoding is tested elsewhere + assert.strictEqual(copied, url); // 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'); From 1e24343a6ec16af9ba131a8fdf503b85c63d39fe Mon Sep 17 00:00:00 2001 From: Isaac Hill <71404865+isaachilly@users.noreply.github.com> Date: Tue, 25 Aug 2026 14:54:41 +0200 Subject: [PATCH 10/12] Test share button with a stale address bar Add a test case verifying that the share button copies the current in-memory filter state even when the address bar hasn't updated yet (due to the 500ms rate limit on route updates). --- InfoLogger/test/public/share-mocha.js | 35 ++++++++++++++++++++++++--- 1 file changed, 32 insertions(+), 3 deletions(-) diff --git a/InfoLogger/test/public/share-mocha.js b/InfoLogger/test/public/share-mocha.js index c4df58e52..8c2a22c80 100644 --- a/InfoLogger/test/public/share-mocha.js +++ b/InfoLogger/test/public/share-mocha.js @@ -80,10 +80,39 @@ describe('Share button test-suite', () => { // What is copied is exactly what the model builds, whose encoding is tested elsewhere assert.strictEqual(copied, url); + }); + + it('should copy the current filters while the address bar is still behind', async () => { + await page.goto(test.helpers.baseUrl, { waitUntil: 'networkidle0' }); + await page.waitForSelector(SHARE_BUTTON); + await page.waitForFunction(() => window.model?.log?.filter); + await page.evaluate(() => { + window.__copiedValue = ''; + Object.defineProperty(navigator, 'clipboard', { + value: { + writeText: (value) => { + window.__copiedValue = value; + return Promise.resolve(); + }, + }, + configurable: true, + }); + }); + + /* + * The route update is rate limited to 500ms, running the first call immediately and deferring + * the next. Two changes in a row therefore leave the address bar behind the model, and the + * click happens in the same synchronous turn so it lands inside that window. + */ + const { copied, href } = await page.evaluate(() => { + window.model.log.filter.setCriteria('message', 'match', 'FIRST'); + window.model.log.filter.setCriteria('message', 'match', 'SECOND'); + document.getElementById('share-button').click(); + return { copied: window.__copiedValue, href: window.location.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"}}'); + assert.ok(!href.includes('SECOND'), 'address bar must still be stale for this test to mean anything'); + assert.ok(copied.includes('SECOND'), 'copied link must reflect the filters as they are now'); }); it('should show a danger notification if the clipboard API is not available', async () => { From 950f1f6817d5a07d91f8c6e8a9dd89f358da5d35 Mon Sep 17 00:00:00 2001 From: Isaac Hill <71404865+isaachilly@users.noreply.github.com> Date: Tue, 25 Aug 2026 16:53:01 +0200 Subject: [PATCH 11/12] Refactor filter tests to avoid string duplication Extract repeated string literals into variables so each assertion compares against a single source of truth. --- .../test/public/log-filter-actions-mocha.js | 26 ++++++++++++------- 1 file changed, 16 insertions(+), 10 deletions(-) diff --git a/InfoLogger/test/public/log-filter-actions-mocha.js b/InfoLogger/test/public/log-filter-actions-mocha.js index 6b1a9956c..5dd600ca6 100644 --- a/InfoLogger/test/public/log-filter-actions-mocha.js +++ b/InfoLogger/test/public/log-filter-actions-mocha.js @@ -652,36 +652,42 @@ describe('Filter actions test-suite', async () => { 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'); + const stringToTest = 'a""b'; + assert.strictEqual(await roundTrip(stringToTest), stringToTest); }); 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'); + const stringToTest = 'C:\\temp'; + assert.strictEqual(await roundTrip(stringToTest), stringToTest); }); 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'); + const stringToTest = 'C:\\xyz'; + assert.strictEqual(await roundTrip(stringToTest), stringToTest); }); it('should preserve a multi-line message filter', async () => { - assert.strictEqual(await roundTrip('first\nsecond'), 'first\nsecond'); + const stringToTest = 'first\nsecond'; + assert.strictEqual(await roundTrip(stringToTest), stringToTest); }); 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 日本語' + const stringToTest = 'a&b#c=d?e %20 a+b c %d #anchor & = héllo wörld 日本語'; + assert.strictEqual(await roundTrip(stringToTest), + stringToTest ); }); it('should store the value unencoded in the model', async () => { - const stored = await page.evaluate(() => { - window.model.log.filter.setCriteria('message', 'match', 'foo bar'); + const stringToTest = 'a&b#c=d?e %20 a+b c %d #anchor & = héllo wörld 日本語'; + const stored = await page.evaluate((stringToTest) => { + window.model.log.filter.setCriteria('message', 'match', stringToTest); return window.model.log.filter.toObject().message.match; - }); + }, stringToTest); - assert.strictEqual(stored, 'foo bar'); + assert.strictEqual(stored, stringToTest); }); }); }); From 9ecc30a5f59d43710f91d3173c86f2e160096c5c Mon Sep 17 00:00:00 2001 From: Isaac Hill <71404865+isaachilly@users.noreply.github.com> Date: Tue, 25 Aug 2026 17:01:17 +0200 Subject: [PATCH 12/12] Update share button title and fix import --- InfoLogger/public/log/commandLogs.js | 2 +- InfoLogger/test/mocha-index.js | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/InfoLogger/public/log/commandLogs.js b/InfoLogger/public/log/commandLogs.js index 1bfd4ea49..40e728a58 100644 --- a/InfoLogger/public/log/commandLogs.js +++ b/InfoLogger/public/log/commandLogs.js @@ -253,7 +253,7 @@ const shareButton = (model) => h('button.btn', { onclick: () => copyLinkToShareCurrentView(model), id: 'share-button', - title: 'Copy shareable link of current filters', + title: 'Copy URL', }, h('span', { style: 'font-size:0.9em' }, iconShare())); /** diff --git a/InfoLogger/test/mocha-index.js b/InfoLogger/test/mocha-index.js index c11205f31..270bf32d3 100644 --- a/InfoLogger/test/mocha-index.js +++ b/InfoLogger/test/mocha-index.js @@ -114,7 +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'); + require('./public/share-mocha'); after(async () => { await browser.close();