diff --git a/src/embed/init.impl.ts b/src/embed/init.impl.ts index 6ca9a8c..dca582b 100644 --- a/src/embed/init.impl.ts +++ b/src/embed/init.impl.ts @@ -1,5 +1,6 @@ /* eslint-disable no-underscore-dangle, camelcase */ import { Logger, LogMessageType } from '../utils/logger'; +import { getFsNamespace } from '../utils/fsNamespace'; import { startsWith } from '../utils/object'; import { DataLayerObserver } from '../observer'; import { @@ -112,8 +113,8 @@ export function attachDloFullStoryLifecycle(win: { [key: string]: any }): void { return; } - const ns = win._fs_namespace; - const fs = (typeof ns === 'string' && ns) ? win[ns] : undefined; + const ns = getFsNamespace(win as any); + const fs = win[ns]; if (typeof fs !== 'function') { return; } diff --git a/src/operators/fsApi/fsApi.ts b/src/operators/fsApi/fsApi.ts index 1943409..adc7f5a 100644 --- a/src/operators/fsApi/fsApi.ts +++ b/src/operators/fsApi/fsApi.ts @@ -1,5 +1,6 @@ import { Operator, OperatorOptions, OperatorValidator } from '../../operator'; import { getGlobal } from '../../utils/object'; +import { getFsNamespace } from '../../utils/fsNamespace'; export interface FSApiOperatorOptions extends OperatorOptions { @@ -14,10 +15,9 @@ export default abstract class FSApiOperator implements Operator { handleData(data: any[]): any[] | null { const thisArg: object = getGlobal(); - // @ts-ignore - const fsFunction:any = thisArg[thisArg._fs_namespace]; // eslint-disable-line + const fsFunction: any = (thisArg as any)[getFsNamespace(thisArg as any)]; if (typeof fsFunction !== 'function') { - throw new Error('_fs_namespace is not a function'); + throw new Error('Fullstory namespace is not a function'); } // subclasses will determine how to prepare the data const realData = this.prepareData(data); diff --git a/src/target.ts b/src/target.ts index 8003ba8..7dfa659 100644 --- a/src/target.ts +++ b/src/target.ts @@ -1,5 +1,6 @@ import { parsePath, ElementKind, select } from './selector'; import { Logger, LogMessage } from './utils/logger'; +import { getFsNamespace } from './utils/fsNamespace'; import { getGlobal } from './utils/object'; import DataLayerValue from './value'; @@ -48,8 +49,7 @@ export default class DataLayerTarget implements DataLayerValue { constructor(public subject: Object, public property: string, public path: string, public selector = '') { // NB special case added to support new FS.dataLayer observation - // eslint-disable-next-line no-underscore-dangle - if (typeof subject !== 'object' && subject !== (window as any)[(window as any)._fs_namespace]) { + if (typeof subject !== 'object' && subject !== (window as any)[getFsNamespace(window)]) { throw new Error(LogMessage.TargetSubjectObject); } diff --git a/src/utils/fsNamespace.ts b/src/utils/fsNamespace.ts new file mode 100644 index 0000000..1f18f38 --- /dev/null +++ b/src/utils/fsNamespace.ts @@ -0,0 +1,28 @@ +/* eslint-disable no-underscore-dangle, import/prefer-default-export */ + +/** + * Resolves the FullStory client namespace on `window`. + * + * Mirrors the resolution order used by `@fullstory/browser-api`: + * 1. The `data-fs-namespace` attribute on `document.currentScript` (best-effort). + * 2. The `_fs_namespace` global set on `window` by the FullStory snippet. + * 3. The default namespace `'FS'`. + * + * Note: `document.currentScript` is only meaningful while the helper module is being + * evaluated synchronously by a script tag. DLO calls this helper from deferred callbacks + * (appender log, operator handleData, lifecycle hook), so the attribute lookup is + * opportunistic and the global is the practical fallback floor — same behavior as the + * existing `(window as any)._fs_namespace` reads it replaces. + * + * @param win the global to read from; defaults to `window` + */ +export function getFsNamespace(win: any = window): string { + try { + const script = (win && win.document && win.document.currentScript) as HTMLScriptElement | null; + const attr = script && script.getAttribute && script.getAttribute('data-fs-namespace'); + if (attr) return attr; + } catch (_) { + // ignore: cross-origin or detached document access can throw + } + return (win && win._fs_namespace) || 'FS'; +} diff --git a/src/utils/logger.ts b/src/utils/logger.ts index c2476af..587fc00 100644 --- a/src/utils/logger.ts +++ b/src/utils/logger.ts @@ -1,5 +1,7 @@ /* eslint-disable no-console, max-classes-per-file */ +import { getFsNamespace } from './fsNamespace'; + /** * A LogAppender simply serializes a LogEvent to a sink. */ @@ -88,7 +90,7 @@ export class FullStoryAppender implements LogAppender { /* eslint-disable class-methods-use-this */ log(event: LogEvent): void { - const fs = (window as any)[(window as any)._fs_namespace]; // eslint-disable-line no-underscore-dangle + const fs = (window as any)[getFsNamespace(window)]; const customEventName = 'Data Layer Observer'; const customEventSource = 'dlo-log'; diff --git a/test/fs-namespace.spec.ts b/test/fs-namespace.spec.ts new file mode 100644 index 0000000..942da8d --- /dev/null +++ b/test/fs-namespace.spec.ts @@ -0,0 +1,83 @@ +/* eslint-disable no-underscore-dangle, @typescript-eslint/no-explicit-any */ +import { expect } from 'chai'; +import 'mocha'; + +import { getFsNamespace } from '../src/utils/fsNamespace'; + +/** + * Builds a minimal fake `window`-like object with optional `_fs_namespace` and + * `document.currentScript` pieces so we can exercise the resolution order without + * mutating the real jsdom globals (and without coupling this spec to whatever + * other specs leave on the real `window`). + */ +function makeWin(opts: { + fsNamespace?: string; + scriptAttr?: string | null; + hasDocument?: boolean; + throwOnCurrentScript?: boolean; +} = {}): any { + const { + fsNamespace, scriptAttr, hasDocument = true, throwOnCurrentScript = false, + } = opts; + + const script = scriptAttr === undefined ? null : { + getAttribute: (name: string) => (name === 'data-fs-namespace' ? scriptAttr : null), + }; + + const win: any = {}; + if (fsNamespace !== undefined) { + win._fs_namespace = fsNamespace; + } + if (hasDocument) { + Object.defineProperty(win, 'document', { + get() { + if (throwOnCurrentScript) { + throw new Error('boom'); + } + return { currentScript: script }; + }, + }); + } + return win; +} + +describe('getFsNamespace', () => { + it('returns the data-fs-namespace attribute when set on document.currentScript', () => { + const win = makeWin({ scriptAttr: 'CustomFS', fsNamespace: 'FS' }); + expect(getFsNamespace(win)).to.eq('CustomFS'); + }); + + it('prefers the script attribute over _fs_namespace when both are present', () => { + const win = makeWin({ scriptAttr: 'AttrWins', fsNamespace: 'GlobalLoses' }); + expect(getFsNamespace(win)).to.eq('AttrWins'); + }); + + it('falls back to _fs_namespace when the script attribute is missing', () => { + const win = makeWin({ scriptAttr: null, fsNamespace: 'MyFS' }); + expect(getFsNamespace(win)).to.eq('MyFS'); + }); + + it('falls back to _fs_namespace when document.currentScript is null (deferred call)', () => { + const win = makeWin({ fsNamespace: 'MyFS' }); + expect(getFsNamespace(win)).to.eq('MyFS'); + }); + + it("falls back to 'FS' when neither the script attribute nor _fs_namespace are set", () => { + const win = makeWin({}); + expect(getFsNamespace(win)).to.eq('FS'); + }); + + it('treats an empty-string script attribute as not set and falls through to the global', () => { + const win = makeWin({ scriptAttr: '', fsNamespace: 'MyFS' }); + expect(getFsNamespace(win)).to.eq('MyFS'); + }); + + it('swallows errors from document access and falls back to the global', () => { + const win = makeWin({ throwOnCurrentScript: true, fsNamespace: 'MyFS' }); + expect(getFsNamespace(win)).to.eq('MyFS'); + }); + + it("returns 'FS' when the global object is missing entirely", () => { + expect(getFsNamespace(undefined as any)).to.eq('FS'); + }); +});