From 0a1f009124a3b75ef4ae044f94dc8d7f74caf14f Mon Sep 17 00:00:00 2001 From: Dan Leech Date: Mon, 1 Jun 2026 16:24:37 +0100 Subject: [PATCH 1/3] hasOwn and toSorted fixes --- .../components/Tooltip/getTooltipPosition.js | 4 +- src/InteractiveMap/polyfills.js | 43 +++- src/InteractiveMap/polyfills.test.js | 238 ++++++++++-------- webpack.dev.mjs | 2 +- 4 files changed, 177 insertions(+), 110 deletions(-) diff --git a/src/App/components/Tooltip/getTooltipPosition.js b/src/App/components/Tooltip/getTooltipPosition.js index 48e0d218..391150e5 100755 --- a/src/App/components/Tooltip/getTooltipPosition.js +++ b/src/App/components/Tooltip/getTooltipPosition.js @@ -21,8 +21,8 @@ export function getTooltipPosition (triggerEl, containerEl) { // Step 1: Find the actual smallest space const entries = Object.entries(space) - const sorted = entries.toSorted(([, a], [, b]) => a - b) - const [leastSide, leastValue] = sorted[0] + entries.sort(([, a], [, b]) => a - b) + const [leastSide, leastValue] = entries[0] // Step 2: Prefer horizontal if it's close in smallness if ((leastSide === 'top' || leastSide === 'bottom')) { diff --git a/src/InteractiveMap/polyfills.js b/src/InteractiveMap/polyfills.js index 83f63d96..e34002df 100644 --- a/src/InteractiveMap/polyfills.js +++ b/src/InteractiveMap/polyfills.js @@ -10,6 +10,12 @@ if (typeof crypto !== 'undefined' && !crypto.randomUUID) { } } +// Object.hasOwn — capture before polyfilling so the worker injection condition below is correct +const needsHasOwn = !Object.hasOwn +if (needsHasOwn) { + Object.hasOwn = (obj, key) => Object.prototype.hasOwnProperty.call(obj, key) // NOSONAR +} + // AbortSignal.throwIfAborted const needsThrowIfAborted = typeof AbortController !== 'undefined' && !Object.getPrototypeOf(new AbortController().signal).throwIfAborted @@ -25,15 +31,40 @@ if (needsThrowIfAborted) { } } -// Inject polyfill into web workers created from blob URLs (e.g. MapLibre GL) -// Workers have their own global scope so main thread polyfills don't apply -if (needsThrowIfAborted && typeof URL !== 'undefined' && URL.createObjectURL) { +// Inject polyfills into web workers — workers have their own global scope so +// main thread polyfills don't apply. Two cases: +// 1. Blob URL workers (MapLibre default): intercept URL.createObjectURL +// 2. String URL workers (MapLibre workerUrl, used in CSP environments): intercept Worker constructor +const needsWorkerPolyfills = (needsThrowIfAborted || needsHasOwn) && typeof URL !== 'undefined' && URL.createObjectURL + +if (needsWorkerPolyfills) { + const JS_MIME = 'text/javascript' + const workerPolyfillCode = [ + 'if(!Object.hasOwn){Object.hasOwn=function(o,k){return Object.prototype.hasOwnProperty.call(o,k)}}\n', + needsThrowIfAborted ? 'if(typeof AbortController!=="undefined"){var _p=Object.getPrototypeOf(new AbortController().signal);if(!_p.throwIfAborted){_p.throwIfAborted=function(){if(this.aborted){var e=new Error("The operation was aborted.");e.name="AbortError";throw e}}}}\n' : '' + ].join('') + + // Save original before overriding, so the Worker override below can use it directly + // and avoid double-injecting the polyfill code const _createObjectURL = URL.createObjectURL.bind(URL) + URL.createObjectURL = (blob) => { - if (blob instanceof Blob && blob.type === 'text/javascript') { - const p = 'if(typeof AbortController!=="undefined"){var _p=Object.getPrototypeOf(new AbortController().signal);if(!_p.throwIfAborted){_p.throwIfAborted=function(){if(this.aborted){var e=new Error("The operation was aborted.");e.name="AbortError";throw e}}}}\n' - blob = new Blob([p, blob], { type: 'text/javascript' }) + if (blob instanceof Blob && blob.type === JS_MIME) { + blob = new Blob([workerPolyfillCode, blob], { type: JS_MIME }) } return _createObjectURL(blob) } + + if (typeof Worker !== 'undefined') { + const NativeWorker = Worker + // eslint-disable-next-line no-global-assign + Worker = function (url, options) { + if (typeof url === 'string' && !url.startsWith('blob:')) { + const blob = new Blob([`${workerPolyfillCode}importScripts(${JSON.stringify(url)})`], { type: JS_MIME }) + url = _createObjectURL(blob) + } + return new NativeWorker(url, options) + } + Worker.prototype = NativeWorker.prototype + } } diff --git a/src/InteractiveMap/polyfills.test.js b/src/InteractiveMap/polyfills.test.js index 92177c0b..b753dabc 100644 --- a/src/InteractiveMap/polyfills.test.js +++ b/src/InteractiveMap/polyfills.test.js @@ -1,127 +1,163 @@ -describe('Polyfills', () => { - const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/ +const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/ +const originalCryptoUUID = crypto.randomUUID +const originalCreateObjectURL = URL.createObjectURL +const originalHasOwn = Object.hasOwn +const originalWorker = globalThis.Worker +const signalProto = Object.getPrototypeOf(new AbortController().signal) +const originalThrowIfAborted = signalProto.throwIfAborted + +beforeEach(() => { jest.resetModules() }) + +afterEach(() => { + Object.defineProperty(crypto, 'randomUUID', { value: originalCryptoUUID, configurable: true, writable: true }) + URL.createObjectURL = originalCreateObjectURL + Object.hasOwn = originalHasOwn + globalThis.Worker = originalWorker + if (originalThrowIfAborted) { + signalProto.throwIfAborted = originalThrowIfAborted + } else { + delete signalProto.throwIfAborted + } +}) - const originalCryptoUUID = crypto.randomUUID - const originalCreateObjectURL = URL.createObjectURL - const signalProto = Object.getPrototypeOf(new AbortController().signal) - const originalThrowIfAborted = signalProto.throwIfAborted +const load = () => jest.isolateModules(() => require('./polyfills.js')) - beforeEach(() => { - jest.resetModules() - }) +const readBlobText = (blob) => new Promise((resolve) => { + const reader = new FileReader() + reader.onload = () => resolve(reader.result) + reader.readAsText(blob) // NOSONAR: Blob#text() is not available in this jsdom version +}) - afterEach(() => { - Object.defineProperty(crypto, 'randomUUID', { - value: originalCryptoUUID, - configurable: true, - writable: true - }) +// ─── crypto.randomUUID ─────────────────────────────────────────────────────── - URL.createObjectURL = originalCreateObjectURL +describe('crypto.randomUUID', () => { + const nullifyUUID = () => Object.defineProperty(crypto, 'randomUUID', { value: null, configurable: true, writable: true }) - if (originalThrowIfAborted) { - signalProto.throwIfAborted = originalThrowIfAborted - } else { - delete signalProto.throwIfAborted - } + test('polyfills crypto.randomUUID when missing', () => { + nullifyUUID() + load() + expect(typeof crypto.randomUUID).toBe('function') + expect(crypto.randomUUID()).toMatch(UUID_RE) }) - const load = () => jest.isolateModules(() => require('./polyfills.js')) + test('generates unique UUIDs', () => { + nullifyUUID() + load() + const ids = new Set(Array.from({ length: 100 }, () => crypto.randomUUID())) + expect(ids.size).toBe(100) + }) - // Helper to read Blob text in environments without blob.text() - const readBlobText = (blob) => new Promise((resolve) => { - const reader = new FileReader() - reader.onload = () => resolve(reader.result) - reader.readAsText(blob) + test('does not overwrite existing crypto.randomUUID', () => { + const fake = jest.fn(() => 'fake') + Object.defineProperty(crypto, 'randomUUID', { value: fake, configurable: true, writable: true }) + load() + expect(crypto.randomUUID).toBe(fake) }) +}) + +// ─── Object.hasOwn ─────────────────────────────────────────────────────────── - describe('crypto.randomUUID', () => { - test('polyfills crypto.randomUUID when missing (Lines 3-8)', () => { - Object.defineProperty(crypto, 'randomUUID', { - value: undefined, - configurable: true, - writable: true - }) - - load() - - expect(typeof crypto.randomUUID).toBe('function') - expect(crypto.randomUUID()).toMatch(UUID_RE) - }) - - test('crypto.randomUUID generates unique UUIDs', () => { - Object.defineProperty(crypto, 'randomUUID', { - value: undefined, - configurable: true, - writable: true - }) - load() - const ids = new Set(Array.from({ length: 100 }, () => crypto.randomUUID())) - expect(ids.size).toBe(100) - }) - - test('does not overwrite existing crypto.randomUUID', () => { - const fake = jest.fn(() => 'fake') - Object.defineProperty(crypto, 'randomUUID', { - value: fake, - configurable: true, - writable: true - }) - load() - expect(crypto.randomUUID).toBe(fake) - }) +describe('Object.hasOwn', () => { + test('polyfills Object.hasOwn when missing', () => { + delete Object.hasOwn + load() + expect(typeof Object.hasOwn).toBe('function') + expect(Object.hasOwn({ a: 1 }, 'a')).toBe(true) + expect(Object.hasOwn({ a: 1 }, 'b')).toBe(false) }) - describe('AbortSignal.throwIfAborted', () => { - test('throws AbortError when aborted (True branch)', () => { - delete signalProto.throwIfAborted - load() - const ac = new AbortController() - ac.abort() - expect(() => ac.signal.throwIfAborted()).toThrow('The operation was aborted.') - }) + test('does not overwrite existing Object.hasOwn', () => { + const fake = jest.fn() + Object.hasOwn = fake + load() + expect(Object.hasOwn).toBe(fake) + }) +}) - test('does nothing when not aborted (False branch)', () => { - delete signalProto.throwIfAborted - load() - const ac = new AbortController() - // This call should execute line 20, see that aborted is false, and return undefined - expect(() => ac.signal.throwIfAborted()).not.toThrow() - }) +// ─── AbortSignal.throwIfAborted ─────────────────────────────────────────────── - test('wraps URL.createObjectURL for JS blobs', async () => { - delete signalProto.throwIfAborted +describe('AbortSignal.throwIfAborted', () => { + test('throws AbortError when aborted', () => { + delete signalProto.throwIfAborted + load() + const ac = new AbortController() + ac.abort() + expect(() => ac.signal.throwIfAborted()).toThrow('The operation was aborted.') + }) - const mockCreate = jest.fn(() => 'blob:mock') - URL.createObjectURL = mockCreate + test('does nothing when not aborted', () => { + delete signalProto.throwIfAborted + load() + expect(() => new AbortController().signal.throwIfAborted()).not.toThrow() + }) - load() + test('wraps URL.createObjectURL for JS blobs', async () => { + delete signalProto.throwIfAborted + const mockCreate = jest.fn(() => 'blob:mock') + URL.createObjectURL = mockCreate + load() - const content = 'console.log(1)' - const blob = new Blob([content], { type: 'text/javascript' }) - const result = URL.createObjectURL(blob) + URL.createObjectURL(new Blob(['console.log(1)'], { type: 'text/javascript' })) + const text = await readBlobText(mockCreate.mock.calls[0][0]) + expect(text).toContain('throwIfAborted') + expect(text).toContain('console.log(1)') + }) - expect(result).toBe('blob:mock') - expect(mockCreate).toHaveBeenCalled() + test('does not wrap URL.createObjectURL for non-JS blobs', () => { + delete signalProto.throwIfAborted + const mockCreate = jest.fn(() => 'blob:mock') + URL.createObjectURL = mockCreate + load() - const passedBlob = mockCreate.mock.calls[0][0] - const text = await readBlobText(passedBlob) + const blob = new Blob(['{}'], { type: 'application/json' }) + URL.createObjectURL(blob) + expect(mockCreate).toHaveBeenCalledWith(blob) + }) +}) - expect(text).toContain('throwIfAborted') - expect(text).toContain(content) - }) +// ─── Worker constructor (string workerUrl) ──────────────────────────────────── + +describe('Worker constructor (string workerUrl)', () => { + const setupWorkerMock = () => { + const MockWorker = jest.fn() + MockWorker.prototype = {} + globalThis.Worker = MockWorker + return MockWorker + } + + test('wraps Worker with string URL to inject polyfill via importScripts', async () => { + delete Object.hasOwn + const MockWorker = setupWorkerMock() + const mockCreate = jest.fn(() => 'blob:injected') + URL.createObjectURL = mockCreate + load() + + expect(new Worker('/worker.js')).toBeDefined() + const text = await readBlobText(mockCreate.mock.calls[0][0]) + expect(text).toContain('Object.hasOwn') + expect(text).toContain('importScripts("/worker.js")') + expect(MockWorker).toHaveBeenCalledWith('blob:injected', undefined) + }) - test('does not wrap URL.createObjectURL for non-JS blobs', () => { - delete signalProto.throwIfAborted - const mockCreate = jest.fn(() => 'blob:mock') - URL.createObjectURL = mockCreate + test('passes blob URLs through without wrapping', () => { + delete Object.hasOwn + const MockWorker = setupWorkerMock() + const mockCreate = jest.fn(() => 'blob:new') + URL.createObjectURL = mockCreate + load() - load() + expect(new Worker('blob:http://localhost/existing')).toBeDefined() + expect(mockCreate).not.toHaveBeenCalled() + expect(MockWorker).toHaveBeenCalledWith('blob:http://localhost/existing', undefined) + }) - const blob = new Blob(['{}'], { type: 'application/json' }) - URL.createObjectURL(blob) + test('preserves the Worker prototype', () => { + delete Object.hasOwn + const MockWorker = setupWorkerMock() + const mockProto = { foo: 'bar' } + MockWorker.prototype = mockProto + load() - expect(mockCreate).toHaveBeenCalledWith(blob) - }) + expect(Worker.prototype).toBe(mockProto) }) }) diff --git a/webpack.dev.mjs b/webpack.dev.mjs index d097d993..eb39f619 100755 --- a/webpack.dev.mjs +++ b/webpack.dev.mjs @@ -21,7 +21,7 @@ export default { draw: path.join(__dirname, 'demo/js/draw.js'), 'draw-ol': path.join(__dirname, 'demo/js/draw-ol.js'), farming: path.join(__dirname, 'demo/js/farming.js'), - planning: path.join(__dirname, 'demo/js/planning.js'), + // planning: path.join(__dirname, 'demo/js/planning.js'), 'planning-ol': path.join(__dirname, 'demo/js/planning-ol.js'), gep: path.join(__dirname, 'demo/js/gep.js'), multimap: path.join(__dirname, 'demo/js/multimap.js') From fd12bd633bcad73fd35501a36e66d4e51e598fb3 Mon Sep 17 00:00:00 2001 From: Dan Leech Date: Mon, 1 Jun 2026 16:25:21 +0100 Subject: [PATCH 2/3] Webpack.dev planning reinstated --- webpack.dev.mjs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/webpack.dev.mjs b/webpack.dev.mjs index eb39f619..d097d993 100755 --- a/webpack.dev.mjs +++ b/webpack.dev.mjs @@ -21,7 +21,7 @@ export default { draw: path.join(__dirname, 'demo/js/draw.js'), 'draw-ol': path.join(__dirname, 'demo/js/draw-ol.js'), farming: path.join(__dirname, 'demo/js/farming.js'), - // planning: path.join(__dirname, 'demo/js/planning.js'), + planning: path.join(__dirname, 'demo/js/planning.js'), 'planning-ol': path.join(__dirname, 'demo/js/planning-ol.js'), gep: path.join(__dirname, 'demo/js/gep.js'), multimap: path.join(__dirname, 'demo/js/multimap.js') From 49a375bfa1cc58f205c09bf71a6c9af6419b44c1 Mon Sep 17 00:00:00 2001 From: Dan Leech Date: Mon, 1 Jun 2026 16:47:40 +0100 Subject: [PATCH 3/3] Sonar cloud fixes and test simplification --- src/InteractiveMap/polyfills.test.js | 25 ++++++++++--------------- 1 file changed, 10 insertions(+), 15 deletions(-) diff --git a/src/InteractiveMap/polyfills.test.js b/src/InteractiveMap/polyfills.test.js index b753dabc..35c5a2d4 100644 --- a/src/InteractiveMap/polyfills.test.js +++ b/src/InteractiveMap/polyfills.test.js @@ -48,10 +48,8 @@ describe('crypto.randomUUID', () => { }) test('does not overwrite existing crypto.randomUUID', () => { - const fake = jest.fn(() => 'fake') - Object.defineProperty(crypto, 'randomUUID', { value: fake, configurable: true, writable: true }) load() - expect(crypto.randomUUID).toBe(fake) + expect(crypto.randomUUID).toBe(originalCryptoUUID) }) }) @@ -67,18 +65,17 @@ describe('Object.hasOwn', () => { }) test('does not overwrite existing Object.hasOwn', () => { - const fake = jest.fn() - Object.hasOwn = fake load() - expect(Object.hasOwn).toBe(fake) + expect(Object.hasOwn).toBe(originalHasOwn) }) }) // ─── AbortSignal.throwIfAborted ─────────────────────────────────────────────── describe('AbortSignal.throwIfAborted', () => { + beforeEach(() => { delete signalProto.throwIfAborted }) + test('throws AbortError when aborted', () => { - delete signalProto.throwIfAborted load() const ac = new AbortController() ac.abort() @@ -86,13 +83,11 @@ describe('AbortSignal.throwIfAborted', () => { }) test('does nothing when not aborted', () => { - delete signalProto.throwIfAborted load() expect(() => new AbortController().signal.throwIfAborted()).not.toThrow() }) test('wraps URL.createObjectURL for JS blobs', async () => { - delete signalProto.throwIfAborted const mockCreate = jest.fn(() => 'blob:mock') URL.createObjectURL = mockCreate load() @@ -104,7 +99,6 @@ describe('AbortSignal.throwIfAborted', () => { }) test('does not wrap URL.createObjectURL for non-JS blobs', () => { - delete signalProto.throwIfAborted const mockCreate = jest.fn(() => 'blob:mock') URL.createObjectURL = mockCreate load() @@ -118,6 +112,8 @@ describe('AbortSignal.throwIfAborted', () => { // ─── Worker constructor (string workerUrl) ──────────────────────────────────── describe('Worker constructor (string workerUrl)', () => { + beforeEach(() => { delete Object.hasOwn }) + const setupWorkerMock = () => { const MockWorker = jest.fn() MockWorker.prototype = {} @@ -126,13 +122,13 @@ describe('Worker constructor (string workerUrl)', () => { } test('wraps Worker with string URL to inject polyfill via importScripts', async () => { - delete Object.hasOwn const MockWorker = setupWorkerMock() const mockCreate = jest.fn(() => 'blob:injected') URL.createObjectURL = mockCreate load() - expect(new Worker('/worker.js')).toBeDefined() + // eslint-disable-next-line no-new + new Worker('/worker.js') // NOSONAR const text = await readBlobText(mockCreate.mock.calls[0][0]) expect(text).toContain('Object.hasOwn') expect(text).toContain('importScripts("/worker.js")') @@ -140,19 +136,18 @@ describe('Worker constructor (string workerUrl)', () => { }) test('passes blob URLs through without wrapping', () => { - delete Object.hasOwn const MockWorker = setupWorkerMock() const mockCreate = jest.fn(() => 'blob:new') URL.createObjectURL = mockCreate load() - expect(new Worker('blob:http://localhost/existing')).toBeDefined() + // eslint-disable-next-line no-new + new Worker('blob:http://localhost/existing') // NOSONAR expect(mockCreate).not.toHaveBeenCalled() expect(MockWorker).toHaveBeenCalledWith('blob:http://localhost/existing', undefined) }) test('preserves the Worker prototype', () => { - delete Object.hasOwn const MockWorker = setupWorkerMock() const mockProto = { foo: 'bar' } MockWorker.prototype = mockProto