diff --git a/packages/utils/src/lib/profiler/trace-file-utils.ts b/packages/utils/src/lib/profiler/trace-file-utils.ts index 1061062d3..b5deed7bc 100644 --- a/packages/utils/src/lib/profiler/trace-file-utils.ts +++ b/packages/utils/src/lib/profiler/trace-file-utils.ts @@ -335,17 +335,27 @@ function processDetail( return target; } +function encodeDetailToString( + target: T, +): T & { detail?: string } { + return processDetail(target, (detail: string | object) => + typeof detail === 'object' ? JSON.stringify(detail) : detail, + ) as T & { detail?: string }; +} + /** * Decodes a JSON string detail property back to its original object form. * @param target - Object containing a detail property as a JSON string * @returns UserTimingDetail with the detail property parsed from JSON */ -export function decodeDetail(target: { detail: string }): UserTimingDetail { +export function decodeDetail( + target: T, +): T { return processDetail(target, detail => typeof detail === 'string' ? (JSON.parse(detail) as string | object) : detail, - ) as UserTimingDetail; + ); } /** @@ -353,14 +363,14 @@ export function decodeDetail(target: { detail: string }): UserTimingDetail { * @param target - UserTimingDetail object with detail property to encode * @returns UserTimingDetail with object details converted to JSON strings */ -export function encodeDetail(target: UserTimingDetail): UserTimingDetail { +export function encodeDetail( + target: T, +): T { return processDetail( - target as UserTimingDetail & { detail?: unknown }, + target as T & { detail?: unknown }, (detail: string | object) => - typeof detail === 'object' - ? JSON.stringify(detail as UserTimingDetail) - : detail, - ) as UserTimingDetail; + typeof detail === 'object' ? JSON.stringify(detail) : detail, + ); } /** @@ -406,13 +416,13 @@ export function encodeTraceEvent({ return rest as TraceEventRaw; } - const processedArgs = encodeDetail(args as UserTimingDetail); + const processedArgs = encodeDetailToString(args as { detail?: unknown }); if ('data' in args && args.data && typeof args.data === 'object') { const result: TraceEventRaw = { ...rest, args: { ...processedArgs, - data: encodeDetail(args.data as UserTimingDetail), + data: encodeDetailToString(args.data as { detail?: unknown }), }, }; return result; diff --git a/packages/utils/src/lib/profiler/trace-file-utils.unit.test.ts b/packages/utils/src/lib/profiler/trace-file-utils.unit.test.ts index aa21887af..2ae974c81 100644 --- a/packages/utils/src/lib/profiler/trace-file-utils.unit.test.ts +++ b/packages/utils/src/lib/profiler/trace-file-utils.unit.test.ts @@ -623,16 +623,18 @@ describe('getTraceMetadata', () => { describe('decodeDetail', () => { it('should decode string detail back to object', () => { - const input = { detail: '{"key": "value"}' }; + const input = { + detail: '{"devtools":{"dataType":"marker","color":"primary"}}', + }; const result = decodeDetail(input); expect(result).toStrictEqual({ - detail: { key: 'value' }, + detail: { devtools: { dataType: 'marker', color: 'primary' } }, }); }); it('should return object detail unchanged', () => { - const input = { detail: { key: 'value' } }; + const input = { detail: { devtools: { dataType: 'marker' as const } } }; const result = decodeDetail(input); expect(result).toStrictEqual(input); @@ -655,11 +657,11 @@ describe('decodeDetail', () => { describe('encodeDetail', () => { it('should encode object detail to JSON string', () => { - const input = { detail: { key: 'value' } }; + const input = { detail: { devtools: { dataType: 'marker' as const } } }; const result = encodeDetail(input); expect(result).toStrictEqual({ - detail: '{"key":"value"}', + detail: '{"devtools":{"dataType":"marker"}}', }); }); @@ -695,8 +697,11 @@ describe('decodeTraceEvent', () => { tid: 456, ts: 1000, args: { - detail: '{"custom": "data"}', - data: { detail: '{"nested": "value"}' }, + detail: '{"devtools":{"dataType":"marker","color":"primary"}}', + data: { + detail: + '{"devtools":{"dataType":"track-entry","track":"test-track"}}', + }, }, }; @@ -710,8 +715,14 @@ describe('decodeTraceEvent', () => { tid: 456, ts: 1000, args: { - detail: { custom: 'data' }, - data: { detail: { nested: 'value' } }, + detail: { + devtools: { dataType: 'marker' as const, color: 'primary' as const }, + }, + data: { + detail: { + devtools: { dataType: 'track-entry' as const, track: 'test-track' }, + }, + }, }, }); }); @@ -724,6 +735,7 @@ describe('decodeTraceEvent', () => { pid: 123, tid: 456, ts: 1000, + args: {}, }; const result = decodeTraceEvent(rawEvent); @@ -735,6 +747,7 @@ describe('decodeTraceEvent', () => { pid: 123, tid: 456, ts: 1000, + args: {}, }); }); @@ -747,7 +760,7 @@ describe('decodeTraceEvent', () => { tid: 456, ts: 1000, args: { - detail: '{"custom": "data"}', + detail: '{"devtools":{"dataType":"marker","color":"primary"}}', }, }; @@ -761,7 +774,9 @@ describe('decodeTraceEvent', () => { tid: 456, ts: 1000, args: { - detail: { custom: 'data' }, + detail: { + devtools: { dataType: 'marker' as const, color: 'primary' as const }, + }, }, }); }); @@ -777,8 +792,14 @@ describe('encodeTraceEvent', () => { tid: 456, ts: 1000, args: { - detail: { custom: 'data' }, - data: { detail: { nested: 'value' } }, + detail: { + devtools: { dataType: 'marker' as const, color: 'primary' as const }, + }, + data: { + detail: { + devtools: { dataType: 'track-entry' as const, track: 'test-track' }, + }, + }, }, }; @@ -792,8 +813,11 @@ describe('encodeTraceEvent', () => { tid: 456, ts: 1000, args: { - detail: '{"custom":"data"}', - data: { detail: '{"nested":"value"}' }, + detail: '{"devtools":{"dataType":"marker","color":"primary"}}', + data: { + detail: + '{"devtools":{"dataType":"track-entry","track":"test-track"}}', + }, }, }); }); @@ -806,6 +830,7 @@ describe('encodeTraceEvent', () => { pid: 123, tid: 456, ts: 1000, + args: {}, }; const result = encodeTraceEvent(event); @@ -817,6 +842,7 @@ describe('encodeTraceEvent', () => { pid: 123, tid: 456, ts: 1000, + args: {}, }); }); @@ -829,7 +855,9 @@ describe('encodeTraceEvent', () => { tid: 456, ts: 1000, args: { - detail: { custom: 'data' }, + detail: { + devtools: { dataType: 'marker' as const, color: 'primary' as const }, + }, }, }; @@ -843,7 +871,7 @@ describe('encodeTraceEvent', () => { tid: 456, ts: 1000, args: { - detail: '{"custom":"data"}', + detail: '{"devtools":{"dataType":"marker","color":"primary"}}', }, }); }); diff --git a/packages/utils/src/lib/reports/load-report.unit.test.ts b/packages/utils/src/lib/reports/load-report.unit.test.ts index acafeb61c..0fa3c3b92 100644 --- a/packages/utils/src/lib/reports/load-report.unit.test.ts +++ b/packages/utils/src/lib/reports/load-report.unit.test.ts @@ -19,6 +19,7 @@ describe('loadReport', () => { outputDir: MEMFS_VOLUME, filename: 'report', format: 'json', + skipReports: false, }), ).resolves.toEqual(reportMock()); }); @@ -38,6 +39,7 @@ describe('loadReport', () => { outputDir: MEMFS_VOLUME, format: 'md', filename: 'report', + skipReports: false, }), ).resolves.toBe('test-42'); }); @@ -58,6 +60,7 @@ describe('loadReport', () => { outputDir: MEMFS_VOLUME, filename: 'report', format: 'json', + skipReports: false, }), ).rejects.toThrow('slug has to follow the pattern'); }); diff --git a/packages/utils/src/lib/user-timing-extensibility-api.type.ts b/packages/utils/src/lib/user-timing-extensibility-api.type.ts index 9c0ed19c7..ed93242ad 100644 --- a/packages/utils/src/lib/user-timing-extensibility-api.type.ts +++ b/packages/utils/src/lib/user-timing-extensibility-api.type.ts @@ -135,6 +135,12 @@ export type WithDevToolsPayload = { devtools?: T; }; +/** + * Combined detail payload type for performance entries with DevTools support. + */ +export type DetailPayloadWithDevtools = WithDevToolsPayload< + TrackEntryPayload | MarkerPayload +>; /** * Extended MarkOptions that supports DevTools payload in detail. * @example diff --git a/packages/utils/src/perf-hooks.d.ts b/packages/utils/src/perf-hooks.d.ts new file mode 100644 index 000000000..fe0b7c35b --- /dev/null +++ b/packages/utils/src/perf-hooks.d.ts @@ -0,0 +1,176 @@ +import type { + EventLoopUtilityFunction, + MeasureOptions as OriginalMeasureOptions, + Performance as OriginalPerformance, + PerformanceEntry as OriginalPerformanceEntry, + PerformanceMark as OriginalPerformanceMark, + PerformanceMeasure as OriginalPerformanceMeasure, + PerformanceObserverEntryList as OriginalPerformanceObserverEntryList, + PerformanceNodeTiming, + PerformanceResourceTiming, + TimerifyOptions, + performance, +} from 'node:perf_hooks'; +import type { DetailPayloadWithDevtools } from './lib/user-timing-extensibility-api.type'; + +export type EntryType = 'mark' | 'measure'; + +export type DOMHighResTimeStamp = number; + +/* == Internal Overrides Start == */ +// This is needed to get pickedup by the IDE. We cand directly do this in the exported definitions +interface PerformanceEntryExtended { + name: string; + entryType: string; + startTime: number; + duration: number; + readonly detail?: DetailPayloadWithDevtools; + toJSON(): any; +} + +interface MarkEntryExtended extends OriginalPerformanceMark { + readonly entryType: 'mark'; +} + +interface MeasureEntryExtended extends OriginalPerformanceMeasure { + readonly entryType: 'measure'; +} + +interface PerformanceMarkExtended extends PerformanceEntryExtended { + readonly entryType: 'mark'; + readonly duration: 0; +} + +interface PerformanceMeasureExtended extends PerformanceEntryExtended { + readonly entryType: 'measure'; +} + +interface PerformanceObserverEntryListExtended + extends OriginalPerformanceObserverEntryList { + getEntries(): PerformanceEntryExtended[]; + getEntriesByName(name: string, type?: EntryType): PerformanceEntryExtended[]; + getEntriesByType(type: EntryType): PerformanceEntryExtended[]; +} + +interface PerformanceMarkOptionsExtended { + detail?: DetailPayloadWithDevtools; + startTime?: DOMHighResTimeStamp; +} + +interface PerformanceMeasureOptionsExtended { + detail?: DetailPayloadWithDevtools; + start?: string | number; + end?: string | number; + duration?: number; +} +interface PerformanceEntryListExtended { + getEntries(): PerformanceEntryExtended[]; + getEntriesByName(name: string, type?: EntryType): PerformanceEntryExtended[]; + getEntriesByType(type: EntryType): PerformanceEntryExtended[]; +} + +declare class PerformanceExtended { + clearMarks(name?: string): void; + clearMeasures(name?: string): void; + clearResourceTimings(name?: string): void; + eventLoopUtilization: EventLoopUtilityFunction; + + mark( + name: string, + options?: PerformanceMarkOptionsExtended, + ): PerformanceMarkExtended; + + markResourceTiming( + timingInfo: object, + requestedUrl: string, + initiatorType: string, + global: object, + cacheMode: '' | 'local', + bodyInfo: object, + responseStatus: number, + deliveryType?: string, + ): PerformanceResourceTiming; + + measure( + name: string, + startMarkOrOptions?: string | PerformanceMeasureOptionsExtended, + endMark?: string, + ): PerformanceMeasureExtended; + + readonly nodeTiming: PerformanceNodeTiming; + now(): number; + setResourceTimingBufferSize(maxSize: number): void; + readonly timeOrigin: number; + timerify any>( + fn: T, + options?: TimerifyOptions, + ): T; + toJSON(): any; + + getEntriesByType: (type: EntryType) => PerformanceEntryExtended[]; + getEntries(): PerformanceEntryExtended[]; + getEntriesByName(name: string, type?: EntryType): PerformanceEntryExtended[]; +} +/* == Internal Overrides End == */ + +declare module 'perf_hooks' { + export interface PerformanceEntry extends OriginalPerformanceEntry { + readonly detail?: DetailPayloadWithDevtools; + } + + export interface PerformanceEntryList extends PerformanceEntryListExtended {} + + export interface MarkEntry extends PerformanceMark, MarkEntryExtended {} + + export interface MeasureEntry + extends PerformanceMeasure, + MeasureEntryExtended {} + + export interface PerformanceMark extends PerformanceMarkExtended {} + + export interface PerformanceMeasure extends PerformanceMeasureExtended {} + + export interface MarkOptions extends PerformanceMarkOptionsExtended {} + + export interface MeasureOptions extends PerformanceMeasureOptionsExtended {} + + export type PerformanceMarkOptions = PerformanceMarkOptionsExtended; + + export type PerformanceMeasureOptions = PerformanceMeasureOptionsExtended; + + export interface PerformanceObserverEntryList + extends PerformanceObserverEntryListExtended {} + + export const performance: PerformanceExtended; +} + +declare module 'node:perf_hooks' { + export interface PerformanceEntry extends OriginalPerformanceEntry { + readonly detail?: DetailPayloadWithDevtools; + } + + export interface PerformanceEntryList extends PerformanceEntryListExtended {} + + export interface MarkEntry extends PerformanceMark, MarkEntryExtended {} + + export interface MeasureEntry + extends PerformanceMeasure, + MeasureEntryExtended {} + + export interface PerformanceMark extends PerformanceMarkExtended {} + + export interface PerformanceMeasure extends PerformanceMeasureExtended {} + + export interface MarkOptions extends PerformanceMarkOptionsExtended {} + + export interface MeasureOptions extends PerformanceMeasureOptionsExtended {} + + export type PerformanceMarkOptions = PerformanceMarkOptionsExtended; + + export type PerformanceMeasureOptions = PerformanceMeasureOptionsExtended; + + export interface PerformanceObserverEntryList + extends PerformanceObserverEntryListExtended {} + + export const performance: PerformanceExtended; +} diff --git a/packages/utils/src/perf-hooks.type.test.ts b/packages/utils/src/perf-hooks.type.test.ts new file mode 100644 index 000000000..50b5cd9c7 --- /dev/null +++ b/packages/utils/src/perf-hooks.type.test.ts @@ -0,0 +1,218 @@ +import { + type PerformanceEntry, + type PerformanceMarkOptions, + type PerformanceMeasureOptions, + PerformanceObserver, + performance, +} from 'perf_hooks'; +import type { DetailPayloadWithDevtools } from './lib/user-timing-extensibility-api.type'; + +// interfaces: PerformanceMarkOptions should be type safe +// Valid complete example +({ + startTime: 0, + detail: { + devtools: { + color: 'error', + track: 'test-track', + trackGroup: 'test-trackGroup', + properties: [['Key', '42']], + tooltipText: 'test-tooltipText', + }, + }, +}) satisfies PerformanceMarkOptions; +// Invalid examples +({ + startTime: 0, + detail: { + devtools: { + // @ts-expect-error - dataType should be marker | track + dataType: 'markerr', + // @ts-expect-error - color should be DevToolsColor + color: 'other', + // @ts-expect-error - properties should be an array of [string, string] + properties: { wrong: 'shape' }, + }, + }, +}) satisfies PerformanceMarkOptions; + +// interfaces: PerformanceMeasureOptions should be type safe +// Valid complete example +({ + start: 'start-mark', + end: 'end-mark', + detail: { + devtools: { + dataType: 'track-entry', + track: 'test-track', + color: 'primary', + }, + }, +}) satisfies PerformanceMeasureOptionsWithDevtools; +// Invalid examples +({ + start: 'start-mark', + end: 'end-mark', + detail: { + devtools: { + // @ts-expect-error - dataType should be track-entry | marker + dataType: 'markerr', + track: 'test-track', + color: 'primary', + }, + }, +}) satisfies PerformanceMeasureOptionsWithDevtools; + +// interfaces: PerformanceEntry should be type safe (todo) +// Valid complete example +({ + name: 'test-entry', + entryType: 'mark', + startTime: 0, + duration: 0, + toJSON: () => ({}), + detail: { + devtools: { + dataType: 'marker', + color: 'primary', + }, + }, +}) satisfies PerformanceEntryWithDevtools; +// Invalid examples +({ + name: 'test-entry', + entryType: 'mark', + startTime: 0, + duration: 0, + toJSON: () => ({}), + detail: { + devtools: { + // @ts-expect-error - dataType should be valid + dataType: 'invalid-type', + // @ts-expect-error - color should be DevToolsColor + color: 'invalid-color', + // @ts-expect-error - properties should be an array of [string, string] + properties: { wrong: 'shape' }, + }, + }, +}) satisfies PerformanceEntryWithDevtools; + +// interfaces: performance.getEntriesByType returns extended entries +const entries = performance.getEntriesByType('mark'); + +entries.forEach(e => { + e.detail?.devtools; +}); + +// API: performance.mark should be type safe +// Valid complete example +performance.mark('name', { + detail: { + devtools: { + dataType: 'marker', + color: 'error', + }, + }, +}); +// Invalid examples +performance.mark('name', { + detail: { + devtools: { + // @ts-expect-error - dataType should be marker | track + dataType: 'markerrr', + // @ts-expect-error - color should be DevToolsColor + color: 'invalid-color', + // @ts-expect-error - properties should be an array of [string, string] + properties: 'invalid-properties', + }, + }, +}); + +// API: performance.measure should be type safe +// Create marks for measurement +performance.mark('start-mark'); +performance.mark('end-mark'); + +// Valid examples +performance.measure('measure-name', 'start-mark', 'end-mark'); +performance.measure('measure-name', { + start: 'start-mark', + end: 'end-mark', + detail: { + devtools: { + dataType: 'track-entry', + track: 'test-track', + color: 'primary', + }, + }, +}); +// Invalid examples +performance.measure('measure-name', { + start: 'start-mark', + end: 'end-mark', + detail: { + devtools: { + // @ts-expect-error - dataType should be track-entry | marker + dataType: 'invalid-type', + // @ts-expect-error - color should be DevToolsColor + color: 'invalid-color', + }, + }, +}); + +// API: performance.getEntriesByType should be type safe +// Valid examples +performance.getEntriesByType('mark').forEach(e => { + e.detail?.devtools?.dataType === 'marker'; +}); +// Invalid examples +performance.getEntriesByType('mark').forEach(e => { + // @ts-expect-error - dataType should be valid + e.detail?.devtools?.dataType === 'markerr'; +}); + +// API: performance.getEntriesByName should be type safe +// Valid examples +performance.getEntriesByName('name', 'mark').forEach(e => { + e.detail?.devtools?.dataType === 'marker'; +}); +// Invalid examples +performance.getEntriesByName('name', 'mark').forEach(e => { + // @ts-expect-error - dataType should be valid + e.detail?.devtools?.dataType === 'markerr'; +}); + +// API: performance.getEntries should be type safe +// Valid examples +performance.getEntries().forEach(e => { + e.detail?.devtools?.dataType === 'marker'; +}); +// Invalid examples +performance.getEntries().forEach(e => { + // @ts-expect-error - dataType should be valid + e.detail?.devtools?.dataType === 'markerr'; +}); + +// API: PerformanceObserver.takeRecords should be type safe +const observerRecords = new PerformanceObserver( + () => {}, +).takeRecords() as PerformanceEntryWithDevtools[]; +// Valid examples +observerRecords.forEach(e => { + e.detail?.devtools?.dataType === 'marker'; +}); +// Invalid examples +observerRecords.forEach(e => { + // @ts-expect-error - dataType should be valid + e.detail?.devtools?.dataType === 'markerr'; +}); + +// API: performance.clearMarks should be type safe +// Valid examples +performance.clearMarks(); +performance.clearMarks('name'); + +// API: performance.clearMeasures should be type safe +// Valid examples +performance.clearMeasures(); +// Invalid examples diff --git a/packages/utils/tsconfig.lib.json b/packages/utils/tsconfig.lib.json index 17dadcedf..a9e4fc5be 100644 --- a/packages/utils/tsconfig.lib.json +++ b/packages/utils/tsconfig.lib.json @@ -1,11 +1,11 @@ { "extends": "./tsconfig.json", "compilerOptions": { - "outDir": "../../dist/out-tsc", + "outDir": "dist", "declaration": true, "types": ["node"] }, - "include": ["src/**/*.ts"], + "include": ["src/**/*.ts", "src/*.d.ts", "src/**/*.d.ts"], "exclude": [ "vitest.unit.config.ts", "vitest.int.config.ts", diff --git a/packages/utils/tsconfig.test.json b/packages/utils/tsconfig.test.json index 54cacd82f..0c4f6d806 100644 --- a/packages/utils/tsconfig.test.json +++ b/packages/utils/tsconfig.test.json @@ -12,6 +12,7 @@ "src/**/*.test.tsx", "src/**/*.test.js", "src/**/*.test.jsx", + "src/*.d.ts", "src/**/*.d.ts", "../../testing/test-setup/src/vitest.d.ts" ]