Skip to content
Merged
7 changes: 4 additions & 3 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -71,11 +71,12 @@ scripts/ # Build, deploy, release automation

## Critical Patterns

### Test-Driven Development
### Unit Tests

- Spec files co-located with implementation: `feature.ts` → `feature.spec.ts`
- Test framework: Jasmine + Karma. Spec files co-located with implementation: `feature.ts` → `feature.spec.ts`
- Focus tests with `fit()` / `fdescribe()`, skip with `xit()` / `xdescribe()`
- Use `registerCleanupTask()` for cleanup, NOT `afterEach()`
- Test framework: Jasmine + Karma
- Mock values/functions: wrap with `mockable()` in source, use `replaceMockable()` or `replaceMockableWithSpy()` in tests (auto-cleanup)
Comment on lines +74 to +79
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

👏 praise: ‏Yeah! 🤖


## Commit Messages

Expand Down
61 changes: 33 additions & 28 deletions packages/core/src/domain/allowedTrackingOrigins.spec.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { STACK_WITH_INIT_IN_EXTENSION, STACK_WITH_INIT_IN_PAGE } from '../../test'
import { replaceMockable, STACK_WITH_INIT_IN_EXTENSION, STACK_WITH_INIT_IN_PAGE } from '../../test'
import { display } from '../tools/display'
import {
isAllowedTrackingOrigins,
Expand All @@ -15,57 +15,63 @@ const DEFAULT_CONFIG = {
describe('checkForAllowedTrackingOrigins', () => {
let displayErrorSpy: jasmine.Spy

function mockOrigin(origin: string) {
replaceMockable(location, { origin } as Location)
}

beforeEach(() => {
displayErrorSpy = spyOn(display, 'error')
})

it('should not warn if not in extension environment', () => {
const result = isAllowedTrackingOrigins(DEFAULT_CONFIG, STACK_WITH_INIT_IN_PAGE, 'https://app.example.com')
mockOrigin('https://app.example.com')
const result = isAllowedTrackingOrigins(DEFAULT_CONFIG, STACK_WITH_INIT_IN_PAGE)
expect(displayErrorSpy).not.toHaveBeenCalled()
expect(result).toBe(true)
})

describe('when configuration has allowedTrackingOrigins and origin is allowed', () => {
it('should not warn if origin matches exactly', () => {
mockOrigin('https://app.example.com')
const result = isAllowedTrackingOrigins(
{
...DEFAULT_CONFIG,
allowedTrackingOrigins: ['https://app.example.com'],
},
STACK_WITH_INIT_IN_PAGE,
'https://app.example.com'
STACK_WITH_INIT_IN_PAGE
)
expect(displayErrorSpy).not.toHaveBeenCalled()
expect(result).toBe(true)
})

it('should not warn if origin matches regex pattern', () => {
mockOrigin('https://app.example.com')
const result = isAllowedTrackingOrigins(
{
...DEFAULT_CONFIG,
allowedTrackingOrigins: [/^https:\/\/.*\.example\.com$/],
},
STACK_WITH_INIT_IN_PAGE,
'https://app.example.com'
STACK_WITH_INIT_IN_PAGE
)
expect(displayErrorSpy).not.toHaveBeenCalled()
expect(result).toBe(true)
})

it('should not warn if origin matches predicate function', () => {
mockOrigin('https://app.example.com')
const result = isAllowedTrackingOrigins(
{
...DEFAULT_CONFIG,
allowedTrackingOrigins: [(origin: string) => origin.includes('example.com')],
},
STACK_WITH_INIT_IN_PAGE,
'https://app.example.com'
STACK_WITH_INIT_IN_PAGE
)
expect(displayErrorSpy).not.toHaveBeenCalled()
expect(result).toBe(true)
})

it('should handle multiple patterns', () => {
mockOrigin('https://app.example.com')
const result = isAllowedTrackingOrigins(
{
...DEFAULT_CONFIG,
Expand All @@ -75,8 +81,7 @@ describe('checkForAllowedTrackingOrigins', () => {
(origin: string) => origin.startsWith('https://app.'),
],
},
STACK_WITH_INIT_IN_PAGE,
'https://app.example.com'
STACK_WITH_INIT_IN_PAGE
)
expect(displayErrorSpy).not.toHaveBeenCalled()
expect(result).toBe(true)
Expand All @@ -85,45 +90,46 @@ describe('checkForAllowedTrackingOrigins', () => {

describe('when configuration has allowedTrackingOrigins but origin is not allowed in extension context', () => {
it('should error when origin does not match any allowed pattern', () => {
mockOrigin('https://example.com')
const result = isAllowedTrackingOrigins(
{
...DEFAULT_CONFIG,
allowedTrackingOrigins: ['https://different.com'],
},
STACK_WITH_INIT_IN_EXTENSION,
'https://example.com'
STACK_WITH_INIT_IN_EXTENSION
)
expect(displayErrorSpy).toHaveBeenCalledWith(ERROR_NOT_ALLOWED_TRACKING_ORIGIN)
expect(result).toBe(false)
})

it('should error when origin does not match regex pattern', () => {
mockOrigin('https://example.com')
const result = isAllowedTrackingOrigins(
{
...DEFAULT_CONFIG,
allowedTrackingOrigins: [/^https:\/\/specific-[a-z]+\.com$/],
},
STACK_WITH_INIT_IN_EXTENSION,
'https://example.com'
STACK_WITH_INIT_IN_EXTENSION
)
expect(displayErrorSpy).toHaveBeenCalledWith(ERROR_NOT_ALLOWED_TRACKING_ORIGIN)
expect(result).toBe(false)
})

it('should error when origin does not match predicate function', () => {
mockOrigin('https://example.com')
const result = isAllowedTrackingOrigins(
{
...DEFAULT_CONFIG,
allowedTrackingOrigins: [(origin: string) => origin.includes('specific-id')],
},
STACK_WITH_INIT_IN_EXTENSION,
'https://example.com'
STACK_WITH_INIT_IN_EXTENSION
)
expect(displayErrorSpy).toHaveBeenCalledWith(ERROR_NOT_ALLOWED_TRACKING_ORIGIN)
expect(result).toBe(false)
})

it('should error when origin does not match any of multiple patterns', () => {
mockOrigin('https://example.com')
const result = isAllowedTrackingOrigins(
{
...DEFAULT_CONFIG,
Expand All @@ -133,34 +139,33 @@ describe('checkForAllowedTrackingOrigins', () => {
(origin: string) => origin.includes('specific-id'),
],
},
STACK_WITH_INIT_IN_EXTENSION,
'https://example.com'
STACK_WITH_INIT_IN_EXTENSION
)
expect(displayErrorSpy).toHaveBeenCalledWith(ERROR_NOT_ALLOWED_TRACKING_ORIGIN)
expect(result).toBe(false)
})

it('should error when origin is a partial match', () => {
mockOrigin('https://example.com.extra.com')
const result = isAllowedTrackingOrigins(
{
...DEFAULT_CONFIG,
allowedTrackingOrigins: ['https://example.com'],
},
STACK_WITH_INIT_IN_EXTENSION,
'https://example.com.extra.com'
STACK_WITH_INIT_IN_EXTENSION
)
expect(displayErrorSpy).toHaveBeenCalledWith(ERROR_NOT_ALLOWED_TRACKING_ORIGIN)
expect(result).toBe(false)
})

it('should not error when in extension and origin matches', () => {
mockOrigin('chrome-extension://abcdefghijklmno')
const result = isAllowedTrackingOrigins(
{
...DEFAULT_CONFIG,
allowedTrackingOrigins: [/^chrome-extension:\/\//],
},
STACK_WITH_INIT_IN_EXTENSION,
'chrome-extension://abcdefghijklmno'
STACK_WITH_INIT_IN_EXTENSION
)
expect(displayErrorSpy).not.toHaveBeenCalled()
expect(result).toBe(true)
Expand All @@ -169,39 +174,39 @@ describe('checkForAllowedTrackingOrigins', () => {

describe('when configuration does not have allowedTrackingOrigins', () => {
it('should log an error when in extension environment and allowedTrackingOrigins is undefined', () => {
mockOrigin('https://example.com')
const result = isAllowedTrackingOrigins(
{
...DEFAULT_CONFIG,
allowedTrackingOrigins: undefined,
},
STACK_WITH_INIT_IN_EXTENSION,
'https://example.com'
STACK_WITH_INIT_IN_EXTENSION
)
expect(displayErrorSpy).toHaveBeenCalledWith(ERROR_DOES_NOT_HAVE_ALLOWED_TRACKING_ORIGIN)
expect(result).toBe(false)
})

it('should error when in extension environment and allowedTrackingOrigins is an empty array', () => {
mockOrigin('https://example.com')
const result = isAllowedTrackingOrigins(
{
...DEFAULT_CONFIG,
allowedTrackingOrigins: [],
},
STACK_WITH_INIT_IN_EXTENSION,
'https://example.com'
STACK_WITH_INIT_IN_EXTENSION
)
expect(displayErrorSpy).toHaveBeenCalledWith(ERROR_NOT_ALLOWED_TRACKING_ORIGIN)
expect(result).toBe(false)
})

it('should not warn when not in extension environment and allowedTrackingOrigins is undefined', () => {
mockOrigin('https://example.com')
const result = isAllowedTrackingOrigins(
{
...DEFAULT_CONFIG,
allowedTrackingOrigins: undefined,
},
STACK_WITH_INIT_IN_PAGE,
'https://example.com'
STACK_WITH_INIT_IN_PAGE
)
expect(displayErrorSpy).not.toHaveBeenCalled()
expect(result).toBe(true)
Expand Down
10 changes: 5 additions & 5 deletions packages/core/src/domain/allowedTrackingOrigins.ts
Original file line number Diff line number Diff line change
@@ -1,17 +1,17 @@
import { display } from '../tools/display'
import { getGlobalObject } from '../tools/globalObject'
import { matchList } from '../tools/matchOption'
import { mockable } from '../tools/mockable'
import type { InitConfiguration } from './configuration'
import { isUnsupportedExtensionEnvironment } from './extension/extensionUtils'

export const ERROR_DOES_NOT_HAVE_ALLOWED_TRACKING_ORIGIN =
'Running the Browser SDK in a Web extension content script is forbidden unless the `allowedTrackingOrigins` option is provided.'
export const ERROR_NOT_ALLOWED_TRACKING_ORIGIN = 'SDK initialized on a non-allowed domain.'

export function isAllowedTrackingOrigins(
configuration: InitConfiguration,
errorStack: string,
windowOrigin = typeof location !== 'undefined' ? location.origin : ''
): boolean {
export function isAllowedTrackingOrigins(configuration: InitConfiguration, errorStack: string): boolean {
const location = mockable(getGlobalObject().location)
const windowOrigin = location ? location.origin : ''
const allowedTrackingOrigins = configuration.allowedTrackingOrigins
if (!allowedTrackingOrigins) {
if (isUnsupportedExtensionEnvironment(windowOrigin, errorStack)) {
Expand Down
6 changes: 4 additions & 2 deletions packages/core/src/domain/bufferedData.spec.ts
Original file line number Diff line number Diff line change
@@ -1,13 +1,15 @@
import { registerCleanupTask } from '../../test'
import { replaceMockable, registerCleanupTask } from '../../test'
import { Observable } from '../tools/observable'
import { clocksNow } from '../tools/utils/timeUtils'
import { BufferedDataType, startBufferingData } from './bufferedData'
import { ErrorHandling, ErrorSource, type RawError } from './error/error.types'
import { trackRuntimeError } from './error/trackRuntimeError'

describe('startBufferingData', () => {
it('collects runtime errors', (done) => {
const runtimeErrorObservable = new Observable<RawError>()
const { observable, stop } = startBufferingData(() => runtimeErrorObservable)
replaceMockable(trackRuntimeError, () => runtimeErrorObservable)
const { observable, stop } = startBufferingData()
registerCleanupTask(stop)

const rawError = {
Expand Down
5 changes: 3 additions & 2 deletions packages/core/src/domain/bufferedData.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { BufferedObservable } from '../tools/observable'
import { mockable } from '../tools/mockable'
import type { RawError } from './error/error.types'
import { trackRuntimeError } from './error/trackRuntimeError'

Expand All @@ -13,10 +14,10 @@ export interface BufferedData {
error: RawError
}

export function startBufferingData(trackRuntimeErrorImpl = trackRuntimeError) {
export function startBufferingData() {
const observable = new BufferedObservable<BufferedData>(BUFFER_LIMIT)

const runtimeErrorSubscription = trackRuntimeErrorImpl().subscribe((error) => {
const runtimeErrorSubscription = mockable(trackRuntimeError)().subscribe((error) => {
observable.notify({
type: BufferedDataType.RUNTIME_ERROR,
error,
Expand Down
1 change: 1 addition & 0 deletions packages/core/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,7 @@ export * from './tools/utils/browserDetection'
export { sendToExtension } from './tools/sendToExtension'
export { runOnReadyState, asyncRunOnReadyState } from './browser/runOnReadyState'
export { getZoneJsOriginalValue } from './tools/getZoneJsOriginalValue'
export { mockable } from './tools/mockable'
export type { InstrumentedMethodCall } from './tools/instrumentMethod'
export { instrumentMethod, instrumentSetter } from './tools/instrumentMethod'
export {
Expand Down
32 changes: 32 additions & 0 deletions packages/core/src/tools/mockable.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
declare const __BUILD_ENV__SDK_VERSION__: string

export const mockableReplacements = new Map<unknown, unknown>()

/**
* Wraps a value to make it mockable in tests. In production builds, this is a no-op
* that returns the value as-is. In test builds, it checks if a mock replacement has
* been registered and returns that instead.
*
* @example
* // In source file:
* import { mockable } from '../tools/mockable'
* export function formatNavigationEntry(): string {
* const navigationEntry = mockable(getNavigationEntry)()
* ...
* }
*
* // In test file:
* import { replaceMockable } from '@datadog/browser-core/test'
* it('...', () => {
* replaceMockable(getNavigationEntry, () => FAKE_NAVIGATION_ENTRY)
* expect(formatNavigationEntry()).toEqual(...)
* })
*/
export function mockable<T>(value: T): T {
// In test builds, return a wrapper that checks for mocks at call time
if (__BUILD_ENV__SDK_VERSION__ === 'test' && mockableReplacements.has(value)) {
return mockableReplacements.get(value)! as T
}
// In production, return the value as-is
return value
}
4 changes: 2 additions & 2 deletions packages/core/test/forEach.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,10 +9,10 @@ import { resetMonitor } from '../src/tools/monitor'
import { resetTelemetry } from '../src/domain/telemetry'
import { startLeakDetection } from './leakDetection'
import type { BuildEnvWindow } from './buildEnv'
;(window as unknown as BuildEnvWindow).__BUILD_ENV__SDK_VERSION__ = 'test'
;(window as any).IS_REACT_ACT_ENVIRONMENT = true

beforeEach(() => {
;(window as unknown as BuildEnvWindow).__BUILD_ENV__SDK_VERSION__ = 'test'
;(window as any).IS_REACT_ACT_ENVIRONMENT = true
// prevent 'Some of your tests did a full page reload!' issue
window.onbeforeunload = () => 'stop'
startLeakDetection()
Expand Down
1 change: 1 addition & 0 deletions packages/core/test/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,3 +30,4 @@ export * from './consoleLog'
export * from './createHooks'
export * from './fakeSessionStoreStrategy'
export * from './readFormData'
export * from './replaceMockable'
Loading