Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
110 changes: 110 additions & 0 deletions packages/core/src/helpers/options.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,110 @@
// 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 { Logger } from '@dd/core/types';

import { resetEnableWarnings, resolveEnable } from './options';

const mockLogger: Logger = {
getLogger: jest.fn(),
time: jest.fn() as unknown as Logger['time'],
error: jest.fn(),
warn: jest.fn(),
info: jest.fn(),
debug: jest.fn(),
};

beforeEach(() => {
jest.clearAllMocks();
resetEnableWarnings();
});

describe('resolveEnable', () => {
describe('standard boolean / omitted values', () => {
const cases = [
{
description: 'return false when the config key is undefined',
options: {},
expected: false,
},
{
description: 'return false when the config key is null',
options: { myPlugin: null },
expected: false,
},
{
description: 'return true when the config key is a truthy object without enable',
options: { myPlugin: { someOther: 'val' } },
expected: true,
},
{
description: 'return true when enable is true',
options: { myPlugin: { enable: true } },
expected: true,
},
{
description: 'return false when enable is false',
options: { myPlugin: { enable: false } },
expected: false,
},
{
description: 'return true when enable is undefined (object present)',
options: { myPlugin: { enable: undefined } },
expected: true,
},
];

test.each(cases)('should $description', ({ options, expected }) => {
expect(resolveEnable(options, 'myPlugin', mockLogger)).toBe(expected);
expect(mockLogger.warn).not.toHaveBeenCalled();
});
});

describe('non-boolean coercion with deprecation warning', () => {
const cases = [
{
description: 'coerce enable: 1 to true and warn',
options: { myPlugin: { enable: 1 } },
expected: true,
},
{
description: 'coerce enable: 0 to false and warn',
options: { myPlugin: { enable: 0 } },
expected: false,
},
{
description: 'coerce enable: "true" to true and warn',
options: { myPlugin: { enable: 'true' } },
expected: true,
},
{
description: 'coerce enable: "" to false and warn',
options: { myPlugin: { enable: '' } },
expected: false,
},
];

test.each(cases)('should $description', ({ options, expected }) => {
expect(resolveEnable(options, 'myPlugin', mockLogger)).toBe(expected);
expect(mockLogger.warn).toHaveBeenCalledTimes(1);
expect(mockLogger.warn).toHaveBeenCalledWith(
expect.stringContaining('myPlugin.enable'),
);
});
});

describe('warn-once behavior', () => {
test('should only warn once per config key across multiple calls', () => {
resolveEnable({ myPlugin: { enable: 1 } }, 'myPlugin', mockLogger);
resolveEnable({ myPlugin: { enable: 'yes' } }, 'myPlugin', mockLogger);
expect(mockLogger.warn).toHaveBeenCalledTimes(1);
});

test('should warn separately for different config keys', () => {
resolveEnable({ pluginA: { enable: 1 } }, 'pluginA', mockLogger);
resolveEnable({ pluginB: { enable: 1 } }, 'pluginB', mockLogger);
expect(mockLogger.warn).toHaveBeenCalledTimes(2);
});
});
});
52 changes: 52 additions & 0 deletions packages/core/src/helpers/options.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
// 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 { Logger } from '@dd/core/types';

const warnedKeys = new Set<string>();

/**
* Resolve the `enable` value for a plugin config key, emitting a deprecation
* warning when the caller passes a non-boolean truthy/falsy value.
*
* Semantics:
* - Config key absent / undefined / falsy → false (plugin disabled).
* - Config key is a truthy object without an `enable` property → true.
* - Config key is a truthy object with `enable` set → coerce to boolean,
* warning once per key if it isn't already a boolean.
*/
export const resolveEnable = <T extends { [K in C]?: unknown }, C extends string>(
options: T,
configKey: C,
log: Logger,
): boolean => {
const pluginConfig = options[configKey];

if (pluginConfig && typeof pluginConfig === 'object' && 'enable' in pluginConfig) {
const value = (pluginConfig as Record<string, unknown>).enable;

if (typeof value !== 'boolean' && value !== undefined) {
if (!warnedKeys.has(configKey)) {
warnedKeys.add(configKey);
log.warn(
`\`${configKey}.enable\` should be a boolean, got ${typeof value}. ` +
`Non-boolean values are coerced today but will be rejected in the next major.`,
);
}
}

if (value !== undefined) {
// TODO(next major): drop this coercion and reject non-boolean `enable`
// outright. The warning above gives callers one major to migrate.
return !!value;
}
}

return !!pluginConfig;
};

