From 8cda91985b56bcbdfc51c127e3f0cf4db2454f09 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Beno=C3=AEt=20Zugmeyer?= Date: Thu, 29 Jan 2026 11:05:05 +0100 Subject: [PATCH 1/4] add the --seed argument to karma runner --- AGENTS.md | 3 +++ test/unit/karma.base.conf.js | 36 ++++++++++++++++-------------------- 2 files changed, 19 insertions(+), 20 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 4893b5dfdb..89b94497ef 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -24,6 +24,9 @@ yarn test:unit # Run specific test file yarn test:unit --spec packages/core/src/path/to/feature.spec.ts +# Run tests on a specific seed +yarn test:unit --seed 123 + # setup E2E tests (installs Playwright and builds test apps) yarn test:e2e:init diff --git a/test/unit/karma.base.conf.js b/test/unit/karma.base.conf.js index 07a5a062c9..795a4dc908 100644 --- a/test/unit/karma.base.conf.js +++ b/test/unit/karma.base.conf.js @@ -33,14 +33,29 @@ const FILES_SPECS = [ 'developer-extension/@(src|test)/**/*.spec.@(ts|tsx)', ] +const { values } = parseArgs({ + allowPositionals: true, + strict: false, + options: { + spec: { + type: 'string', + multiple: true, + }, + seed: { + type: 'string', + }, + }, +}) + // eslint-disable-next-line import/no-default-export export default { basePath: '../..', - files: getFiles(), + files: [...FILES, ...(values.spec || FILES_SPECS)], frameworks: ['jasmine', 'webpack'], client: { jasmine: { random: true, + seed: values.seed, stopSpecOnExpectationFailure: true, }, }, @@ -132,22 +147,3 @@ function overrideTsLoaderRule(module) { return module } - -function getFiles() { - const { values } = parseArgs({ - allowPositionals: true, - strict: false, - options: { - spec: { - type: 'string', - multiple: true, - }, - }, - }) - - if (values.spec) { - return FILES.concat(values.spec) - } - - return FILES.concat(FILES_SPECS) -} From 4ddaa9145c81751ce2f564c11da461f10fbdcef1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Beno=C3=AEt=20Zugmeyer?= Date: Thu, 29 Jan 2026 11:05:05 +0100 Subject: [PATCH 2/4] improve cleanup of unit tests --- packages/core/src/browser/xhrObservable.ts | 9 +++++ .../storeStrategies/sessionInCookie.spec.ts | 11 +++--- packages/core/src/tools/valueHistory.ts | 15 ++++++-- packages/core/test/forEach.spec.ts | 34 ++++++++++++++----- .../src/domain/contexts/viewHistory.spec.ts | 3 +- .../viewMetrics/interactionCountPolyfill.ts | 7 ++++ test/unit/karma.base.conf.js | 6 ---- 7 files changed, 64 insertions(+), 21 deletions(-) diff --git a/packages/core/src/browser/xhrObservable.ts b/packages/core/src/browser/xhrObservable.ts index e1e8b24a51..e724806460 100644 --- a/packages/core/src/browser/xhrObservable.ts +++ b/packages/core/src/browser/xhrObservable.ts @@ -132,3 +132,12 @@ function abortXhr({ target: xhr }: InstrumentedMethodCall { { initConfiguration: { clientToken: 'abc' }, cookieOptions: {}, - cookieString: /^dd_cookie_test_[\w-]+=[^;]*;expires=[^;]+;path=\/;samesite=strict$/, + cookieString: /^dd_[\w_-]+=[^;]*;expires=[^;]+;path=\/;samesite=strict$/, description: 'should set samesite to strict by default', }, { initConfiguration: { clientToken: 'abc', useSecureSessionCookie: true }, cookieOptions: { secure: true }, - cookieString: /^dd_cookie_test_[\w-]+=[^;]*;expires=[^;]+;path=\/;samesite=strict;secure$/, + cookieString: /^dd_[\w_-]+=[^;]*;expires=[^;]+;path=\/;samesite=strict;secure$/, description: 'should add secure attribute when defined', }, { initConfiguration: { clientToken: 'abc', trackSessionAcrossSubdomains: true }, cookieOptions: { domain: 'foo.bar' }, cookieString: new RegExp( - `^dd_cookie_test_[\\w-]+=[^;]*;expires=[^;]+;path=\\/;samesite=strict;domain=${getCurrentSite()}$` + `^dd_[\\w_-]+=[^;]*;expires=[^;]+;path=\\/;samesite=strict;domain=${getCurrentSite()}$` ), description: 'should set cookie domain when tracking accross subdomains', }, @@ -114,7 +114,10 @@ describe('session in cookie strategy', () => { it(description, () => { const cookieSetSpy = spyOnProperty(document, 'cookie', 'set') selectCookieStrategy(initConfiguration) - expect(cookieSetSpy.calls.argsFor(0)[0]).toMatch(cookieString) + expect(cookieSetSpy).toHaveBeenCalled() + for (const call of cookieSetSpy.calls.all()) { + expect(call.args[0]).toMatch(cookieString) + } }) }) }) diff --git a/packages/core/src/tools/valueHistory.ts b/packages/core/src/tools/valueHistory.ts index 59fdfcbf3d..95c75eb733 100644 --- a/packages/core/src/tools/valueHistory.ts +++ b/packages/core/src/tools/valueHistory.ts @@ -30,7 +30,7 @@ export interface ValueHistory { stop: () => void } -let cleanupHistoriesInterval: TimeoutId | null = null +let cleanupHistoriesInterval: TimeoutId | undefined const cleanupTasks: Set<() => void> = new Set() @@ -143,9 +143,20 @@ export function createValueHistory({ cleanupTasks.delete(clearExpiredValues) if (cleanupTasks.size === 0 && cleanupHistoriesInterval) { clearInterval(cleanupHistoriesInterval) - cleanupHistoriesInterval = null + cleanupHistoriesInterval = undefined } } return { add, find, closeActive, findAll, reset, stop } } + +/** + * Reset all global state. This is useful for testing to ensure clean state between tests. + * + * @internal + */ +export function resetValueHistoryGlobals() { + cleanupTasks.clear() + clearInterval(cleanupHistoriesInterval) + cleanupHistoriesInterval = undefined +} diff --git a/packages/core/test/forEach.spec.ts b/packages/core/test/forEach.spec.ts index 3376150304..2bccf45747 100644 --- a/packages/core/test/forEach.spec.ts +++ b/packages/core/test/forEach.spec.ts @@ -1,23 +1,41 @@ -import type { BuildEnvWindow } from './buildEnv' +import { resetValueHistoryGlobals } from '../src/tools/valueHistory' +import { resetFetchObservable } from '../src/browser/fetchObservable' +import { resetConsoleObservable } from '../src/domain/console/consoleObservable' +import { resetXhrObservable } from '../src/browser/xhrObservable' +import { resetGetCurrentSite } from '../src/browser/cookie' +import { resetReplayStats } from '../../rum/src/domain/replayStats' +import { resetInteractionCountPolyfill } from '../../rum-core/src/domain/view/viewMetrics/interactionCountPolyfill' +import { resetMonitor } from '../src/tools/monitor' +import { resetTelemetry } from '../src/domain/telemetry' import { startLeakDetection } from './leakDetection' +import type { BuildEnvWindow } from './buildEnv' beforeEach(() => { ;(window as unknown as BuildEnvWindow).__BUILD_ENV__SDK_VERSION__ = 'test' - // reset globals - ;(window as any).DD_LOGS = {} - ;(window as any).DD_RUM = {} ;(window as any).IS_REACT_ACT_ENVIRONMENT = true // prevent 'Some of your tests did a full page reload!' issue window.onbeforeunload = () => 'stop' startLeakDetection() - // Note: clearing cookies should be done in `beforeEach` rather than `afterEach`, because in some - // cases the test patches the `document.cookie` getter (ex: `spyOnProperty(document, 'cookie', - // 'get')`), which would prevent the `clearAllCookies` function from working properly. +}) + +afterEach(() => { + // reset globals + delete (window as any).DD_LOGS + delete (window as any).DD_RUM clearAllCookies() + resetValueHistoryGlobals() + resetFetchObservable() + resetConsoleObservable() + resetXhrObservable() + resetGetCurrentSite() + resetReplayStats() + resetMonitor() + resetTelemetry() + resetInteractionCountPolyfill() }) function clearAllCookies() { document.cookie.split(';').forEach((c) => { - document.cookie = c.replace(/=.*/, `=;expires=${new Date().toUTCString()};path=/;samesite=strict`) + document.cookie = c.replace(/=.*/, `=;expires=${new Date(0).toUTCString()};path=/;samesite=strict`) }) } diff --git a/packages/rum-core/src/domain/contexts/viewHistory.spec.ts b/packages/rum-core/src/domain/contexts/viewHistory.spec.ts index 10ddcedbdc..cb2136d849 100644 --- a/packages/rum-core/src/domain/contexts/viewHistory.spec.ts +++ b/packages/rum-core/src/domain/contexts/viewHistory.spec.ts @@ -10,7 +10,6 @@ import { startViewHistory, VIEW_CONTEXT_TIME_OUT_DELAY } from './viewHistory' describe('ViewHistory', () => { const FAKE_ID = 'fake' const startClocks = relativeToClocks(10 as RelativeTime) - const lifeCycle = new LifeCycle() function buildViewCreatedEvent(partialViewCreatedEvent: Partial = {}): ViewCreatedEvent { return { @@ -21,10 +20,12 @@ describe('ViewHistory', () => { } let clock: Clock + let lifeCycle: LifeCycle let viewHistory: ViewHistory beforeEach(() => { clock = mockClock() + lifeCycle = new LifeCycle() viewHistory = startViewHistory(lifeCycle) registerCleanupTask(() => { diff --git a/packages/rum-core/src/domain/view/viewMetrics/interactionCountPolyfill.ts b/packages/rum-core/src/domain/view/viewMetrics/interactionCountPolyfill.ts index 4e55ea74f6..3fb8d0ac49 100644 --- a/packages/rum-core/src/domain/view/viewMetrics/interactionCountPolyfill.ts +++ b/packages/rum-core/src/domain/view/viewMetrics/interactionCountPolyfill.ts @@ -53,3 +53,10 @@ export function initInteractionCountPolyfill() { */ export const getInteractionCount = () => observer ? interactionCountEstimate : (window as BrowserWindow).performance.interactionCount! || 0 + +export function resetInteractionCountPolyfill() { + if (observer) { + observer.disconnect() + observer = undefined + } +} diff --git a/test/unit/karma.base.conf.js b/test/unit/karma.base.conf.js index 795a4dc908..480210acd5 100644 --- a/test/unit/karma.base.conf.js +++ b/test/unit/karma.base.conf.js @@ -83,12 +83,6 @@ export default { devtool: false, mode: 'development', plugins: webpackConfig.plugins, - optimization: { - // By default, karma-webpack creates a bundle with one entry point for each spec file, but - // with all dependencies shared. Our test suite does not support sharing dependencies, each - // spec bundle should include its own copy of dependencies. - runtimeChunk: false, - }, ignoreWarnings: [ // we will see warnings about missing exports in some files // this is because we set transpileOnly option in ts-loader From 02c399c9f275a5c880f7e8324833bcfba804c1b8 Mon Sep 17 00:00:00 2001 From: Benoit Zugmeyer Date: Fri, 6 Feb 2026 18:04:45 +0100 Subject: [PATCH 3/4] remove now redundant cleanups --- packages/core/src/browser/cookie.spec.ts | 6 +----- packages/core/src/domain/telemetry/telemetry.spec.ts | 5 ----- packages/core/src/domain/telemetry/telemetry.ts | 3 +-- packages/core/src/index.ts | 5 ++--- packages/core/src/tools/monitor.spec.ts | 5 +---- packages/core/src/tools/timer.spec.ts | 7 ++----- packages/core/test/emulate/mockTelemetry.ts | 3 --- packages/logs/src/boot/preStartLogs.spec.ts | 12 +----------- .../networkError/networkErrorCollection.spec.ts | 3 +-- packages/rum-core/src/boot/preStartRum.spec.ts | 5 ----- .../src/domain/error/trackConsoleError.spec.ts | 3 +-- .../rum-core/src/domain/requestCollection.spec.ts | 8 +------- packages/rum/src/boot/lazyLoadRecorder.spec.ts | 2 -- packages/rum/src/boot/recorderApi.spec.ts | 1 - packages/rum/src/boot/startRecording.spec.ts | 2 -- .../rum/src/domain/deflate/deflateWorker.spec.ts | 12 ++---------- packages/rum/src/domain/getSessionReplayLink.spec.ts | 5 +---- packages/rum/src/domain/record/record.spec.ts | 3 +-- packages/rum/src/domain/replayStats.spec.ts | 6 +----- .../rum/src/domain/segmentCollection/segment.spec.ts | 7 +------ 20 files changed, 17 insertions(+), 86 deletions(-) diff --git a/packages/core/src/browser/cookie.spec.ts b/packages/core/src/browser/cookie.spec.ts index 66e3764375..fe4b2f750f 100644 --- a/packages/core/src/browser/cookie.spec.ts +++ b/packages/core/src/browser/cookie.spec.ts @@ -1,12 +1,8 @@ import { mockCookies } from '../../test' -import { getCurrentSite, resetGetCurrentSite } from './cookie' +import { getCurrentSite } from './cookie' describe('cookie', () => { describe('getCurrentSite', () => { - beforeEach(() => { - resetGetCurrentSite() - }) - it('returns the eTLD+1 for example.com', () => { mockCookies() expect(getCurrentSite('example.com')).toBe('example.com') diff --git a/packages/core/src/domain/telemetry/telemetry.spec.ts b/packages/core/src/domain/telemetry/telemetry.spec.ts index 0c3b1c9d30..3c591d89b9 100644 --- a/packages/core/src/domain/telemetry/telemetry.spec.ts +++ b/packages/core/src/domain/telemetry/telemetry.spec.ts @@ -20,7 +20,6 @@ import type { StackTrace } from '../../tools/stackTrace/computeStackTrace' import { HookNames } from '../../tools/abstractHooks' import { addTelemetryError, - resetTelemetry, scrubCustomerFrames, formatError, addTelemetryConfiguration, @@ -71,10 +70,6 @@ function startAndSpyTelemetry( } describe('telemetry', () => { - afterEach(() => { - resetTelemetry() - }) - it('collects "monitor" errors', async () => { const { getTelemetryEvents } = startAndSpyTelemetry() callMonitored(() => { diff --git a/packages/core/src/domain/telemetry/telemetry.ts b/packages/core/src/domain/telemetry/telemetry.ts index c2d079b82b..8051537cd3 100644 --- a/packages/core/src/domain/telemetry/telemetry.ts +++ b/packages/core/src/domain/telemetry/telemetry.ts @@ -8,7 +8,7 @@ import { buildTags } from '../tags' import { INTAKE_SITE_STAGING, INTAKE_SITE_US1_FED } from '../intakeSites' import { BufferedObservable, Observable } from '../../tools/observable' import { clocksNow } from '../../tools/utils/timeUtils' -import { displayIfDebugEnabled, startMonitorErrorCollection, resetMonitor } from '../../tools/monitor' +import { displayIfDebugEnabled, startMonitorErrorCollection } from '../../tools/monitor' import { sendToExtension } from '../../tools/sendToExtension' import { performDraw } from '../../tools/utils/numberUtils' import { jsonStringify } from '../../tools/serialisation/jsonStringify' @@ -247,7 +247,6 @@ function getRuntimeEnvInfo(): RuntimeEnvInfo { export function resetTelemetry() { telemetryObservable = undefined - resetMonitor() } /** diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 68e5647c34..9f1049506c 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -42,7 +42,6 @@ export { startTelemetry, addTelemetryDebug, addTelemetryError, - resetTelemetry, TelemetryService, TelemetryMetrics, addTelemetryConfiguration, @@ -106,7 +105,7 @@ export type { CookieStore, WeakRef, WeakRefConstructor } from './browser/browser export type { XhrCompleteContext, XhrStartContext } from './browser/xhrObservable' export { initXhrObservable } from './browser/xhrObservable' export type { FetchResolveContext, FetchStartContext, FetchContext } from './browser/fetchObservable' -export { initFetchObservable, resetFetchObservable, ResponseBodyAction } from './browser/fetchObservable' +export { initFetchObservable, ResponseBodyAction } from './browser/fetchObservable' export { fetch } from './browser/fetch' export type { PageMayExitEvent } from './browser/pageMayExitObservable' export { createPageMayExitObservable, PageExitReason, isPageExitReason } from './browser/pageMayExitObservable' @@ -115,7 +114,7 @@ export { requestIdleCallback } from './tools/requestIdleCallback' export * from './tools/taskQueue' export * from './tools/timer' export type { ConsoleLog } from './domain/console/consoleObservable' -export { initConsoleObservable, resetConsoleObservable } from './domain/console/consoleObservable' +export { initConsoleObservable } from './domain/console/consoleObservable' export type { BoundedBuffer } from './tools/boundedBuffer' export { createBoundedBuffer } from './tools/boundedBuffer' export { catchUserErrors } from './tools/catchUserErrors' diff --git a/packages/core/src/tools/monitor.spec.ts b/packages/core/src/tools/monitor.spec.ts index 3d2f75ef53..63cfb8616a 100644 --- a/packages/core/src/tools/monitor.spec.ts +++ b/packages/core/src/tools/monitor.spec.ts @@ -1,5 +1,5 @@ import { display } from './display' -import { callMonitored, monitor, monitored, startMonitorErrorCollection, resetMonitor, setDebugMode } from './monitor' +import { callMonitored, monitor, monitored, startMonitorErrorCollection, setDebugMode } from './monitor' describe('monitor', () => { let onMonitorErrorCollectedSpy: jasmine.Spy<(error: unknown) => void> @@ -7,9 +7,6 @@ describe('monitor', () => { beforeEach(() => { onMonitorErrorCollectedSpy = jasmine.createSpy() }) - afterEach(() => { - resetMonitor() - }) describe('decorator', () => { class Candidate { diff --git a/packages/core/src/tools/timer.spec.ts b/packages/core/src/tools/timer.spec.ts index 067985bdd4..5ced697864 100644 --- a/packages/core/src/tools/timer.spec.ts +++ b/packages/core/src/tools/timer.spec.ts @@ -1,6 +1,6 @@ -import { mockClock, mockZoneJs, registerCleanupTask } from '../../test' +import { mockClock, mockZoneJs } from '../../test' import type { Clock, MockZoneJs } from '../../test' -import { resetMonitor, startMonitorErrorCollection } from './monitor' +import { startMonitorErrorCollection } from './monitor' import { setTimeout, clearTimeout, setInterval, clearInterval } from './timer' import { noop } from './utils/functionUtils' ;[ @@ -21,9 +21,6 @@ import { noop } from './utils/functionUtils' beforeEach(() => { clock = mockClock() - registerCleanupTask(() => { - resetMonitor() - }) zoneJs = mockZoneJs() }) diff --git a/packages/core/test/emulate/mockTelemetry.ts b/packages/core/test/emulate/mockTelemetry.ts index 8e7a746b9d..97806fdb06 100644 --- a/packages/core/test/emulate/mockTelemetry.ts +++ b/packages/core/test/emulate/mockTelemetry.ts @@ -2,7 +2,6 @@ import { startMonitorErrorCollection } from '../../src/tools/monitor' import { addTelemetryError, getTelemetryObservable, - resetTelemetry, type RawTelemetryEvent, type Telemetry, } from '../../src/domain/telemetry' @@ -16,7 +15,6 @@ export interface MockTelemetry { } export function startMockTelemetry() { - resetTelemetry() const events: RawTelemetryEvent[] = [] const telemetryObservable = getTelemetryObservable() @@ -29,7 +27,6 @@ export function startMockTelemetry() { registerCleanupTask(() => { subscription.unsubscribe() - resetTelemetry() }) function getEvents() { diff --git a/packages/logs/src/boot/preStartLogs.spec.ts b/packages/logs/src/boot/preStartLogs.spec.ts index e95e854418..f6f2f9493e 100644 --- a/packages/logs/src/boot/preStartLogs.spec.ts +++ b/packages/logs/src/boot/preStartLogs.spec.ts @@ -6,13 +6,7 @@ import { createFakeTelemetryObject, } from '@datadog/browser-core/test' import type { TimeStamp, TrackingConsentState } from '@datadog/browser-core' -import { - ONE_SECOND, - TrackingConsent, - createTrackingConsentState, - display, - resetFetchObservable, -} from '@datadog/browser-core' +import { ONE_SECOND, TrackingConsent, createTrackingConsentState, display } from '@datadog/browser-core' import type { CommonContext } from '../rawLogsEvent.types' import type { HybridInitConfiguration, LogsInitConfiguration } from '../domain/configuration' import type { Logger } from '../domain/logger' @@ -32,10 +26,6 @@ describe('preStartLogs', () => { clock = mockClock() }) - afterEach(() => { - resetFetchObservable() - }) - describe('configuration validation', () => { let displaySpy: jasmine.Spy let doStartLogsSpy: jasmine.Spy diff --git a/packages/logs/src/domain/networkError/networkErrorCollection.spec.ts b/packages/logs/src/domain/networkError/networkErrorCollection.spec.ts index 29e9bfc521..df6ed73b88 100644 --- a/packages/logs/src/domain/networkError/networkErrorCollection.spec.ts +++ b/packages/logs/src/domain/networkError/networkErrorCollection.spec.ts @@ -1,4 +1,4 @@ -import { ErrorSource, resetFetchObservable } from '@datadog/browser-core' +import { ErrorSource } from '@datadog/browser-core' import type { MockFetch, MockFetchManager } from '@datadog/browser-core/test' import { SPEC_ENDPOINTS, mockFetch, registerCleanupTask } from '@datadog/browser-core/test' import type { RawNetworkLogsEvent } from '../../rawLogsEvent.types' @@ -34,7 +34,6 @@ describe('network error collection', () => { const { stop } = startNetworkErrorCollection({ ...CONFIGURATION, forwardErrorsToLogs }, lifeCycle) registerCleanupTask(() => { stop() - resetFetchObservable() }) fetch = window.fetch as MockFetch } diff --git a/packages/rum-core/src/boot/preStartRum.spec.ts b/packages/rum-core/src/boot/preStartRum.spec.ts index 431853227a..0d58b90ac3 100644 --- a/packages/rum-core/src/boot/preStartRum.spec.ts +++ b/packages/rum-core/src/boot/preStartRum.spec.ts @@ -8,7 +8,6 @@ import { TrackingConsent, createTrackingConsentState, DefaultPrivacyLevel, - resetFetchObservable, ExperimentalFeature, } from '@datadog/browser-core' import type { Clock } from '@datadog/browser-core/test' @@ -40,10 +39,6 @@ const FAKE_WORKER = {} as DeflateWorker const PUBLIC_API = {} as RumPublicApi describe('preStartRum', () => { - afterEach(() => { - resetFetchObservable() - }) - describe('configuration validation', () => { let strategy: Strategy let doStartRumSpy: jasmine.Spy diff --git a/packages/rum-core/src/domain/error/trackConsoleError.spec.ts b/packages/rum-core/src/domain/error/trackConsoleError.spec.ts index d8f6df05d6..da7b7c07c0 100644 --- a/packages/rum-core/src/domain/error/trackConsoleError.spec.ts +++ b/packages/rum-core/src/domain/error/trackConsoleError.spec.ts @@ -1,5 +1,5 @@ import type { RawError, Subscription } from '@datadog/browser-core' -import { ErrorHandling, ErrorSource, Observable, clocksNow, resetConsoleObservable } from '@datadog/browser-core' +import { ErrorHandling, ErrorSource, Observable, clocksNow } from '@datadog/browser-core' import { ignoreConsoleLogs, mockClock } from '@datadog/browser-core/test' import { trackConsoleError } from './trackConsoleError' @@ -18,7 +18,6 @@ describe('trackConsoleError', () => { }) afterEach(() => { - resetConsoleObservable() subscription.unsubscribe() }) diff --git a/packages/rum-core/src/domain/requestCollection.spec.ts b/packages/rum-core/src/domain/requestCollection.spec.ts index 52cbdc5c9d..659ccf9af2 100644 --- a/packages/rum-core/src/domain/requestCollection.spec.ts +++ b/packages/rum-core/src/domain/requestCollection.spec.ts @@ -1,5 +1,5 @@ import type { Payload } from '@datadog/browser-core' -import { RequestType, resetFetchObservable } from '@datadog/browser-core' +import { RequestType } from '@datadog/browser-core' import type { MockFetch, MockFetchManager } from '@datadog/browser-core/test' import { registerCleanupTask, SPEC_ENDPOINTS, mockFetch, mockXhr, withXhr } from '@datadog/browser-core/test' import { mockRumConfiguration } from '../../test' @@ -41,7 +41,6 @@ describe('collect fetch', () => { registerCleanupTask(() => { stopFetchTracking() - resetFetchObservable() }) }) @@ -340,10 +339,6 @@ describe('collect xhr', () => { describe('GraphQL response text collection', () => { const FAKE_GRAPHQL_URL = 'http://fake-url/graphql' - beforeEach(() => { - resetFetchObservable() - }) - function setupGraphQlFetchTest(trackResponseErrors: boolean) { const mockFetchManager = mockFetch() const completeSpy = jasmine.createSpy('requestComplete') @@ -357,7 +352,6 @@ describe('GraphQL response text collection', () => { const { stop } = trackFetch(lifeCycle, configuration, tracerStub as Tracer) registerCleanupTask(() => { stop() - resetFetchObservable() }) return { mockFetchManager, completeSpy, fetch: window.fetch as MockFetch } diff --git a/packages/rum/src/boot/lazyLoadRecorder.spec.ts b/packages/rum/src/boot/lazyLoadRecorder.spec.ts index 7b4d75314b..7b59737691 100644 --- a/packages/rum/src/boot/lazyLoadRecorder.spec.ts +++ b/packages/rum/src/boot/lazyLoadRecorder.spec.ts @@ -7,7 +7,6 @@ import { createRumSessionManagerMock, mockRumConfiguration, mockViewHistory } fr import type { CreateDeflateWorker } from '../domain/deflate' import { resetDeflateWorkerState } from '../domain/deflate' import { MockWorker } from '../../test' -import * as replayStats from '../domain/replayStats' import { makeRecorderApi } from './recorderApi' import type { StartRecording } from './postStartStrategy' import { lazyLoadRecorder } from './lazyLoadRecorder' @@ -86,7 +85,6 @@ describe('lazyLoadRecorder', () => { registerCleanupTask(() => { resetDeflateWorkerState() - replayStats.resetReplayStats() }) } diff --git a/packages/rum/src/boot/recorderApi.spec.ts b/packages/rum/src/boot/recorderApi.spec.ts index fa7ea93fda..f02f0911a1 100644 --- a/packages/rum/src/boot/recorderApi.spec.ts +++ b/packages/rum/src/boot/recorderApi.spec.ts @@ -75,7 +75,6 @@ describe('makeRecorderApi', () => { registerCleanupTask(() => { resetDeflateWorkerState() - replayStats.resetReplayStats() }) } diff --git a/packages/rum/src/boot/startRecording.spec.ts b/packages/rum/src/boot/startRecording.spec.ts index 934cb1b4f7..df715486af 100644 --- a/packages/rum/src/boot/startRecording.spec.ts +++ b/packages/rum/src/boot/startRecording.spec.ts @@ -25,7 +25,6 @@ import type { ReplayPayload } from '../domain/segmentCollection' import { setSegmentBytesLimit } from '../domain/segmentCollection' import { RecordType } from '../types' -import { resetReplayStats } from '../domain/replayStats' import { createDeflateEncoder, resetDeflateWorkerState, startDeflateWorker } from '../domain/deflate' import { startRecording } from './startRecording' @@ -41,7 +40,6 @@ describe('startRecording', () => { function setupStartRecording() { const configuration = mockRumConfiguration({ defaultPrivacyLevel: DefaultPrivacyLevel.ALLOW }) - resetReplayStats() const worker = startDeflateWorker(configuration, 'Session Replay', noop) requestSendSpy = jasmine.createSpy() diff --git a/packages/rum/src/domain/deflate/deflateWorker.spec.ts b/packages/rum/src/domain/deflate/deflateWorker.spec.ts index c7487ddc47..2e1cf39c2b 100644 --- a/packages/rum/src/domain/deflate/deflateWorker.spec.ts +++ b/packages/rum/src/domain/deflate/deflateWorker.spec.ts @@ -1,7 +1,7 @@ -import { display, resetTelemetry } from '@datadog/browser-core' +import { display } from '@datadog/browser-core' import type { RumConfiguration } from '@datadog/browser-rum-core' import type { Clock, MockTelemetry } from '@datadog/browser-core/test' -import { mockClock, registerCleanupTask, startMockTelemetry } from '@datadog/browser-core/test' +import { mockClock, startMockTelemetry } from '@datadog/browser-core/test' import { MockWorker } from '../../../test' import type { CreateDeflateWorker } from './deflateWorker' import { startDeflateWorker, resetDeflateWorkerState, INITIALIZATION_TIME_OUT_DELAY } from './deflateWorker' @@ -79,10 +79,6 @@ describe('startDeflateWorker', () => { CSP_ERROR = new DOMException( "Failed to construct 'Worker': Access to the script at 'blob:https://example.org/9aadbb61-effe-41ee-aa76-fc607053d642' is denied by the document's Content Security Policy." ) - - registerCleanupTask(() => { - resetTelemetry() - }) }) describe('Chrome and Safari behavior: exception during worker creation', () => { @@ -205,10 +201,6 @@ describe('startDeflateWorker', () => { telemetry = startMockTelemetry() }) - afterEach(() => { - resetTelemetry() - }) - it('displays an error message when the worker creation throws an unknown error', () => { createDeflateWorkerSpy.and.throwError(UNKNOWN_ERROR) startDeflateWorkerWithDefaults() diff --git a/packages/rum/src/domain/getSessionReplayLink.spec.ts b/packages/rum/src/domain/getSessionReplayLink.spec.ts index 464c06a011..9b668fa591 100644 --- a/packages/rum/src/domain/getSessionReplayLink.spec.ts +++ b/packages/rum/src/domain/getSessionReplayLink.spec.ts @@ -2,16 +2,13 @@ import type { RumConfiguration, ViewHistory } from '@datadog/browser-rum-core' import { registerCleanupTask } from '@datadog/browser-core/test' import { createRumSessionManagerMock } from '../../../rum-core/test' import { getSessionReplayLink } from './getSessionReplayLink' -import { addRecord, resetReplayStats } from './replayStats' +import { addRecord } from './replayStats' const DEFAULT_CONFIGURATION = { site: 'datadoghq.com', } as RumConfiguration describe('getReplayLink', () => { - afterEach(() => { - resetReplayStats() - }) it('should return url without query param if no view', () => { const sessionManager = createRumSessionManagerMock().setId('session-id-1') const viewHistory = { findView: () => undefined } as ViewHistory diff --git a/packages/rum/src/domain/record/record.spec.ts b/packages/rum/src/domain/record/record.spec.ts index 18905df5f7..881dbca9a5 100644 --- a/packages/rum/src/domain/record/record.spec.ts +++ b/packages/rum/src/domain/record/record.spec.ts @@ -19,7 +19,7 @@ import type { } from '../../types' import { NodeType, RecordType, IncrementalSource } from '../../types' import { appendElement } from '../../../../rum-core/test' -import { getReplayStats, resetReplayStats } from '../replayStats' +import { getReplayStats } from '../replayStats' import type { RecordAPI } from './record' import { record } from './record' import type { EmitRecordCallback } from './record.types' @@ -351,7 +351,6 @@ describe('record', () => { describe('updates record replay stats', () => { it('when recording new records', () => { - resetReplayStats() startRecording() const records = getEmittedRecords() diff --git a/packages/rum/src/domain/replayStats.spec.ts b/packages/rum/src/domain/replayStats.spec.ts index e9d0f1a736..6d365b2a3f 100644 --- a/packages/rum/src/domain/replayStats.spec.ts +++ b/packages/rum/src/domain/replayStats.spec.ts @@ -1,10 +1,6 @@ -import { getReplayStats, resetReplayStats, MAX_STATS_HISTORY, addSegment, addRecord, addWroteData } from './replayStats' +import { getReplayStats, MAX_STATS_HISTORY, addSegment, addRecord, addWroteData } from './replayStats' describe('replayStats', () => { - afterEach(() => { - resetReplayStats() - }) - describe('getReplayStats', () => { it('returns undefined for new views', () => { expect(getReplayStats('view-id')).toBeUndefined() diff --git a/packages/rum/src/domain/segmentCollection/segment.spec.ts b/packages/rum/src/domain/segmentCollection/segment.spec.ts index 9978077094..959d216b94 100644 --- a/packages/rum/src/domain/segmentCollection/segment.spec.ts +++ b/packages/rum/src/domain/segmentCollection/segment.spec.ts @@ -5,7 +5,7 @@ import { registerCleanupTask } from '@datadog/browser-core/test' import { MockWorker } from '../../../test' import type { CreationReason, BrowserRecord, SegmentContext, BrowserSegment, BrowserSegmentMetadata } from '../../types' import { RecordType } from '../../types' -import { getReplayStats, resetReplayStats } from '../replayStats' +import { getReplayStats } from '../replayStats' import { createDeflateEncoder } from '../deflate' import type { SerializationStats } from '../record' import type { AddRecordCallback, FlushCallback, Segment } from './segment' @@ -42,7 +42,6 @@ describe('Segment', () => { worker = new MockWorker() encoder = createDeflateEncoder(configuration, worker, DeflateEncoderStreamId.REPLAY) setDebugMode(true) - resetReplayStats() registerCleanupTask(() => { setDebugMode(false) @@ -299,10 +298,6 @@ describe('Segment', () => { }) describe('updates segment replay stats', () => { - beforeEach(() => { - resetReplayStats() - }) - it('when creating a segment', () => { createTestSegment() worker.processAllMessages() From 67c3cebdcc3aece48ac45ed5ccf88d72d8740d39 Mon Sep 17 00:00:00 2001 From: Benoit Zugmeyer Date: Thu, 29 Jan 2026 14:09:19 +0100 Subject: [PATCH 4/4] experiment with a vite-based test runner --- LICENSE-3rdparty.csv | 2 + eslint.config.mjs | 1 + package.json | 6 +- packages/core/test/forEach.spec.ts | 2 +- .../vite-experiment/browser/allJsonSchemas.ts | 2 + .../unit/vite-experiment/browser/bootstrap.ts | 194 ++++++++++++++++++ test/unit/vite-experiment/browser/reporter.ts | 71 +++++++ test/unit/vite-experiment/chromeLauncher.ts | 34 +++ test/unit/vite-experiment/index.html | 10 + test/unit/vite-experiment/main.ts | 108 ++++++++++ .../vite-experiment/testExecutionHandler.ts | 168 +++++++++++++++ test/unit/vite-experiment/tsconfig.json | 15 ++ test/unit/vite-experiment/types/globals.d.ts | 5 + .../vite-experiment/types/jasmine-core.d.ts | 9 + tsconfig.default.json | 3 + tsconfig.json | 3 +- yarn.lock | 16 +- 17 files changed, 644 insertions(+), 5 deletions(-) create mode 100644 test/unit/vite-experiment/browser/allJsonSchemas.ts create mode 100644 test/unit/vite-experiment/browser/bootstrap.ts create mode 100644 test/unit/vite-experiment/browser/reporter.ts create mode 100644 test/unit/vite-experiment/chromeLauncher.ts create mode 100644 test/unit/vite-experiment/index.html create mode 100644 test/unit/vite-experiment/main.ts create mode 100644 test/unit/vite-experiment/testExecutionHandler.ts create mode 100644 test/unit/vite-experiment/tsconfig.json create mode 100644 test/unit/vite-experiment/types/globals.d.ts create mode 100644 test/unit/vite-experiment/types/jasmine-core.d.ts diff --git a/LICENSE-3rdparty.csv b/LICENSE-3rdparty.csv index a3ac2d3aa6..141b7f110d 100644 --- a/LICENSE-3rdparty.csv +++ b/LICENSE-3rdparty.csv @@ -25,6 +25,7 @@ dev,@types/node-forge,MIT,Copyright Microsoft Corporation dev,@types/pako,MIT,Copyright Microsoft Corporation dev,@types/react,MIT,Copyright Microsoft Corporation dev,@types/react-dom,MIT,Copyright Microsoft Corporation +dev,@types/ws,MIT,Copyright Microsoft Corporation dev,@wxt-dev/module-react,MIT,Copyright (c) 2023 Aaron dev,@vitejs/plugin-react,MIT,Copyright (c) 2019-present Evan You & Vite Contributors dev,ajv,MIT,Copyright 2015-2017 Evgeny Poberezkin @@ -76,4 +77,5 @@ dev,vite,MIT,Copyright (c) 2019-present, VoidZero Inc. and Vite contributors dev,webpack,MIT,Copyright JS Foundation and other contributors dev,webpack-cli,MIT,Copyright JS Foundation and other contributors dev,webpack-dev-middleware,MIT,Copyright JS Foundation and other contributors +dev,ws,MIT,Copyright (c) 2011 Einar Otto Stangvik Copyright (c) 2013 Arnout Kazemier and contributors Copyright (c) 2016 Luigi Pinca and contributors dev,wxt,MIT,Copyright (c) 2023 Aaron diff --git a/eslint.config.mjs b/eslint.config.mjs index 0c04bbf93a..0156cd3bf7 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -61,6 +61,7 @@ export default tseslint.config( './test/e2e/tsconfig.json', './test/performance/tsconfig.json', './test/apps/**/tsconfig.json', + './test/unit/vite-experiment/tsconfig.json', ], sourceType: 'module', diff --git a/package.json b/package.json index 6f26b4a8cf..b8815c6d2a 100644 --- a/package.json +++ b/package.json @@ -25,6 +25,7 @@ "version": "scripts/cli version", "test": "yarn test:unit:watch", "test:unit": "karma start ./test/unit/karma.local.conf.js", + "test:unit:patou": "node ./test/unit/vite-experiment/main.ts --headless", "test:script": "node --test --experimental-test-module-mocks './scripts/**/*.spec.*'", "test:unit:watch": "yarn test:unit --no-single-run", "test:unit:bs": "node ./scripts/test/bs-wrapper.ts karma start test/unit/karma.bs.conf.js", @@ -56,6 +57,7 @@ "@types/jasmine": "3.10.19", "@types/node": "25.0.10", "@types/node-forge": "1.3.14", + "@types/ws": "8.18.1", "ajv": "8.17.1", "browserstack-local": "1.5.8", "chrome-webstore-upload": "4.0.3", @@ -97,9 +99,11 @@ "typescript": "5.9.3", "typescript-eslint": "8.53.1", "undici": "7.19.1", + "vite": "7.3.1", "webpack": "5.104.1", "webpack-cli": "6.0.1", - "webpack-dev-middleware": "7.4.5" + "webpack-dev-middleware": "7.4.5", + "ws": "8.19.0" }, "resolutions": { "puppeteer-core@npm:21.11.0/ws": "8.17.1" diff --git a/packages/core/test/forEach.spec.ts b/packages/core/test/forEach.spec.ts index 2bccf45747..2a6a28fb59 100644 --- a/packages/core/test/forEach.spec.ts +++ b/packages/core/test/forEach.spec.ts @@ -14,7 +14,7 @@ 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' + //window.onbeforeunload = () => 'stop' startLeakDetection() }) diff --git a/test/unit/vite-experiment/browser/allJsonSchemas.ts b/test/unit/vite-experiment/browser/allJsonSchemas.ts new file mode 100644 index 0000000000..568ee47112 --- /dev/null +++ b/test/unit/vite-experiment/browser/allJsonSchemas.ts @@ -0,0 +1,2 @@ +const modules = import.meta.glob('../../../../rum-events-format/schemas/**/*.json', { eager: true }) +export const allJsonSchemas = Object.values(modules) diff --git a/test/unit/vite-experiment/browser/bootstrap.ts b/test/unit/vite-experiment/browser/bootstrap.ts new file mode 100644 index 0000000000..d2569750e3 --- /dev/null +++ b/test/unit/vite-experiment/browser/bootstrap.ts @@ -0,0 +1,194 @@ +import { createJasmineReporter } from './reporter.ts' + +// Vite defines `module.exports` when loading Jasmine, and Jasmine assumes it's in Node.js, so it +// uses `global` instead of `window` as a global variable. +window.global = globalThis +// Jasmine uses an undeclared variable `i`, which breaks in strict mode +// https://github.com/jasmine/jasmine/blob/v3.99.1/lib/jasmine-core/jasmine.js#L3369 +window.i = undefined + +const { default: jasmineRequire } = await import('jasmine-core/lib/jasmine-core/jasmine.js') + +main() + +async function main() { + const jasmine = jasmineRequire.core(jasmineRequire) + const env = jasmine.getEnv({ global: globalThis }) + Object.assign(globalThis, jasmineRequire.interface(jasmine, env)) + + // Establish WebSocket connection + const ws = await createWebSocketClient() + + // Wait for run-tests message from CLI + const options = await waitForRunTestsMessage(ws) + console.log(`Received spec pattern from CLI: ${options.specPattern === null ? 'all specs' : options.specPattern}`) + + // Configure Jasmine options + const jasmineConfig: any = {} + if (options.seed) { + console.log(`Using seed: ${options.seed}`) + jasmineConfig.seed = options.seed + } + if (options.stopOnFailure) { + console.log('Stop on failure enabled') + jasmineConfig.stopOnSpecFailure = true + } + if (Object.keys(jasmineConfig).length > 0) { + env.configure(jasmineConfig) + } + + const reporter = createJasmineReporter(ws) + + env.addReporter(reporter) + + try { + const { specFileCount } = await loadSpecFiles(options.specPattern) + console.log(`Loaded ${specFileCount} spec files`) + } catch (error) { + console.error('Failed to load specs:', error) + document.body.innerHTML = `

