diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index bdae1550f..f01d53b81 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -29,3 +29,6 @@ packages/plugins/analytics @yoannmoin # Custom Hooks packages/plugins/custom-hooks @yoannmoinet + +# Synthetics +packages/plugins/synthetics @yoannmoinet @DataDog/synthetics-ct diff --git a/README.md b/README.md index de30e57ab..725f6ae4b 100644 --- a/README.md +++ b/README.md @@ -29,6 +29,7 @@ To interact with Datadog directly from your builds. - [`customPlugins`](#customplugins) - [Features](#features) - [Error Tracking](#error-tracking-----) + - [Synthetics](#synthetics-----) - [Telemetry](#telemetry-----) - [Contributing](#contributing) - [License](#license) @@ -103,6 +104,9 @@ Follow the specific documentation for each bundler: service: string; }; }; + synthetics?: { + disabled?: boolean; + }; telemetry?: { disabled?: boolean; enableTracing?: boolean; @@ -246,6 +250,26 @@ datadogWebpackPlugin({ +### Synthetics ESBuild Rollup Rspack Vite Webpack + +> Interact with Synthetics at build time. + +#### [📝 Full documentation ➡️](/packages/plugins/synthetics#readme) + +
+ +Configuration + +```typescript +datadogWebpackPlugin({ + synthetics?: { + disabled?: boolean, + } +}); +``` + +
+ ### Telemetry ESBuild Rollup Rspack Vite Webpack > Display and send telemetry data as metrics to Datadog. diff --git a/global.d.ts b/global.d.ts index 1be630767..4a289433c 100644 --- a/global.d.ts +++ b/global.d.ts @@ -19,6 +19,16 @@ declare global { * For instance, we only submit logs to Datadog when the environment is `production`. */ BUILD_PLUGINS_ENV?: Env; + /** + * The port of the dev server of our synthetics plugin. + * + * This is only used by datadog-ci, in its build-and-test command, + * using the customer's build command, if it includes our plugin, + * will launch a dev server to serve the outdir of the build so datadog-ci + * can trigger a CI batch over the branch's code using a tunnel. + * + */ + BUILD_PLUGINS_S8S_PORT?: string; /** * Defined in github actions when running in CI. */ diff --git a/packages/core/src/helpers/server.ts b/packages/core/src/helpers/server.ts index 8a897aa99..aae3275d8 100644 --- a/packages/core/src/helpers/server.ts +++ b/packages/core/src/helpers/server.ts @@ -24,7 +24,7 @@ type File = { content: string; }; type RouteVerb = 'get' | 'post' | 'put' | 'patch' | 'delete'; -type Routes = Record< +export type Routes = Record< string, Partial< Record< @@ -33,13 +33,13 @@ type Routes = Record< > > >; -type Response = { +export type Response = { statusCode: number; headers: Record; body: string; error?: Error; }; -type RunServerOptions = { +export type RunServerOptions = { port: number; root: string; routes?: Routes; @@ -76,8 +76,8 @@ export const prepareFile = async (root: string, requestUrl: string): Promise { - const server = http.createServer(async (req, res) => { +export const getServer = ({ root, middleware, routes }: Omit) => { + return http.createServer(async (req, res) => { const response: Response = { statusCode: 200, headers: {}, @@ -125,7 +125,10 @@ export const runServer = ({ port, root, middleware, routes }: RunServerOptions) res.writeHead(response.statusCode, response.headers); res.end(response.body); }); +}; +export const runServer = ({ port, root, middleware, routes }: RunServerOptions) => { + const server = getServer({ root, middleware, routes }); server.listen(port); return server; }; diff --git a/packages/core/src/types.ts b/packages/core/src/types.ts index 1cd1d9d80..4121672c1 100644 --- a/packages/core/src/types.ts +++ b/packages/core/src/types.ts @@ -13,6 +13,8 @@ import type { ErrorTrackingOptions } from '@dd/error-tracking-plugin/types'; import type * as errorTracking from '@dd/error-tracking-plugin'; import type { RumOptions } from '@dd/rum-plugin/types'; import type * as rum from '@dd/rum-plugin'; +import type { SyntheticsOptions } from '@dd/synthetics-plugin/types'; +import type * as synthetics from '@dd/synthetics-plugin'; import type { TelemetryOptions } from '@dd/telemetry-plugin/types'; import type * as telemetry from '@dd/telemetry-plugin'; // #imports-injection-marker @@ -22,8 +24,11 @@ import type { UnpluginOptions } from 'unplugin'; import type { ALL_ENVS, FULL_NAME_BUNDLERS, SUPPORTED_BUNDLERS } from './constants'; +// Re-assign B into A. export type Assign = Omit & B; -export type WithRequired = T & { [P in K]-?: T[P] }; +// Type object with specified keys required. +export type Ensure = Assign>>; +// Target one item from an iterable. export type IterableElement> = IterableType extends Iterable ? ElementType : never; @@ -197,6 +202,7 @@ export interface Options extends BaseOptions { // #types-injection-marker [errorTracking.CONFIG_KEY]?: ErrorTrackingOptions; [rum.CONFIG_KEY]?: RumOptions; + [synthetics.CONFIG_KEY]?: SyntheticsOptions; [telemetry.CONFIG_KEY]?: TelemetryOptions; // #types-injection-marker customPlugins?: GetCustomPlugins; diff --git a/packages/factory/package.json b/packages/factory/package.json index a6c97eea5..a3270ce94 100644 --- a/packages/factory/package.json +++ b/packages/factory/package.json @@ -25,6 +25,7 @@ "@dd/internal-git-plugin": "workspace:*", "@dd/internal-injection-plugin": "workspace:*", "@dd/rum-plugin": "workspace:*", + "@dd/synthetics-plugin": "workspace:*", "@dd/telemetry-plugin": "workspace:*", "chalk": "2.3.1", "unplugin": "1.16.0" diff --git a/packages/factory/src/index.ts b/packages/factory/src/index.ts index 9c203c823..2b3d348ec 100644 --- a/packages/factory/src/index.ts +++ b/packages/factory/src/index.ts @@ -28,6 +28,7 @@ import { getContext, validateOptions } from './helpers'; // #imports-injection-marker import * as errorTracking from '@dd/error-tracking-plugin'; import * as rum from '@dd/rum-plugin'; +import * as synthetics from '@dd/synthetics-plugin'; import * as telemetry from '@dd/telemetry-plugin'; import { getAnalyticsPlugins } from '@dd/internal-analytics-plugin'; import { getBuildReportPlugins } from '@dd/internal-build-report-plugin'; @@ -39,6 +40,7 @@ import { getInjectionPlugins } from '@dd/internal-injection-plugin'; // #types-export-injection-marker export type { types as ErrorTrackingTypes } from '@dd/error-tracking-plugin'; export type { types as RumTypes } from '@dd/rum-plugin'; +export type { types as SyntheticsTypes } from '@dd/synthetics-plugin'; export type { types as TelemetryTypes } from '@dd/telemetry-plugin'; // #types-export-injection-marker @@ -102,6 +104,7 @@ export const buildPluginFactory = ({ // #configs-injection-marker errorTracking, rum, + synthetics, telemetry, // #configs-injection-marker ]; diff --git a/packages/plugins/synthetics/README.md b/packages/plugins/synthetics/README.md new file mode 100644 index 000000000..82b7d2858 --- /dev/null +++ b/packages/plugins/synthetics/README.md @@ -0,0 +1,27 @@ +# Synthetics Plugin + +Interact with Synthetics at build time. + + + +## Table of content + + + + +- [Configuration](#configuration) +- [Build and test](#build-and-test) + + +## Configuration + +```ts +synthetics?: { + disabled?: boolean; +} +``` + +## Build and test + +Using [`datadog-ci`'s `synthetics build-and-test` command](https://github.com/DataDog/datadog-ci/tree/master/src/commands/synthetics#run-tests-command), +you can have the build spin a dev server to serve the outdir of the build in order [to trigger a CI batch](https://docs.datadoghq.com/continuous_testing/) over the branch's code. diff --git a/packages/plugins/synthetics/package.json b/packages/plugins/synthetics/package.json new file mode 100644 index 000000000..52997ed3b --- /dev/null +++ b/packages/plugins/synthetics/package.json @@ -0,0 +1,26 @@ +{ + "name": "@dd/synthetics-plugin", + "packageManager": "yarn@4.0.2", + "license": "MIT", + "private": true, + "author": "Datadog", + "description": "Interact with Synthetics at build time.", + "homepage": "https://github.com/DataDog/build-plugins/tree/main/packages/plugins/synthetics#readme", + "repository": { + "type": "git", + "url": "https://github.com/DataDog/build-plugins", + "directory": "packages/plugins/synthetics" + }, + "exports": { + ".": "./src/index.ts", + "./*": "./src/*.ts" + }, + "scripts": { + "typecheck": "tsc --noEmit" + }, + "dependencies": { + "@dd/core": "workspace:*", + "@dd/error-tracking-plugin": "workspace:*", + "chalk": "2.3.1" + } +} diff --git a/packages/plugins/synthetics/src/constants.ts b/packages/plugins/synthetics/src/constants.ts new file mode 100644 index 000000000..541a2d603 --- /dev/null +++ b/packages/plugins/synthetics/src/constants.ts @@ -0,0 +1,10 @@ +// 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 { PluginName } from '@dd/core/types'; + +export const CONFIG_KEY = 'synthetics' as const; +export const PLUGIN_NAME: PluginName = 'datadog-synthetics-plugin' as const; + +export const API_PREFIX = '_datadog-ci_' as const; diff --git a/packages/plugins/synthetics/src/helpers/server.ts b/packages/plugins/synthetics/src/helpers/server.ts new file mode 100644 index 000000000..abffdf2b6 --- /dev/null +++ b/packages/plugins/synthetics/src/helpers/server.ts @@ -0,0 +1,209 @@ +// 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 { doRequest } from '@dd/core/helpers/request'; +import type { Routes } from '@dd/core/helpers/server'; +import { getServer } from '@dd/core/helpers/server'; +import type { Options, PluginOptions, Logger } from '@dd/core/types'; +import { CONFIG_KEY as ERROR_TRACKING } from '@dd/error-tracking-plugin'; +import type { Server } from 'http'; +import url from 'url'; + +import { API_PREFIX, PLUGIN_NAME } from '../constants'; +import type { ServerResponse, SyntheticsOptionsWithDefaults } from '../types'; + +const identifier = 'Server Running from the Datadog Synthetics Plugin'; +const triedPorts: number[] = []; +export const getPort = (): number => { + const MIN_PORT = 49152; + const MAX_PORT = 65535; + const port = Math.floor(Math.random() * (MAX_PORT - MIN_PORT + 1)) + MIN_PORT; + if (triedPorts.includes(port)) { + return getPort(); + } + triedPorts.push(port); + return port; +}; + +const killServer = (server?: Server) => { + if (!server) { + return; + } + server.close(); + server.closeAllConnections(); + server.closeIdleConnections(); +}; + +// Keep it global to avoid creating a new server on each run. +let SERVER: Server | undefined; +const RUNNING_SERVERS: number[] = []; + +const verifyServer = async ( + server: Server, + port: number, + cb: (portUsed: number) => Promise | void, +): Promise => { + // Listen for errors. + const errorListener = async (e: any) => { + server.removeListener('listening', successListener); + // If the port is already in use. + if (e.code === 'EADDRINUSE') { + // Verify if another instance of the plugin is running on this port. + const resp = await doRequest({ + url: `http://127.0.0.1:${port}/${API_PREFIX}/build-status`, + retries: 0, + type: 'json', + }); + + if (resp.identifier === identifier) { + // Another instance of the plugin is running on this port. + // Instrument the other server so we can piggyback on it. + return verifyServer(server, getPort(), cb); + } else { + // We have something else running here. + // Throw an error as the feature can't work. + throw new Error(`Something else is running on port ${port}.`); + } + } else { + // Something else happened. + // Forward the error. + throw e; + } + }; + + const successListener = async () => { + // Remove the error listener. + server.removeListener('error', errorListener); + // Callback to communicate the port used. + await cb(port); + }; + + server.once('error', errorListener); + server.once('listening', successListener); + server.listen(port); +}; + +const setupMasterServer = (routes: Routes, log: Logger, runningServers: number[]) => { + // The master server should forward file request to its sub servers. + // Could use middleware to do this. + // It should also have a register route. + routes[`/${API_PREFIX}/register`] = { + get: (req, res) => { + const sendError = (message: string) => { + res.writeHead(400, { 'Content-Type': 'text/html' }); + res.end(message); + }; + + if (!req.url) { + return sendError('Missing URL.'); + } + + const query = url.parse(req.url, true).query; + if (!query) { + return sendError('Missing query.'); + } + + if (!query.port) { + return sendError('Missing port.'); + } + + log.debug(`Registering port ${query.port} to the master server.`); + const portsToRegister = Array.isArray(query.port) ? query.port : [query.port]; + runningServers.push(...portsToRegister.map(Number)); + + res.writeHead(200, { 'Content-Type': 'text/html' }); + res.end('ok'); + }, + }; +}; + +const setupSubServer = async (masterPort: number, port: number) => { + // The sub server should register to the master server. + await doRequest({ + url: `http://127.0.0.1:${masterPort}/${API_PREFIX}/register?port=${port}`, + }); +}; + +export const getServerPlugin = ( + opts: Options, + options: SyntheticsOptionsWithDefaults, + log: Logger, +): PluginOptions => { + // This is the mutable response the server will use to report the build's status. + const response: ServerResponse = { + publicPath: opts[ERROR_TRACKING]?.sourcemaps?.minifiedPathPrefix, + status: 'running', + identifier, + }; + + const routes: Routes = { + // TODO: The master server should forward file request to its sub servers. + // Could use a special __catch_all__, "/*" or "/" route. + // Route to get the build status. + [`/${API_PREFIX}/build-status`]: { + get: (req, res) => { + // TODO: Status should be based on all the running servers. + res.writeHead(200, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify(response)); + }, + }, + // Route to kill the server. + [`/${API_PREFIX}/kill`]: { + get: (req, res) => { + // TODO: Kill all the sub-servers if we're the master server. + res.writeHead(200, { 'Content-Type': 'text/html' }); + res.end('ok'); + // kill kill kill. + killServer(SERVER); + }, + }, + }; + + return { + name: PLUGIN_NAME, + // Wait for us to have the bundler report to start the server over the outDir. + bundlerReport(bundlerReport) { + response.outDir = bundlerReport.outDir; + if (options.server?.run && !SERVER) { + const port = options.server.port; + + // Only create the server first. + SERVER = getServer({ + root: response.outDir, + routes, + }); + + try { + verifyServer(SERVER, port, async (portUsed) => { + if (portUsed === port) { + log.debug(`Setting up master server on port ${portUsed}.`); + setupMasterServer(routes, log, RUNNING_SERVERS); + } else { + log.debug(`Setting up sub server on port ${portUsed}.`); + await setupSubServer(port, portUsed); + } + }); + } catch (e) { + log.error(`Error starting Synthetics local server: ${e}`); + } + } + }, + buildReport(buildReport) { + if (buildReport.errors.length) { + response.status = 'fail'; + } + }, + writeBundle() { + response.status = 'success'; + }, + esbuild: { + setup(build) { + build.onDispose(() => { + // We kill the plugin when the build is disposed. + killServer(SERVER); + }); + }, + }, + }; +}; diff --git a/packages/plugins/synthetics/src/index.test.ts b/packages/plugins/synthetics/src/index.test.ts new file mode 100644 index 000000000..5f3acd51a --- /dev/null +++ b/packages/plugins/synthetics/src/index.test.ts @@ -0,0 +1,314 @@ +// 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 { runServer } from '@dd/core/helpers/server'; +import { getContext } from '@dd/factory/helpers'; +import { API_PREFIX } from '@dd/synthetics-plugin/constants'; +import type { ServerResponse } from '@dd/synthetics-plugin/types'; +import { getPlugins } from '@dd/synthetics-plugin'; +import { getContextMock, mockLogFn, mockLogger } from '@dd/tests/_jest/helpers/mocks'; +import { BUNDLERS, runBundlers } from '@dd/tests/_jest/helpers/runBundlers'; +import fs from 'fs'; +import nock from 'nock'; +import path from 'path'; + +jest.mock('@dd/core/helpers/server', () => { + const original = jest.requireActual('@dd/core/helpers/server'); + return { + ...original, + runServer: jest.fn(original.runServer), + }; +}); + +jest.mock('@dd/factory/helpers', () => { + const original = jest.requireActual('@dd/factory/helpers'); + return { + ...original, + getContext: jest.fn(original.getContext), + }; +}); + +const runServerMocked = jest.mocked(runServer); +const getContextMocked = jest.mocked(getContext); + +// Get an incremental port to prevent conflicts. +let PORT = 1234; +const getPort = () => PORT++; +const getApiUrl = (port: number = getPort()) => `http://127.0.0.1:${port}`; +const getInternalApiUrl = (port: number = getPort()) => `${getApiUrl(port)}/${API_PREFIX}`; +const safeFetch = async (route: string, port: number) => { + try { + return await fetch(`${getInternalApiUrl(port)}${route}`); + } catch (e) { + // Do nothing. + } +}; + +// Wait for the local server to tell us that the build is complete. +const waitingForBuild = (port: number, cb: (resp: ServerResponse) => void) => { + return new Promise((resolve, reject) => { + // Stop the polling after 10 seconds. + const timeout = setTimeout(() => { + clearInterval(interval); + reject(new Error('Timeout.')); + }, 10000); + + // Poll all the local servers until we get the build status. + const interval = setInterval(async () => { + const res = await safeFetch('/build-status', port); + if (res?.ok) { + const data = (await res.json()) as ServerResponse; + cb(data); + + if (['success', 'fail'].includes(data.status)) { + clearInterval(interval); + clearTimeout(timeout); + + if (data.status === 'success') { + resolve(); + } + if (data.status === 'fail') { + reject(new Error('Build failed.')); + } + } + } + }, 100); + }); +}; + +describe('Synthetics Plugin', () => { + describe('getPlugins', () => { + test('Should not initialize the plugin if disabled', async () => { + expect(getPlugins({ synthetics: { disabled: true } }, getContextMock())).toHaveLength( + 0, + ); + }); + + test('Should initialize the plugin if enabled and not configured', async () => { + expect( + getPlugins({ synthetics: { disabled: false } }, getContextMock()).length, + ).toBeGreaterThan(0); + expect(getPlugins({}, getContextMock()).length).toBeGreaterThan(0); + }); + }); + + describe('Server', () => { + beforeAll(() => { + // Allow local server. + nock.enableNetConnect('127.0.0.1'); + }); + + afterAll(() => { + nock.cleanAll(); + nock.disableNetConnect(); + }); + + describe('to run or not to run', () => { + afterEach(async () => { + // Remove the variable we may have set. + delete process.env.BUILD_PLUGINS_S8S_PORT; + }); + + const expectations = [ + { + description: 'not run with no env', + env: {}, + config: {}, + triggers: 1, + expectedNumberOfCalls: 0, + }, + { + description: 'run with port in env', + env: { + BUILD_PLUGINS_S8S_PORT: JSON.stringify(getPort()), + }, + config: {}, + triggers: 1, + expectedNumberOfCalls: 1, + }, + { + description: 'not run with disabled and port in env', + env: { + BUILD_PLUGINS_S8S_PORT: JSON.stringify(getPort()), + }, + config: { + synthetics: { + disabled: true, + }, + }, + triggers: 1, + expectedNumberOfCalls: 0, + }, + { + description: 'not run more than once', + env: { + BUILD_PLUGINS_S8S_PORT: JSON.stringify(getPort()), + }, + config: {}, + triggers: 3, + expectedNumberOfCalls: 1, + }, + ]; + + test.each(expectations)( + 'Should $description.', + async ({ config, env, expectedNumberOfCalls, triggers }) => { + // Set the variables. + Object.assign(process.env, env); + + // Run the plugin. + const [plugin] = getPlugins(config, getContextMock()); + if (plugin?.bundlerReport) { + // Trigger the bundlerReport hook where the server starts. + for (let i = 0; i < triggers; i++) { + plugin.bundlerReport(getContextMock().bundler); + } + } + + // Check the server. + expect(runServerMocked).toHaveBeenCalledTimes(expectedNumberOfCalls); + + // No error should have been logged. + expect(mockLogFn).not.toHaveBeenCalledWith( + expect.stringContaining('Error starting Synthetics local server'), + 'error', + ); + + if (env.BUILD_PLUGINS_S8S_PORT) { + // Kill the server. + await safeFetch('/kill', Number(env.BUILD_PLUGINS_S8S_PORT)); + } + }, + ); + + test('Should not throw if the server fails to start.', async () => { + // Set the variables. + Object.assign(process.env, { + BUILD_PLUGINS_S8S_PORT: JSON.stringify(getPort()), + }); + + // Make `runServer` throw an error. + runServerMocked.mockImplementationOnce(() => { + throw new Error('Not available.'); + }); + + // Run the plugin. + const [plugin] = getPlugins({ synthetics: { disabled: false } }, getContextMock()); + if (plugin?.bundlerReport) { + plugin.bundlerReport(getContextMock().bundler); + } + + expect(runServerMocked).toHaveBeenCalledTimes(1); + expect(mockLogFn).toHaveBeenCalledWith( + expect.stringContaining('Not available'), + 'error', + ); + }); + }); + + // We need to loop over bundlers because we'll use a different port for each one of them + // to avoid port conflicts. + describe.each(BUNDLERS)('$name', (bundler) => { + const port = getPort(); + + let buildProm: Promise; + let outDir: string; + const serverResponses: Set = new Set(); + + beforeEach(async () => { + // Set the variables. + Object.assign(process.env, { + BUILD_PLUGINS_S8S_PORT: JSON.stringify(port), + }); + + // Mock the getPlugins function to mock the context. + getContextMocked.mockImplementation((opts) => { + const original = jest.requireActual('@dd/factory/helpers'); + const mockContext = original.getContext(opts); + mockContext.getLogger = jest.fn(() => mockLogger); + return mockContext; + }); + + // Run the builds. + // Do not await the promise as the server will be running. + buildProm = runBundlers( + { + // Use a custom plugin to get the cwd and outdir of the build. + customPlugins: () => [ + { + name: 'get-outdirs', + bundlerReport: (report) => { + outDir = report.outDir; + }, + }, + ], + }, + undefined, + [bundler.name], + ); + + // Instead, wait for the server to tell us that the build is complete. + await waitingForBuild(port, (resp) => { + serverResponses.add(resp); + }); + }); + + afterEach(async () => { + // Remove the variable we may have set. + delete process.env.BUILD_PLUGINS_S8S_PORT; + // Kill the server. + await safeFetch('/kill', port); + // Wait for the build to finish now that the server is killed. + if (buildProm) { + await buildProm; + } + }); + + // NOTE: Due to how mocks reset and the beforeEach/afterEach, we need to have only one test. + test('Should respond as expected.', async () => { + // Build status. + // Verify that we have the running and success statuses. + const reportedStatus = Array.from(serverResponses).filter((resp) => + ['fail', 'success', 'running'].includes(resp.status), + ); + expect(reportedStatus.length).toBeGreaterThan(0); + + // Outdir. + // Verify that we have the running and success statuses. + const reportedOutDirs = new Set( + Array.from(serverResponses).map((resp) => resp.outDir), + ); + // We should have only one outDir. + expect(reportedOutDirs.size).toBe(1); + // It should be the same as the one we reported from the build. + expect(reportedOutDirs.values().next().value).toEqual(outDir); + + // Built files. + // Query a file from the server. + const res = await fetch(`${getApiUrl(port)}/main.js`); + expect(res.ok).toBe(true); + const text = await res.text(); + // Confirm that the file served by the server is the same as the one on disk. + expect(text).toEqual(fs.readFileSync(path.join(outDir, 'main.js'), 'utf-8')); + + // Only one log should have been called. + expect(mockLogFn).toHaveBeenCalledWith( + expect.stringContaining('Starting Synthetics local server'), + 'debug', + ); + const nbCalls = mockLogFn.mock.calls.filter( + ([message, level]) => + message.includes('Starting Synthetics local server') && level === 'debug', + ).length; + expect(nbCalls).toBe(1); + + // No error should have been logged. + expect(mockLogFn).not.toHaveBeenCalledWith( + expect.stringContaining('Error starting Synthetics local server'), + 'error', + ); + }); + }); + }); +}); diff --git a/packages/plugins/synthetics/src/index.ts b/packages/plugins/synthetics/src/index.ts new file mode 100644 index 000000000..98a3d73d7 --- /dev/null +++ b/packages/plugins/synthetics/src/index.ts @@ -0,0 +1,29 @@ +// 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 { GlobalContext, GetPlugins, Options } from '@dd/core/types'; + +import { CONFIG_KEY, PLUGIN_NAME } from './constants'; +import { getServerPlugin } from './helpers/server'; +import type { SyntheticsOptions } from './types'; +import { validateOptions } from './validate'; + +export { CONFIG_KEY, PLUGIN_NAME }; + +export type types = { + // Add the types you'd like to expose here. + SyntheticsOptions: SyntheticsOptions; +}; + +export const getPlugins: GetPlugins = (opts: Options, context: GlobalContext) => { + const log = context.getLogger(PLUGIN_NAME); + // Verify configuration. + const options = validateOptions(opts); + + if (options.disabled) { + return []; + } + + return [getServerPlugin(opts, options, log)]; +}; diff --git a/packages/plugins/synthetics/src/types.ts b/packages/plugins/synthetics/src/types.ts new file mode 100644 index 000000000..a7dbb554d --- /dev/null +++ b/packages/plugins/synthetics/src/types.ts @@ -0,0 +1,32 @@ +// 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 { Assign, Ensure } from '@dd/core/types'; + +export type ServerOptions = { + port: number; + run: boolean; +}; + +export type SyntheticsOptions = { + disabled?: boolean; +}; + +export type SyntheticsOptionsWithDefaults = Assign< + Ensure, + { + server?: ServerOptions; + } +>; + +export type BuildStatus = 'running' | 'success' | 'fail'; +export type ServerResponse = { + outDir?: string; + publicPath?: string; + status: BuildStatus; + /** + * Used to identify the server in case of multiple instances. + */ + identifier: string; +}; diff --git a/packages/plugins/synthetics/src/validate.ts b/packages/plugins/synthetics/src/validate.ts new file mode 100644 index 000000000..196179714 --- /dev/null +++ b/packages/plugins/synthetics/src/validate.ts @@ -0,0 +1,30 @@ +// 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 { CONFIG_KEY } from './constants'; +import type { SyntheticsOptionsWithDefaults } from './types'; + +export const validateOptions = (config: Options): SyntheticsOptionsWithDefaults => { + // Get values from environment. + const { BUILD_PLUGINS_S8S_PORT } = process.env; + + // Define defaults. + const validatedOptions: SyntheticsOptionsWithDefaults = { + // We don't want to disable it by default. + disabled: false, + ...config[CONFIG_KEY], + }; + + // We will only run the server if we have a port defined in the environment. + if (BUILD_PLUGINS_S8S_PORT) { + validatedOptions.server = { + run: true, + port: +BUILD_PLUGINS_S8S_PORT, + }; + } + + return validatedOptions; +}; diff --git a/packages/plugins/synthetics/tsconfig.json b/packages/plugins/synthetics/tsconfig.json new file mode 100644 index 000000000..6c1d3065e --- /dev/null +++ b/packages/plugins/synthetics/tsconfig.json @@ -0,0 +1,10 @@ +{ + "extends": "../../../tsconfig.json", + "compilerOptions": { + "baseUrl": "./", + "rootDir": "./", + "outDir": "./dist" + }, + "include": ["**/*"], + "exclude": ["dist", "node_modules"] +} \ No newline at end of file diff --git a/packages/published/esbuild-plugin/src/index.ts b/packages/published/esbuild-plugin/src/index.ts index 6ff523630..0444ce184 100644 --- a/packages/published/esbuild-plugin/src/index.ts +++ b/packages/published/esbuild-plugin/src/index.ts @@ -11,6 +11,7 @@ import type { // #types-export-injection-marker ErrorTrackingTypes, RumTypes, + SyntheticsTypes, TelemetryTypes, // #types-export-injection-marker } from '@dd/factory'; @@ -24,6 +25,7 @@ export type { // #types-export-injection-marker ErrorTrackingTypes, RumTypes, + SyntheticsTypes, TelemetryTypes, // #types-export-injection-marker }; diff --git a/packages/published/rollup-plugin/src/index.ts b/packages/published/rollup-plugin/src/index.ts index 59c46cba7..55b7cb292 100644 --- a/packages/published/rollup-plugin/src/index.ts +++ b/packages/published/rollup-plugin/src/index.ts @@ -11,6 +11,7 @@ import type { // #types-export-injection-marker ErrorTrackingTypes, RumTypes, + SyntheticsTypes, TelemetryTypes, // #types-export-injection-marker } from '@dd/factory'; @@ -24,6 +25,7 @@ export type { // #types-export-injection-marker ErrorTrackingTypes, RumTypes, + SyntheticsTypes, TelemetryTypes, // #types-export-injection-marker }; diff --git a/packages/published/rspack-plugin/src/index.ts b/packages/published/rspack-plugin/src/index.ts index aec0805e7..c36b2f570 100644 --- a/packages/published/rspack-plugin/src/index.ts +++ b/packages/published/rspack-plugin/src/index.ts @@ -11,6 +11,7 @@ import type { // #types-export-injection-marker ErrorTrackingTypes, RumTypes, + SyntheticsTypes, TelemetryTypes, // #types-export-injection-marker } from '@dd/factory'; @@ -24,6 +25,7 @@ export type { // #types-export-injection-marker ErrorTrackingTypes, RumTypes, + SyntheticsTypes, TelemetryTypes, // #types-export-injection-marker }; diff --git a/packages/published/vite-plugin/src/index.ts b/packages/published/vite-plugin/src/index.ts index 7ea5cfbf6..492cbadd9 100644 --- a/packages/published/vite-plugin/src/index.ts +++ b/packages/published/vite-plugin/src/index.ts @@ -11,6 +11,7 @@ import type { // #types-export-injection-marker ErrorTrackingTypes, RumTypes, + SyntheticsTypes, TelemetryTypes, // #types-export-injection-marker } from '@dd/factory'; @@ -24,6 +25,7 @@ export type { // #types-export-injection-marker ErrorTrackingTypes, RumTypes, + SyntheticsTypes, TelemetryTypes, // #types-export-injection-marker }; diff --git a/packages/published/webpack-plugin/src/index.ts b/packages/published/webpack-plugin/src/index.ts index 24a29bbfd..e7e847877 100644 --- a/packages/published/webpack-plugin/src/index.ts +++ b/packages/published/webpack-plugin/src/index.ts @@ -11,6 +11,7 @@ import type { // #types-export-injection-marker ErrorTrackingTypes, RumTypes, + SyntheticsTypes, TelemetryTypes, // #types-export-injection-marker } from '@dd/factory'; @@ -24,6 +25,7 @@ export type { // #types-export-injection-marker ErrorTrackingTypes, RumTypes, + SyntheticsTypes, TelemetryTypes, // #types-export-injection-marker }; diff --git a/yarn.lock b/yarn.lock index 5466520b7..700d17de1 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1690,6 +1690,7 @@ __metadata: "@dd/internal-git-plugin": "workspace:*" "@dd/internal-injection-plugin": "workspace:*" "@dd/rum-plugin": "workspace:*" + "@dd/synthetics-plugin": "workspace:*" "@dd/telemetry-plugin": "workspace:*" chalk: "npm:2.3.1" unplugin: "npm:1.16.0" @@ -1756,6 +1757,16 @@ __metadata: languageName: unknown linkType: soft +"@dd/synthetics-plugin@workspace:*, @dd/synthetics-plugin@workspace:packages/plugins/synthetics": + version: 0.0.0-use.local + resolution: "@dd/synthetics-plugin@workspace:packages/plugins/synthetics" + dependencies: + "@dd/core": "workspace:*" + "@dd/error-tracking-plugin": "workspace:*" + chalk: "npm:2.3.1" + languageName: unknown + linkType: soft + "@dd/telemetry-plugin@workspace:*, @dd/telemetry-plugin@workspace:packages/plugins/telemetry": version: 0.0.0-use.local resolution: "@dd/telemetry-plugin@workspace:packages/plugins/telemetry"