From f557ba9c2e64080537a6243ba06ca7bccfba1920 Mon Sep 17 00:00:00 2001 From: Alex Prudhomme <78121423+alexprudhomme@users.noreply.github.com> Date: Mon, 3 Aug 2026 17:13:03 -0400 Subject: [PATCH 1/3] refactor(headless): import coveo.analytics modules instead of vendoring them j:KIT-5959 --- .changeset/headless-cajs-per-module-esm.md | 5 + packages/headless/esbuild.mjs | 18 + .../analytics/coveo.analytics/cookie.test.ts | 210 ---------- .../api/analytics/coveo.analytics/cookie.ts | 64 ---- .../coveo.analytics/detector.test.ts | 122 ------ .../api/analytics/coveo.analytics/detector.ts | 27 -- .../coveo.analytics/history-store.test.ts | 4 +- .../coveo.analytics/history-store.ts | 187 +-------- .../analytics/coveo.analytics/storage.test.ts | 358 ------------------ .../api/analytics/coveo.analytics/storage.ts | 73 ---- 10 files changed, 35 insertions(+), 1033 deletions(-) create mode 100644 .changeset/headless-cajs-per-module-esm.md delete mode 100644 packages/headless/src/api/analytics/coveo.analytics/cookie.test.ts delete mode 100644 packages/headless/src/api/analytics/coveo.analytics/cookie.ts delete mode 100644 packages/headless/src/api/analytics/coveo.analytics/detector.test.ts delete mode 100644 packages/headless/src/api/analytics/coveo.analytics/detector.ts delete mode 100644 packages/headless/src/api/analytics/coveo.analytics/storage.test.ts delete mode 100644 packages/headless/src/api/analytics/coveo.analytics/storage.ts diff --git a/.changeset/headless-cajs-per-module-esm.md b/.changeset/headless-cajs-per-module-esm.md new file mode 100644 index 00000000000..be8077b107a --- /dev/null +++ b/.changeset/headless-cajs-per-module-esm.md @@ -0,0 +1,5 @@ +--- +'@coveo/headless': patch +--- + +Import the `cookie`, `detector`, `storage` and `history-store` modules from `coveo.analytics` instead of keeping vendored copies of them, removing a source of drift between the two packages. No public API change. diff --git a/packages/headless/esbuild.mjs b/packages/headless/esbuild.mjs index 707f00b31fe..df925dd2f40 100644 --- a/packages/headless/esbuild.mjs +++ b/packages/headless/esbuild.mjs @@ -212,6 +212,18 @@ function resolveEsm(moduleName) { return resolve(dirname(packageJsonPath), packageJson.module || packageJson.main); } +/** + * Absolute path for a package subpath. `packages: 'external'` does not externalize absolute + * paths, so aliasing to one keeps the module bundled. Required for the unbundled ESM modules of + * coveo.analytics: left as bare specifiers they would emit `require()` of an `.mjs` file, which + * throws `ERR_REQUIRE_ESM` on the older Node versions this package still supports. + * + * @param {string} subpath + */ +function resolveSubpath(subpath) { + return require.resolve(subpath); +} + function resolveBrowser(moduleName) { const packageJsonPath = require.resolve(`${moduleName}/package.json`); const packageJson = require(packageJsonPath); @@ -245,6 +257,9 @@ async function buildBrowserConfig(options, outDir) { plugins: [ alias({ 'coveo.analytics': resolveEsm('coveo.analytics'), + 'coveo.analytics/dist/esm/history.mjs': resolveSubpath( + 'coveo.analytics/dist/esm/history.mjs' + ), pino: resolveBrowser('pino'), }), ...(options.plugins || []), @@ -282,6 +297,9 @@ async function buildNodeConfig(options, outDir) { plugins: [ alias({ 'coveo.analytics': resolveEsm('coveo.analytics'), + 'coveo.analytics/dist/esm/history.mjs': resolveSubpath( + 'coveo.analytics/dist/esm/history.mjs' + ), }), ], ...options, diff --git a/packages/headless/src/api/analytics/coveo.analytics/cookie.test.ts b/packages/headless/src/api/analytics/coveo.analytics/cookie.test.ts deleted file mode 100644 index db4d84df88e..00000000000 --- a/packages/headless/src/api/analytics/coveo.analytics/cookie.test.ts +++ /dev/null @@ -1,210 +0,0 @@ -import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest'; -import {Cookie} from './cookie.js'; - -describe('Cookie', () => { - const mockDocument = { - cookie: '', - }; - - const mockWindow = { - location: { - hostname: '', - protocol: '', - }, - }; - - beforeEach(() => { - // Mock global objects - vi.stubGlobal('document', mockDocument); - vi.stubGlobal('window', mockWindow); - - // Reset window properties before each test - mockWindow.location.hostname = 'example.com'; - mockWindow.location.protocol = 'http:'; - - // Reset document.cookie before each test - mockDocument.cookie = ''; - }); - - afterEach(() => { - vi.restoreAllMocks(); - vi.unstubAllGlobals(); - }); - - describe('set', () => { - it('should set a cookie with name and value on a single domain', () => { - mockWindow.location.hostname = 'localhost'; - - Cookie.set('testCookie', 'testValue'); - - expect(mockDocument.cookie).toBe( - 'testCookie=testValue;path=/;SameSite=Lax' - ); - }); - - it('should set a cookie with domain for multi-level domain', () => { - mockWindow.location.hostname = 'subdomain.example.com'; - - Cookie.set('testCookie', 'testValue'); - - expect(mockDocument.cookie).toBe( - 'testCookie=testValue;domain=example.com;path=/;SameSite=Lax' - ); - }); - - it('should set a cookie with expiration date when expire is provided', () => { - mockWindow.location.hostname = 'localhost'; - const expireTime = 3600000; // 1 hour in milliseconds - const expectedDate = new Date(); - expectedDate.setTime(expectedDate.getTime() + expireTime); - - Cookie.set('testCookie', 'testValue', expireTime); - - expect(mockDocument.cookie).toContain('testCookie=testValue'); - expect(mockDocument.cookie).toContain('expires='); - expect(mockDocument.cookie).toContain('path=/;SameSite=Lax'); - }); - - it('should extract correct domain from multi-level subdomain', () => { - mockWindow.location.hostname = 'deep.subdomain.example.com'; - - Cookie.set('testCookie', 'testValue'); - - expect(mockDocument.cookie).toBe( - 'testCookie=testValue;domain=example.com;path=/;SameSite=Lax' - ); - }); - - it('should handle domains with dots correctly', () => { - mockWindow.location.hostname = 'test.co.uk'; - - Cookie.set('testCookie', 'testValue'); - - expect(mockDocument.cookie).toBe( - 'testCookie=testValue;domain=co.uk;path=/;SameSite=Lax' - ); - }); - - it('should set cookie with Secure attribute when protocol is https', () => { - mockWindow.location.protocol = 'https:'; - mockWindow.location.hostname = 'localhost'; - - Cookie.set('testCookie', 'testValue'); - - expect(mockDocument.cookie).toBe( - 'testCookie=testValue;path=/;SameSite=Lax;Secure' - ); - }); - }); - - describe('get', () => { - it('should return the cookie value with trailing spaces when cookie exists', () => { - mockDocument.cookie = 'testCookie=testValue; otherCookie=otherValue'; - - const result = Cookie.get('testCookie'); - - expect(result).toBe('testValue'); - }); - - it('should return the cookie value when cookie has spaces', () => { - mockDocument.cookie = ' testCookie=testValue ; otherCookie=otherValue'; - - const result = Cookie.get('testCookie'); - - expect(result).toBe('testValue '); - }); - - it('should return null when cookie does not exist', () => { - mockDocument.cookie = 'otherCookie=otherValue'; - - const result = Cookie.get('testCookie'); - - expect(result).toBeNull(); - }); - - it('should return null when no cookies exist', () => { - mockDocument.cookie = ''; - - const result = Cookie.get('testCookie'); - - expect(result).toBeNull(); - }); - - it('should handle cookies with empty values', () => { - mockDocument.cookie = 'testCookie=; otherCookie=otherValue'; - - const result = Cookie.get('testCookie'); - - expect(result).toBe(''); - }); - - it('should handle cookies with complex values', () => { - const complexValue = 'value with spaces and symbols!@#$%'; - mockDocument.cookie = `testCookie=${complexValue}; otherCookie=otherValue`; - - const result = Cookie.get('testCookie'); - - expect(result).toBe(complexValue); - }); - - it('should return the first matching cookie when multiple cookies have the same prefix', () => { - mockDocument.cookie = - 'testCookie=firstValue; testCookieExtended=secondValue'; - - const result = Cookie.get('testCookie'); - - expect(result).toBe('firstValue'); - }); - }); - - describe('erase', () => { - it('should call set with empty value and negative expiration', () => { - const setSpy = vi.spyOn(Cookie, 'set'); - - Cookie.erase('testCookie'); - - expect(setSpy).toHaveBeenCalledWith('testCookie', '', -1); - }); - }); - - describe('edge cases', () => { - it('should handle hostname with only one dot', () => { - mockWindow.location.hostname = 'example.com'; - - Cookie.set('testCookie', 'testValue'); - - expect(mockDocument.cookie).toBe( - 'testCookie=testValue;domain=example.com;path=/;SameSite=Lax' - ); - }); - - it('should handle IP addresses as hostname', () => { - mockWindow.location.hostname = '192.168.1.1'; - - Cookie.set('testCookie', 'testValue'); - - expect(mockDocument.cookie).toBe( - 'testCookie=testValue;path=/;SameSite=Lax' - ); - }); - - it('should handle empty cookie name gracefully', () => { - mockWindow.location.hostname = 'localhost'; - - Cookie.set('', 'testValue'); - - expect(mockDocument.cookie).toBe('=testValue;path=/;SameSite=Lax'); - }); - - it('should handle special characters in cookie values', () => { - mockWindow.location.hostname = 'localhost'; - const specialValue = 'test=value;with,special|chars'; - - Cookie.set('testCookie', specialValue); - - expect(mockDocument.cookie).toBe( - `testCookie=${specialValue};path=/;SameSite=Lax` - ); - }); - }); -}); diff --git a/packages/headless/src/api/analytics/coveo.analytics/cookie.ts b/packages/headless/src/api/analytics/coveo.analytics/cookie.ts deleted file mode 100644 index 3b058df5e23..00000000000 --- a/packages/headless/src/api/analytics/coveo.analytics/cookie.ts +++ /dev/null @@ -1,64 +0,0 @@ -// Code originally modified from : https://developers.livechatinc.com/blog/setting-cookies-to-subdomains-in-javascript/ -export class Cookie { - static set(name: string, value: string, expire?: number) { - let domain: string, expirationDate: Date | undefined, domainParts: string[]; - if (expire) { - expirationDate = new Date(); - expirationDate.setTime(expirationDate.getTime() + expire); - } - const host = window.location.hostname; - - // Check if it's an IPv4 address - const ipv4Regex = /^(\d{1,3}\.){3}\d{1,3}$/; - // Check if it's an IPv6 address - const ipv6Regex = /^([0-9a-fA-F]{0,4}:){2,7}[0-9a-fA-F]{0,4}$/; - - if (ipv4Regex.test(host) || ipv6Regex.test(host)) { - // IP address - set cookie without domain - writeCookie(name, value, expirationDate); - } else if (host.indexOf('.') === -1) { - // no "." in a domain - single domain name, it's localhost or something similar - writeCookie(name, value, expirationDate); - } else { - domainParts = host.split('.'); - // we always have at least 2 domain parts - domain = - domainParts[domainParts.length - 2] + - '.' + - domainParts[domainParts.length - 1]; - writeCookie(name, value, expirationDate, domain); - } - } - - static get(name: string) { - const cookiePrefix = name + '='; - const cookieArray = document.cookie.split(';'); - for (let i = 0; i < cookieArray.length; i++) { - let cookie = cookieArray[i]; - cookie = cookie.replace(/^\s+/, ''); //strip whitespace from front of cookie only - if (cookie.lastIndexOf(cookiePrefix, 0) === 0) { - return cookie.substring(cookiePrefix.length, cookie.length); - } - } - return null; - } - - static erase(name: string) { - Cookie.set(name, '', -1); - } -} - -function writeCookie( - name: string, - value: string, - expirationDate?: Date, - domain?: string -) { - document.cookie = - `${name}=${value}` + - (expirationDate ? `;expires=${expirationDate.toUTCString()}` : '') + - (domain ? `;domain=${domain}` : '') + - ';path=/' + - ';SameSite=Lax' + - (window.location.protocol === 'https:' ? ';Secure' : ''); -} diff --git a/packages/headless/src/api/analytics/coveo.analytics/detector.test.ts b/packages/headless/src/api/analytics/coveo.analytics/detector.test.ts deleted file mode 100644 index 8177957890f..00000000000 --- a/packages/headless/src/api/analytics/coveo.analytics/detector.test.ts +++ /dev/null @@ -1,122 +0,0 @@ -import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest'; -import { - hasCookieStorage, - hasDocument, - hasLocalStorage, - hasNavigator, - hasSessionStorage, -} from './detector.js'; - -describe('detector', () => { - beforeEach(() => { - vi.resetAllMocks(); - }); - - afterEach(() => { - vi.restoreAllMocks(); - }); - - describe('#hasNavigator', () => { - it('should return true when navigator is defined', () => { - vi.stubGlobal('navigator', {}); - - expect(hasNavigator()).toBe(true); - }); - - it('should return false when navigator is undefined', () => { - vi.stubGlobal('navigator', undefined); - - expect(hasNavigator()).toBe(false); - }); - }); - - describe('#hasDocument', () => { - it('should return true when document is defined', () => { - vi.stubGlobal('document', {}); - - expect(hasDocument()).toBe(true); - }); - - it('should return false when document is undefined', () => { - vi.stubGlobal('document', undefined); - - expect(hasDocument()).toBe(false); - }); - }); - - describe('#hasLocalStorage', () => { - it('should return true when localStorage is defined', () => { - vi.stubGlobal('localStorage', {}); - - expect(hasLocalStorage()).toBe(true); - }); - - it('should return false when localStorage is undefined', () => { - vi.stubGlobal('localStorage', undefined); - - expect(hasLocalStorage()).toBe(false); - }); - - it('should return false when localStorage throws an error', () => { - Object.defineProperty(global, 'localStorage', { - get() { - throw new Error('localStorage not available'); - }, - configurable: true, - }); - - expect(hasLocalStorage()).toBe(false); - }); - }); - - describe('#hasSessionStorage', () => { - it('should return true when sessionStorage is defined', () => { - vi.stubGlobal('sessionStorage', {}); - - expect(hasSessionStorage()).toBe(true); - }); - - it('should return false when sessionStorage is undefined', () => { - vi.stubGlobal('sessionStorage', undefined); - - expect(hasSessionStorage()).toBe(false); - }); - - it('should return false when sessionStorage throws an error', () => { - Object.defineProperty(global, 'sessionStorage', { - get() { - throw new Error('sessionStorage not available'); - }, - configurable: true, - }); - - expect(hasSessionStorage()).toBe(false); - }); - }); - - describe('#hasCookieStorage', () => { - it('should return true when navigator exists and cookieEnabled is true', () => { - vi.stubGlobal('navigator', {cookieEnabled: true}); - - expect(hasCookieStorage()).toBe(true); - }); - - it('should return false when navigator exists and cookieEnabled is false', () => { - vi.stubGlobal('navigator', {cookieEnabled: false}); - - expect(hasCookieStorage()).toBe(false); - }); - - it('should return false when navigator does not exist', () => { - vi.stubGlobal('navigator', undefined); - - expect(hasCookieStorage()).toBe(false); - }); - - it('should return false when navigator exists but cookieEnabled is undefined', () => { - vi.stubGlobal('navigator', {}); - - expect(hasCookieStorage()).toBe(false); - }); - }); -}); diff --git a/packages/headless/src/api/analytics/coveo.analytics/detector.ts b/packages/headless/src/api/analytics/coveo.analytics/detector.ts deleted file mode 100644 index e9a9c66af95..00000000000 --- a/packages/headless/src/api/analytics/coveo.analytics/detector.ts +++ /dev/null @@ -1,27 +0,0 @@ -export function hasNavigator(): boolean { - return typeof navigator !== 'undefined'; -} - -export function hasDocument(): boolean { - return typeof document !== 'undefined'; -} - -export function hasLocalStorage(): boolean { - try { - return typeof localStorage !== 'undefined'; - } catch (error) { - return false; - } -} - -export function hasSessionStorage(): boolean { - try { - return typeof sessionStorage !== 'undefined'; - } catch (error) { - return false; - } -} - -export function hasCookieStorage(): boolean { - return Boolean(hasNavigator() && navigator.cookieEnabled); -} diff --git a/packages/headless/src/api/analytics/coveo.analytics/history-store.test.ts b/packages/headless/src/api/analytics/coveo.analytics/history-store.test.ts index 1c2976e1535..f3d84b59fdd 100644 --- a/packages/headless/src/api/analytics/coveo.analytics/history-store.test.ts +++ b/packages/headless/src/api/analytics/coveo.analytics/history-store.test.ts @@ -12,9 +12,9 @@ import { MAX_NUMBER_OF_HISTORY_ELEMENTS, MAX_VALUE_SIZE, STORE_KEY, -} from './history-store.js'; +} from 'coveo.analytics/dist/esm/history.mjs'; +import type {WebStorage} from 'coveo.analytics/dist/esm/storage.mjs'; import HistoryStore from './history-store.js'; -import type {WebStorage} from './storage.js'; describe('HistoryStore', () => { let mockStorage: Mocked; diff --git a/packages/headless/src/api/analytics/coveo.analytics/history-store.ts b/packages/headless/src/api/analytics/coveo.analytics/history-store.ts index 8728043a783..181c41a135e 100644 --- a/packages/headless/src/api/analytics/coveo.analytics/history-store.ts +++ b/packages/headless/src/api/analytics/coveo.analytics/history-store.ts @@ -1,187 +1,20 @@ -import {getAvailableStorage, type WebStorage} from './storage.js'; +import {HistoryStore as CoveoAnalyticsHistoryStore} from 'coveo.analytics/dist/esm/history.mjs'; +import type {WebStorage} from 'coveo.analytics/dist/esm/storage.mjs'; -export const STORE_KEY: string = '__coveo.analytics.history'; -export const MAX_NUMBER_OF_HISTORY_ELEMENTS: number = 20; -const MIN_THRESHOLD_FOR_DUPLICATE_VALUE: number = 1000 * 60; -export const MAX_VALUE_SIZE = 75; +export type {HistoryElement} from 'coveo.analytics/dist/esm/history.mjs'; -class HistoryStore { +/** + * Headless shares a single history store across the engine, whereas coveo.analytics exposes a + * plain class. Subclassing keeps `HistoryStore` usable as both a value and a type at the existing + * call sites while the behavior lives in coveo.analytics. + */ +export default class HistoryStore extends CoveoAnalyticsHistoryStore { private static instance: HistoryStore | null = null; + public static getInstance(store?: WebStorage): HistoryStore { if (!HistoryStore.instance) { HistoryStore.instance = new HistoryStore(store); } return HistoryStore.instance; } - private store: WebStorage; - private constructor(store?: WebStorage) { - this.store = store || getAvailableStorage(); - } - - /** - * @deprecated Synchronous method is deprecated, use addElementAsync instead. This method will NOT work with react-native. - */ - addElement(elem: HistoryElement) { - elem.internalTime = new Date().getTime(); - elem = this.cropQueryElement(this.stripEmptyQuery(elem)); - const currentHistory = this.getHistoryWithInternalTime(); - if (currentHistory !== null) { - if (this.isValidEntry(elem)) { - this.setHistory([elem].concat(currentHistory)); - } - } else { - this.setHistory([elem]); - } - } - - async addElementAsync(elem: HistoryElement) { - elem.internalTime = new Date().getTime(); - elem = this.cropQueryElement(this.stripEmptyQuery(elem)); - const currentHistory = await this.getHistoryWithInternalTimeAsync(); - if (currentHistory !== null) { - if (this.isValidEntry(elem)) { - this.setHistory([elem].concat(currentHistory)); - } - } else { - this.setHistory([elem]); - } - } - - /** - * @deprecated Synchronous method is deprecated, use getHistoryAsync instead. This method will NOT work with react-native. - */ - getHistory(): HistoryElement[] { - const history = this.getHistoryWithInternalTime(); - return this.stripEmptyQueries(this.stripInternalTime(history)); - } - - async getHistoryAsync(): Promise { - const history = await this.getHistoryWithInternalTimeAsync(); - return this.stripEmptyQueries(this.stripInternalTime(history)); - } - - private getHistoryWithInternalTime(): HistoryElement[] { - try { - const elements = this.store.getItem(STORE_KEY); - if (elements && typeof elements === 'string') { - return JSON.parse(elements) as HistoryElement[]; - } else { - return []; - } - } catch (e) { - // When using the Storage APIs (localStorage/sessionStorage) - // Safari says that those APIs are available but throws when making - // a call to them. - return []; - } - } - - private async getHistoryWithInternalTimeAsync(): Promise { - try { - const elements = await this.store.getItem(STORE_KEY); - if (elements) { - return JSON.parse(elements) as HistoryElement[]; - } else { - return []; - } - } catch (e) { - // When using the Storage APIs (localStorage/sessionStorage) - // Safari says that those APIs are available but throws when making - // a call to them. - return []; - } - } - - setHistory(history: HistoryElement[]) { - try { - this.store.setItem( - STORE_KEY, - JSON.stringify(history.slice(0, MAX_NUMBER_OF_HISTORY_ELEMENTS)) - ); - } catch (e) { - /* refer to this.getHistory() */ - } - } - - clear() { - try { - this.store.removeItem(STORE_KEY); - } catch (e) { - /* refer to this.getHistory() */ - } - } - - getMostRecentElement(): HistoryElement | null { - const currentHistory = this.getHistoryWithInternalTime(); - if (Array.isArray(currentHistory)) { - const sorted = currentHistory.sort( - (first: HistoryElement, second: HistoryElement) => { - // Internal time might not be set for all history element (on upgrade). - // Ensure to return the most recent element for which we have a value for internalTime. - return (second.internalTime || 0) - (first.internalTime || 0); - } - ); - return sorted[0]; - } - return null; - } - - private cropQueryElement(part: HistoryElement) { - if (part.name && part.value && part.name.toLowerCase() === 'query') { - part.value = part.value.slice(0, MAX_VALUE_SIZE); - } - - return part; - } - - private isValidEntry(elem: HistoryElement): boolean { - const lastEntry = this.getMostRecentElement(); - - if (lastEntry && lastEntry.value === elem.value) { - return ( - (elem.internalTime || 0) - (lastEntry.internalTime || 0) > - MIN_THRESHOLD_FOR_DUPLICATE_VALUE - ); - } - return true; - } - - private stripInternalTime(history: HistoryElement[]): HistoryElement[] { - if (Array.isArray(history)) { - return history.map((part) => { - const {name, time, value} = part; - return {name, time, value}; - }); - } - return []; - } - - private stripEmptyQuery(part: HistoryElement) { - const {name, time, value} = part; - if ( - name && - typeof value === 'string' && - name.toLowerCase() === 'query' && - value.trim() === '' - ) { - return {name, time}; - } - - return part; - } - - private stripEmptyQueries(history: HistoryElement[]): HistoryElement[] { - return history.map((part) => this.stripEmptyQuery(part)); - } -} - -export interface HistoryElement { - name: string; - value?: string; - time: string; - internalTime?: number; } - - - -export default HistoryStore; diff --git a/packages/headless/src/api/analytics/coveo.analytics/storage.test.ts b/packages/headless/src/api/analytics/coveo.analytics/storage.test.ts deleted file mode 100644 index e95535dcf3c..00000000000 --- a/packages/headless/src/api/analytics/coveo.analytics/storage.test.ts +++ /dev/null @@ -1,358 +0,0 @@ -import { - afterEach, - beforeEach, - describe, - expect, - it, - type Mocked, - vi, -} from 'vitest'; -import {Cookie} from './cookie.js'; -import * as detector from './detector.js'; -import { - CookieAndLocalStorage, - CookieStorage, - getAvailableStorage, - NullStorage, - preferredStorage, - type WebStorage, -} from './storage.js'; - -// Mock the detector module -vi.mock('./detector.js'); -vi.mock('./cookie.js'); - -describe('storage', () => { - const mockDetector = vi.mocked(detector); - const mockCookie = vi.mocked(Cookie); - - beforeEach(() => { - vi.resetAllMocks(); - - // Default mock implementations - mockDetector.hasLocalStorage.mockReturnValue(false); - mockDetector.hasCookieStorage.mockReturnValue(false); - mockDetector.hasSessionStorage.mockReturnValue(false); - }); - - afterEach(() => { - vi.restoreAllMocks(); - }); - - describe('#getAvailableStorage', () => { - it('should return preferredStorage when it is set', () => { - const mockStorage = { - getItem: vi.fn(), - setItem: vi.fn(), - removeItem: vi.fn(), - }; - - // We need to mock the module's preferredStorage - vi.doMock('./storage.js', () => ({ - ...vi.importActual('./storage.js'), - preferredStorage: mockStorage, - })); - - // Since we can't easily modify the imported preferredStorage, - // let's test the behavior when preferredStorage is null (default case) - expect(preferredStorage).toBeNull(); - }); - - it('should return localStorage when available', () => { - mockDetector.hasLocalStorage.mockReturnValue(true); - const mockLocalStorage = { - getItem: vi.fn(), - setItem: vi.fn(), - removeItem: vi.fn(), - }; - vi.stubGlobal('localStorage', mockLocalStorage); - - const result = getAvailableStorage(); - - expect(result).toBe(mockLocalStorage); - }); - - it('should return CookieStorage when localStorage unavailable but cookies available', () => { - mockDetector.hasLocalStorage.mockReturnValue(false); - mockDetector.hasCookieStorage.mockReturnValue(true); - - const result = getAvailableStorage(); - - expect(result).toBeInstanceOf(CookieStorage); - }); - - it('should return sessionStorage when localStorage and cookies unavailable', () => { - mockDetector.hasLocalStorage.mockReturnValue(false); - mockDetector.hasCookieStorage.mockReturnValue(false); - mockDetector.hasSessionStorage.mockReturnValue(true); - - const mockSessionStorage = { - getItem: vi.fn(), - setItem: vi.fn(), - removeItem: vi.fn(), - }; - vi.stubGlobal('sessionStorage', mockSessionStorage); - - const result = getAvailableStorage(); - - expect(result).toBe(mockSessionStorage); - }); - - it('should return NullStorage when no storage is available', () => { - mockDetector.hasLocalStorage.mockReturnValue(false); - mockDetector.hasCookieStorage.mockReturnValue(false); - mockDetector.hasSessionStorage.mockReturnValue(false); - - const result = getAvailableStorage(); - - expect(result).toBeInstanceOf(NullStorage); - }); - }); - - describe('#CookieStorage', () => { - let cookieStorage: CookieStorage; - - beforeEach(() => { - cookieStorage = new CookieStorage(); - }); - - describe('#getItem', () => { - it('should call Cookie.get with prefixed key', () => { - mockCookie.get.mockReturnValue('testValue'); - - const result = cookieStorage.getItem('testKey'); - - expect(mockCookie.get).toHaveBeenCalledWith('coveo_testKey'); - expect(result).toBe('testValue'); - }); - - it('should return null when cookie does not exist', () => { - mockCookie.get.mockReturnValue(null); - - const result = cookieStorage.getItem('nonexistentKey'); - - expect(result).toBeNull(); - }); - }); - - describe('#setItem', () => { - it('should call Cookie.set with prefixed key and data', () => { - cookieStorage.setItem('testKey', 'testData'); - - expect(mockCookie.set).toHaveBeenCalledWith( - 'coveo_testKey', - 'testData', - undefined - ); - }); - - it('should call Cookie.set with prefixed key, data, and expiration', () => { - const expiration = 3600000; - cookieStorage.setItem('testKey', 'testData', expiration); - - expect(mockCookie.set).toHaveBeenCalledWith( - 'coveo_testKey', - 'testData', - expiration - ); - }); - }); - - describe('#removeItem', () => { - it('should call Cookie.erase with prefixed key', () => { - cookieStorage.removeItem('testKey'); - - expect(mockCookie.erase).toHaveBeenCalledWith('coveo_testKey'); - }); - }); - - describe('prefix', () => { - it('should have correct prefix', () => { - expect(CookieStorage.prefix).toBe('coveo_'); - }); - }); - }); - - describe('#CookieAndLocalStorage', () => { - let storage: CookieAndLocalStorage; - let mockLocalStorage: Mocked; - - beforeEach(() => { - mockLocalStorage = { - getItem: vi.fn(), - setItem: vi.fn(), - removeItem: vi.fn(), - }; - - vi.stubGlobal('localStorage', mockLocalStorage); - storage = new CookieAndLocalStorage(); - }); - - describe('#getItem', () => { - it('should return localStorage value when available', () => { - mockLocalStorage.getItem.mockReturnValue('localStorageValue'); - - const result = storage.getItem('testKey'); - - expect(mockLocalStorage.getItem).toHaveBeenCalledWith('testKey'); - expect(result).toBe('localStorageValue'); - }); - - it('should return cookie value when localStorage returns null', () => { - mockLocalStorage.getItem.mockReturnValue(null); - mockCookie.get.mockReturnValue('cookieValue'); - - const result = storage.getItem('testKey'); - - expect(mockLocalStorage.getItem).toHaveBeenCalledWith('testKey'); - expect(mockCookie.get).toHaveBeenCalledWith('coveo_testKey'); - expect(result).toBe('cookieValue'); - }); - - it('should return cookie value when localStorage returns empty string', () => { - mockLocalStorage.getItem.mockReturnValue(''); - mockCookie.get.mockReturnValue('cookieValue'); - - const result = storage.getItem('testKey'); - - expect(result).toBe('cookieValue'); - }); - - it('should return null when both localStorage and cookie return null', () => { - mockLocalStorage.getItem.mockReturnValue(null); - mockCookie.get.mockReturnValue(null); - - const result = storage.getItem('testKey'); - - expect(result).toBeNull(); - }); - }); - - describe('#setItem', () => { - it('should set both localStorage and cookie', () => { - storage.setItem('testKey', 'testData'); - - expect(mockLocalStorage.setItem).toHaveBeenCalledWith( - 'testKey', - 'testData' - ); - expect(mockCookie.set).toHaveBeenCalledWith( - 'coveo_testKey', - 'testData', - 31556926000 - ); - }); - - it('should use 1 year expiration for cookie (31556926000 ms)', () => { - storage.setItem('testKey', 'testData'); - - expect(mockCookie.set).toHaveBeenCalledWith( - 'coveo_testKey', - 'testData', - 31556926000 - ); - }); - }); - - describe('#removeItem', () => { - it('should remove from both localStorage and cookie storage', () => { - storage.removeItem('testKey'); - - expect(mockCookie.erase).toHaveBeenCalledWith('coveo_testKey'); - expect(mockLocalStorage.removeItem).toHaveBeenCalledWith('testKey'); - }); - }); - }); - - describe('#NullStorage', () => { - let nullStorage: NullStorage; - - beforeEach(() => { - nullStorage = new NullStorage(); - }); - - describe('#getItem', () => { - it('should always return null', () => { - expect(nullStorage.getItem('anyKey')).toBeNull(); - expect(nullStorage.getItem('')).toBeNull(); - expect(nullStorage.getItem('nonexistent')).toBeNull(); - }); - }); - - describe('#setItem', () => { - it('should do nothing and not throw', () => { - expect(() => { - nullStorage.setItem('testKey', 'testData'); - }).not.toThrow(); - }); - - it('should handle empty keys and values', () => { - expect(() => { - nullStorage.setItem('', ''); - }).not.toThrow(); - }); - }); - - describe('#removeItem', () => { - it('should do nothing and not throw', () => { - expect(() => { - nullStorage.removeItem('testKey'); - }).not.toThrow(); - }); - - it('should handle empty keys', () => { - expect(() => { - nullStorage.removeItem(''); - }).not.toThrow(); - }); - }); - }); - - describe('integration scenarios', () => { - it('should prioritize localStorage over cookies when both are available', () => { - mockDetector.hasLocalStorage.mockReturnValue(true); - mockDetector.hasCookieStorage.mockReturnValue(true); - - const mockLocalStorage = { - getItem: vi.fn(), - setItem: vi.fn(), - removeItem: vi.fn(), - }; - vi.stubGlobal('localStorage', mockLocalStorage); - - const result = getAvailableStorage(); - - expect(result).toBe(mockLocalStorage); - }); - - it('should fall back gracefully through storage options', () => { - // Test the fallback chain: localStorage -> cookies -> sessionStorage -> null - - // First test: no localStorage, no cookies, no sessionStorage - mockDetector.hasLocalStorage.mockReturnValue(false); - mockDetector.hasCookieStorage.mockReturnValue(false); - mockDetector.hasSessionStorage.mockReturnValue(false); - - let result = getAvailableStorage(); - expect(result).toBeInstanceOf(NullStorage); - - // Second test: no localStorage, no cookies, has sessionStorage - mockDetector.hasSessionStorage.mockReturnValue(true); - const mockSessionStorage = { - getItem: vi.fn(), - setItem: vi.fn(), - removeItem: vi.fn(), - }; - vi.stubGlobal('sessionStorage', mockSessionStorage); - - result = getAvailableStorage(); - expect(result).toBe(mockSessionStorage); - - // Third test: no localStorage, has cookies - mockDetector.hasCookieStorage.mockReturnValue(true); - - result = getAvailableStorage(); - expect(result).toBeInstanceOf(CookieStorage); - }); - }); -}); diff --git a/packages/headless/src/api/analytics/coveo.analytics/storage.ts b/packages/headless/src/api/analytics/coveo.analytics/storage.ts deleted file mode 100644 index 2615b402b97..00000000000 --- a/packages/headless/src/api/analytics/coveo.analytics/storage.ts +++ /dev/null @@ -1,73 +0,0 @@ -import {Cookie} from './cookie.js'; -import { - hasCookieStorage, - hasLocalStorage, - hasSessionStorage, -} from './detector.js'; - -export const preferredStorage: WebStorage | null = null; - -export interface WebStorage { - getItem(key: string): string | null | Promise; - removeItem(key: string): void; - setItem(key: string, data: string): void | Promise; -} - -export function getAvailableStorage(): WebStorage { - if (preferredStorage) { - return preferredStorage; - } - if (hasLocalStorage()) { - return localStorage; - } - if (hasCookieStorage()) { - return new CookieStorage(); - } - if (hasSessionStorage()) { - return sessionStorage; - } - return new NullStorage(); -} - -export class CookieStorage implements WebStorage { - static prefix = 'coveo_'; - getItem(key: string): string | null { - return Cookie.get(`${CookieStorage.prefix}${key}`); - } - removeItem(key: string) { - Cookie.erase(`${CookieStorage.prefix}${key}`); - } - setItem(key: string, data: string, expire?: number): void { - Cookie.set(`${CookieStorage.prefix}${key}`, data, expire); - } -} - -export class CookieAndLocalStorage implements WebStorage { - private cookieStorage = new CookieStorage(); - - getItem(key: string): string | null { - return localStorage.getItem(key) || this.cookieStorage.getItem(key); - } - - removeItem(key: string): void { - this.cookieStorage.removeItem(key); - localStorage.removeItem(key); - } - - setItem(key: string, data: string): void { - localStorage.setItem(key, data); - this.cookieStorage.setItem(key, data, 31556926000); // 1 year first party cookie - } -} - -export class NullStorage implements WebStorage { - getItem(_key: string): string | null { - return null; - } - removeItem(_key: string) { - /**/ - } - setItem(_key: string, _data: string): void { - /**/ - } -} From 251dd92830f49f61f333bf1511bef67a05a2694a Mon Sep 17 00:00:00 2001 From: Alex Prudhomme <78121423+alexprudhomme@users.noreply.github.com> Date: Thu, 13 Aug 2026 12:34:50 -0400 Subject: [PATCH 2/3] Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- .changeset/headless-cajs-per-module-esm.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.changeset/headless-cajs-per-module-esm.md b/.changeset/headless-cajs-per-module-esm.md index be8077b107a..b86a3da7198 100644 --- a/.changeset/headless-cajs-per-module-esm.md +++ b/.changeset/headless-cajs-per-module-esm.md @@ -2,4 +2,4 @@ '@coveo/headless': patch --- -Import the `cookie`, `detector`, `storage` and `history-store` modules from `coveo.analytics` instead of keeping vendored copies of them, removing a source of drift between the two packages. No public API change. +Import `HistoryStore` from `coveo.analytics` instead of keeping a vendored copy of the history store (and its supporting helpers), removing a source of drift between the two packages. No public API change. From 7aeeb49cfcd3c1f43185979ad2feb2682085f699 Mon Sep 17 00:00:00 2001 From: Alex Prudhomme <78121423+alexprudhomme@users.noreply.github.com> Date: Fri, 14 Aug 2026 16:07:05 -0400 Subject: [PATCH 3/3] refactor(headless): restore private HistoryStore constructor and share cajs aliases Keeps `new HistoryStore()` unreachable from outside the class, as the vendored copy did, and extracts the duplicated coveo.analytics alias map so the browser and node esbuild configs cannot drift. --- packages/headless/esbuild.mjs | 19 +++++++++++-------- .../coveo.analytics/history-store.ts | 4 ++++ 2 files changed, 15 insertions(+), 8 deletions(-) diff --git a/packages/headless/esbuild.mjs b/packages/headless/esbuild.mjs index df925dd2f40..91d615f705d 100644 --- a/packages/headless/esbuild.mjs +++ b/packages/headless/esbuild.mjs @@ -32,6 +32,15 @@ const buenoVersion = isNightly const buenoBase = commitSha ? `/bueno/commits/${commitSha}` : `/bueno/${buenoVersion}`; const buenoCdnPath = `${buenoBase}/bueno.esm.js`; +/** + * Shared by the browser and node configs so they cannot drift. Every deep import into + * coveo.analytics must be listed here, see {@link resolveSubpath}. + */ +const coveoAnalyticsAliases = { + 'coveo.analytics': resolveEsm('coveo.analytics'), + 'coveo.analytics/dist/esm/history.mjs': resolveSubpath('coveo.analytics/dist/esm/history.mjs'), +}; + function getUmdGlobalName(useCase) { const map = { search: 'CoveoHeadless', @@ -256,10 +265,7 @@ async function buildBrowserConfig(options, outDir) { external: ['crypto', ...(options.external || [])], plugins: [ alias({ - 'coveo.analytics': resolveEsm('coveo.analytics'), - 'coveo.analytics/dist/esm/history.mjs': resolveSubpath( - 'coveo.analytics/dist/esm/history.mjs' - ), + ...coveoAnalyticsAliases, pino: resolveBrowser('pino'), }), ...(options.plugins || []), @@ -296,10 +302,7 @@ async function buildNodeConfig(options, outDir) { treeShaking: true, plugins: [ alias({ - 'coveo.analytics': resolveEsm('coveo.analytics'), - 'coveo.analytics/dist/esm/history.mjs': resolveSubpath( - 'coveo.analytics/dist/esm/history.mjs' - ), + ...coveoAnalyticsAliases, }), ], ...options, diff --git a/packages/headless/src/api/analytics/coveo.analytics/history-store.ts b/packages/headless/src/api/analytics/coveo.analytics/history-store.ts index 181c41a135e..5089abc8771 100644 --- a/packages/headless/src/api/analytics/coveo.analytics/history-store.ts +++ b/packages/headless/src/api/analytics/coveo.analytics/history-store.ts @@ -17,4 +17,8 @@ export default class HistoryStore extends CoveoAnalyticsHistoryStore { } return HistoryStore.instance; } + + private constructor(store?: WebStorage) { + super(store); + } }