diff --git a/README.md b/README.md index f18f1cbea..b45759973 100644 --- a/README.md +++ b/README.md @@ -31,6 +31,7 @@ To interact with Datadog directly from your builds. - [`enableGit`](#enablegit) - [`logLevel`](#loglevel) - [`metadata.name`](#metadataname) + - [`metadata.version`](#metadataversion) - [Features](#features) - [Error Tracking](#error-tracking-----) - [Metrics](#metrics-----) @@ -100,6 +101,7 @@ Follow the specific documentation for each bundler: logLevel?: 'debug' | 'info' | 'warn' | 'error' | 'none', metadata?: { name?: string; + version?: string; }; errorTracking?: { enable?: boolean; @@ -271,6 +273,12 @@ Which level of log do you want to show. The name of the build.
This is used to identify the build in logs, metrics and spans. +### `metadata.version` +> default: `null` + +An immutable identifier for the deployed build (typically a release tag, a git commit SHA, or a CI build ID).
+This is the canonical place to declare the version once. Plugins that need a build version (for sourcemap upload, source-code resolution, runtime SDK initialization, etc.) read it from here unless they're given a more specific override. + ## Features diff --git a/packages/core/src/types.ts b/packages/core/src/types.ts index 527530fee..59bcff65f 100644 --- a/packages/core/src/types.ts +++ b/packages/core/src/types.ts @@ -92,6 +92,7 @@ export type Timer = { export type BuildMetadata = { name?: string; + version?: string; }; export type BuildReport = { diff --git a/packages/factory/src/validate.test.ts b/packages/factory/src/validate.test.ts new file mode 100644 index 000000000..18da6de83 --- /dev/null +++ b/packages/factory/src/validate.test.ts @@ -0,0 +1,71 @@ +// Unless explicitly stated otherwise all files in this repository are licensed under the MIT License. +// This product includes software developed at Datadog (https://www.datadoghq.com/). +// Copyright 2019-Present Datadog, Inc. + +import type { Options } from '@dd/core/types'; + +import { validateOptions } from './validate'; + +describe('factory validateOptions', () => { + describe('defaults', () => { + it('should return defaults when no options are provided', () => { + expect(validateOptions()).toEqual( + expect.objectContaining({ + enableGit: true, + logLevel: 'warn', + metadata: {}, + }), + ); + }); + + it('should preserve user-provided metadata', () => { + const result = validateOptions({ + metadata: { name: 'my-build', version: '1.0.0' }, + }); + expect(result.metadata).toEqual({ name: 'my-build', version: '1.0.0' }); + }); + + it('should accept metadata with only name set', () => { + const result = validateOptions({ metadata: { name: 'my-build' } }); + expect(result.metadata).toEqual({ name: 'my-build' }); + }); + + it('should accept metadata with only version set', () => { + const result = validateOptions({ metadata: { version: '1.0.0' } }); + expect(result.metadata).toEqual({ version: '1.0.0' }); + }); + + it('should accept an empty metadata block', () => { + const result = validateOptions({ metadata: {} }); + expect(result.metadata).toEqual({}); + }); + }); + + describe('metadata validation', () => { + const cases = [ + { + description: 'reject metadata.version when not a string', + input: { metadata: { version: 123 } }, + errorPattern: /metadata\.version.*must be a string/, + }, + { + description: 'reject metadata.version when null', + input: { metadata: { version: null } }, + errorPattern: /metadata\.version.*must be a string/, + }, + ]; + + test.each(cases)('should $description', ({ input, errorPattern }) => { + expect(() => validateOptions(input as unknown as Options)).toThrow(errorPattern); + }); + + it('should accept non-string metadata.name for backwards compatibility', () => { + expect(() => + validateOptions({ metadata: { name: 123 } } as unknown as Options), + ).not.toThrow(); + expect(() => + validateOptions({ metadata: { name: null } } as unknown as Options), + ).not.toThrow(); + }); + }); +}); diff --git a/packages/factory/src/validate.ts b/packages/factory/src/validate.ts index 67ccb6abc..0d523f498 100644 --- a/packages/factory/src/validate.ts +++ b/packages/factory/src/validate.ts @@ -3,9 +3,35 @@ // Copyright 2019-Present Datadog, Inc. import { getDDEnvValue } from '@dd/core/helpers/env'; -import type { AuthOptionsWithDefaults, Options, OptionsWithDefaults } from '@dd/core/types'; +import type { + AuthOptionsWithDefaults, + BuildMetadata, + Options, + OptionsWithDefaults, +} from '@dd/core/types'; + +const validateMetadata = (metadata: BuildMetadata | undefined): string[] => { + const errors: string[] = []; + if (metadata === undefined) { + return errors; + } + // TODO(next-major): also reject non-string `metadata.name`. Skipped today + // because `metadata.name` has historically been unvalidated (and the root + // README documents its default as `null`), so adding a type-check here + // would be a breaking change for users who took the docs literally. + if (metadata.version !== undefined && typeof metadata.version !== 'string') { + errors.push('metadata.version must be a string'); + } + return errors; +}; export const validateOptions = (options: Options = {}): OptionsWithDefaults => { + const errors: string[] = [...validateMetadata(options.metadata)]; + + if (errors.length) { + throw new Error(`Invalid Datadog plugin configuration:\n - ${errors.join('\n - ')}`); + } + const auth: AuthOptionsWithDefaults = { // DATADOG_SITE env var takes precedence over configuration site: getDDEnvValue('SITE') || options.auth?.site || 'datadoghq.com', diff --git a/packages/plugins/error-tracking/README.md b/packages/plugins/error-tracking/README.md index 91574c0c7..3b2ef7786 100644 --- a/packages/plugins/error-tracking/README.md +++ b/packages/plugins/error-tracking/README.md @@ -74,10 +74,12 @@ Example: if you're uploading `dist/file.js` to `https://example.com/static/file. ### errorTracking.sourcemaps.releaseVersion -> required +> required (or set via [`metadata.version`](/README.md#metadataversion)) Is similar and will be used to match the `version` tag set on the RUM SDK. +If omitted, the plugin falls back to the shared top-level `metadata.version`. At least one of the two must be set. + ### errorTracking.sourcemaps.service > required diff --git a/packages/plugins/error-tracking/src/types.ts b/packages/plugins/error-tracking/src/types.ts index 0d213210f..018350600 100644 --- a/packages/plugins/error-tracking/src/types.ts +++ b/packages/plugins/error-tracking/src/types.ts @@ -9,7 +9,9 @@ export type SourcemapsOptions = { dryRun?: boolean; maxConcurrency?: number; minifiedPathPrefix: MinifiedPathPrefix; - releaseVersion: string; + // Optional: when omitted, the validator falls back to the shared + // top-level `metadata.version`. At least one of the two must be set. + releaseVersion?: string; service: string; }; diff --git a/packages/plugins/error-tracking/src/validate.test.ts b/packages/plugins/error-tracking/src/validate.test.ts index 4568f0ba7..a657672d7 100644 --- a/packages/plugins/error-tracking/src/validate.test.ts +++ b/packages/plugins/error-tracking/src/validate.test.ts @@ -54,7 +54,7 @@ describe('Error Tracking Plugins validate', () => { expect(errors).toHaveLength(3); const expectedErrors = [ - 'sourcemaps.releaseVersion is required.', + 'sourcemaps.releaseVersion is required (set it directly or via metadata.version).', 'sourcemaps.service is required.', 'sourcemaps.minifiedPathPrefix is required.', ]; @@ -83,6 +83,64 @@ describe('Error Tracking Plugins validate', () => { }); }); + test('Should fall back to metadata.version when sourcemaps.releaseVersion is unset', () => { + const { config, errors } = validateSourcemapsOptions({ + metadata: { version: '2.0.0' }, + errorTracking: { + sourcemaps: getMinimalSourcemapsConfiguration({ + releaseVersion: undefined, + }), + }, + }); + + expect(errors).toHaveLength(0); + expect(config).toEqual(expect.objectContaining({ releaseVersion: '2.0.0' })); + }); + + test('Should prefer an explicit sourcemaps.releaseVersion over metadata.version', () => { + const { config, errors } = validateSourcemapsOptions({ + metadata: { version: '1.0.0' }, + errorTracking: { + sourcemaps: getMinimalSourcemapsConfiguration({ + releaseVersion: '1.0.0', + }), + }, + }); + + expect(errors).toHaveLength(0); + expect(config).toEqual(expect.objectContaining({ releaseVersion: '1.0.0' })); + }); + + test('Should error when sourcemaps.releaseVersion and metadata.version mismatch', () => { + const { errors } = validateSourcemapsOptions({ + metadata: { version: '2.0.0' }, + errorTracking: { + sourcemaps: getMinimalSourcemapsConfiguration({ + releaseVersion: '1.0.0', + }), + }, + }); + + expect(errors).toHaveLength(1); + expect(stripAnsi(errors[0])).toBe( + 'sourcemaps.releaseVersion must match metadata.version when both are configured.', + ); + }); + + test('Should error when neither sourcemaps.releaseVersion nor metadata.version is set', () => { + const { errors } = validateSourcemapsOptions({ + errorTracking: { + sourcemaps: getMinimalSourcemapsConfiguration({ + releaseVersion: undefined, + }), + }, + }); + + expect(stripAnsi(errors[0])).toBe( + 'sourcemaps.releaseVersion is required (set it directly or via metadata.version).', + ); + }); + test('Should return an error with a bad minifiedPathPrefix', () => { const { errors } = validateSourcemapsOptions({ errorTracking: { diff --git a/packages/plugins/error-tracking/src/validate.ts b/packages/plugins/error-tracking/src/validate.ts index 1d314efc1..8afffe494 100644 --- a/packages/plugins/error-tracking/src/validate.ts +++ b/packages/plugins/error-tracking/src/validate.ts @@ -72,36 +72,58 @@ export const validateSourcemapsOptions = ( }; if (validatedOptions.sourcemaps) { + const sourcemapsCfg = validatedOptions.sourcemaps; + + // Resolve `releaseVersion`: prefer the plugin-specific option, then + // fall back to the shared top-level `metadata.version`. Letting users + // configure one canonical build version at the top level keeps every + // consumer (live-debugger, sourcemaps, …) reading from the same place. + const releaseVersion = sourcemapsCfg.releaseVersion || config.metadata?.version; + // Validate the configuration. - if (!validatedOptions.sourcemaps.releaseVersion) { - toReturn.errors.push(`${red('sourcemaps.releaseVersion')} is required.`); + if (!releaseVersion) { + toReturn.errors.push( + `${red('sourcemaps.releaseVersion')} is required (set it directly or via ${red('metadata.version')}).`, + ); + } + if ( + sourcemapsCfg.releaseVersion && + config.metadata?.version && + sourcemapsCfg.releaseVersion !== config.metadata.version + ) { + toReturn.errors.push( + `${red('sourcemaps.releaseVersion')} must match ${red('metadata.version')} when both are configured.`, + ); } - if (!validatedOptions.sourcemaps.service) { + if (!sourcemapsCfg.service) { toReturn.errors.push(`${red('sourcemaps.service')} is required.`); } - if (!validatedOptions.sourcemaps.minifiedPathPrefix) { + if (!sourcemapsCfg.minifiedPathPrefix) { toReturn.errors.push(`${red('sourcemaps.minifiedPathPrefix')} is required.`); } // Validate the minifiedPathPrefix. - if (validatedOptions.sourcemaps.minifiedPathPrefix) { - if (!validateMinifiedPathPrefix(validatedOptions.sourcemaps.minifiedPathPrefix)) { - toReturn.errors.push( - `${red('sourcemaps.minifiedPathPrefix')} must be a valid URL or start with '/'.`, - ); - } + if ( + sourcemapsCfg.minifiedPathPrefix && + !validateMinifiedPathPrefix(sourcemapsCfg.minifiedPathPrefix) + ) { + toReturn.errors.push( + `${red('sourcemaps.minifiedPathPrefix')} must be a valid URL or start with '/'.`, + ); } - // Add the defaults. - const sourcemapsWithDefaults: SourcemapsOptionsWithDefaults = { - bailOnError: false, - dryRun: false, - maxConcurrency: 20, - ...validatedOptions.sourcemaps, - }; - - // Save the config. - toReturn.config = sourcemapsWithDefaults; + // Build the resolved config only when `releaseVersion` actually + // resolves; otherwise an error has been recorded and the caller will + // throw before the config is read. + if (releaseVersion) { + toReturn.config = { + bailOnError: false, + dryRun: false, + maxConcurrency: 20, + ...sourcemapsCfg, + releaseVersion, + }; + } } return toReturn; diff --git a/packages/plugins/live-debugger/README.md b/packages/plugins/live-debugger/README.md index 24952d8ff..e7a246bd6 100644 --- a/packages/plugins/live-debugger/README.md +++ b/packages/plugins/live-debugger/README.md @@ -13,7 +13,7 @@ Automatically instrument JavaScript functions at build time to enable Live Debug - [Configuration](#configuration) - [How it works](#how-it-works) - [liveDebugger.enable](#livedebuggerenable) - - [liveDebugger.version](#livedebuggerversion) + - [metadata.version](#metadataversion) - [liveDebugger.include](#livedebuggerinclude) - [liveDebugger.exclude](#livedebuggerexclude) - [liveDebugger.honorSkipComments](#livedebuggerhonorskipcomments) @@ -51,7 +51,6 @@ the plugin throws an error with the exact install command above. ```ts liveDebugger?: { enable?: boolean; - version?: string; include?: (string | RegExp)[]; exclude?: (string | RegExp)[]; honorSkipComments?: boolean; @@ -60,6 +59,8 @@ liveDebugger?: { } ``` +Live Debugger also reads the build version from the top-level [`metadata.version`](#metadataversion) option. + ## How it works The Live Debugger plugin automatically instruments all JavaScript functions in your application at build time. It adds lightweight checks that can be activated at runtime without rebuilding your code. @@ -74,7 +75,7 @@ Each instrumented function gets: The instrumentation checks whether probes are active by calling `$dd_probes(functionId)`. When no probes are active, the function returns `undefined` and all instrumentation is skipped — only the `$dd_probes` call and a conditional check remain on the hot path. -When `liveDebugger.version` is set, it should match the immutable deployed build identifier used by your Browser Debugger SDK initialization. If you also upload sourcemaps through the Error Tracking plugin, use the same value for `errorTracking.sourcemaps.releaseVersion`. +When `metadata.version` is set, it should match the immutable deployed build identifier used by your Browser Debugger SDK initialization. If you also upload sourcemaps through the Error Tracking plugin, use the same value for `errorTracking.sourcemaps.releaseVersion`. **Example transformation (block body):** @@ -126,12 +127,20 @@ const double = (x) => { Enable or disable the plugin without removing its configuration. -### liveDebugger.version +### metadata.version + +> default: `undefined` + +An immutable identifier for the deployed browser build. Set it at the top level of your Datadog plugin configuration: -Optional. When set, use an immutable deployed browser build identifier. This value should match: +```ts +datadogBuildPlugins({ + metadata: { version: '1.0.0' }, + liveDebugger: {}, +}); +``` -- the `version` passed to `@datadog/browser-debugger` -- `errorTracking.sourcemaps.releaseVersion` when sourcemap upload is enabled +When set, Live Debugger injects the value into runtime-visible build metadata so the Browser Debugger SDK uses it as the default `version` during `init()`. If omitted, Live Debugger instrumentation still works, but browser build lookup and source-code-aware resolution will gracefully degrade. diff --git a/packages/plugins/live-debugger/src/constants.ts b/packages/plugins/live-debugger/src/constants.ts index c36ae343a..137408d06 100644 --- a/packages/plugins/live-debugger/src/constants.ts +++ b/packages/plugins/live-debugger/src/constants.ts @@ -9,11 +9,3 @@ export const PLUGIN_NAME: PluginName = 'datadog-live-debugger-plugin' as const; // Skip instrumentation comment export const SKIP_INSTRUMENTATION_COMMENT = '@dd-no-instrumentation'; - -// Minimal no-op stub injected into all chunks as a banner. -// $dd_probes is called unconditionally by every instrumented function; -// $dd_entry, $dd_return, and $dd_throw are guarded by `if (probe)` so -// they only need to exist once the SDK activates probes. -// When the Datadog Browser Debugger SDK loads, its init() overwrites -// $dd_probes with the real implementation. -export const RUNTIME_STUBS = `if(typeof globalThis.$dd_probes==='undefined'){globalThis.$dd_probes=function(){}}`; diff --git a/packages/plugins/live-debugger/src/index.test.ts b/packages/plugins/live-debugger/src/index.test.ts index e351d4084..331427cc4 100644 --- a/packages/plugins/live-debugger/src/index.test.ts +++ b/packages/plugins/live-debugger/src/index.test.ts @@ -6,8 +6,9 @@ import { InjectPosition } from '@dd/core/types'; import { getContextMock, getGetPluginsArg } from '@dd/tests/_jest/helpers/mocks'; import type { UnpluginBuildContext, UnpluginContext } from 'unplugin'; -import { PLUGIN_NAME, RUNTIME_STUBS } from './constants'; +import { PLUGIN_NAME } from './constants'; import { getLiveDebuggerPlugin, getPlugins } from './index'; +import { getRuntimeBootstrap } from './runtime-bootstrap'; import type { LiveDebuggerOptionsWithDefaults } from './types'; const makeOptions = ( @@ -324,7 +325,42 @@ describe('getPlugins', () => { type: 'code', position: InjectPosition.BEFORE, injectIntoAllChunks: true, - value: RUNTIME_STUBS, + value: getRuntimeBootstrap(), + }); + }); + + it('should inject build metadata when metadata.version is provided', () => { + const arg = getGetPluginsArg({ + liveDebugger: {}, + metadata: { version: '1.0.0' }, + }); + + const plugins = getPlugins(arg); + + expect(plugins).toHaveLength(1); + expect(plugins[0].name).toBe(PLUGIN_NAME); + expect(arg.context.inject).toHaveBeenCalledWith({ + type: 'code', + position: InjectPosition.BEFORE, + injectIntoAllChunks: true, + value: getRuntimeBootstrap('1.0.0'), + }); + }); + + it('should not inject build metadata when only metadata.name is provided', () => { + const arg = getGetPluginsArg({ + liveDebugger: {}, + metadata: { name: 'my-build' }, + }); + + const plugins = getPlugins(arg); + + expect(plugins).toHaveLength(1); + expect(arg.context.inject).toHaveBeenCalledWith({ + type: 'code', + position: InjectPosition.BEFORE, + injectIntoAllChunks: true, + value: getRuntimeBootstrap(), }); }); }); diff --git a/packages/plugins/live-debugger/src/index.ts b/packages/plugins/live-debugger/src/index.ts index dc504cf65..c88e5b9da 100644 --- a/packages/plugins/live-debugger/src/index.ts +++ b/packages/plugins/live-debugger/src/index.ts @@ -5,7 +5,8 @@ import type { GetPlugins, GlobalContext, PluginOptions } from '@dd/core/types'; import { InjectPosition } from '@dd/core/types'; -import { CONFIG_KEY, PLUGIN_NAME, RUNTIME_STUBS } from './constants'; +import { CONFIG_KEY, PLUGIN_NAME } from './constants'; +import { getRuntimeBootstrap } from './runtime-bootstrap'; import { transformCode } from './transform'; import type { LiveDebuggerOptions, LiveDebuggerOptionsWithDefaults } from './types'; import { validateOptions } from './validate'; @@ -152,7 +153,7 @@ export const getPlugins: GetPlugins = ({ options, context }) => { type: 'code', position: InjectPosition.BEFORE, injectIntoAllChunks: true, - value: RUNTIME_STUBS, + value: getRuntimeBootstrap(validatedOptions.version), }); return [getLiveDebuggerPlugin(validatedOptions, context)]; diff --git a/packages/plugins/live-debugger/src/runtime-bootstrap.ts b/packages/plugins/live-debugger/src/runtime-bootstrap.ts new file mode 100644 index 000000000..dde314b75 --- /dev/null +++ b/packages/plugins/live-debugger/src/runtime-bootstrap.ts @@ -0,0 +1,21 @@ +// Unless explicitly stated otherwise all files in this repository are licensed under the MIT License. +// This product includes software developed at Datadog (https://www.datadoghq.com/). +// Copyright 2019-Present Datadog, Inc. + +// Minimal runtime stub injected into all chunks. +// `$dd_probes` is called unconditionally by instrumented functions, so it must +// exist before the Browser Debugger SDK initializes. The SDK's `init()` later +// replaces the stubbed globals with the real implementations. +const runtimeStubs = `if(typeof globalThis.$dd_probes==='undefined'){globalThis.$dd_probes=function(){}}`; +const buildMetadataGlobal = '__DD_LIVE_DEBUGGER_BUILD__' as const; + +// Build the runtime bootstrap injected into all chunks. When +// `metadata.version` is configured, also expose build metadata so the +// Browser Debugger SDK can default its runtime version from the injected value. +export const getRuntimeBootstrap = (version?: string): string => { + if (version === undefined) { + return runtimeStubs; + } + + return `${runtimeStubs};if(typeof globalThis.${buildMetadataGlobal}==='undefined'){globalThis.${buildMetadataGlobal}={version:${JSON.stringify(version)}}}`; +}; diff --git a/packages/plugins/live-debugger/src/types.ts b/packages/plugins/live-debugger/src/types.ts index 0ac32fddd..98afc0bc9 100644 --- a/packages/plugins/live-debugger/src/types.ts +++ b/packages/plugins/live-debugger/src/types.ts @@ -15,7 +15,6 @@ export type FunctionKind = (typeof VALID_FUNCTION_KINDS)[number]; export type LiveDebuggerOptions = { enable?: boolean; - version?: string; include?: (string | RegExp)[]; exclude?: (string | RegExp)[]; honorSkipComments?: boolean; diff --git a/packages/plugins/live-debugger/src/validate.test.ts b/packages/plugins/live-debugger/src/validate.test.ts index a40654857..d08219a28 100644 --- a/packages/plugins/live-debugger/src/validate.test.ts +++ b/packages/plugins/live-debugger/src/validate.test.ts @@ -2,23 +2,25 @@ // This product includes software developed at Datadog (https://www.datadoghq.com/). // Copyright 2019-Present Datadog, Inc. -import type { Logger, Options } from '@dd/core/types'; +import type { BuildMetadata, Options } from '@dd/core/types'; +import { getMockLogger } from '@dd/tests/_jest/helpers/mocks'; import { PLUGIN_NAME } from './constants'; -import type { LiveDebuggerOptionsWithDefaults } from './types'; +import type { LiveDebuggerOptions, LiveDebuggerOptionsWithDefaults } from './types'; import { validateOptions } from './validate'; -const mockLogger: Logger = { - getLogger: jest.fn(() => mockLogger), - time: jest.fn() as unknown as Logger['time'], - error: jest.fn(), - warn: jest.fn(), - info: jest.fn(), - debug: jest.fn(), -}; +const mockError = jest.fn(); +const mockLogger = getMockLogger({ error: mockError }); -const makeConfig = (liveDebugger?: unknown, errorTracking?: unknown): Options => - ({ liveDebugger, errorTracking }) as unknown as Options; +const makeConfig = (liveDebugger?: LiveDebuggerOptions, metadata?: BuildMetadata): Options => ({ + liveDebugger, + metadata, +}); + +type InvalidLiveDebuggerOptions = Partial>; + +const makeInvalidConfig = (liveDebugger: InvalidLiveDebuggerOptions): Options => + ({ liveDebugger }) as Options; beforeEach(() => { jest.clearAllMocks(); @@ -45,6 +47,11 @@ describe('validateOptions', () => { input: makeConfig({ enable: false }), expected: expect.objectContaining({ enable: false, version: undefined }), }, + { + description: 'honor enable: false even when metadata.version is provided', + input: makeConfig({ enable: false }, { version: '1.0.0' }), + expected: expect.objectContaining({ enable: false, version: '1.0.0' }), + }, { description: 'enable and return defaults when an empty object is provided', input: makeConfig({}), @@ -59,13 +66,13 @@ describe('validateOptions', () => { } satisfies LiveDebuggerOptionsWithDefaults, }, { - description: 'honor enable: true when version is provided', - input: makeConfig({ enable: true, version: '1.0.0' }), + description: 'honor enable: true and forward metadata.version', + input: makeConfig({ enable: true }, { version: '1.0.0' }), expected: expect.objectContaining({ enable: true, version: '1.0.0' }), }, { - description: 'enable when a config object with version is provided', - input: makeConfig({ version: '1.0.0' }), + description: 'enable and forward metadata.version when liveDebugger is empty', + input: makeConfig({}, { version: '1.0.0' }), expected: { enable: true, version: '1.0.0', @@ -76,6 +83,16 @@ describe('validateOptions', () => { namedOnly: false, } satisfies LiveDebuggerOptionsWithDefaults, }, + { + description: 'leave version undefined when metadata is omitted', + input: makeConfig({}), + expected: expect.objectContaining({ enable: true, version: undefined }), + }, + { + description: 'leave version undefined when only metadata.name is set', + input: makeConfig({}, { name: 'my-build' }), + expected: expect.objectContaining({ enable: true, version: undefined }), + }, ]; test.each(cases)('should $description', ({ input, expected }) => { @@ -91,63 +108,55 @@ describe('validateOptions', () => { describe('valid options', () => { const cases = [ { - description: 'accept version as a string', - input: makeConfig({ version: '1.0.0' }), + description: 'forward metadata.version when present', + input: makeConfig({}, { version: '1.0.0' }), expected: expect.objectContaining({ version: '1.0.0' }), }, { description: 'accept string include patterns', - input: makeConfig({ version: '1.0.0', include: ['src/'] }), - expected: expect.objectContaining({ version: '1.0.0', include: ['src/'] }), + input: makeConfig({ include: ['src/'] }), + expected: expect.objectContaining({ include: ['src/'] }), }, { description: 'accept RegExp include patterns', - input: makeConfig({ version: '1.0.0', include: [/\.tsx?$/] }), - expected: expect.objectContaining({ version: '1.0.0', include: [/\.tsx?$/] }), + input: makeConfig({ include: [/\.tsx?$/] }), + expected: expect.objectContaining({ include: [/\.tsx?$/] }), }, { description: 'accept mixed include patterns', - input: makeConfig({ version: '1.0.0', include: ['src/', /\.tsx?$/] }), - expected: expect.objectContaining({ - version: '1.0.0', - include: ['src/', /\.tsx?$/], - }), + input: makeConfig({ include: ['src/', /\.tsx?$/] }), + expected: expect.objectContaining({ include: ['src/', /\.tsx?$/] }), }, { description: 'accept string exclude patterns', - input: makeConfig({ version: '1.0.0', exclude: ['vendor/'] }), - expected: expect.objectContaining({ version: '1.0.0', exclude: ['vendor/'] }), + input: makeConfig({ exclude: ['vendor/'] }), + expected: expect.objectContaining({ exclude: ['vendor/'] }), }, { description: 'accept RegExp exclude patterns', - input: makeConfig({ version: '1.0.0', exclude: [/node_modules/] }), - expected: expect.objectContaining({ version: '1.0.0', exclude: [/node_modules/] }), + input: makeConfig({ exclude: [/node_modules/] }), + expected: expect.objectContaining({ exclude: [/node_modules/] }), }, { description: 'accept honorSkipComments as true', - input: makeConfig({ version: '1.0.0', honorSkipComments: true }), - expected: expect.objectContaining({ version: '1.0.0', honorSkipComments: true }), + input: makeConfig({ honorSkipComments: true }), + expected: expect.objectContaining({ honorSkipComments: true }), }, { description: 'accept honorSkipComments as false', - input: makeConfig({ version: '1.0.0', honorSkipComments: false }), - expected: expect.objectContaining({ version: '1.0.0', honorSkipComments: false }), + input: makeConfig({ honorSkipComments: false }), + expected: expect.objectContaining({ honorSkipComments: false }), }, { description: 'accept valid functionTypes', - input: makeConfig({ - version: '1.0.0', - functionTypes: ['arrowFunction', 'classMethod'], - }), + input: makeConfig({ functionTypes: ['arrowFunction', 'classMethod'] }), expected: expect.objectContaining({ - version: '1.0.0', functionTypes: ['arrowFunction', 'classMethod'], }), }, { description: 'accept all valid functionTypes', input: makeConfig({ - version: '1.0.0', functionTypes: [ 'functionDeclaration', 'functionExpression', @@ -158,7 +167,6 @@ describe('validateOptions', () => { ], }), expected: expect.objectContaining({ - version: '1.0.0', functionTypes: [ 'functionDeclaration', 'functionExpression', @@ -171,28 +179,28 @@ describe('validateOptions', () => { }, { description: 'accept namedOnly as true', - input: makeConfig({ version: '1.0.0', namedOnly: true }), - expected: expect.objectContaining({ version: '1.0.0', namedOnly: true }), + input: makeConfig({ namedOnly: true }), + expected: expect.objectContaining({ namedOnly: true }), }, { description: 'accept namedOnly as false', - input: makeConfig({ version: '1.0.0', namedOnly: false }), - expected: expect.objectContaining({ version: '1.0.0', namedOnly: false }), + input: makeConfig({ namedOnly: false }), + expected: expect.objectContaining({ namedOnly: false }), }, { description: 'accept an empty include array', - input: makeConfig({ version: '1.0.0', include: [] }), - expected: expect.objectContaining({ version: '1.0.0', include: [] }), + input: makeConfig({ include: [] }), + expected: expect.objectContaining({ include: [] }), }, { description: 'accept an empty exclude array', - input: makeConfig({ version: '1.0.0', exclude: [] }), - expected: expect.objectContaining({ version: '1.0.0', exclude: [] }), + input: makeConfig({ exclude: [] }), + expected: expect.objectContaining({ exclude: [] }), }, { description: 'accept an empty functionTypes array', - input: makeConfig({ version: '1.0.0', functionTypes: [] }), - expected: expect.objectContaining({ version: '1.0.0', functionTypes: [] }), + input: makeConfig({ functionTypes: [] }), + expected: expect.objectContaining({ functionTypes: [] }), }, ]; @@ -205,17 +213,17 @@ describe('validateOptions', () => { const cases = [ { description: 'reject include when not an array', - input: makeConfig({ include: 'src/' }), + input: makeInvalidConfig({ include: 'src/' }), errorPattern: /include.*must be an array/, }, { description: 'reject include with invalid pattern type', - input: makeConfig({ include: [42] }), + input: makeInvalidConfig({ include: [42] }), errorPattern: /include.*patterns must be strings or RegExp/, }, { description: 'reject include with a mix of valid and invalid patterns', - input: makeConfig({ include: [/\.ts$/, true] }), + input: makeInvalidConfig({ include: [/\.ts$/, true] }), errorPattern: /include.*patterns must be strings or RegExp/, }, ]; @@ -224,39 +232,7 @@ describe('validateOptions', () => { expect(() => validateOptions(input, mockLogger)).toThrow( `Invalid configuration for ${PLUGIN_NAME}.`, ); - expect(mockLogger.error).toHaveBeenCalledWith(expect.stringMatching(errorPattern)); - }); - }); - - describe('version validation', () => { - it('should reject version when not a string', () => { - expect(() => validateOptions(makeConfig({ version: 123 }), mockLogger)).toThrow( - `Invalid configuration for ${PLUGIN_NAME}.`, - ); - expect(mockLogger.error).toHaveBeenCalledWith( - expect.stringMatching(/version.*must be a string/), - ); - }); - - it('should reject version mismatch with sourcemap releaseVersion', () => { - expect(() => - validateOptions( - makeConfig( - { version: '1.0.0' }, - { - sourcemaps: { - releaseVersion: '2.0.0', - }, - }, - ), - mockLogger, - ), - ).toThrow(`Invalid configuration for ${PLUGIN_NAME}.`); - expect(mockLogger.error).toHaveBeenCalledWith( - expect.stringMatching( - /version.*must match.*errorTracking\.sourcemaps\.releaseVersion/, - ), - ); + expect(mockError).toHaveBeenCalledWith(expect.stringMatching(errorPattern)); }); }); @@ -264,12 +240,12 @@ describe('validateOptions', () => { const cases = [ { description: 'reject exclude when not an array', - input: makeConfig({ exclude: /node_modules/ }), + input: makeInvalidConfig({ exclude: /node_modules/ }), errorPattern: /exclude.*must be an array/, }, { description: 'reject exclude with invalid pattern type', - input: makeConfig({ exclude: [null] }), + input: makeInvalidConfig({ exclude: [null] }), errorPattern: /exclude.*patterns must be strings or RegExp/, }, ]; @@ -278,7 +254,7 @@ describe('validateOptions', () => { expect(() => validateOptions(input, mockLogger)).toThrow( `Invalid configuration for ${PLUGIN_NAME}.`, ); - expect(mockLogger.error).toHaveBeenCalledWith(expect.stringMatching(errorPattern)); + expect(mockError).toHaveBeenCalledWith(expect.stringMatching(errorPattern)); }); }); @@ -286,11 +262,11 @@ describe('validateOptions', () => { const cases = [ { description: 'reject enable when a string', - input: makeConfig({ enable: 'yes' }), + input: makeInvalidConfig({ enable: 'yes' }), }, { description: 'reject enable when a number', - input: makeConfig({ enable: 1 }), + input: makeInvalidConfig({ enable: 1 }), }, ]; @@ -298,7 +274,7 @@ describe('validateOptions', () => { expect(() => validateOptions(input, mockLogger)).toThrow( `Invalid configuration for ${PLUGIN_NAME}.`, ); - expect(mockLogger.error).toHaveBeenCalledWith( + expect(mockError).toHaveBeenCalledWith( expect.stringMatching(/enable.*must be a boolean/), ); }); @@ -308,11 +284,11 @@ describe('validateOptions', () => { const cases = [ { description: 'reject honorSkipComments when not a boolean', - input: makeConfig({ honorSkipComments: 'true' }), + input: makeInvalidConfig({ honorSkipComments: 'true' }), }, { description: 'reject honorSkipComments when a number', - input: makeConfig({ honorSkipComments: 1 }), + input: makeInvalidConfig({ honorSkipComments: 1 }), }, ]; @@ -320,7 +296,7 @@ describe('validateOptions', () => { expect(() => validateOptions(input, mockLogger)).toThrow( `Invalid configuration for ${PLUGIN_NAME}.`, ); - expect(mockLogger.error).toHaveBeenCalledWith( + expect(mockError).toHaveBeenCalledWith( expect.stringMatching(/honorSkipComments.*must be a boolean/), ); }); @@ -330,12 +306,12 @@ describe('validateOptions', () => { const cases = [ { description: 'reject functionTypes when not an array', - input: makeConfig({ functionTypes: 'arrowFunction' }), + input: makeInvalidConfig({ functionTypes: 'arrowFunction' }), errorPattern: /functionTypes.*must be an array/, }, { description: 'reject functionTypes with an invalid value', - input: makeConfig({ functionTypes: ['arrowFunction', 'lambda'] }), + input: makeInvalidConfig({ functionTypes: ['arrowFunction', 'lambda'] }), errorPattern: /functionTypes.*contains invalid value "lambda"/, }, ]; @@ -344,7 +320,7 @@ describe('validateOptions', () => { expect(() => validateOptions(input, mockLogger)).toThrow( `Invalid configuration for ${PLUGIN_NAME}.`, ); - expect(mockLogger.error).toHaveBeenCalledWith(expect.stringMatching(errorPattern)); + expect(mockError).toHaveBeenCalledWith(expect.stringMatching(errorPattern)); }); }); @@ -352,11 +328,11 @@ describe('validateOptions', () => { const cases = [ { description: 'reject namedOnly when not a boolean', - input: makeConfig({ namedOnly: 'yes' }), + input: makeInvalidConfig({ namedOnly: 'yes' }), }, { description: 'reject namedOnly when a number', - input: makeConfig({ namedOnly: 0 }), + input: makeInvalidConfig({ namedOnly: 0 }), }, ]; @@ -364,7 +340,7 @@ describe('validateOptions', () => { expect(() => validateOptions(input, mockLogger)).toThrow( `Invalid configuration for ${PLUGIN_NAME}.`, ); - expect(mockLogger.error).toHaveBeenCalledWith( + expect(mockError).toHaveBeenCalledWith( expect.stringMatching(/namedOnly.*must be a boolean/), ); }); @@ -372,7 +348,7 @@ describe('validateOptions', () => { describe('multiple errors', () => { it('should aggregate all validation errors before throwing', () => { - const input = makeConfig({ + const input = makeInvalidConfig({ enable: 'yes', include: 'bad', exclude: 'bad', @@ -385,7 +361,7 @@ describe('validateOptions', () => { `Invalid configuration for ${PLUGIN_NAME}.`, ); - const errorMessage = (mockLogger.error as jest.Mock).mock.calls[0][0] as string; + const errorMessage = mockError.mock.calls[0][0]; expect(errorMessage).toMatch(/enable/); expect(errorMessage).toMatch(/include/); expect(errorMessage).toMatch(/exclude/); diff --git a/packages/plugins/live-debugger/src/validate.ts b/packages/plugins/live-debugger/src/validate.ts index 292ab3c2d..ede13beee 100644 --- a/packages/plugins/live-debugger/src/validate.ts +++ b/packages/plugins/live-debugger/src/validate.ts @@ -13,28 +13,14 @@ const red = chalk.bold.red; export const validateOptions = (config: Options, log: Logger): LiveDebuggerOptionsWithDefaults => { const pluginConfig: LiveDebuggerOptions = config[CONFIG_KEY] || {}; + const metadataVersion = config.metadata?.version; const errors: string[] = []; - const sourcemapReleaseVersion = config.errorTracking?.sourcemaps?.releaseVersion; // Validate enable option if (pluginConfig.enable !== undefined && typeof pluginConfig.enable !== 'boolean') { errors.push(`${red('enable')} must be a boolean`); } - // Validate version option - if (pluginConfig.version !== undefined && typeof pluginConfig.version !== 'string') { - errors.push(`${red('version')} must be a string`); - } - if ( - pluginConfig.version && - sourcemapReleaseVersion && - pluginConfig.version !== sourcemapReleaseVersion - ) { - errors.push( - `${red('version')} must match ${red('errorTracking.sourcemaps.releaseVersion')} when both Live Debugger and sourcemap upload are configured`, - ); - } - // Validate include option if (pluginConfig.include !== undefined) { if (!Array.isArray(pluginConfig.include)) { @@ -101,7 +87,7 @@ export const validateOptions = (config: Options, log: Logger): LiveDebuggerOptio // Build the final configuration with defaults return { enable: pluginConfig.enable ?? !!config[CONFIG_KEY], - version: pluginConfig.version, + version: metadataVersion, include: pluginConfig.include || [/\.[jt]sx?$/], // .js, .jsx, .ts, .tsx exclude: pluginConfig.exclude || [ /\/node_modules\//, diff --git a/packages/plugins/rum/src/validate.test.ts b/packages/plugins/rum/src/validate.test.ts index 6d0a6fa7a..9da4a92c6 100644 --- a/packages/plugins/rum/src/validate.test.ts +++ b/packages/plugins/rum/src/validate.test.ts @@ -64,4 +64,26 @@ describe('sourceCodeContext validation', () => { expect.arrayContaining([expect.stringContaining('"rum.sourceCodeContext.service"')]), ); }); + + test('should fall back to metadata.version when sourceCodeContext.version is unset', () => { + const pluginOptions = { + ...defaultPluginOptions, + metadata: { version: '1.2.3' }, + rum: { sourceCodeContext: { service: 'checkout' } }, + }; + const result = validateSourceCodeContextOptions(pluginOptions); + expect(result.errors).toHaveLength(0); + expect(result.config).toEqual({ service: 'checkout', version: '1.2.3' }); + }); + + test('should prefer an explicit sourceCodeContext.version over metadata.version', () => { + const pluginOptions = { + ...defaultPluginOptions, + metadata: { version: '1.2.3' }, + rum: { sourceCodeContext: { service: 'checkout', version: '4.5.6' } }, + }; + const result = validateSourceCodeContextOptions(pluginOptions); + expect(result.errors).toHaveLength(0); + expect(result.config).toEqual({ service: 'checkout', version: '4.5.6' }); + }); }); diff --git a/packages/plugins/rum/src/validate.ts b/packages/plugins/rum/src/validate.ts index ab817284d..3fe8a8603 100644 --- a/packages/plugins/rum/src/validate.ts +++ b/packages/plugins/rum/src/validate.ts @@ -174,6 +174,12 @@ export const validateSourceCodeContextOptions = ( toReturn.errors.push(`Missing ${red('"rum.sourceCodeContext.service"')}.`); } - toReturn.config = cfg; + // Resolve `version`: prefer the plugin-specific option, then fall back to + // the shared top-level `metadata.version`. This keeps `metadata.version` + // as the single canonical place to declare the deployed build identifier. + toReturn.config = { + ...cfg, + version: cfg.version || options.metadata?.version, + }; return toReturn; }; diff --git a/packages/tools/src/commands/integrity/readme.ts b/packages/tools/src/commands/integrity/readme.ts index e25ff829a..279fc4c9b 100644 --- a/packages/tools/src/commands/integrity/readme.ts +++ b/packages/tools/src/commands/integrity/readme.ts @@ -362,6 +362,7 @@ export const updateReadmes = async (plugins: Workspace[], bundlers: Workspace[]) logLevel?: 'debug' | 'info' | 'warn' | 'error' | 'none', metadata?: { name?: string; + version?: string; } `, ];