/** @internal Exposed only for tests to reset the warn-once set between cases. */
export const resetEnableWarnings = (): void => {
warnedKeys.clear();
};
54 changes: 54 additions & 0 deletions packages/factory/src/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,17 @@
// This product includes software developed at Datadog (https://www.datadoghq.com/).
// Copyright 2019-Present Datadog, Inc.

import type { PluginOptions, Options } from '@dd/core/types';

const invokeFactory = async (opts: Options): Promise<PluginOptions[]> => {
const { buildPluginFactory } = await import('@dd/factory');
const factory = buildPluginFactory({ bundler: {}, version: '1.0.0' });
return factory.raw(opts, { framework: 'esbuild' }) as PluginOptions[];
};

const hasPlugin = (plugins: PluginOptions[], name: string) =>
plugins.some((plugin) => plugin.name.includes(name));

describe('Factory', () => {
test('Should not throw with no options', async () => {
const { buildPluginFactory } = await import('@dd/factory');
Expand All @@ -12,4 +23,47 @@ describe('Factory', () => {
factory.vite();
}).not.toThrow();
});

describe('enable gating for user-facing plugins', () => {
// The factory is the single source of truth for `<configKey>.enable`.
// Each user-facing plugin is skipped when its config key is absent or
// explicitly disabled, and included when the config key is present.

test('Should skip a plugin when its config key is absent', async () => {
const plugins = await invokeFactory({ logLevel: 'none' });
expect(hasPlugin(plugins, 'output')).toBe(false);
expect(hasPlugin(plugins, 'metrics')).toBe(false);
expect(hasPlugin(plugins, 'rum')).toBe(false);
});

test('Should include a plugin when its config key is present', async () => {
const plugins = await invokeFactory({ logLevel: 'none', output: {} });
expect(hasPlugin(plugins, 'output')).toBe(true);
});

test('Should skip a plugin when enable: false', async () => {
const plugins = await invokeFactory({
logLevel: 'none',
output: { enable: false },
});
expect(hasPlugin(plugins, 'output')).toBe(false);
});

test('Should include a plugin when enable: true', async () => {
const plugins = await invokeFactory({
logLevel: 'none',
output: { enable: true },
});
expect(hasPlugin(plugins, 'output')).toBe(true);
});

test('Should coerce a non-boolean enable value and still include the plugin', async () => {
const plugins = await invokeFactory({
logLevel: 'none',
// @ts-expect-error - intentional non-boolean to exercise coercion
output: { enable: 1 },
});
expect(hasPlugin(plugins, 'output')).toBe(true);
});
});
});
29 changes: 20 additions & 9 deletions packages/factory/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ import { getContext } from './helpers/context';
import { wrapGetPlugins } from './helpers/wrapPlugins';
import { ALL_ENVS, HOST_NAME } from '@dd/core/constants';
import { notifyOnEnvOverrides } from '@dd/core/helpers/env';
import { resolveEnable } from '@dd/core/helpers/options';
// #imports-injection-marker
import * as apps from '@dd/apps-plugin';
import * as errorTracking from '@dd/error-tracking-plugin';
Expand Down Expand Up @@ -160,17 +161,27 @@ export const buildPluginFactory = ({
pluginsToAdd.push(['custom', options.customPlugins]);
}

// Add the customer facing plugins.
pluginsToAdd.push(
// Customer-facing plugins are gated by their `<configKey>.enable` flag.
// Resolving here lets every plugin share the same semantics:
// - config key absent → disabled
// - config key present without `enable` → enabled
// - non-boolean `enable` → coerced, with a single deprecation warning
const userFacingPlugins: [name: string, configKey: string, GetPlugins][] = [
// #configs-injection-marker
['apps', apps.getPlugins],
['error-tracking', errorTracking.getPlugins],
['live-debugger', liveDebugger.getPlugins],
['metrics', metrics.getPlugins],
['output', output.getPlugins],
['rum', rum.getPlugins],
['apps', apps.CONFIG_KEY, apps.getPlugins],
['error-tracking', errorTracking.CONFIG_KEY, errorTracking.getPlugins],
['live-debugger', liveDebugger.CONFIG_KEY, liveDebugger.getPlugins],
['metrics', metrics.CONFIG_KEY, metrics.getPlugins],
['output', output.CONFIG_KEY, output.getPlugins],
['rum', rum.CONFIG_KEY, rum.getPlugins],
// #configs-injection-marker
);
];

for (const [name, configKey, getPlugins] of userFacingPlugins) {
if (resolveEnable(options, configKey, log)) {
pluginsToAdd.push([name, getPlugins]);
}
}

// Initialize all our plugins.
for (const [name, getPlugins] of pluginsToAdd) {
Expand Down
4 changes: 3 additions & 1 deletion packages/plugins/apps/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -54,10 +54,12 @@ Setting the `apps.dryRun` configuration will override any value set in the envir

### apps.enable

> default: `true` when an `apps` config block is present
> default: `true` when an `apps` config block is present, `false` otherwise.

Enable or disable the plugin without removing its configuration.

Must be a boolean. Non-boolean values are coerced today but will be rejected in a future major release.

### apps.include

> default: `[]`
Expand Down
3 changes: 0 additions & 3 deletions packages/plugins/apps/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,9 +19,6 @@ export type types = {
export const getPlugins: GetPlugins = ({ options, context, bundler }) => {
const log = context.getLogger(PLUGIN_NAME);
const validatedOptions = validateOptions(options);
if (!validatedOptions.enable) {
return [];
}

if (context.bundler.name !== 'vite') {
log.warn(`The apps plugin only supports Vite; skipping under '${context.bundler.name}'.`);
Expand Down
5 changes: 4 additions & 1 deletion packages/plugins/apps/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,4 +25,7 @@ export type AppsManifest = {
};

// We don't enforce identifier, as it needs to be dynamically computed if absent.
export type AppsOptionsWithDefaults = WithRequired<AppsOptions, 'enable' | 'include' | 'dryRun'>;
export type AppsOptionsWithDefaults = Omit<
WithRequired<AppsOptions, 'include' | 'dryRun'>,
'enable'
>;
32 changes: 0 additions & 32 deletions packages/plugins/apps/src/validate.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,42 +5,11 @@
import { validateOptions } from '@dd/apps-plugin/validate';

describe('Apps Plugin - validateOptions', () => {
describe('enable flag', () => {
const cases = [
{
description: 'return false when no apps config is provided',
input: {},
expected: false,
},
{
description: 'return true when apps config is an empty object',
input: { apps: {} },
expected: true,
},
{
description: 'respect explicit enable true',
input: { apps: { enable: true } },
expected: true,
},
{
description: 'respect explicit enable false',
input: { apps: { enable: false } },
expected: false,
},
];

test.each(cases)('Should $description', ({ input, expected }) => {
const result = validateOptions(input);
expect(result.enable).toBe(expected);
});
});

describe('defaults', () => {
test('Should set defaults when nothing is provided', () => {
const result = validateOptions({});
expect(result).toEqual({
dryRun: true,
enable: false,
include: [],
identifier: undefined,
name: undefined,
Expand Down Expand Up @@ -91,7 +60,6 @@ describe('Apps Plugin - validateOptions', () => {

expect(result).toEqual({
dryRun: true,
enable: true,
include: ['public/**/*', 'dist/**/*'],
identifier: 'my-app',
name: undefined,
Expand Down
6 changes: 1 addition & 5 deletions packages/plugins/apps/src/validate.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,15 +10,11 @@ import type { AppsOptions, AppsOptionsWithDefaults } from './types';

export const validateOptions = (options: Options): AppsOptionsWithDefaults => {
const resolvedOptions = (options[CONFIG_KEY] || {}) as AppsOptions;
const enable = resolvedOptions.enable ?? !!options[CONFIG_KEY];

const validatedOptions: AppsOptionsWithDefaults = {
enable,
return {
include: resolvedOptions.include || [],
dryRun: resolvedOptions.dryRun ?? !getDDEnvValue('APPS_UPLOAD_ASSETS'),
identifier: resolvedOptions.identifier?.trim(),
name: resolvedOptions.name?.trim() || options.metadata?.name?.trim(),
};

return validatedOptions;
};
9 changes: 9 additions & 0 deletions packages/plugins/error-tracking/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ Interact with Error Tracking directly from your build system.

<!-- #toc -->
- [Configuration](#configuration)
- [errorTracking.enable](#errortrackingenable)
- [Sourcemaps Upload](#sourcemaps-upload)
- [errorTracking.sourcemaps.bailOnError](#errortrackingsourcemapsbailonerror)
- [errorTracking.sourcemaps.dryRun](#errortrackingsourcemapsdryrun)
Expand All @@ -35,6 +36,14 @@ errorTracking?: {
}
```

### errorTracking.enable

> default: `true` when an `errorTracking` config block is present, `false` otherwise.

Enable or disable the plugin without removing its configuration.

Must be a boolean. Non-boolean values are coerced today but will be rejected in a future major release.

## Sourcemaps Upload

Upload JavaScript sourcemaps to Datadog to un-minify your errors.
Expand Down
Loading
Loading