Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,9 @@
"@types/node-forge": "1.3.14",
"@types/ws": "8.18.1",
"@typescript-eslint/utils": "8.60.0",
"@vitest/browser": "4.1.8",
"@vitest/browser-playwright": "4.1.8",
"@vitest/browser-webdriverio": "4.1.8",
"ajv": "8.20.0",
"browserstack-local": "1.5.13",
"busboy": "1.6.0",
Expand Down Expand Up @@ -95,6 +98,8 @@
"typescript": "6.0.3",
"typescript-eslint": "8.60.0",
"undici": "8.3.0",
"vitest": "4.1.8",
"webdriverio": "9.27.2",
"webpack": "5.107.2",
"webpack-cli": "7.0.2",
"webpack-dev-middleware": "8.0.3",
Expand Down
8 changes: 6 additions & 2 deletions packages/core/src/domain/error/trackRuntimeError.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,8 +7,6 @@ describe('trackRuntimeError', () => {
const ERROR_MESSAGE = 'foo'

const errorViaTrackRuntimeError = async (callback: () => void): Promise<RawError> => {
disableJasmineUncaughtExceptionTracking()

const errorObservable = trackRuntimeError()
const errorNotification = new Promise<RawError>((resolve) => {
errorObservable.subscribe((e: RawError) => resolve(e))
Expand All @@ -22,6 +20,8 @@ describe('trackRuntimeError', () => {
}
}

beforeEach(disableJasmineUncaughtExceptionTracking)

it('should collect unhandled error', async () => {
const error = await errorViaTrackRuntimeError(() => {
throw new Error(ERROR_MESSAGE)
Expand Down Expand Up @@ -63,6 +63,8 @@ describe('instrumentOnError', () => {
}
}

beforeEach(disableJasmineUncaughtExceptionTracking)

it('should call original error handler', async () => {
// withInstrumentOnError() asserts that the original error handler has been called for
// every test, so we don't need an explicit expectation here.
Expand Down Expand Up @@ -288,6 +290,8 @@ describe('instrumentUnhandledRejection', () => {
}
}

beforeEach(disableJasmineUncaughtExceptionTracking)

it('should call original unhandled rejection handler', async () => {
// withInstrumentOnUnhandledRejection() asserts that the original unhandled
// rejection handler has been called for every test, so we don't need an
Expand Down
Original file line number Diff line number Diff line change
@@ -1,8 +1,14 @@
import { registerCleanupTask } from './registerCleanupTask'

/**
* Disable Jasmine's uncaught error handling. This is useful for test cases throwing exceptions or
* unhandled rejections that are expected to be caught somehow, but Jasmine also catch them and
* fails the test.
*/
export function disableJasmineUncaughtExceptionTracking() {
spyOn(window as any, 'onerror')
const originalJasmineOnerror = window.onerror
window.onerror = null
registerCleanupTask(() => {
window.onerror = originalJasmineOnerror
})
}
10 changes: 6 additions & 4 deletions packages/core/test/emulate/mockReportingObserver.ts
Original file line number Diff line number Diff line change
Expand Up @@ -52,11 +52,13 @@ export type MockCspEventListener = ReturnType<typeof mockCspEventListener>
export function mockCspEventListener() {
// eslint-disable-next-line @typescript-eslint/unbound-method
const originalAddEventListener = EventTarget.prototype.addEventListener
EventTarget.prototype.addEventListener = jasmine
.createSpy()
.and.callFake((_type: string, listener: EventListener) => {
EventTarget.prototype.addEventListener = jasmine.createSpy().and.callFake(function (this: any, type, listener) {
if (type === 'securitypolicyviolation') {
listeners.push(listener)
})
} else {
originalAddEventListener.call(this, type, listener)
}
})

registerCleanupTask(() => {
EventTarget.prototype.addEventListener = originalAddEventListener
Expand Down
File renamed without changes.
2 changes: 2 additions & 0 deletions test/unit-vitest/allJsonSchemas.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
const modules = import.meta.glob('../../rum-events-format/schemas/**/*.json', { eager: true })
export const allJsonSchemas = Object.values(modules)
23 changes: 23 additions & 0 deletions test/unit-vitest/disableJasmineUncaughtExceptionTracking.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
import { noop } from '../../packages/core/src/tools/utils/functionUtils'
import { registerCleanupTask } from './registerCleanupTask'

/**
* Vitest will ignore uncaught errors if we add our own listener, see
* https://vitest.dev/guide/features.html#unhandled-errors
*/
export function disableJasmineUncaughtExceptionTracking() {
window.addEventListener('error', noop)
window.addEventListener('unhandledrejection', noop)
const originalConsoleError = console.error
console.error = (...args) => {
if (new Error().stack!.includes('error-catcher.js')) {
return
}
originalConsoleError(...args)
}
registerCleanupTask(() => {
console.error = originalConsoleError
window.removeEventListener('error', noop)
window.removeEventListener('unhandledrejection', noop)
})
}
23 changes: 23 additions & 0 deletions test/unit-vitest/getCurrentJasmineSpec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
import { beforeEach, onTestFinished } from 'vitest'

let currentSpec: { fullName: string } | null = null

export function getCurrentJasmineSpec() {
return currentSpec
}

beforeEach((context) => {
const parts: string[] = []
let current: any = context.task
while (current) {
if (current.name) {
parts.unshift(current.name)
}
current = current.suite
}
currentSpec = { fullName: parts.join(' ') }

onTestFinished(() => {
currentSpec = null
})
})
3 changes: 3 additions & 0 deletions test/unit-vitest/registerCleanupTask.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
import { onTestFinished } from 'vitest'

export const registerCleanupTask = onTestFinished
80 changes: 80 additions & 0 deletions test/unit-vitest/vite.config.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
import { resolve } from 'path'
import { defineConfig } from 'vitest/config'
import { webdriverio } from '@vitest/browser-webdriverio'
import { playwright } from '@vitest/browser-playwright'
import { buildEnvKeys, getBuildEnvValue } from '../../scripts/lib/buildEnv.ts'

const ROOT = resolve(import.meta.dirname, '../..')

// eslint-disable-next-line import-x/no-default-export
export default defineConfig({
test: {
include: ['packages/*/@(src|test)/**/*.spec.@(ts|tsx)', 'developer-extension/@(src|test)/**/*.spec.@(ts|tsx)'],

browser: {
enabled: true,
headless: true,
provider: playwright(),
instances: [{ browser: 'chromium' }],
// Use an empty tester HTML file to avoid interfering with unit tests that serialize the whole
// document
testerHtmlPath: resolve(import.meta.dirname, 'vitest.tester.html'),
},

setupFiles: [resolve(import.meta.dirname, 'vitest.setup.ts')],
restoreMocks: true,
},
optimizeDeps: {
include: ['jasmine-core/lib/jasmine-core/jasmine.js', 'pako'],
rolldownOptions: {
plugins: [
{
name: 'fix-jasmine-strict-mode',
// jasmine-core 3.99.1 uses undeclared `i` in asymmetricEqualityTesterArgCompatShim.
// This works in non-strict mode but fails when Vite converts CJS to strict ESM.
transform: {
filter: { id: { include: [/jasmine\.js$/] } },
handler(code) {
return code.replace(
'for (i = 0; i < customEqualityTesters.length; i++)',
'for (var i = 0; i < customEqualityTesters.length; i++)'
)
},
},
},
],
},
},

oxc: {
jsx: { runtime: 'automatic' },
},

resolve: {
alias: [
{ find: /^@datadog\/browser-([^\\/]+)$/, replacement: `${ROOT}/packages/$1/src` },
{ find: /^@datadog\/browser-(.+\/.*)$/, replacement: `${ROOT}/packages/$1` },
{ find: /^packages\/(.*)$/, replacement: `${ROOT}/packages/$1` },
{ find: /.*\/allJsonSchemas$/, replacement: resolve(import.meta.dirname, 'allJsonSchemas.ts') },
{
find: /.*\/getCurrentJasmineSpec$/,
replacement: resolve(import.meta.dirname, 'getCurrentJasmineSpec.ts'),
},
{
find: /.*\/registerCleanupTask$/,
replacement: resolve(import.meta.dirname, 'registerCleanupTask.ts'),
},
{
find: /.*\/disableJasmineUncaughtExceptionTracking/,
replacement: resolve(import.meta.dirname, 'disableJasmineUncaughtExceptionTracking.ts'),
},
],
},
define: {
// jasmine-core uses `global` in its CJS module detection; without this, it falls back to an
// empty object as the "global", causing the mock clock to patch {} instead of window.
global: 'globalThis',

...Object.fromEntries(buildEnvKeys.map((key) => [`__BUILD_ENV__${key}__`, JSON.stringify(getBuildEnvValue(key))])),
},
})
89 changes: 89 additions & 0 deletions test/unit-vitest/vitest.setup.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
// <reference types="jasmine" />

import * as vitest from 'vitest'
import jasmineRequire from 'jasmine-core/lib/jasmine-core/jasmine.js'

const jasmineLib = jasmineRequire.core(jasmineRequire)
const env = jasmineLib.getEnv()

// Create a standalone SpyRegistry that doesn't require a jasmine execution context.
// vitest's afterEach (registered below) will clear it between tests.
const spiesForCurrentTest: any[] = []
const spyRegistry = new jasmineLib.SpyRegistry({
currentSpies: () => spiesForCurrentTest,
createSpy: (name: string, fn: Function) => env.createSpy(name, fn),
})

window.spyOn = (object, method) => spyRegistry.spyOn(object, method)
window.spyOnProperty = (object, property, accessType) => spyRegistry.spyOnProperty(object, property, accessType)

window.jasmine = {
...jasmineLib,
getEnv: () => env,
clock: () => env.clock,
createSpy: (name, originalFn) => env.createSpy(name, originalFn),
}

// Wrap test/hook functions to support Jasmine's `done` callback pattern, which Vitest v4 dropped.
// If a function accepts 1+ parameters, we assume the first is `done` and wrap it in a Promise.
function supportDone(fn: jasmine.ImplementationCallback): () => unknown {
if (fn.length === 0) {
return fn as () => unknown
}
return () =>
new Promise<void>((resolve, reject) => {
const done: any = () => resolve()
done.fail = (error?: unknown) => reject(error instanceof Error ? error : new Error(String(error)))
const result = fn(done)
if (result && typeof result.then === 'function') {
throw new Error('Either use the done parameter or return a Promise, not both')
}
})
}

// Support Jasmine's `pending()` — skips the current test at runtime using the Vitest context.
let currentTestCtx: vitest.TestContext | null = null
vitest.beforeEach((ctx) => {
currentTestCtx = ctx
})
vitest.afterEach(() => {
currentTestCtx = null
})
window.pending = (reason) => currentTestCtx?.skip(reason)

window.describe = vitest.describe
window.fdescribe = vitest.describe.only
window.xdescribe = vitest.describe.skip
window.it = (name, fn, timeout) => vitest.it(name, fn && supportDone(fn), timeout)
window.fit = (name, fn, timeout) => vitest.it.only(name, fn && supportDone(fn), timeout)
window.xit = (name, fn, timeout) => vitest.it.skip(name, fn && supportDone(fn), timeout)
window.beforeEach = (fn, timeout) => vitest.beforeEach(supportDone(fn), timeout)
window.afterEach = (fn, timeout) => vitest.afterEach(supportDone(fn), timeout)
window.beforeAll = (fn, timeout) => vitest.beforeAll(supportDone(fn), timeout)
window.afterAll = (fn, timeout) => vitest.afterAll(supportDone(fn), timeout)

const matchersUtil = new jasmineLib.MatchersUtil({
customTesters: [],
pp: jasmineLib.makePrettyPrinter(),
})

window.expect = ((actual: any) =>
jasmineLib.Expectation.factory({
matchersUtil,
actual,
addExpectationResult(passed: boolean, result: any) {
if (!passed) {
throw new Error(result.message)
}
},
})) as unknown as typeof window.expect

// After each is executed before `onTestFinished`, so it's too soon to clear the spies
vitest.beforeEach(() => {
vitest.onTestFinished(() => {
spyRegistry.clearSpies()
spiesForCurrentTest.length = 0
})
})

await import('../../packages/core/test/forEach.ts')
8 changes: 8 additions & 0 deletions test/unit-vitest/vitest.tester.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<title>Browser SDK unit tests</title>
</head>
<body></body>
</html>
2 changes: 1 addition & 1 deletion test/unit/karma.base.conf.js
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@ const FILES = [
// Make sure 'forEach.spec' is the first file to be loaded, so its `beforeEach` hook is executed
// before all other `beforeEach` hooks, and its `afterEach` hook is executed after all other
// `afterEach` hooks.
'packages/core/test/forEach.spec.ts',
'packages/core/test/forEach.ts',
'packages/rum/test/record/toto.css',
]

Expand Down
1 change: 1 addition & 0 deletions tsconfig.default.json
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@
"eslint.config.ts",
"eslint-local-rules",
"test/unit",
"test/unit-vitest/vite.config.ts",

// Files included in ./test/e2e/tsconfig.json
"test/e2e",
Expand Down
10 changes: 9 additions & 1 deletion tsconfig.scripts.json
Original file line number Diff line number Diff line change
Expand Up @@ -15,5 +15,13 @@
"checkJs": false,
"allowImportingTsExtensions": true
},
"include": ["scripts", "**/webpack.*.*", "test/envUtils.ts", "eslint.config.ts", "eslint-local-rules", "test/unit"]
"include": [
"scripts",
"**/webpack.*.*",
"test/envUtils.ts",
"eslint.config.ts",
"eslint-local-rules",
"test/unit",
"test/unit-vitest/vite.config.ts"
]
}
Loading
Loading