Failed to load specs

${error instanceof Error ? error.stack : String(error)}
` + return + } + + await env.execute() + + displayReporterResults(reporter) + + document.body.innerHTML = '

Test suite complete (see console)

' +} + +async function createWebSocketClient(): Promise { + const ws = new WebSocket(`ws://${window.location.host}`) + + return new Promise((resolve, reject) => { + ws.onopen = () => { + console.log('Connected to CLI via WebSocket') + resolve(ws) + } + ws.onerror = (error) => { + console.error('WebSocket connection failed:', error) + document.body.innerHTML = '

WebSocket connection failed

' + reject(error) + } + }) +} + +interface RunTestsOptions { + specPattern: string | null + seed?: string + stopOnFailure?: boolean +} + +async function waitForRunTestsMessage(ws: WebSocket): Promise { + return new Promise((resolve, reject) => { + const messageHandler = (event: MessageEvent) => { + try { + const message = JSON.parse(event.data) + if (message.type === 'run-tests') { + ws.removeEventListener('message', messageHandler) + resolve(message.options) + } + } catch (error) { + console.error('Error parsing message:', error) + reject(error) + } + } + + ws.addEventListener('message', messageHandler) + }) +} + +function displayReporterResults(reporter: ReturnType) { + console.group('Test execution complete') + + if (reporter.overallResult?.order.random) { + console.log(`Jasmine randomized with seed: ${reporter.overallResult.order.seed}`) + } + + function countSpecsByStatus(status: string) { + return reporter.specResults.reduce((count, spec) => (spec.status === status ? count + 1 : count), 0) + } + + console.log( + `%c${countSpecsByStatus('passed')} passed, %c${countSpecsByStatus('failed')} failed, %c${countSpecsByStatus( + 'excluded' + )} excluded, %c${countSpecsByStatus('pending')} pending`, + 'color: green', + 'color: red', + 'color: gray', + 'color: gray' + ) + console.groupEnd() + + const failedSpecs = reporter.specResults.filter((spec: any) => spec.status === 'failed') + if (failedSpecs.length > 0) { + console.group('\nFailed tests:') + failedSpecs.forEach((spec: any) => { + console.group(`\n❌ ${spec.fullName}`) + printFailedExpectations(spec.failedExpectations) + console.groupEnd() + }) + console.groupEnd() + } + + const failedSuites = reporter.suitesResults.filter((suite: any) => suite.status === 'failed') + if (failedSuites.length > 0) { + console.group('\nFailed suites:') + failedSuites.forEach((suite: any) => { + console.group(`\n❌ ${suite.fullName}`) + printFailedExpectations(suite.failedExpectations) + console.groupEnd() + }) + console.groupEnd() + } + + if (reporter.overallResult?.failedExpectations.length ?? 0 > 0) { + console.group('\nGlobal failed expectations:') + printFailedExpectations(reporter.overallResult!.failedExpectations) + console.groupEnd() + } +} + +function printFailedExpectations(expectations: any[]) { + expectations.forEach((expectation: any) => { + console.error(expectation.stack) + }) +} + +async function loadSpecFiles(specPattern: string | null) { + // Use eager: false to prevent immediate evaluation + const allSpecImports = import.meta.glob('../../../../packages/**/*.spec.ts', { eager: false }) + + // Filter specs based on the pattern + const specImports: Record Promise> = {} + for (const [path, value] of Object.entries(allSpecImports)) { + // If pattern is null, include all specs + if (specPattern === null) { + specImports[path] = value as () => Promise + } else { + // Otherwise, check if the path includes the pattern + if (path.includes(specPattern)) { + specImports[path] = value as () => Promise + } + } + } + + console.log(`Filtered ${Object.keys(specImports).length} specs from ${Object.keys(allSpecImports).length} total`) + + // First import forEach.spec.ts + for (const [path, value] of Object.entries(specImports)) { + if (path.includes('forEach.spec.ts')) { + await value() + } + } + + // Then import all other specs + for (const [path, value] of Object.entries(specImports)) { + if (path.includes('forEach.spec.ts')) { + continue + } + await value() + } + + return { specFileCount: Object.keys(specImports).length } +} diff --git a/test/unit/vite-experiment/browser/reporter.ts b/test/unit/vite-experiment/browser/reporter.ts new file mode 100644 index 0000000000..d0e914c9fc --- /dev/null +++ b/test/unit/vite-experiment/browser/reporter.ts @@ -0,0 +1,71 @@ +export function createJasmineReporter(ws: WebSocket) { + const specResults: jasmine.SpecResult[] = [] + const suitesResults: jasmine.SuiteResult[] = [] + let overallResult: jasmine.JasmineDoneInfo | undefined + + function sendMessage(message: any) { + if (ws.readyState === WebSocket.OPEN) { + ws.send(JSON.stringify(message)) + } + } + + return { + jasmineStarted(data: jasmine.JasmineStartedInfo) { + sendMessage({ + type: 'jasmine-started', + data, + }) + }, + + jasmineDone(result: jasmine.JasmineDoneInfo) { + overallResult = result + sendMessage({ + type: 'jasmine-done', + data: result, + }) + }, + + suiteStarted(result: jasmine.SuiteResult) { + sendMessage({ + type: 'suite-started', + data: result, + }) + }, + + suiteDone(result: jasmine.SuiteResult) { + suitesResults.push(result) + sendMessage({ + type: 'suite-done', + data: result, + }) + }, + + specStarted(result: jasmine.SpecResult) { + sendMessage({ + type: 'spec-started', + data: result, + }) + }, + + specDone(specResult: jasmine.SpecResult) { + specResults.push(specResult) + + if (specResult.status === 'passed') { + console.log(specResult.fullName, '✅') + } else if (specResult.status === 'failed') { + console.error(specResult.fullName, '❌') + } + + sendMessage({ + type: 'spec-done', + data: specResult, + }) + }, + + specResults, + suitesResults, + get overallResult() { + return overallResult + }, + } +} diff --git a/test/unit/vite-experiment/chromeLauncher.ts b/test/unit/vite-experiment/chromeLauncher.ts new file mode 100644 index 0000000000..f67ee038f8 --- /dev/null +++ b/test/unit/vite-experiment/chromeLauncher.ts @@ -0,0 +1,34 @@ +import { spawn } from 'node:child_process' + +function getChromeBinaryPath(): string { + const platform = process.platform + if (platform === 'darwin') { + return '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome' + } else if (platform === 'linux') { + return 'google-chrome' + } else if (platform === 'win32') { + return 'C:\\Program Files\\Google\\Chrome\\Application\\chrome.exe' + } + throw new Error(`Unsupported platform: ${platform}`) +} + +export function launchChrome(url: string): void { + const chromePath = getChromeBinaryPath() + const chromeArgs = ['--headless', '--disable-gpu', '--no-sandbox', '--disable-dev-shm-usage', url] + + console.log('Launching Chrome headless...') + const chromeProcess = spawn(chromePath, chromeArgs, { + stdio: 'ignore', + detached: false, + }) + + chromeProcess.on('error', (error) => { + console.error('Failed to launch Chrome:', error.message) + process.exit(1) + }) + + // Clean up Chrome process on exit + process.on('exit', () => { + chromeProcess.kill() + }) +} diff --git a/test/unit/vite-experiment/index.html b/test/unit/vite-experiment/index.html new file mode 100644 index 0000000000..35ac3fd46b --- /dev/null +++ b/test/unit/vite-experiment/index.html @@ -0,0 +1,10 @@ + + + + + Datadog Browser SDK unit tests runner + + + + + diff --git a/test/unit/vite-experiment/main.ts b/test/unit/vite-experiment/main.ts new file mode 100644 index 0000000000..65e577afc8 --- /dev/null +++ b/test/unit/vite-experiment/main.ts @@ -0,0 +1,108 @@ +#!/usr/bin/env node +import { parseArgs } from 'node:util' +import { resolve } from 'path' +import express from 'express' +import { createServer as createViteServer } from 'vite' +import { WebSocketServer } from 'ws' +import { buildEnvKeys, getBuildEnvValue } from '../../../scripts/lib/buildEnv.ts' +import { handleTestExecution } from './testExecutionHandler.ts' +import { launchChrome } from './chromeLauncher.ts' + +const ROOT = resolve(import.meta.dirname, '../../..') + +// Parse CLI arguments +const { values } = parseArgs({ + options: { + spec: { + type: 'string', + short: 's', + description: 'Focus on a specific spec file pattern', + }, + watch: { + type: 'boolean', + short: 'w', + description: 'Watch mode - keep server running after tests complete', + }, + seed: { + type: 'string', + description: 'Random seed for test execution', + }, + 'stop-on-failure': { + type: 'boolean', + description: 'Stop test execution on first failure', + }, + headless: { + type: 'boolean', + description: 'Launch Chrome headless automatically', + }, + }, +}) + +const specPattern: string | null = values.spec ?? null +const watchMode = values.watch ?? false +const seed: string | undefined = values.seed +const stopOnFailure = values['stop-on-failure'] ?? false +const headless = values.headless ?? false + +console.log('Starting Vite dev server...') + +const viteServer = await createViteServer({ + root: import.meta.dirname, + server: { middlewareMode: true, watch: watchMode ? {} : null }, + 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: `${import.meta.dirname}/browser/allJsonSchemas.ts` }, + ], + }, + define: Object.fromEntries( + buildEnvKeys.map((key) => [`__BUILD_ENV__${key}__`, JSON.stringify(getBuildEnvValue(key))]) + ), +}) + +const app = express() +const httpServer = app.listen(8080, () => { + console.log('Vite dev server started on http://localhost:8080') + + // Launch Chrome headless if requested + if (headless) { + launchChrome('http://localhost:8080') + } +}) + +app.use(viteServer.middlewares) + +// Create WebSocket server +const wss = new WebSocketServer({ server: httpServer }) + +wss.on('connection', async (ws) => { + console.log('Browser connected via WebSocket') + console.log(`Running specs matching: ${specPattern === null ? 'all specs' : specPattern}`) + if (watchMode) { + console.log('Watch mode enabled - server will stay running') + } + if (seed) { + console.log(`Using seed: ${seed}`) + } + if (stopOnFailure) { + console.log('Stop on failure enabled') + } + + try { + const result = await handleTestExecution(ws, { specPattern, seed, stopOnFailure }) + + if (watchMode) { + console.log('\nWaiting for changes...') + } else { + const exitCode = result.success ? 0 : 1 + process.exit(exitCode) + } + } catch (error) { + console.error('Test execution error:', error) + if (!watchMode) { + process.exit(1) + } + } +}) diff --git a/test/unit/vite-experiment/testExecutionHandler.ts b/test/unit/vite-experiment/testExecutionHandler.ts new file mode 100644 index 0000000000..12bdecfb97 --- /dev/null +++ b/test/unit/vite-experiment/testExecutionHandler.ts @@ -0,0 +1,168 @@ +import type { WebSocket } from 'ws' + +type JasmineMessage = + | { type: 'jasmine-started'; data: jasmine.JasmineStartedInfo } + | { type: 'suite-started'; data: jasmine.SuiteResult } + | { type: 'spec-started'; data: jasmine.SpecResult } + | { type: 'spec-done'; data: jasmine.SpecResult } + | { type: 'suite-done'; data: jasmine.SuiteResult } + | { type: 'jasmine-done'; data: jasmine.JasmineDoneInfo } + +export interface RunTestsOptions { + specPattern: string | null + seed?: string + stopOnFailure?: boolean +} + +type CLIMessage = { type: 'run-tests'; options: RunTestsOptions } + +export function handleTestExecution(ws: WebSocket, options: RunTestsOptions): Promise<{ success: boolean }> { + return new Promise((resolve, reject) => { + // Send run-tests message to browser + const runTestsMessage: CLIMessage = { type: 'run-tests', options } + ws.send(JSON.stringify(runTestsMessage)) + + // Test execution state - independent per connection + const testState = { + total: 0, + specResults: [] as jasmine.SpecResult[], + suiteResults: [] as jasmine.SuiteResult[], + overallResult: null as jasmine.JasmineDoneInfo | null, + } + + function countSpecsByStatus(status: string) { + return testState.specResults.reduce((count, spec) => (spec.status === status ? count + 1 : count), 0) + } + + function handleJasmineStarted(data: jasmine.JasmineStartedInfo) { + testState.total = data.totalSpecsDefined + console.log(`\nStarting test execution: ${data.totalSpecsDefined} specs`) + if (data.order.random) { + console.log(`Randomized with seed: ${data.order.seed}`) + } + console.log('') + } + + function handleSpecStarted(data: jasmine.SpecResult) { + // Optional: Could log spec start if desired + } + + function handleSpecDone(data: jasmine.SpecResult) { + testState.specResults.push(data) + + // Display progress indicator + if (data.status === 'passed') { + process.stdout.write('.') + } else if (data.status === 'failed') { + process.stdout.write('F') + } else if (data.status === 'pending') { + process.stdout.write('*') + } else if (data.status === 'excluded') { + process.stdout.write('-') + } + + // Show progress every 50 specs + if (testState.specResults.length % 50 === 0) { + process.stdout.write(` ${testState.specResults.length}/${testState.total}\n`) + } + } + + function handleSuiteStarted(data: jasmine.SuiteResult) { + // Optional: Could log suite start if desired + } + + function handleSuiteDone(data: jasmine.SuiteResult) { + testState.suiteResults.push(data) + } + + function handleJasmineDone(data: jasmine.JasmineDoneInfo) { + testState.overallResult = data + + // Calculate counts from collected results + const passed = countSpecsByStatus('passed') + const failed = countSpecsByStatus('failed') + const pending = countSpecsByStatus('pending') + const excluded = countSpecsByStatus('excluded') + + // Print final summary + console.log('\n\n' + '='.repeat(60)) + console.log('TEST RESULTS') + console.log('='.repeat(60)) + console.log(`✅ ${passed} passed | ❌ ${failed} failed | ⏭️ ${pending} pending | ⊘ ${excluded} excluded`) + + const failedSpecs = testState.specResults.filter((spec) => spec.status === 'failed') + if (failedSpecs.length > 0) { + console.log('\nFailed tests:') + failedSpecs.forEach((spec) => { + console.log(`\n ❌ ${spec.fullName}`) + spec.failedExpectations.forEach((expectation) => { + console.log(` ${expectation.stack || expectation.message}`) + }) + }) + } + + const failedSuites = testState.suiteResults.filter((suite) => suite.status === 'failed') + if (failedSuites.length > 0) { + console.log('\nFailed suites:') + failedSuites.forEach((suite) => { + console.log(`\n ❌ ${suite.fullName}`) + suite.failedExpectations.forEach((expectation) => { + console.log(` ${expectation.stack || expectation.message}`) + }) + }) + } + + if (data.failedExpectations.length > 0) { + console.log('\nGlobal failures:') + data.failedExpectations.forEach((expectation) => { + console.log(` ${expectation.stack || expectation.message}`) + }) + } + + console.log('='.repeat(60)) + + // Resolve promise with test results + const success = failed === 0 && data.failedExpectations.length === 0 + resolve({ success }) + } + + ws.on('message', (data) => { + try { + const message = JSON.parse(data.toString()) as JasmineMessage + + switch (message.type) { + case 'jasmine-started': + handleJasmineStarted(message.data) + break + case 'spec-started': + handleSpecStarted(message.data) + break + case 'spec-done': + handleSpecDone(message.data) + break + case 'suite-started': + handleSuiteStarted(message.data) + break + case 'suite-done': + handleSuiteDone(message.data) + break + case 'jasmine-done': + handleJasmineDone(message.data) + break + } + } catch (error) { + console.error('Error parsing WebSocket message:', error) + reject(error) + } + }) + + ws.on('close', () => { + console.log('\nBrowser disconnected') + }) + + ws.on('error', (error) => { + console.error('WebSocket error:', error) + reject(error) + }) + }) +} diff --git a/test/unit/vite-experiment/tsconfig.json b/test/unit/vite-experiment/tsconfig.json new file mode 100644 index 0000000000..3b0dc0cc9b --- /dev/null +++ b/test/unit/vite-experiment/tsconfig.json @@ -0,0 +1,15 @@ +{ + "extends": "../../../tsconfig.base.json", + "compilerOptions": { + "module": "preserve", + "target": "ES2024", + "lib": ["ES2024", "DOM"], + "types": ["node", "jasmine", "vite/client"], + "allowSyntheticDefaultImports": true, + "esModuleInterop": true, + "resolveJsonModule": true, + "noEmit": true, + "allowImportingTsExtensions": true + }, + "include": ["**/*.ts", "types/**/*.d.ts", "../../../scripts/lib/buildEnv.ts"] +} diff --git a/test/unit/vite-experiment/types/globals.d.ts b/test/unit/vite-experiment/types/globals.d.ts new file mode 100644 index 0000000000..db410ad8dd --- /dev/null +++ b/test/unit/vite-experiment/types/globals.d.ts @@ -0,0 +1,5 @@ +// Extend Window interface for Jasmine compatibility +interface Window { + global: typeof globalThis + i: any +} diff --git a/test/unit/vite-experiment/types/jasmine-core.d.ts b/test/unit/vite-experiment/types/jasmine-core.d.ts new file mode 100644 index 0000000000..cc238f2d8c --- /dev/null +++ b/test/unit/vite-experiment/types/jasmine-core.d.ts @@ -0,0 +1,9 @@ +declare module 'jasmine-core/lib/jasmine-core/jasmine.js' { + interface JasmineRequire { + core(jasmineRequire: JasmineRequire): any + interface(jasmine: any, env: any): any + } + + const jasmineRequire: JasmineRequire + export default jasmineRequire +} diff --git a/tsconfig.default.json b/tsconfig.default.json index 4a26abf026..9f5c9875d6 100644 --- a/tsconfig.default.json +++ b/tsconfig.default.json @@ -22,6 +22,9 @@ "test/e2e", "test/lib", + // Files included in ./test/unit/vite-experiment/tsconfig.json + "test/unit/vite-experiment", + // Files included in ./test/performance/tsconfig.json "test/performance", diff --git a/tsconfig.json b/tsconfig.json index 013707afb2..ae41269b30 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -5,6 +5,7 @@ { "path": "./tsconfig.scripts.json" }, { "path": "./developer-extension/tsconfig.json" }, { "path": "./test/e2e/tsconfig.json" }, - { "path": "./test/performance/tsconfig.json" } + { "path": "./test/performance/tsconfig.json" }, + { "path": "./test/unit/vite-experiment/tsconfig.json" } ] } diff --git a/yarn.lock b/yarn.lock index 5f4711cc5c..60cc1eb6bf 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3085,6 +3085,15 @@ __metadata: languageName: node linkType: hard +"@types/ws@npm:8.18.1": + version: 8.18.1 + resolution: "@types/ws@npm:8.18.1" + dependencies: + "@types/node": "npm:*" + checksum: 10c0/61aff1129143fcc4312f083bc9e9e168aa3026b7dd6e70796276dcfb2c8211c4292603f9c4864fae702f2ed86e4abd4d38aa421831c2fd7f856c931a481afbab + languageName: node + linkType: hard + "@types/yauzl@npm:^2.9.1": version: 2.10.3 resolution: "@types/yauzl@npm:2.10.3" @@ -4458,6 +4467,7 @@ __metadata: "@types/jasmine": "npm:3.10.19" "@types/node": "npm:25.0.10" "@types/node-forge": "npm:1.3.14" + "@types/ws": "npm:8.18.1" ajv: "npm:8.17.1" browserstack-local: "npm:1.5.8" chrome-webstore-upload: "npm:4.0.3" @@ -4499,9 +4509,11 @@ __metadata: typescript: "npm:5.9.3" typescript-eslint: "npm:8.53.1" undici: "npm:7.19.1" + vite: "npm:7.3.1" webpack: "npm:5.104.1" webpack-cli: "npm:6.0.1" webpack-dev-middleware: "npm:7.4.5" + ws: "npm:8.19.0" languageName: unknown linkType: soft @@ -15051,7 +15063,7 @@ __metadata: languageName: node linkType: hard -"vite@npm:^5.4.19 || ^6.3.4 || ^7.0.0, vite@npm:^7.3.1": +"vite@npm:7.3.1, vite@npm:^5.4.19 || ^6.3.4 || ^7.0.0, vite@npm:^7.3.1": version: 7.3.1 resolution: "vite@npm:7.3.1" dependencies: @@ -15598,7 +15610,7 @@ __metadata: languageName: node linkType: hard -"ws@npm:^8.19.0": +"ws@npm:8.19.0, ws@npm:^8.19.0": version: 8.19.0 resolution: "ws@npm:8.19.0" peerDependencies: