From 76404c40d463fbc45cff593654c4b25be98c8d3f Mon Sep 17 00:00:00 2001 From: John Doe Date: Wed, 14 Jan 2026 19:02:36 +0100 Subject: [PATCH 1/4] feat: add profiler class and measure API --- packages/utils/src/lib/profiler/constants.ts | 1 + .../src/lib/profiler/profiler.int.test.ts | 285 ++++++++++++++++++ packages/utils/src/lib/profiler/profiler.ts | 283 +++++++++++++++++ .../src/lib/profiler/profiler.unit.test.ts | 210 +++++++++++++ 4 files changed, 779 insertions(+) create mode 100644 packages/utils/src/lib/profiler/constants.ts create mode 100644 packages/utils/src/lib/profiler/profiler.int.test.ts create mode 100644 packages/utils/src/lib/profiler/profiler.ts create mode 100644 packages/utils/src/lib/profiler/profiler.unit.test.ts diff --git a/packages/utils/src/lib/profiler/constants.ts b/packages/utils/src/lib/profiler/constants.ts new file mode 100644 index 000000000..d52e31d90 --- /dev/null +++ b/packages/utils/src/lib/profiler/constants.ts @@ -0,0 +1 @@ +export const PROFILER_ENABLED = 'CP_PROFILING'; diff --git a/packages/utils/src/lib/profiler/profiler.int.test.ts b/packages/utils/src/lib/profiler/profiler.int.test.ts new file mode 100644 index 000000000..542849a1f --- /dev/null +++ b/packages/utils/src/lib/profiler/profiler.int.test.ts @@ -0,0 +1,285 @@ +import { performance } from 'node:perf_hooks'; +import { beforeEach, describe, expect, it } from 'vitest'; +import type { ActionTrackEntryPayload } from '../user-timing-extensibility-api.type.js'; +import { Profiler } from './profiler.js'; + +describe('Profiler Integration', () => { + let profiler: Profiler>; + + beforeEach(() => { + // Clear all performance entries before each test + performance.clearMarks(); + performance.clearMeasures(); + + profiler = new Profiler({ + prefix: 'test', + track: 'integration-tests', + color: 'primary', + tracks: { + async: { track: 'async-ops', color: 'secondary' }, + sync: { track: 'sync-ops', color: 'tertiary' }, + }, + enabled: true, // Explicitly enable for integration tests + }); + }); + + it('should create complete performance timeline for sync operation', () => { + const result = profiler.measure('sync-test', () => + Array.from({ length: 1000 }, (_, i) => i).reduce( + (sum, num) => sum + num, + 0, + ), + ); + + expect(result).toBe(499_500); + + // Verify performance entries were created + const marks = performance.getEntriesByType('mark'); + const measures = performance.getEntriesByType('measure'); + + expect(marks).toEqual( + expect.arrayContaining([ + expect.objectContaining({ name: 'test:sync-test:start' }), + expect.objectContaining({ name: 'test:sync-test:end' }), + ]), + ); + + expect(measures).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + name: 'test:sync-test', + duration: expect.any(Number), + }), + ]), + ); + }); + + it('should create complete performance timeline for async operation', async () => { + const result = await profiler.measureAsync('async-test', async () => { + // Simulate async work + await new Promise(resolve => setTimeout(resolve, 10)); + return 'async-result'; + }); + + expect(result).toBe('async-result'); + + // Verify performance entries were created + const marks = performance.getEntriesByType('mark'); + const measures = performance.getEntriesByType('measure'); + + expect(marks).toEqual( + expect.arrayContaining([ + expect.objectContaining({ name: 'test:async-test:start' }), + expect.objectContaining({ name: 'test:async-test:end' }), + ]), + ); + + expect(measures).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + name: 'test:async-test', + duration: expect.any(Number), + }), + ]), + ); + }); + + it('should handle nested measurements correctly', () => { + profiler.measure('outer', () => { + profiler.measure('inner', () => 'inner-result'); + return 'outer-result'; + }); + + const marks = performance.getEntriesByType('mark'); + const measures = performance.getEntriesByType('measure'); + + expect(marks).toHaveLength(4); // 2 for outer + 2 for inner + expect(measures).toHaveLength(2); // 1 for outer + 1 for inner + + // Check all marks exist + const markNames = marks.map(m => m.name); + expect(markNames).toStrictEqual( + expect.arrayContaining([ + 'test:outer:start', + 'test:outer:end', + 'test:inner:start', + 'test:inner:end', + ]), + ); + + // Check all measures exist + const measureNames = measures.map(m => m.name); + expect(measureNames).toStrictEqual( + expect.arrayContaining(['test:outer', 'test:inner']), + ); + }); + + it('should create markers with proper metadata', () => { + profiler.marker('test-marker', { + color: 'warning', + tooltipText: 'Test marker tooltip', + properties: [ + ['event', 'test-event'], + ['timestamp', Date.now()], + ], + }); + + const marks = performance.getEntriesByType('mark'); + expect(marks).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + name: 'test-marker', + detail: { + devtools: expect.objectContaining({ + dataType: 'marker', + color: 'warning', + tooltipText: 'Test marker tooltip', + properties: [ + ['event', 'test-event'], + ['timestamp', expect.any(Number)], + ], + }), + }, + }), + ]), + ); + }); + + it('should create proper DevTools payloads for tracks', () => { + profiler.measure('track-test', () => 'result', { + success: result => ({ + properties: [['result', result]], + tooltipText: 'Track test completed', + }), + }); + + const measures = performance.getEntriesByType('measure'); + expect(measures).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + detail: { + devtools: expect.objectContaining({ + dataType: 'track-entry', + track: 'integration-tests', + color: 'primary', + properties: [['result', 'result']], + tooltipText: 'Track test completed', + }), + }, + }), + ]), + ); + }); + + it('should merge track defaults with measurement options', () => { + // Use the sync track from our configuration + profiler.measure('sync-op', () => 'sync-result', { + success: result => ({ + properties: [ + ['operation', 'sync'], + ['result', result], + ], + }), + }); + + const measures = performance.getEntriesByType('measure'); + expect(measures).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + detail: { + devtools: expect.objectContaining({ + dataType: 'track-entry', + track: 'integration-tests', // default track + color: 'primary', // default color + properties: [ + ['operation', 'sync'], + ['result', 'sync-result'], + ], + }), + }, + }), + ]), + ); + }); + + it('should mark errors with red color in DevTools', () => { + const error = new Error('Test error'); + + expect(() => { + profiler.measure('error-test', () => { + throw error; + }); + }).toThrow(error); + + const measures = performance.getEntriesByType('measure'); + expect(measures).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + detail: { + devtools: expect.objectContaining({ + color: 'error', + properties: expect.arrayContaining([ + ['Error Type', 'Error'], + ['Error Message', 'Test error'], + ]), + }), + }, + }), + ]), + ); + }); + + it('should include error metadata in DevTools properties', () => { + const customError = new TypeError('Custom type error'); + + expect(() => { + profiler.measure('custom-error-test', () => { + throw customError; + }); + }).toThrow(customError); + + const measures = performance.getEntriesByType('measure'); + expect(measures).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + detail: { + devtools: expect.objectContaining({ + properties: expect.arrayContaining([ + ['Error Type', 'TypeError'], + ['Error Message', 'Custom type error'], + ]), + }), + }, + }), + ]), + ); + }); + + it('should not create performance entries when disabled', async () => { + const disabledProfiler = new Profiler({ + prefix: 'disabled', + track: 'disabled-tests', + color: 'primary', + tracks: {}, + enabled: false, + }); + + // Test sync measurement + const syncResult = disabledProfiler.measure('disabled-sync', () => 'sync'); + expect(syncResult).toBe('sync'); + + // Test async measurement + const asyncResult = disabledProfiler.measureAsync( + 'disabled-async', + async () => 'async', + ); + await expect(asyncResult).resolves.toBe('async'); + + // Test marker + disabledProfiler.marker('disabled-marker'); + + // Verify no performance entries were created + expect(performance.getEntriesByType('mark')).toHaveLength(0); + expect(performance.getEntriesByType('measure')).toHaveLength(0); + }); +}); diff --git a/packages/utils/src/lib/profiler/profiler.ts b/packages/utils/src/lib/profiler/profiler.ts new file mode 100644 index 000000000..491b9824b --- /dev/null +++ b/packages/utils/src/lib/profiler/profiler.ts @@ -0,0 +1,283 @@ +import process from 'node:process'; +import { isEnvVarEnabled } from '../env.js'; +import { + type MeasureOptions, + asOptions, + markerPayload, + measureCtx, + setupTracks, +} from '../user-timing-extensibility-api-utils.js'; +import type { + ActionColorPayload, + ActionTrackEntryPayload, + DevToolsColor, + EntryMeta, + TrackMeta, +} from '../user-timing-extensibility-api.type.js'; +import { PROFILER_ENABLED } from './constants.js'; + +/** Default track configuration combining metadata and color options. */ +type DefaultTrackOptions = TrackMeta & ActionColorPayload; + +/** + * Configuration options for creating a Profiler instance. + * + * @template T - Record type defining available track names and their configurations + */ +type ProfilerMeasureOptions> = + DefaultTrackOptions & { + /** Custom track configurations that will be merged with default settings */ + tracks: Record>; + /** Whether profiling should be enabled (defaults to CP_PROFILING env var) */ + enabled?: boolean; + /** Prefix for all performance measurement names to avoid conflicts */ + prefix: string; + }; + +/** + * Options for configuring a Profiler instance. + * + * This is an alias for ProfilerMeasureOptions for backward compatibility. + * + * @template T - Record type defining available track names and their configurations + */ +export type ProfilerOptions> = + ProfilerMeasureOptions; + +/** + * Performance profiler that creates structured timing measurements with DevTools visualization. + * + * This class provides high-level APIs for performance monitoring with automatic DevTools + * integration for Chrome DevTools Performance panel. It supports both synchronous and + * asynchronous operations with customizable track visualization. + * + * @example + * ```typescript + * const profiler = new Profiler({ + * prefix: 'api', + * track: 'backend-calls', + * trackGroup: 'api', + * color: 'secondary', + * tracks: { + * database: { track: 'database', color: 'tertiary' }, + * external: { track: 'external-apis', color: 'primary' } + * } + * }); + * + * // Measure synchronous operation + * const result = profiler.measure('fetch-user', () => api.getUser(id)); + * + * // Measure async operation + * const asyncResult = await profiler.measureAsync('save-data', + * () => api.saveData(data) + * ); + * + * // Add marker + * profiler.marker('cache-invalidated', { + * color: 'warning', + * tooltipText: 'Cache cleared due to stale data' + * }); + * ``` + */ +export class Profiler> { + #enabled: boolean; + private readonly defaults: ActionTrackEntryPayload; + readonly tracks: Record; + private readonly ctxOf: ReturnType; + + /** + * Creates a new Profiler instance with the specified configuration. + * + * @param options - Configuration options for the profiler + * @param options.tracks - Custom track configurations merged with defaults + * @param options.prefix - Prefix for all measurement names + * @param options.track - Default track name for measurements + * @param options.trackGroup - Default track group for organization + * @param options.color - Default color for track entries + * @param options.enabled - Whether profiling is enabled (defaults to CP_PROFILING env var) + * + * @example + * ```typescript + * const profiler = new Profiler({ + * prefix: 'api', + * track: 'backend-calls', + * trackGroup: 'api', + * color: 'secondary', + * enabled: true, + * tracks: { + * database: { track: 'database', color: 'tertiary' }, + * cache: { track: 'cache', color: 'primary' } + * } + * }); + * ``` + */ + constructor(options: ProfilerOptions) { + const { tracks, prefix, enabled, ...defaults } = options; + const dataType = 'track-entry'; + + this.#enabled = enabled ?? isEnvVarEnabled(PROFILER_ENABLED); + this.defaults = { ...defaults, dataType }; + this.tracks = setupTracks({ ...defaults, dataType }, tracks); + this.ctxOf = measureCtx({ + ...defaults, + dataType, + prefix, + }); + } + + /** + * Sets enabled state for this profiler. + * + * Also sets the `CP_PROFILING` environment variable. + * This means any future {@link Profiler} instantiations (including child processes) will use the same enabled state. + * + * @param enabled - Whether profiling should be enabled + */ + setEnabled(enabled: boolean): void { + process.env[PROFILER_ENABLED] = `${enabled}`; + this.#enabled = enabled; + } + + /** + * Is profiling enabled? + * + * Profiling is enabled by {@link setEnabled} call or `CP_PROFILING` environment variable. + * + * @returns Whether profiling is currently enabled + */ + isEnabled(): boolean { + return this.#enabled; + } + + /** + * Creates a performance marker in the DevTools Performance panel. + * + * Markers appear as vertical lines spanning all tracks and can include custom metadata + * for debugging and performance analysis. When profiling is disabled, this method + * returns immediately without creating any performance entries. + * + * @param name - Unique name for the marker + * @param opt - Optional metadata and styling for the marker + * @param opt.color - Color of the marker line (defaults to profiler default) + * @param opt.tooltipText - Text shown on hover + * @param opt.properties - Key-value pairs for detailed view + * + * @example + * ```typescript + * profiler.marker('user-action-start', { + * color: 'primary', + * tooltipText: 'User clicked save button', + * properties: [ + * ['action', 'save'], + * ['elementId', 'save-btn'] + * ] + * }); + * ``` + */ + marker(name: string, opt?: EntryMeta & { color: DevToolsColor }) { + if (!this.#enabled) { + return; + } + + performance.mark( + name, + asOptions( + markerPayload({ + // marker only supports color no TrackMeta + ...(this.defaults.color ? { color: this.defaults.color } : {}), + ...opt, + }), + ), + ); + } + + /** + * Measures the execution time of a synchronous operation. + * + * Creates start/end marks and a final measure entry in the performance timeline. + * The operation appears in the configured track with proper DevTools visualization. + * When profiling is disabled, executes the work function directly without overhead. + * + * @template R - The return type of the work function + * @param event - Name for this measurement event + * @param work - Function to execute and measure + * @param options - Optional measurement configuration overrides + * @returns The result of the work function + * + * @example + * ```typescript + * const user = profiler.measure('fetch-user', () => { + * return api.getUser(userId); + * }, { + * success: (result) => ({ + * properties: [['userId', result.id], ['loadTime', Date.now()]] + * }) + * }); + * ``` + */ + measure(event: string, work: () => R, options?: MeasureOptions): R { + if (!this.#enabled) { + return work(); + } + + const { start, success, error } = this.ctxOf(event, options); + start(); + try { + const r = work(); + success(r); + return r; + } catch (error_) { + error(error_); + throw error_; + } + } + + /** + * Measures the execution time of an asynchronous operation. + * + * Creates start/end marks and a final measure entry in the performance timeline. + * The operation appears in the configured track with proper DevTools visualization. + * When profiling is disabled, executes and awaits the work function directly without overhead. + * + * @template R - The resolved type of the work promise + * @param event - Name for this measurement event + * @param work - Function returning a promise to execute and measure + * @param options - Optional measurement configuration overrides + * @returns Promise that resolves to the result of the work function + * + * @example + * ```typescript + * const data = await profiler.measureAsync('save-form', async () => { + * const result = await api.saveForm(formData); + * return result; + * }, { + * success: (result) => ({ + * properties: [['recordsSaved', result.count]] + * }), + * error: (err) => ({ + * properties: [['errorType', err.name]] + * }) + * }); + * ``` + */ + async measureAsync( + event: string, + work: () => Promise, + options?: MeasureOptions, + ): Promise { + if (!this.#enabled) { + return await work(); + } + + const { start, success, error } = this.ctxOf(event, options); + start(); + try { + const r = work(); + success(r); + return await r; + } catch (error_) { + error(error_); + throw error_; + } + } +} diff --git a/packages/utils/src/lib/profiler/profiler.unit.test.ts b/packages/utils/src/lib/profiler/profiler.unit.test.ts new file mode 100644 index 000000000..0965139e7 --- /dev/null +++ b/packages/utils/src/lib/profiler/profiler.unit.test.ts @@ -0,0 +1,210 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import type { ActionTrackEntryPayload } from '../user-timing-extensibility-api.type.js'; +import { Profiler } from './profiler.js'; + +describe('Profiler', () => { + let profiler: Profiler>; + + beforeEach(() => { + // Environment variables are mocked in individual tests using vi.stubEnv + + profiler = new Profiler({ + prefix: 'test', + track: 'test-track', + color: 'primary', + tracks: {}, + }); + }); + + it('should initialize with default enabled state from env', () => { + vi.stubEnv('CP_PROFILING', 'true'); + const profilerWithEnv = new Profiler({ + prefix: 'test', + track: 'test-track', + color: 'primary', + tracks: {}, + }); + + expect(profilerWithEnv.isEnabled()).toBe(true); + }); + + it('should override enabled state from options', () => { + vi.stubEnv('CP_PROFILING', 'false'); + const profilerWithOverride = new Profiler({ + prefix: 'test', + track: 'test-track', + color: 'primary', + tracks: {}, + enabled: true, + }); + + expect(profilerWithOverride.isEnabled()).toBe(true); + }); + + it('should setup tracks with defaults merged', () => { + const profilerWithTracks = new Profiler({ + prefix: 'test', + track: 'default-track', + trackGroup: 'default-group', + color: 'primary', + tracks: { + custom: { track: 'custom-track', color: 'secondary' }, + partial: { color: 'tertiary' }, // partial override + }, + }); + + expect(profilerWithTracks.tracks.custom).toEqual({ + track: 'custom-track', + trackGroup: 'default-group', + color: 'secondary', + dataType: 'track-entry', + }); + + expect(profilerWithTracks.tracks.partial).toEqual({ + track: 'default-track', // inherited from defaults + trackGroup: 'default-group', + color: 'tertiary', // overridden + dataType: 'track-entry', + }); + }); + + it('should set and get enabled state', () => { + expect(profiler.isEnabled()).toBe(false); + + profiler.setEnabled(true); + expect(profiler.isEnabled()).toBe(true); + + profiler.setEnabled(false); + expect(profiler.isEnabled()).toBe(false); + }); + + it('should update environment variable', () => { + profiler.setEnabled(true); + expect(process.env.CP_PROFILING).toBe('true'); + + profiler.setEnabled(false); + expect(process.env.CP_PROFILING).toBe('false'); + }); + + it('should execute marker without error when enabled', () => { + profiler.setEnabled(true); + + expect(() => { + profiler.marker('test-marker', { + color: 'primary', + tooltipText: 'Test marker', + properties: [['key', 'value']], + }); + }).not.toThrow(); + }); + + it('should execute marker without error when disabled', () => { + profiler.setEnabled(false); + + expect(() => { + profiler.marker('test-marker'); + }).not.toThrow(); + }); + + it('should execute work and return result when measure enabled', () => { + profiler.setEnabled(true); + + const workFn = vi.fn(() => 'result'); + const result = profiler.measure('test-event', workFn); + + expect(result).toBe('result'); + expect(workFn).toHaveBeenCalled(); + }); + + it('should execute work directly when measure disabled', () => { + profiler.setEnabled(false); + + const workFn = vi.fn(() => 'result'); + const result = profiler.measure('test-event', workFn); + + expect(result).toBe('result'); + expect(workFn).toHaveBeenCalled(); + }); + + it('should propagate errors when measure enabled', () => { + profiler.setEnabled(true); + + const error = new Error('Test error'); + const workFn = vi.fn(() => { + throw error; + }); + + expect(() => profiler.measure('test-event', workFn)).toThrow(error); + expect(workFn).toHaveBeenCalled(); + }); + + it('should propagate errors when measure disabled', () => { + profiler.setEnabled(false); + + const error = new Error('Test error'); + const workFn = vi.fn(() => { + throw error; + }); + + expect(() => profiler.measure('test-event', workFn)).toThrow(error); + expect(workFn).toHaveBeenCalled(); + }); + + it('should handle async operations correctly when enabled', async () => { + profiler.setEnabled(true); + + const workFn = vi.fn(async () => { + await Promise.resolve(); + return 'async-result'; + }); + + const result = await profiler.measureAsync('test-async-event', workFn); + + expect(result).toBe('async-result'); + expect(workFn).toHaveBeenCalled(); + }); + + it('should execute async work directly when disabled', async () => { + profiler.setEnabled(false); + + const workFn = vi.fn(async () => { + await Promise.resolve(); + return 'async-result'; + }); + + const result = await profiler.measureAsync('test-async-event', workFn); + + expect(result).toBe('async-result'); + expect(workFn).toHaveBeenCalled(); + }); + + it('should propagate async errors when enabled', async () => { + profiler.setEnabled(true); + + const error = new Error('Async test error'); + const workFn = vi.fn(async () => { + await Promise.resolve(); + throw error; + }); + + await expect( + profiler.measureAsync('test-async-event', workFn), + ).rejects.toThrow(error); + expect(workFn).toHaveBeenCalled(); + }); + + it('should propagate async errors when disabled', async () => { + profiler.setEnabled(false); + + const error = new Error('Async test error'); + const workFn = vi.fn(async () => { + await Promise.resolve(); + throw error; + }); + + await expect( + profiler.measureAsync('test-async-event', workFn), + ).rejects.toThrow(error); + expect(workFn).toHaveBeenCalled(); + }); +}); From 923659b07677c663b364b3f0e90315d6664c587e Mon Sep 17 00:00:00 2001 From: John Doe Date: Wed, 14 Jan 2026 20:35:10 +0100 Subject: [PATCH 2/4] feat: add profiler class --- .../src/lib/profiler/profiler.int.test.ts | 41 ++- packages/utils/src/lib/profiler/profiler.ts | 105 ++------ .../src/lib/profiler/profiler.unit.test.ts | 240 +++++++++++++++--- .../user-timing-extensibility-api-utils.ts | 10 +- 4 files changed, 244 insertions(+), 152 deletions(-) diff --git a/packages/utils/src/lib/profiler/profiler.int.test.ts b/packages/utils/src/lib/profiler/profiler.int.test.ts index 542849a1f..5feebcdcc 100644 --- a/packages/utils/src/lib/profiler/profiler.int.test.ts +++ b/packages/utils/src/lib/profiler/profiler.int.test.ts @@ -7,7 +7,6 @@ describe('Profiler Integration', () => { let profiler: Profiler>; beforeEach(() => { - // Clear all performance entries before each test performance.clearMarks(); performance.clearMeasures(); @@ -19,7 +18,7 @@ describe('Profiler Integration', () => { async: { track: 'async-ops', color: 'secondary' }, sync: { track: 'sync-ops', color: 'tertiary' }, }, - enabled: true, // Explicitly enable for integration tests + enabled: true, }); }); @@ -33,18 +32,17 @@ describe('Profiler Integration', () => { expect(result).toBe(499_500); - // Verify performance entries were created const marks = performance.getEntriesByType('mark'); const measures = performance.getEntriesByType('measure'); - expect(marks).toEqual( + expect(marks).toStrictEqual( expect.arrayContaining([ expect.objectContaining({ name: 'test:sync-test:start' }), expect.objectContaining({ name: 'test:sync-test:end' }), ]), ); - expect(measures).toEqual( + expect(measures).toStrictEqual( expect.arrayContaining([ expect.objectContaining({ name: 'test:sync-test', @@ -56,25 +54,23 @@ describe('Profiler Integration', () => { it('should create complete performance timeline for async operation', async () => { const result = await profiler.measureAsync('async-test', async () => { - // Simulate async work await new Promise(resolve => setTimeout(resolve, 10)); return 'async-result'; }); expect(result).toBe('async-result'); - // Verify performance entries were created const marks = performance.getEntriesByType('mark'); const measures = performance.getEntriesByType('measure'); - expect(marks).toEqual( + expect(marks).toStrictEqual( expect.arrayContaining([ expect.objectContaining({ name: 'test:async-test:start' }), expect.objectContaining({ name: 'test:async-test:end' }), ]), ); - expect(measures).toEqual( + expect(measures).toStrictEqual( expect.arrayContaining([ expect.objectContaining({ name: 'test:async-test', @@ -93,10 +89,9 @@ describe('Profiler Integration', () => { const marks = performance.getEntriesByType('mark'); const measures = performance.getEntriesByType('measure'); - expect(marks).toHaveLength(4); // 2 for outer + 2 for inner - expect(measures).toHaveLength(2); // 1 for outer + 1 for inner + expect(marks).toHaveLength(4); + expect(measures).toHaveLength(2); - // Check all marks exist const markNames = marks.map(m => m.name); expect(markNames).toStrictEqual( expect.arrayContaining([ @@ -107,7 +102,6 @@ describe('Profiler Integration', () => { ]), ); - // Check all measures exist const measureNames = measures.map(m => m.name); expect(measureNames).toStrictEqual( expect.arrayContaining(['test:outer', 'test:inner']), @@ -125,7 +119,7 @@ describe('Profiler Integration', () => { }); const marks = performance.getEntriesByType('mark'); - expect(marks).toEqual( + expect(marks).toStrictEqual( expect.arrayContaining([ expect.objectContaining({ name: 'test-marker', @@ -146,7 +140,7 @@ describe('Profiler Integration', () => { }); it('should create proper DevTools payloads for tracks', () => { - profiler.measure('track-test', () => 'result', { + profiler.measure('track-test', (): string => 'result', { success: result => ({ properties: [['result', result]], tooltipText: 'Track test completed', @@ -154,7 +148,7 @@ describe('Profiler Integration', () => { }); const measures = performance.getEntriesByType('measure'); - expect(measures).toEqual( + expect(measures).toStrictEqual( expect.arrayContaining([ expect.objectContaining({ detail: { @@ -172,7 +166,6 @@ describe('Profiler Integration', () => { }); it('should merge track defaults with measurement options', () => { - // Use the sync track from our configuration profiler.measure('sync-op', () => 'sync-result', { success: result => ({ properties: [ @@ -183,14 +176,14 @@ describe('Profiler Integration', () => { }); const measures = performance.getEntriesByType('measure'); - expect(measures).toEqual( + expect(measures).toStrictEqual( expect.arrayContaining([ expect.objectContaining({ detail: { devtools: expect.objectContaining({ dataType: 'track-entry', - track: 'integration-tests', // default track - color: 'primary', // default color + track: 'integration-tests', + color: 'primary', properties: [ ['operation', 'sync'], ['result', 'sync-result'], @@ -212,7 +205,7 @@ describe('Profiler Integration', () => { }).toThrow(error); const measures = performance.getEntriesByType('measure'); - expect(measures).toEqual( + expect(measures).toStrictEqual( expect.arrayContaining([ expect.objectContaining({ detail: { @@ -239,7 +232,7 @@ describe('Profiler Integration', () => { }).toThrow(customError); const measures = performance.getEntriesByType('measure'); - expect(measures).toEqual( + expect(measures).toStrictEqual( expect.arrayContaining([ expect.objectContaining({ detail: { @@ -264,21 +257,17 @@ describe('Profiler Integration', () => { enabled: false, }); - // Test sync measurement const syncResult = disabledProfiler.measure('disabled-sync', () => 'sync'); expect(syncResult).toBe('sync'); - // Test async measurement const asyncResult = disabledProfiler.measureAsync( 'disabled-async', async () => 'async', ); await expect(asyncResult).resolves.toBe('async'); - // Test marker disabledProfiler.marker('disabled-marker'); - // Verify no performance entries were created expect(performance.getEntriesByType('mark')).toHaveLength(0); expect(performance.getEntriesByType('measure')).toHaveLength(0); }); diff --git a/packages/utils/src/lib/profiler/profiler.ts b/packages/utils/src/lib/profiler/profiler.ts index 491b9824b..cc0cf2ad6 100644 --- a/packages/utils/src/lib/profiler/profiler.ts +++ b/packages/utils/src/lib/profiler/profiler.ts @@ -1,6 +1,7 @@ import process from 'node:process'; import { isEnvVarEnabled } from '../env.js'; import { + type MeasureCtxOptions, type MeasureOptions, asOptions, markerPayload, @@ -8,30 +9,23 @@ import { setupTracks, } from '../user-timing-extensibility-api-utils.js'; import type { - ActionColorPayload, ActionTrackEntryPayload, DevToolsColor, EntryMeta, - TrackMeta, } from '../user-timing-extensibility-api.type.js'; import { PROFILER_ENABLED } from './constants.js'; -/** Default track configuration combining metadata and color options. */ -type DefaultTrackOptions = TrackMeta & ActionColorPayload; - /** * Configuration options for creating a Profiler instance. * * @template T - Record type defining available track names and their configurations */ type ProfilerMeasureOptions> = - DefaultTrackOptions & { + MeasureCtxOptions & { /** Custom track configurations that will be merged with default settings */ - tracks: Record>; + tracks?: Record>; /** Whether profiling should be enabled (defaults to CP_PROFILING env var) */ enabled?: boolean; - /** Prefix for all performance measurement names to avoid conflicts */ - prefix: string; }; /** @@ -51,38 +45,11 @@ export type ProfilerOptions> = * integration for Chrome DevTools Performance panel. It supports both synchronous and * asynchronous operations with customizable track visualization. * - * @example - * ```typescript - * const profiler = new Profiler({ - * prefix: 'api', - * track: 'backend-calls', - * trackGroup: 'api', - * color: 'secondary', - * tracks: { - * database: { track: 'database', color: 'tertiary' }, - * external: { track: 'external-apis', color: 'primary' } - * } - * }); - * - * // Measure synchronous operation - * const result = profiler.measure('fetch-user', () => api.getUser(id)); - * - * // Measure async operation - * const asyncResult = await profiler.measureAsync('save-data', - * () => api.saveData(data) - * ); - * - * // Add marker - * profiler.marker('cache-invalidated', { - * color: 'warning', - * tooltipText: 'Cache cleared due to stale data' - * }); - * ``` */ export class Profiler> { #enabled: boolean; private readonly defaults: ActionTrackEntryPayload; - readonly tracks: Record; + readonly tracks: Record | undefined; private readonly ctxOf: ReturnType; /** @@ -96,20 +63,6 @@ export class Profiler> { * @param options.color - Default color for track entries * @param options.enabled - Whether profiling is enabled (defaults to CP_PROFILING env var) * - * @example - * ```typescript - * const profiler = new Profiler({ - * prefix: 'api', - * track: 'backend-calls', - * trackGroup: 'api', - * color: 'secondary', - * enabled: true, - * tracks: { - * database: { track: 'database', color: 'tertiary' }, - * cache: { track: 'cache', color: 'primary' } - * } - * }); - * ``` */ constructor(options: ProfilerOptions) { const { tracks, prefix, enabled, ...defaults } = options; @@ -117,7 +70,9 @@ export class Profiler> { this.#enabled = enabled ?? isEnvVarEnabled(PROFILER_ENABLED); this.defaults = { ...defaults, dataType }; - this.tracks = setupTracks({ ...defaults, dataType }, tracks); + this.tracks = tracks + ? setupTracks({ ...defaults, dataType }, tracks) + : undefined; this.ctxOf = measureCtx({ ...defaults, dataType, @@ -157,13 +112,12 @@ export class Profiler> { * returns immediately without creating any performance entries. * * @param name - Unique name for the marker - * @param opt - Optional metadata and styling for the marker + * @param opt - Metadata and styling for the marker * @param opt.color - Color of the marker line (defaults to profiler default) * @param opt.tooltipText - Text shown on hover * @param opt.properties - Key-value pairs for detailed view * * @example - * ```typescript * profiler.marker('user-action-start', { * color: 'primary', * tooltipText: 'User clicked save button', @@ -172,7 +126,6 @@ export class Profiler> { * ['elementId', 'save-btn'] * ] * }); - * ``` */ marker(name: string, opt?: EntryMeta & { color: DevToolsColor }) { if (!this.#enabled) { @@ -201,26 +154,19 @@ export class Profiler> { * @template R - The return type of the work function * @param event - Name for this measurement event * @param work - Function to execute and measure - * @param options - Optional measurement configuration overrides + * @param options - Measurement configuration overrides * @returns The result of the work function * - * @example - * ```typescript - * const user = profiler.measure('fetch-user', () => { - * return api.getUser(userId); - * }, { - * success: (result) => ({ - * properties: [['userId', result.id], ['loadTime', Date.now()]] - * }) - * }); - * ``` */ - measure(event: string, work: () => R, options?: MeasureOptions): R { + measure(event: string, work: () => R, options?: MeasureOptions): R { if (!this.#enabled) { return work(); } - const { start, success, error } = this.ctxOf(event, options); + const { start, success, error } = this.ctxOf( + event, + options as MeasureOptions, + ); start(); try { const r = work(); @@ -242,34 +188,23 @@ export class Profiler> { * @template R - The resolved type of the work promise * @param event - Name for this measurement event * @param work - Function returning a promise to execute and measure - * @param options - Optional measurement configuration overrides + * @param options - Measurement configuration overrides * @returns Promise that resolves to the result of the work function * - * @example - * ```typescript - * const data = await profiler.measureAsync('save-form', async () => { - * const result = await api.saveForm(formData); - * return result; - * }, { - * success: (result) => ({ - * properties: [['recordsSaved', result.count]] - * }), - * error: (err) => ({ - * properties: [['errorType', err.name]] - * }) - * }); - * ``` */ async measureAsync( event: string, work: () => Promise, - options?: MeasureOptions, + options?: MeasureOptions, ): Promise { if (!this.#enabled) { return await work(); } - const { start, success, error } = this.ctxOf(event, options); + const { start, success, error } = this.ctxOf( + event, + options as MeasureOptions, + ); start(); try { const r = work(); diff --git a/packages/utils/src/lib/profiler/profiler.unit.test.ts b/packages/utils/src/lib/profiler/profiler.unit.test.ts index 0965139e7..a96beb162 100644 --- a/packages/utils/src/lib/profiler/profiler.unit.test.ts +++ b/packages/utils/src/lib/profiler/profiler.unit.test.ts @@ -1,3 +1,4 @@ +import { performance } from 'node:perf_hooks'; import { beforeEach, describe, expect, it, vi } from 'vitest'; import type { ActionTrackEntryPayload } from '../user-timing-extensibility-api.type.js'; import { Profiler } from './profiler.js'; @@ -6,34 +7,33 @@ describe('Profiler', () => { let profiler: Profiler>; beforeEach(() => { - // Environment variables are mocked in individual tests using vi.stubEnv + performance.clearMarks(); + performance.clearMeasures(); profiler = new Profiler({ - prefix: 'test', + prefix: 'cp', track: 'test-track', color: 'primary', tracks: {}, }); }); - it('should initialize with default enabled state from env', () => { + it('constructor should initialize with default enabled state from env', () => { vi.stubEnv('CP_PROFILING', 'true'); const profilerWithEnv = new Profiler({ - prefix: 'test', + prefix: 'cp', track: 'test-track', - color: 'primary', tracks: {}, }); expect(profilerWithEnv.isEnabled()).toBe(true); }); - it('should override enabled state from options', () => { + it('constructor should override enabled state from options', () => { vi.stubEnv('CP_PROFILING', 'false'); const profilerWithOverride = new Profiler({ - prefix: 'test', + prefix: 'cp', track: 'test-track', - color: 'primary', tracks: {}, enabled: true, }); @@ -41,34 +41,93 @@ describe('Profiler', () => { expect(profilerWithOverride.isEnabled()).toBe(true); }); - it('should setup tracks with defaults merged', () => { + it('constructor should use defaults for measure', () => { + const customProfiler = new Profiler({ + prefix: 'custom', + track: 'custom-track', + trackGroup: 'custom-group', + color: 'secondary', + }); + + customProfiler.setEnabled(true); + + const result = customProfiler.measure('test-operation', () => 'success'); + + expect(result).toBe('success'); + + const marks = performance.getEntriesByType('mark'); + const measures = performance.getEntriesByType('measure'); + + expect(marks).toStrictEqual( + expect.arrayContaining([ + expect.objectContaining({ + name: 'custom:test-operation:start', + detail: { + devtools: expect.objectContaining({ + dataType: 'track-entry', + track: 'custom-track', + trackGroup: 'custom-group', + color: 'secondary', + }), + }, + }), + expect.objectContaining({ + name: 'custom:test-operation:end', + detail: { + devtools: expect.objectContaining({ + dataType: 'track-entry', + track: 'custom-track', + trackGroup: 'custom-group', + color: 'secondary', + }), + }, + }), + ]), + ); + expect(measures).toStrictEqual([ + expect.objectContaining({ + name: 'custom:test-operation', + detail: { + devtools: expect.objectContaining({ + dataType: 'track-entry', + track: 'custom-track', + trackGroup: 'custom-group', + color: 'secondary', + }), + }, + }), + ]); + }); + + it('constructor should setup tracks with defaults merged', () => { const profilerWithTracks = new Profiler({ - prefix: 'test', + prefix: 'cp', track: 'default-track', trackGroup: 'default-group', color: 'primary', tracks: { custom: { track: 'custom-track', color: 'secondary' }, - partial: { color: 'tertiary' }, // partial override + partial: { color: 'tertiary' }, }, }); - expect(profilerWithTracks.tracks.custom).toEqual({ - track: 'custom-track', - trackGroup: 'default-group', - color: 'secondary', - dataType: 'track-entry', - }); - - expect(profilerWithTracks.tracks.partial).toEqual({ - track: 'default-track', // inherited from defaults - trackGroup: 'default-group', - color: 'tertiary', // overridden - dataType: 'track-entry', + expect(profilerWithTracks.tracks).toStrictEqual({ + custom: { + track: 'custom-track', + trackGroup: 'default-group', + color: 'secondary', + dataType: 'track-entry', + }, + partial: { + track: 'default-track', + trackGroup: 'default-group', + color: 'tertiary', + dataType: 'track-entry', + }, }); }); - it('should set and get enabled state', () => { + it('isEnabled should set and get enabled state', () => { expect(profiler.isEnabled()).toBe(false); profiler.setEnabled(true); @@ -78,7 +137,7 @@ describe('Profiler', () => { expect(profiler.isEnabled()).toBe(false); }); - it('should update environment variable', () => { + it('isEnabled should update environment variable', () => { profiler.setEnabled(true); expect(process.env.CP_PROFILING).toBe('true'); @@ -86,7 +145,7 @@ describe('Profiler', () => { expect(process.env.CP_PROFILING).toBe('false'); }); - it('should execute marker without error when enabled', () => { + it('marker should execute without error when enabled', () => { profiler.setEnabled(true); expect(() => { @@ -96,17 +155,35 @@ describe('Profiler', () => { properties: [['key', 'value']], }); }).not.toThrow(); + + const marks = performance.getEntriesByType('mark'); + expect(marks).toStrictEqual([ + expect.objectContaining({ + name: 'test-marker', + detail: { + devtools: expect.objectContaining({ + dataType: 'marker', + color: 'primary', + tooltipText: 'Test marker', + properties: [['key', 'value']], + }), + }, + }), + ]); }); - it('should execute marker without error when disabled', () => { + it('marker should execute without error when disabled', () => { profiler.setEnabled(false); expect(() => { profiler.marker('test-marker'); }).not.toThrow(); + + const marks = performance.getEntriesByType('mark'); + expect(marks).toHaveLength(0); }); - it('should execute work and return result when measure enabled', () => { + it('measure should execute work and return result when enabled', () => { profiler.setEnabled(true); const workFn = vi.fn(() => 'result'); @@ -114,19 +191,64 @@ describe('Profiler', () => { expect(result).toBe('result'); expect(workFn).toHaveBeenCalled(); + + const marks = performance.getEntriesByType('mark'); + const measures = performance.getEntriesByType('measure'); + + expect(marks).toStrictEqual( + expect.arrayContaining([ + expect.objectContaining({ + name: 'cp:test-event:start', + detail: { + devtools: expect.objectContaining({ + dataType: 'track-entry', + track: 'test-track', + color: 'primary', + }), + }, + }), + expect.objectContaining({ + name: 'cp:test-event:end', + detail: { + devtools: expect.objectContaining({ + dataType: 'track-entry', + track: 'test-track', + color: 'primary', + }), + }, + }), + ]), + ); + expect(measures).toStrictEqual([ + expect.objectContaining({ + name: 'cp:test-event', + detail: { + devtools: expect.objectContaining({ + dataType: 'track-entry', + track: 'test-track', + color: 'primary', + }), + }, + }), + ]); }); - it('should execute work directly when measure disabled', () => { + it('measure should execute work directly when disabled', () => { profiler.setEnabled(false); - const workFn = vi.fn(() => 'result'); const result = profiler.measure('test-event', workFn); expect(result).toBe('result'); expect(workFn).toHaveBeenCalled(); + + const marks = performance.getEntriesByType('mark'); + const measures = performance.getEntriesByType('measure'); + + expect(marks).toHaveLength(0); + expect(measures).toHaveLength(0); }); - it('should propagate errors when measure enabled', () => { + it('measure should propagate errors when enabled', () => { profiler.setEnabled(true); const error = new Error('Test error'); @@ -138,7 +260,7 @@ describe('Profiler', () => { expect(workFn).toHaveBeenCalled(); }); - it('should propagate errors when measure disabled', () => { + it('measure should propagate errors when disabled', () => { profiler.setEnabled(false); const error = new Error('Test error'); @@ -150,7 +272,7 @@ describe('Profiler', () => { expect(workFn).toHaveBeenCalled(); }); - it('should handle async operations correctly when enabled', async () => { + it('measureAsync should handle async operations correctly when enabled', async () => { profiler.setEnabled(true); const workFn = vi.fn(async () => { @@ -162,9 +284,49 @@ describe('Profiler', () => { expect(result).toBe('async-result'); expect(workFn).toHaveBeenCalled(); + + const marks = performance.getEntriesByType('mark'); + const measures = performance.getEntriesByType('measure'); + + expect(marks).toStrictEqual( + expect.arrayContaining([ + expect.objectContaining({ + name: 'cp:test-async-event:start', + detail: { + devtools: expect.objectContaining({ + dataType: 'track-entry', + track: 'test-track', + color: 'primary', + }), + }, + }), + expect.objectContaining({ + name: 'cp:test-async-event:end', + detail: { + devtools: expect.objectContaining({ + dataType: 'track-entry', + track: 'test-track', + color: 'primary', + }), + }, + }), + ]), + ); + expect(measures).toStrictEqual([ + expect.objectContaining({ + name: 'cp:test-async-event', + detail: { + devtools: expect.objectContaining({ + dataType: 'track-entry', + track: 'test-track', + color: 'primary', + }), + }, + }), + ]); }); - it('should execute async work directly when disabled', async () => { + it('measureAsync should execute async work directly when disabled', async () => { profiler.setEnabled(false); const workFn = vi.fn(async () => { @@ -176,9 +338,15 @@ describe('Profiler', () => { expect(result).toBe('async-result'); expect(workFn).toHaveBeenCalled(); + + const marks = performance.getEntriesByType('mark'); + const measures = performance.getEntriesByType('measure'); + + expect(marks).toHaveLength(0); + expect(measures).toHaveLength(0); }); - it('should propagate async errors when enabled', async () => { + it('measureAsync should propagate async errors when enabled', async () => { profiler.setEnabled(true); const error = new Error('Async test error'); @@ -193,7 +361,7 @@ describe('Profiler', () => { expect(workFn).toHaveBeenCalled(); }); - it('should propagate async errors when disabled', async () => { + it('measureAsync should propagate async errors when disabled', async () => { profiler.setEnabled(false); const error = new Error('Async test error'); diff --git a/packages/utils/src/lib/user-timing-extensibility-api-utils.ts b/packages/utils/src/lib/user-timing-extensibility-api-utils.ts index 6a5cb7484..cf974af0f 100644 --- a/packages/utils/src/lib/user-timing-extensibility-api-utils.ts +++ b/packages/utils/src/lib/user-timing-extensibility-api-utils.ts @@ -369,19 +369,19 @@ function toMarkMeasureOpts(devtools: T) { * Options for customizing measurement behavior and callbacks. * Extends partial ActionTrackEntryPayload to allow overriding default track properties. */ -export type MeasureOptions = Partial & { +export type MeasureOptions = Partial & { /** * Callback invoked when measurement completes successfully. * @param result - The successful result value * @returns Additional DevTools properties to merge for success state */ - success?: (result: unknown) => Partial; + success?: (result: T) => EntryMeta; /** * Callback invoked when measurement fails with an error. * @param error - The error that occurred * @returns Additional DevTools properties to merge for error state */ - error?: (error: unknown) => Partial; + error?: (error: unknown) => EntryMeta; }; /** @@ -473,7 +473,7 @@ export type MeasureCtxOptions = ActionTrackEntryPayload & { * - `error(error)`: Completes failed measurement with error metadata */ -export function measureCtx(cfg: MeasureCtxOptions) { +export function measureCtx(cfg: MeasureCtxOptions) { const { prefix, error: globalErr, ...defaults } = cfg; return (event: string, opt?: MeasureOptions) => { @@ -488,7 +488,7 @@ export function measureCtx(cfg: MeasureCtxOptions) { return { start: () => performance.mark(s, toMarkMeasureOpts(merged)), - success: (r: unknown) => { + success: (r: T) => { const successPayload = mergeDevtoolsPayload(merged, success?.(r) ?? {}); performance.mark(e, toMarkMeasureOpts(successPayload)); performance.measure(m, { From 2cec4245871decc461d6b58dc80204c684aa57c7 Mon Sep 17 00:00:00 2001 From: John Doe Date: Wed, 14 Jan 2026 20:47:21 +0100 Subject: [PATCH 3/4] refactor: fix unit tests --- .../src/lib/profiler/profiler.unit.test.ts | 78 +++++++++++++++++-- .../user-timing-extensibility-api-utils.ts | 7 +- .../src/lib/utils/perf-hooks.mock.ts | 31 +++++--- 3 files changed, 92 insertions(+), 24 deletions(-) diff --git a/packages/utils/src/lib/profiler/profiler.unit.test.ts b/packages/utils/src/lib/profiler/profiler.unit.test.ts index a96beb162..0705839c8 100644 --- a/packages/utils/src/lib/profiler/profiler.unit.test.ts +++ b/packages/utils/src/lib/profiler/profiler.unit.test.ts @@ -9,11 +9,11 @@ describe('Profiler', () => { beforeEach(() => { performance.clearMarks(); performance.clearMeasures(); + delete process.env.CP_PROFILING; profiler = new Profiler({ prefix: 'cp', track: 'test-track', - color: 'primary', tracks: {}, }); }); @@ -183,11 +183,78 @@ describe('Profiler', () => { expect(marks).toHaveLength(0); }); + it('marker should execute without error when enabled with default color', () => { + performance.clearMarks(); + + const profilerWithColor = new Profiler({ + prefix: 'cp', + track: 'test-track', + color: 'primary', + tracks: {}, + }); + profilerWithColor.setEnabled(true); + + expect(() => { + profilerWithColor.marker('test-marker-default-color', { + tooltipText: 'Test marker with default color', + }); + }).not.toThrow(); + + const marks = performance.getEntriesByType('mark'); + expect(marks).toStrictEqual([ + expect.objectContaining({ + name: 'test-marker-default-color', + detail: { + devtools: expect.objectContaining({ + dataType: 'marker', + color: 'primary', // Should use default color + tooltipText: 'Test marker with default color', + }), + }, + }), + ]); + }); + + it('marker should execute without error when enabled with no default color', () => { + const profilerNoColor = new Profiler({ + prefix: 'cp', + track: 'test-track', + tracks: {}, + }); + profilerNoColor.setEnabled(true); + + expect(() => { + profilerNoColor.marker('test-marker-no-color', { + color: 'secondary', + tooltipText: 'Test marker without default color', + properties: [['key', 'value']], + }); + }).not.toThrow(); + + const marks = performance.getEntriesByType('mark'); + expect(marks).toStrictEqual([ + expect.objectContaining({ + name: 'test-marker-no-color', + detail: { + devtools: expect.objectContaining({ + dataType: 'marker', + color: 'secondary', + tooltipText: 'Test marker without default color', + properties: [['key', 'value']], + }), + }, + }), + ]); + }); + it('measure should execute work and return result when enabled', () => { + performance.clearMarks(); + performance.clearMeasures(); + profiler.setEnabled(true); const workFn = vi.fn(() => 'result'); - const result = profiler.measure('test-event', workFn); + const result = profiler.measure('test-event', workFn, { color: 'primary' }); expect(result).toBe('result'); expect(workFn).toHaveBeenCalled(); @@ -203,7 +270,6 @@ describe('Profiler', () => { devtools: expect.objectContaining({ dataType: 'track-entry', track: 'test-track', - color: 'primary', }), }, }), @@ -213,7 +279,6 @@ describe('Profiler', () => { devtools: expect.objectContaining({ dataType: 'track-entry', track: 'test-track', - color: 'primary', }), }, }), @@ -226,7 +291,6 @@ describe('Profiler', () => { devtools: expect.objectContaining({ dataType: 'track-entry', track: 'test-track', - color: 'primary', }), }, }), @@ -280,7 +344,9 @@ describe('Profiler', () => { return 'async-result'; }); - const result = await profiler.measureAsync('test-async-event', workFn); + const result = await profiler.measureAsync('test-async-event', workFn, { + color: 'primary', + }); expect(result).toBe('async-result'); expect(workFn).toHaveBeenCalled(); diff --git a/packages/utils/src/lib/user-timing-extensibility-api-utils.ts b/packages/utils/src/lib/user-timing-extensibility-api-utils.ts index cf974af0f..423909cf1 100644 --- a/packages/utils/src/lib/user-timing-extensibility-api-utils.ts +++ b/packages/utils/src/lib/user-timing-extensibility-api-utils.ts @@ -356,12 +356,7 @@ export function setupTracks< * @returns The mark options without dataType, tooltipText and properties. */ function toMarkMeasureOpts(devtools: T) { - const { - dataType: _, - tooltipText: __, - properties: ___, - ...markDevtools - } = devtools; + const { tooltipText: _, properties: __, ...markDevtools } = devtools; return { detail: { devtools: markDevtools } }; } diff --git a/testing/test-utils/src/lib/utils/perf-hooks.mock.ts b/testing/test-utils/src/lib/utils/perf-hooks.mock.ts index b22e88bd5..7bcea1cb5 100644 --- a/testing/test-utils/src/lib/utils/perf-hooks.mock.ts +++ b/testing/test-utils/src/lib/utils/perf-hooks.mock.ts @@ -33,27 +33,34 @@ export const createPerformanceMock = (timeOrigin = 500_000) => ({ now: vi.fn(() => nowMs), - mark: vi.fn((name: string) => { + mark: vi.fn((name: string, options?: { detail?: unknown }) => { entries.push({ name, entryType: 'mark', startTime: nowMs, duration: 0, + ...(options?.detail ? { detail: options.detail } : {}), } as PerformanceEntry); MockPerformanceObserver.globalEntries = entries; }), - measure: vi.fn((name: string, startMark?: string, endMark?: string) => { - const entry = { - name, - entryType: 'measure', - startTime: nowMs, - duration: nowMs, - } as PerformanceEntry; - entries.push(entry); - MockPerformanceObserver.globalEntries = entries; - triggerObservers([entry]); - }), + measure: vi.fn( + ( + name: string, + options?: { start?: string; end?: string; detail?: unknown }, + ) => { + const entry = { + name, + entryType: 'measure', + startTime: nowMs, + duration: nowMs, + ...(options?.detail ? { detail: options.detail } : {}), + } as PerformanceEntry; + entries.push(entry); + MockPerformanceObserver.globalEntries = entries; + triggerObservers([entry]); + }, + ), getEntries: vi.fn(() => entries.slice()), From 64457f354348944fac242d220040450e6195176b Mon Sep 17 00:00:00 2001 From: Michael Hladky Date: Wed, 14 Jan 2026 23:06:07 +0100 Subject: [PATCH 4/4] refactor: wip --- packages/utils/src/lib/profiler/profiler.ts | 10 +++- .../src/lib/profiler/profiler.unit.test.ts | 58 +++++++------------ 2 files changed, 27 insertions(+), 41 deletions(-) diff --git a/packages/utils/src/lib/profiler/profiler.ts b/packages/utils/src/lib/profiler/profiler.ts index cc0cf2ad6..d56ab5718 100644 --- a/packages/utils/src/lib/profiler/profiler.ts +++ b/packages/utils/src/lib/profiler/profiler.ts @@ -35,8 +35,12 @@ type ProfilerMeasureOptions> = * * @template T - Record type defining available track names and their configurations */ -export type ProfilerOptions> = - ProfilerMeasureOptions; +export type ProfilerOptions< + T extends Record = Record< + string, + ActionTrackEntryPayload + >, +> = ProfilerMeasureOptions; /** * Performance profiler that creates structured timing measurements with DevTools visualization. @@ -127,7 +131,7 @@ export class Profiler> { * ] * }); */ - marker(name: string, opt?: EntryMeta & { color: DevToolsColor }) { + marker(name: string, opt?: EntryMeta & { color?: DevToolsColor }) { if (!this.#enabled) { return; } diff --git a/packages/utils/src/lib/profiler/profiler.unit.test.ts b/packages/utils/src/lib/profiler/profiler.unit.test.ts index 0705839c8..0e285deb2 100644 --- a/packages/utils/src/lib/profiler/profiler.unit.test.ts +++ b/packages/utils/src/lib/profiler/profiler.unit.test.ts @@ -1,30 +1,30 @@ import { performance } from 'node:perf_hooks'; import { beforeEach, describe, expect, it, vi } from 'vitest'; import type { ActionTrackEntryPayload } from '../user-timing-extensibility-api.type.js'; -import { Profiler } from './profiler.js'; +import { Profiler, type ProfilerOptions } from './profiler.js'; describe('Profiler', () => { + const getProfiler = (overrides?: Partial) => + new Profiler({ + prefix: 'cp', + track: 'test-track', + ...overrides, + }); + let profiler: Profiler>; beforeEach(() => { performance.clearMarks(); performance.clearMeasures(); + // eslint-disable-next-line functional/immutable-data delete process.env.CP_PROFILING; - profiler = new Profiler({ - prefix: 'cp', - track: 'test-track', - tracks: {}, - }); + profiler = getProfiler(); }); it('constructor should initialize with default enabled state from env', () => { vi.stubEnv('CP_PROFILING', 'true'); - const profilerWithEnv = new Profiler({ - prefix: 'cp', - track: 'test-track', - tracks: {}, - }); + const profilerWithEnv = getProfiler(); expect(profilerWithEnv.isEnabled()).toBe(true); }); @@ -34,7 +34,6 @@ describe('Profiler', () => { const profilerWithOverride = new Profiler({ prefix: 'cp', track: 'test-track', - tracks: {}, enabled: true, }); @@ -42,12 +41,7 @@ describe('Profiler', () => { }); it('constructor should use defaults for measure', () => { - const customProfiler = new Profiler({ - prefix: 'custom', - track: 'custom-track', - trackGroup: 'custom-group', - color: 'secondary', - }); + const customProfiler = getProfiler({ color: 'secondary' }); customProfiler.setEnabled(true); @@ -61,23 +55,21 @@ describe('Profiler', () => { expect(marks).toStrictEqual( expect.arrayContaining([ expect.objectContaining({ - name: 'custom:test-operation:start', + name: 'cp:test-operation:start', detail: { devtools: expect.objectContaining({ dataType: 'track-entry', - track: 'custom-track', - trackGroup: 'custom-group', + track: 'test-track', color: 'secondary', }), }, }), expect.objectContaining({ - name: 'custom:test-operation:end', + name: 'cp:test-operation:end', detail: { devtools: expect.objectContaining({ dataType: 'track-entry', - track: 'custom-track', - trackGroup: 'custom-group', + track: 'test-track', color: 'secondary', }), }, @@ -86,12 +78,11 @@ describe('Profiler', () => { ); expect(measures).toStrictEqual([ expect.objectContaining({ - name: 'custom:test-operation', + name: 'cp:test-operation', detail: { devtools: expect.objectContaining({ dataType: 'track-entry', - track: 'custom-track', - trackGroup: 'custom-group', + track: 'test-track', color: 'secondary', }), }, @@ -186,12 +177,7 @@ describe('Profiler', () => { it('marker should execute without error when enabled with default color', () => { performance.clearMarks(); - const profilerWithColor = new Profiler({ - prefix: 'cp', - track: 'test-track', - color: 'primary', - tracks: {}, - }); + const profilerWithColor = getProfiler({ color: 'primary' }); profilerWithColor.setEnabled(true); expect(() => { @@ -216,11 +202,7 @@ describe('Profiler', () => { }); it('marker should execute without error when enabled with no default color', () => { - const profilerNoColor = new Profiler({ - prefix: 'cp', - track: 'test-track', - tracks: {}, - }); + const profilerNoColor = getProfiler(); profilerNoColor.setEnabled(true); expect(() => {