diff --git a/packages/browser-core/src/tools/experimentalFeatures.ts b/packages/browser-core/src/tools/experimentalFeatures.ts index c28e399ad5..3d051d9935 100644 --- a/packages/browser-core/src/tools/experimentalFeatures.ts +++ b/packages/browser-core/src/tools/experimentalFeatures.ts @@ -14,6 +14,7 @@ import { objectHasValue } from './utils/objectUtils' // eslint-disable-next-line no-restricted-syntax export enum ExperimentalFeature { + RECORD_CANVAS = 'record_canvas', TRACK_INTAKE_REQUESTS = 'track_intake_requests', TRACK_WEBSOCKETS = 'track_websockets', } diff --git a/packages/browser-rum-core/src/domain/configuration/configuration.spec.ts b/packages/browser-rum-core/src/domain/configuration/configuration.spec.ts index 539c940cc4..5d0e3dfda3 100644 --- a/packages/browser-rum-core/src/domain/configuration/configuration.spec.ts +++ b/packages/browser-rum-core/src/domain/configuration/configuration.spec.ts @@ -1,5 +1,11 @@ import type { InitConfiguration } from '@datadog/browser-core' -import { DefaultPrivacyLevel, display, TraceContextInjection } from '@datadog/browser-core' +import { + addExperimentalFeatures, + DefaultPrivacyLevel, + display, + ExperimentalFeature, + TraceContextInjection, +} from '@datadog/browser-core' import type { ExtractTelemetryConfiguration, CamelToSnakeCase, @@ -322,6 +328,51 @@ describe('validateAndBuildRumConfiguration', () => { }) }) + describe('canvas recording', () => { + it('is disabled by default', () => { + const configuration = validateAndBuildRumConfiguration(DEFAULT_INIT_CONFIGURATION)! + + expect(configuration.recordCanvas).toBeFalse() + expect(configuration.canvasMaxFramesPerSecond).toBe(0) + }) + + it('stays disabled when requested without the experimental feature', () => { + const configuration = validateAndBuildRumConfiguration({ + ...DEFAULT_INIT_CONFIGURATION, + recordCanvas: true, + })! + + expect(configuration.recordCanvas).toBeFalse() + expect(configuration.canvasMaxFramesPerSecond).toBe(0) + }) + + describe('when the experimental feature is enabled', () => { + beforeEach(() => { + addExperimentalFeatures([ExperimentalFeature.RECORD_CANVAS]) + }) + + it('uses one frame per second by default when enabled', () => { + const configuration = validateAndBuildRumConfiguration({ + ...DEFAULT_INIT_CONFIGURATION, + recordCanvas: true, + })! + + expect(configuration.recordCanvas).toBeTrue() + expect(configuration.canvasMaxFramesPerSecond).toBe(1) + }) + + it('uses the configured frame rate', () => { + const configuration = validateAndBuildRumConfiguration({ + ...DEFAULT_INIT_CONFIGURATION, + recordCanvas: true, + canvasMaxFramesPerSecond: 2.5, + })! + + expect(configuration.canvasMaxFramesPerSecond).toBe(2.5) + }) + }) + }) + describe('actionNameAttribute', () => { it('defaults to undefined', () => { expect(validateAndBuildRumConfiguration(DEFAULT_INIT_CONFIGURATION)!.actionNameAttribute).toBeUndefined() @@ -872,6 +923,8 @@ describe('serializeRumConfiguration', () => { trackResourceHeaders: true, betaEnableViewUpdates: true, betaTrackWebSockets: false, + recordCanvas: true, + canvasMaxFramesPerSecond: 2.5, } type MapRumInitConfigurationKey = Key extends keyof InitConfiguration @@ -887,7 +940,8 @@ describe('serializeRumConfiguration', () => { ? 'track_long_task' // We forgot the s, keeping this for backward compatibility : // The following options are not reported as telemetry. Please avoid adding more of them. // `remoteConfiguration` is covered by the legacy `remote_configuration_id` field. - Key extends 'applicationId' | 'subdomain' | 'remoteConfiguration' + Key extends + 'applicationId' | 'subdomain' | 'remoteConfiguration' | 'recordCanvas' | 'canvasMaxFramesPerSecond' ? never : CamelToSnakeCase // By specifying the type here, we can ensure that serializeConfiguration is returning an diff --git a/packages/browser-rum-core/src/domain/configuration/configuration.ts b/packages/browser-rum-core/src/domain/configuration/configuration.ts index cd6dca186a..70816d1660 100644 --- a/packages/browser-rum-core/src/domain/configuration/configuration.ts +++ b/packages/browser-rum-core/src/domain/configuration/configuration.ts @@ -5,6 +5,8 @@ import { DefaultPrivacyLevel, TraceContextInjection, display, + ExperimentalFeature, + isExperimentalFeatureEnabled, isNumber, isNonEmptyArray, BROWSER_CORE_SCHEMA, @@ -211,6 +213,25 @@ export interface RumInitConfiguration extends InitConfiguration { */ startSessionReplayRecordingManually?: boolean | undefined + /** + * Enables recording canvas elements in Session Replay. + * + * @category Session Replay + * @defaultValue false + * @hidden + */ + recordCanvas?: boolean | undefined + + /** + * The maximum number of canvas frames recorded per second. Setting this option to `0` disables canvas frame recording. + * This option has no effect unless {@link RumInitConfiguration.recordCanvas | recordCanvas} is enabled. + * + * @category Session Replay + * @defaultValue 1 + * @hidden + */ + canvasMaxFramesPerSecond?: number | undefined + /** * Enables privacy control for action names. * @@ -394,6 +415,8 @@ export const RUM_SCHEMA = { enablePrivacyForActionName: { type: 'boolean', default: true }, propagateTraceBaggage: { type: 'boolean', default: true }, startSessionReplayRecordingManually: { type: 'boolean', default: false, strict: false }, + recordCanvas: { type: 'boolean', default: false }, + canvasMaxFramesPerSecond: { type: 'number', min: 0, max: 5, default: 1 }, // Enums defaultPrivacyLevel: { @@ -486,8 +509,12 @@ export function validateAndBuildRumConfiguration( return } + const recordCanvas = config.recordCanvas && isExperimentalFeatureEnabled(ExperimentalFeature.RECORD_CANVAS) + return { ...config, + recordCanvas, + canvasMaxFramesPerSecond: recordCanvas ? config.canvasMaxFramesPerSecond : 0, allowedTracingUrls, beforeSend: config.beforeSend ? (catchUserErrors(config.beforeSend, 'beforeSend threw an error:') as typeof config.beforeSend) diff --git a/packages/browser-rum/src/domain/record/canvas/canvasManager.spec.ts b/packages/browser-rum/src/domain/record/canvas/canvasManager.spec.ts new file mode 100644 index 0000000000..fbd3057924 --- /dev/null +++ b/packages/browser-rum/src/domain/record/canvas/canvasManager.spec.ts @@ -0,0 +1,27 @@ +import { createCanvasManager } from './canvasManager' + +describe('createCanvasManager', () => { + it('tracks whether a canvas is dirty', () => { + const canvasManager = createCanvasManager() + const canvas = document.createElement('canvas') + + expect(canvasManager.isCanvasDirty(canvas)).toBeFalse() + + canvasManager.markCanvasDirty(canvas) + expect(canvasManager.isCanvasDirty(canvas)).toBeTrue() + + canvasManager.markCanvasClean(canvas) + expect(canvasManager.isCanvasDirty(canvas)).toBeFalse() + }) + + it('tracks canvases independently', () => { + const canvasManager = createCanvasManager() + const dirtyCanvas = document.createElement('canvas') + const cleanCanvas = document.createElement('canvas') + + canvasManager.markCanvasDirty(dirtyCanvas) + + expect(canvasManager.isCanvasDirty(dirtyCanvas)).toBeTrue() + expect(canvasManager.isCanvasDirty(cleanCanvas)).toBeFalse() + }) +}) diff --git a/packages/browser-rum/src/domain/record/canvas/canvasManager.ts b/packages/browser-rum/src/domain/record/canvas/canvasManager.ts new file mode 100644 index 0000000000..031bdcea9d --- /dev/null +++ b/packages/browser-rum/src/domain/record/canvas/canvasManager.ts @@ -0,0 +1,15 @@ +export interface CanvasManager { + isCanvasDirty: (canvas: HTMLCanvasElement) => boolean + markCanvasClean: (canvas: HTMLCanvasElement) => void + markCanvasDirty: (canvas: HTMLCanvasElement) => void +} + +export function createCanvasManager(): CanvasManager { + const dirtyCanvases = new WeakSet() + + return { + isCanvasDirty: (canvas) => dirtyCanvases.has(canvas), + markCanvasClean: (canvas) => dirtyCanvases.delete(canvas), + markCanvasDirty: (canvas) => dirtyCanvases.add(canvas), + } +} diff --git a/packages/browser-rum/src/domain/record/record.spec.ts b/packages/browser-rum/src/domain/record/record.spec.ts index 3c30e044ba..7045b1b629 100644 --- a/packages/browser-rum/src/domain/record/record.spec.ts +++ b/packages/browser-rum/src/domain/record/record.spec.ts @@ -73,6 +73,38 @@ describe('record', () => { ]) }) + describe('canvas mutation tracking', () => { + it('instruments canvas drawing when canvas recording is enabled', () => { + const originalFillRect = Object.getOwnPropertyDescriptor(CanvasRenderingContext2D.prototype, 'fillRect')!.value + + startRecording({ recordCanvas: true, canvasMaxFramesPerSecond: 1 }) + + expect(Object.getOwnPropertyDescriptor(CanvasRenderingContext2D.prototype, 'fillRect')!.value).not.toBe( + originalFillRect + ) + }) + + it('does not instrument canvas drawing when canvas recording is disabled', () => { + const originalFillRect = Object.getOwnPropertyDescriptor(CanvasRenderingContext2D.prototype, 'fillRect')!.value + + startRecording({ recordCanvas: false, canvasMaxFramesPerSecond: 1 }) + + expect(Object.getOwnPropertyDescriptor(CanvasRenderingContext2D.prototype, 'fillRect')!.value).toBe( + originalFillRect + ) + }) + + it('does not instrument canvas drawing when the maximum frame rate is zero', () => { + const originalFillRect = Object.getOwnPropertyDescriptor(CanvasRenderingContext2D.prototype, 'fillRect')!.value + + startRecording({ recordCanvas: true, canvasMaxFramesPerSecond: 0 }) + + expect(Object.getOwnPropertyDescriptor(CanvasRenderingContext2D.prototype, 'fillRect')!.value).toBe( + originalFillRect + ) + }) + }) + it('flushes pending mutation records before taking a full snapshot', async () => { startRecording() @@ -374,12 +406,12 @@ describe('record', () => { }) }) - function startRecording() { + function startRecording(configuration: Partial = {}) { lifeCycle = new LifeCycle() recordApi = record({ emitRecord: emitSpy, emitStats: noop, - configuration: { defaultPrivacyLevel: DefaultPrivacyLevel.ALLOW } as RumConfiguration, + configuration: { defaultPrivacyLevel: DefaultPrivacyLevel.ALLOW, ...configuration } as RumConfiguration, lifeCycle, viewHistory: { findView: () => ({ id: FAKE_VIEW_ID, startClocks: {} }), diff --git a/packages/browser-rum/src/domain/record/record.ts b/packages/browser-rum/src/domain/record/record.ts index f550a9128b..1317062a1e 100644 --- a/packages/browser-rum/src/domain/record/record.ts +++ b/packages/browser-rum/src/domain/record/record.ts @@ -15,6 +15,7 @@ import { trackViewEnd, trackViewportResize, trackVisualViewportResize, + trackCanvas2DMutations, } from './trackers' import { createElementsScrollPositions } from './elementsScrollPositions' import type { ShadowRootsController } from './shadowRootsController' @@ -22,6 +23,7 @@ import { initShadowRootsController } from './shadowRootsController' import { startFullSnapshots } from './startFullSnapshots' import type { EmitRecordCallback, EmitStatsCallback } from './record.types' import { createRecordingScope } from './recordingScope' +import { createCanvasManager } from './canvas/canvasManager' export interface RecordOptions { emitRecord: EmitRecordCallback @@ -76,6 +78,11 @@ export function record(options: RecordOptions): RecordAPI { trackViewEnd(lifeCycle, processRecord, flushMutations), ] + if (configuration.recordCanvas && configuration.canvasMaxFramesPerSecond > 0) { + const canvasManager = createCanvasManager() + trackers.push(trackCanvas2DMutations(canvasManager.markCanvasDirty)) + } + return { stop: () => { shadowRootsController.stop() diff --git a/packages/browser-rum/src/domain/record/trackers/index.ts b/packages/browser-rum/src/domain/record/trackers/index.ts index 7ccc9fcbab..1845381c81 100644 --- a/packages/browser-rum/src/domain/record/trackers/index.ts +++ b/packages/browser-rum/src/domain/record/trackers/index.ts @@ -8,4 +8,5 @@ export { trackFocus } from './trackFocus' export { trackViewEnd } from './trackViewEnd' export { trackInput } from './trackInput' export { trackMutation } from './trackMutation' +export { trackCanvas2DMutations } from './trackCanvas' export type { Tracker } from './tracker.types' diff --git a/packages/browser-rum/src/domain/record/trackers/trackCanvas.spec.ts b/packages/browser-rum/src/domain/record/trackers/trackCanvas.spec.ts new file mode 100644 index 0000000000..d62035d866 --- /dev/null +++ b/packages/browser-rum/src/domain/record/trackers/trackCanvas.spec.ts @@ -0,0 +1,85 @@ +import { mockClock, registerCleanupTask } from '@datadog/browser-core/test' +import type { Clock } from '@datadog/browser-core/test' +import type { Tracker } from './tracker.types' +import { trackCanvas2DMutations } from './trackCanvas' + +describe('trackCanvas2DMutations', () => { + let canvas: HTMLCanvasElement + let context: CanvasRenderingContext2D + let markCanvasDirtySpy: jasmine.Spy<(canvas: HTMLCanvasElement) => void> + let tracker: Tracker | undefined + + beforeEach(() => { + canvas = document.createElement('canvas') + context = canvas.getContext('2d')! + markCanvasDirtySpy = jasmine.createSpy() + + registerCleanupTask(() => tracker?.stop()) + }) + + it('marks the canvas dirty after drawing operations', () => { + tracker = trackCanvas2DMutations(markCanvasDirtySpy) + const imageData = context.createImageData(1, 1) + const drawingOperations = [ + () => context.clearRect(0, 0, 1, 1), + () => context.fillRect(0, 0, 1, 1), + () => context.strokeRect(0, 0, 1, 1), + () => context.fill(), + () => context.stroke(), + () => context.fillText('foo', 0, 0), + () => context.strokeText('foo', 0, 0), + () => context.drawImage(canvas, 0, 0), + () => context.putImageData(imageData, 0, 0), + () => context.drawFocusIfNeeded(canvas), + () => context.reset(), + ] + + drawingOperations.forEach((draw) => { + markCanvasDirtySpy.calls.reset() + draw() + expect(markCanvasDirtySpy).toHaveBeenCalledOnceWith(canvas) + }) + }) + + it('does not mark the canvas dirty for non-drawing operations', () => { + tracker = trackCanvas2DMutations(markCanvasDirtySpy) + + context.beginPath() + context.moveTo(0, 0) + context.lineTo(1, 1) + + expect(markCanvasDirtySpy).not.toHaveBeenCalled() + }) + + it('does not mark the canvas dirty when a drawing operation throws', () => { + tracker = trackCanvas2DMutations(markCanvasDirtySpy) + + expect(() => context.putImageData(null as unknown as ImageData, 0, 0)).toThrow() + expect(markCanvasDirtySpy).not.toHaveBeenCalled() + }) + + it('marks the canvas dirty when it is resized', () => { + const clock: Clock = mockClock() + tracker = trackCanvas2DMutations(markCanvasDirtySpy) + + canvas.width = 100 + canvas.height = 50 + clock.tick(0) + + expect(markCanvasDirtySpy).toHaveBeenCalledTimes(2) + expect(markCanvasDirtySpy.calls.argsFor(0)[0]).toBe(canvas) + expect(markCanvasDirtySpy.calls.argsFor(1)[0]).toBe(canvas) + }) + + it('stops tracking drawing operations', () => { + const clock: Clock = mockClock() + tracker = trackCanvas2DMutations(markCanvasDirtySpy) + tracker.stop() + + context.fillRect(0, 0, 1, 1) + canvas.width = 100 + clock.tick(0) + + expect(markCanvasDirtySpy).not.toHaveBeenCalled() + }) +}) diff --git a/packages/browser-rum/src/domain/record/trackers/trackCanvas.ts b/packages/browser-rum/src/domain/record/trackers/trackCanvas.ts new file mode 100644 index 0000000000..e1f210fc2f --- /dev/null +++ b/packages/browser-rum/src/domain/record/trackers/trackCanvas.ts @@ -0,0 +1,56 @@ +import { instrumentMethod, instrumentSetter } from '@datadog/browser-core' +import type { Tracker } from './tracker.types' + +export type MarkCanvasDirty = (canvas: HTMLCanvasElement) => void + +type Canvas2DDrawingMethod = + | 'clearRect' + | 'fillRect' + | 'strokeRect' + | 'fill' + | 'stroke' + | 'fillText' + | 'strokeText' + | 'drawImage' + | 'putImageData' + | 'drawFocusIfNeeded' + | 'reset' + +const CANVAS_2D_DRAWING_METHODS: readonly Canvas2DDrawingMethod[] = [ + 'clearRect', + 'fillRect', + 'strokeRect', + 'fill', + 'stroke', + 'fillText', + 'strokeText', + 'drawImage', + 'putImageData', + 'drawFocusIfNeeded', + 'reset', +] + +export function trackCanvas2DMutations(markCanvasDirty: MarkCanvasDirty): Tracker { + const instrumentationStoppers: Tracker[] = [] + + if (typeof CanvasRenderingContext2D !== 'undefined') { + CANVAS_2D_DRAWING_METHODS.forEach((method) => { + instrumentationStoppers.push( + instrumentMethod(CanvasRenderingContext2D.prototype, method, ({ target: context, onPostCall }) => { + onPostCall(() => markCanvasDirty(context.canvas)) + }) + ) + }) + } + + if (typeof HTMLCanvasElement !== 'undefined') { + instrumentationStoppers.push( + instrumentSetter(HTMLCanvasElement.prototype, 'width', markCanvasDirty), + instrumentSetter(HTMLCanvasElement.prototype, 'height', markCanvasDirty) + ) + } + + return { + stop: () => instrumentationStoppers.forEach((stopper) => stopper.stop()), + } +} diff --git a/packages/js-core/api/configuration.api.md b/packages/js-core/api/configuration.api.md index d0713cb64a..d3e5b4a3e8 100644 --- a/packages/js-core/api/configuration.api.md +++ b/packages/js-core/api/configuration.api.md @@ -26,7 +26,7 @@ export type EnumField = ({ } & Optionality & Multiple & Strict); // @public -export type FieldDef = StringField | PercentageField | BooleanField | SiteField | MatchOptionField | EnumField | UnionField | SchemaField | FunctionField; +export type FieldDef = StringField | NumberField | PercentageField | BooleanField | SiteField | MatchOptionField | EnumField | UnionField | SchemaField | FunctionField; // @public export type FunctionField = { @@ -54,6 +54,13 @@ export interface Multiple { multiple?: true; } +// @public +export type NumberField = { + type: 'number'; + min?: number; + max?: number; +} & Optionality & Multiple & Strict; + // @public export type Optionality = { required: true; diff --git a/packages/js-core/src/entries/configuration.spec.ts b/packages/js-core/src/entries/configuration.spec.ts index ac1721c923..08c6a0d737 100644 --- a/packages/js-core/src/entries/configuration.spec.ts +++ b/packages/js-core/src/entries/configuration.spec.ts @@ -69,6 +69,44 @@ describe('validateAndBuildConfiguration', () => { }) }) + describe('number fields', () => { + it('accepts finite numbers without bounds', () => { + const schema = { value: { type: 'number', required: true } } as const + + expect(validateAndBuildConfiguration({ value: -42.5 }, schema, display)).toEqual({ value: -42.5 }) + }) + + it('accepts finite numbers within inclusive bounds', () => { + const schema = { value: { type: 'number', min: 0, max: 5, required: true } } as const + + expect(validateAndBuildConfiguration({ value: 0 }, schema, display)).toEqual({ value: 0 }) + expect(validateAndBuildConfiguration({ value: 2.5 }, schema, display)).toEqual({ value: 2.5 }) + expect(validateAndBuildConfiguration({ value: 5 }, schema, display)).toEqual({ value: 5 }) + }) + + it('rejects non-finite numbers and values outside the bounds', () => { + const schema = { value: { type: 'number', min: 0, max: 5, default: 1 } } as const + + ;[-1, 6, NaN, Infinity, '1'].forEach((value) => { + expect(validateAndBuildConfiguration({ value }, schema, display)).toBeUndefined() + }) + }) + + it('uses the default when missing', () => { + const schema = { value: { type: 'number', default: 1 } } as const + + expect(validateAndBuildConfiguration({}, schema, display)).toEqual({ value: 1 }) + }) + + it('supports one-sided bounds', () => { + const minimumSchema = { value: { type: 'number', min: 0, required: true } } as const + const maximumSchema = { value: { type: 'number', max: 5, required: true } } as const + + expect(validateAndBuildConfiguration({ value: -1 }, minimumSchema, display)).toBeUndefined() + expect(validateAndBuildConfiguration({ value: 6 }, maximumSchema, display)).toBeUndefined() + }) + }) + describe('enum fields with allowAll', () => { const VALUES = ['a', 'b', 'c'] as const const schema = { @@ -489,6 +527,30 @@ describe('validateAndBuildConfiguration', () => { expect(display.error).toHaveBeenCalledOnceWith('"rate" must be a number between 0 and 100') }) + it('reports the configured bounds for an invalid number', () => { + const schema = { value: { type: 'number' as const, min: 0, max: 5, default: 1 } } + validateAndBuildConfiguration({ value: 6 }, schema, display) + expect(display.error).toHaveBeenCalledOnceWith('"value" must be a number between 0 and 5') + }) + + it('reports a configured minimum for an invalid number', () => { + const schema = { value: { type: 'number' as const, min: 0 } } + validateAndBuildConfiguration({ value: -1 }, schema, display) + expect(display.error).toHaveBeenCalledOnceWith('"value" must be a number greater than or equal to 0') + }) + + it('reports a configured maximum for an invalid number', () => { + const schema = { value: { type: 'number' as const, max: 5 } } + validateAndBuildConfiguration({ value: 6 }, schema, display) + expect(display.error).toHaveBeenCalledOnceWith('"value" must be a number less than or equal to 5') + }) + + it('reports finite-number requirements for an unbounded number', () => { + const schema = { value: { type: 'number' as const } } + validateAndBuildConfiguration({ value: Infinity }, schema, display) + expect(display.error).toHaveBeenCalledOnceWith('"value" must be a finite number') + }) + it('reports the right message for an invalid boolean', () => { const schema = { flag: { type: 'boolean' as const, default: false } } validateAndBuildConfiguration({ flag: 'yes' }, schema, display) diff --git a/packages/js-core/src/entries/configuration.ts b/packages/js-core/src/entries/configuration.ts index 6ddae22395..f3772d87df 100644 --- a/packages/js-core/src/entries/configuration.ts +++ b/packages/js-core/src/entries/configuration.ts @@ -43,6 +43,9 @@ export type StringField = { type: 'string' } & Optionality & Multiple & Strict /** A numeric field constrained to the 0–100 range, typically used for sample rates. */ export type PercentageField = { type: 'percentage' } & Optionality & Multiple & Strict +/** A finite numeric field, optionally constrained by inclusive minimum and maximum values. */ +export type NumberField = { type: 'number'; min?: number; max?: number } & Optionality & Multiple & Strict + /** * A boolean field. When `strict: false`, a non-boolean value is coerced with `!!value` * instead of being rejected. @@ -100,6 +103,7 @@ export type FunctionField = { /** The union of all field definition types supported by a {@link ConfigurationSchema}. */ export type FieldDef = | StringField + | NumberField | PercentageField | BooleanField | SiteField @@ -125,7 +129,7 @@ export interface ConfigurationSchema { type InferBase = F extends { type: 'string' } ? string - : F extends { type: 'percentage' } + : F extends { type: 'number' | 'percentage' } ? number : F extends { type: 'boolean' } ? boolean @@ -154,7 +158,7 @@ type InferBase = F extends { type: 'string' } // Non-recursive variant of InferBase used inside UnionField to avoid infinite type instantiation. type InferVariant = F extends { type: 'string' } ? string - : F extends { type: 'percentage' } + : F extends { type: 'number' | 'percentage' } ? number : F extends { type: 'boolean' } ? boolean @@ -229,6 +233,17 @@ function buildErrorMessage(key: string, field: FieldDef): string { switch (field.type) { case 'string': return `"${key}" must be a non-empty string` + case 'number': + if (field.min !== undefined && field.max !== undefined) { + return `"${key}" must be a number between ${field.min} and ${field.max}` + } + if (field.min !== undefined) { + return `"${key}" must be a number greater than or equal to ${field.min}` + } + if (field.max !== undefined) { + return `"${key}" must be a number less than or equal to ${field.max}` + } + return `"${key}" must be a finite number` case 'percentage': return `"${key}" must be a number between 0 and 100` case 'boolean': @@ -375,6 +390,13 @@ function validateField(field: FieldDef, value: unknown, display: Display): unkno return typeof value === 'function' ? value : undefined case 'string': return typeof value === 'string' && value.length > 0 ? value : undefined + case 'number': + return typeof value === 'number' && + Number.isFinite(value) && + (field.min === undefined || value >= field.min) && + (field.max === undefined || value <= field.max) + ? value + : undefined case 'percentage': return isPercentage(value) ? value : undefined case 'boolean':