From 5c9fe0105cb2ab2a0d12ddad4f3682999a2d6992 Mon Sep 17 00:00:00 2001 From: Sunny Wu Date: Mon, 25 May 2026 12:51:10 +1000 Subject: [PATCH] UID2-7159: fix highlightJSON rendering spurious space in link_id values with colons Replace the chained-regex approach in highlightJSON with a single-pass tokeniser regex. The previous implementation applied five sequential .replace() calls to the same string; each later pass could re-process content already inside tags inserted by an earlier pass. Colons inside string values (e.g. "azure:eastus2:uuid") caused the number regex to match ":71" and inject a hardcoded ": " prefix, producing a spurious visual space in the service-link admin page output. The new single regex matches quoted strings (with escape-sequence support), keywords, and numbers in one pass, so string content is never re-examined. Adds a Jest test suite (17 tests) covering the regression case and all value types (keys, strings with embedded colons, numbers, booleans, null, array elements, pre-serialised JSON strings). Co-Authored-By: Claude Sonnet 4.6 --- .gitignore | 3 +- .../js/component/__tests__/output.test.mjs | 134 ++++++++++++++++++ webroot/js/component/output.js | 34 ++++- webroot/package.json | 15 ++ 4 files changed, 178 insertions(+), 8 deletions(-) create mode 100644 webroot/js/component/__tests__/output.test.mjs create mode 100644 webroot/package.json diff --git a/.gitignore b/.gitignore index 0c44dfc0d..225000912 100644 --- a/.gitignore +++ b/.gitignore @@ -5,4 +5,5 @@ target/* .idea/* .idea/ *.iml -.DS_Store \ No newline at end of file +.DS_Storewebroot/node_modules/ +webroot/package-lock.json diff --git a/webroot/js/component/__tests__/output.test.mjs b/webroot/js/component/__tests__/output.test.mjs new file mode 100644 index 000000000..5eb686bde --- /dev/null +++ b/webroot/js/component/__tests__/output.test.mjs @@ -0,0 +1,134 @@ +import { highlightJSON } from '../output.js'; + +// Helper: strip all HTML tags to get the visible text only +const stripTags = html => html.replace(/<[^>]+>/g, ''); + +// Helper: extract all span class values from the highlighted output +const spansOf = (cls, html) => { + const re = new RegExp(`(.*?)<\\/span>`, 'gs'); + return [...html.matchAll(re)].map(m => m[1]); +}; + +describe('highlightJSON', () => { + describe('string values with embedded colons (regression for UID2-7159)', () => { + it('does not introduce a spurious space in a link_id containing colons', () => { + const input = { link_id: 'azure:eastus2:71ad8e1e-aabb-ccdd-eeff-001122334455' }; + const result = highlightJSON(input); + const visible = stripTags(result); + expect(visible).toContain('"azure:eastus2:71ad8e1e-aabb-ccdd-eeff-001122334455"'); + expect(visible).not.toContain('eastus2: 71'); + }); + + it('wraps a link_id value in json-string, not json-number', () => { + const input = { link_id: 'azure:eastus2:71ad8e1e-aabb-ccdd-eeff-001122334455' }; + const result = highlightJSON(input); + expect(spansOf('json-string', result)).toContain('"azure:eastus2:71ad8e1e-aabb-ccdd-eeff-001122334455"'); + expect(spansOf('json-number', result)).toEqual([]); + }); + + it('does not split a string value at an embedded colon', () => { + const input = { key: 'prefix:suffix' }; + const result = highlightJSON(input); + expect(spansOf('json-string', result)).toContain('"prefix:suffix"'); + }); + + it('handles multiple colons inside a string value', () => { + const input = { endpoint: 'https://example.com:8080/path' }; + const result = highlightJSON(input); + expect(spansOf('json-string', result)).toContain('"https://example.com:8080/path"'); + }); + }); + + describe('key highlighting', () => { + it('wraps object keys in json-key spans', () => { + const result = highlightJSON({ my_key: 'value' }); + expect(spansOf('json-key', result)).toContain('"my_key"'); + }); + + it('highlights keys that contain hyphens and underscores', () => { + const result = highlightJSON({ 'link-id': 'v', link_id: 'v' }); + const keys = spansOf('json-key', result); + expect(keys).toContain('"link-id"'); + expect(keys).toContain('"link_id"'); + }); + }); + + describe('primitive value highlighting', () => { + it('wraps integer values in json-number spans', () => { + const result = highlightJSON({ count: 42 }); + expect(spansOf('json-number', result)).toContain('42'); + }); + + it('wraps float values in json-number spans', () => { + const result = highlightJSON({ ratio: 3.14 }); + expect(spansOf('json-number', result)).toContain('3.14'); + }); + + it('wraps boolean true in json-boolean spans', () => { + const result = highlightJSON({ active: true }); + expect(spansOf('json-boolean', result)).toContain('true'); + }); + + it('wraps boolean false in json-boolean spans', () => { + const result = highlightJSON({ active: false }); + expect(spansOf('json-boolean', result)).toContain('false'); + }); + + it('wraps null in json-null spans', () => { + const result = highlightJSON({ value: null }); + expect(spansOf('json-null', result)).toContain('null'); + }); + }); + + describe('string value highlighting', () => { + it('wraps plain string values in json-string spans', () => { + const result = highlightJSON({ name: 'Alice' }); + expect(spansOf('json-string', result)).toContain('"Alice"'); + }); + + it('wraps array string elements in json-string spans', () => { + const result = highlightJSON({ roles: ['MAPPER', 'ID_READER'] }); + const strings = spansOf('json-string', result); + expect(strings).toContain('"MAPPER"'); + expect(strings).toContain('"ID_READER"'); + }); + + it('does not highlight "true" or "false" inside a string value as boolean', () => { + const result = highlightJSON({ flag: 'this is true and false' }); + expect(spansOf('json-string', result)).toContain('"this is true and false"'); + expect(spansOf('json-boolean', result)).toEqual([]); + }); + + it('does not highlight digits inside a string value as numbers', () => { + const result = highlightJSON({ id: 'ref:42:end' }); + expect(spansOf('json-string', result)).toContain('"ref:42:end"'); + expect(spansOf('json-number', result)).toEqual([]); + }); + }); + + describe('visible text fidelity', () => { + it('preserves all original values when HTML is stripped', () => { + const input = { + link_id: 'azure:eastus2:71ad8e1e-aabb-ccdd-eeff-001122334455', + service_id: 3, + name: 'Azure East US 2', + disabled: false, + config: null, + }; + const result = highlightJSON(input); + const visible = stripTags(result); + expect(visible).toContain('"azure:eastus2:71ad8e1e-aabb-ccdd-eeff-001122334455"'); + expect(visible).toContain('3'); + expect(visible).toContain('"Azure East US 2"'); + expect(visible).toContain('false'); + expect(visible).toContain('null'); + }); + + it('accepts a pre-serialised JSON string', () => { + const json = JSON.stringify({ x: 1 }, null, 2); + const result = highlightJSON(json); + expect(stripTags(result)).toContain('"x"'); + expect(stripTags(result)).toContain('1'); + }); + }); +}); diff --git a/webroot/js/component/output.js b/webroot/js/component/output.js index c7aef5e28..699d1c013 100644 --- a/webroot/js/component/output.js +++ b/webroot/js/component/output.js @@ -14,13 +14,33 @@ function highlightJSON(json) { if (typeof json !== 'string') { json = JSON.stringify(json, null, 2); } - - return json - .replace(/("[\w\s_-]+")(\s*:)/g, '$1$2') - .replace(/:\s*(".*?")/g, ': $1') - .replace(/:\s*(\d+\.?\d*)/g, ': $1') - .replace(/:\s*(true|false)/g, ': $1') - .replace(/:\s*(null)/g, ': $1'); + + // Single-pass tokeniser: quoted-string-followed-by-colon must be tested before + // bare quoted-string so that keys are distinguished from string values. + // This prevents colons inside string values (e.g. "azure:eastus2:uuid") from + // being mis-tokenised by subsequent passes, which was the root cause of a + // spurious space appearing in link_id values on the service-link admin page. + return json.replace( + /("(?:\\.|[^"\\])*")\s*:|(true|false|null)|(-?\d+(?:\.\d*)?(?:[eE][+-]?\d+)?)|("(?:\\.|[^"\\])*")/g, + (match, key, keyword, number, string) => { + if (key !== undefined) { + return '' + key + '' + match.slice(key.length); + } + if (keyword === 'true' || keyword === 'false') { + return '' + keyword + ''; + } + if (keyword === 'null') { + return '' + keyword + ''; + } + if (number !== undefined) { + return '' + number + ''; + } + if (string !== undefined) { + return '' + string + ''; + } + return match; + } + ); } function formatOutput(data) { diff --git a/webroot/package.json b/webroot/package.json new file mode 100644 index 000000000..3f392ab6d --- /dev/null +++ b/webroot/package.json @@ -0,0 +1,15 @@ +{ + "name": "uid2-admin-webroot", + "version": "1.0.0", + "description": "Frontend JS for uid2-admin", + "scripts": { + "test": "node --experimental-vm-modules node_modules/.bin/jest" + }, + "jest": { + "testEnvironment": "node", + "transform": {} + }, + "devDependencies": { + "jest": "^30.4.2" + } +}