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..35c5a2d4 100644 --- a/src/InteractiveMap/polyfills.test.js +++ b/src/InteractiveMap/polyfills.test.js @@ -1,127 +1,158 @@ -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 +}) + +// ─── crypto.randomUUID ─────────────────────────────────────────────────────── - afterEach(() => { - Object.defineProperty(crypto, 'randomUUID', { - value: originalCryptoUUID, - configurable: true, - writable: true - }) +describe('crypto.randomUUID', () => { + const nullifyUUID = () => Object.defineProperty(crypto, 'randomUUID', { value: null, configurable: true, writable: true }) - URL.createObjectURL = originalCreateObjectURL + test('polyfills crypto.randomUUID when missing', () => { + nullifyUUID() + load() + expect(typeof crypto.randomUUID).toBe('function') + expect(crypto.randomUUID()).toMatch(UUID_RE) + }) + + test('generates unique UUIDs', () => { + nullifyUUID() + load() + const ids = new Set(Array.from({ length: 100 }, () => crypto.randomUUID())) + expect(ids.size).toBe(100) + }) - if (originalThrowIfAborted) { - signalProto.throwIfAborted = originalThrowIfAborted - } else { - delete signalProto.throwIfAborted - } + test('does not overwrite existing crypto.randomUUID', () => { + load() + expect(crypto.randomUUID).toBe(originalCryptoUUID) }) +}) - const load = () => jest.isolateModules(() => require('./polyfills.js')) +// ─── Object.hasOwn ─────────────────────────────────────────────────────────── - // 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) +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('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) - }) + test('does not overwrite existing Object.hasOwn', () => { + load() + expect(Object.hasOwn).toBe(originalHasOwn) }) +}) - 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.') - }) +// ─── AbortSignal.throwIfAborted ─────────────────────────────────────────────── - 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() - }) +describe('AbortSignal.throwIfAborted', () => { + beforeEach(() => { delete signalProto.throwIfAborted }) - test('wraps URL.createObjectURL for JS blobs', async () => { - delete signalProto.throwIfAborted + test('throws AbortError when aborted', () => { + 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', () => { + load() + expect(() => new AbortController().signal.throwIfAborted()).not.toThrow() + }) - load() + test('wraps URL.createObjectURL for JS blobs', async () => { + 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', () => { + 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)', () => { + beforeEach(() => { delete Object.hasOwn }) + + 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 () => { + const MockWorker = setupWorkerMock() + const mockCreate = jest.fn(() => 'blob:injected') + URL.createObjectURL = mockCreate + load() + + // 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")') + 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', () => { + const MockWorker = setupWorkerMock() + const mockCreate = jest.fn(() => 'blob:new') + URL.createObjectURL = mockCreate + load() - load() + // 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) + }) - const blob = new Blob(['{}'], { type: 'application/json' }) - URL.createObjectURL(blob) + test('preserves the Worker prototype', () => { + const MockWorker = setupWorkerMock() + const mockProto = { foo: 'bar' } + MockWorker.prototype = mockProto + load() - expect(mockCreate).toHaveBeenCalledWith(blob) - }) + expect(Worker.prototype).toBe(mockProto) }) })