diff --git a/src/commands/synthetics/__tests__/cli.test.ts b/src/commands/synthetics/__tests__/cli.test.ts index d742bb11b..4d35a4cf5 100644 --- a/src/commands/synthetics/__tests__/cli.test.ts +++ b/src/commands/synthetics/__tests__/cli.test.ts @@ -5,9 +5,11 @@ import {toBoolean, toNumber, toStringMap} from '../../../helpers/env' import * as ciUtils from '../../../helpers/utils' import * as api from '../api' +import {DEFAULT_IMPORT_TESTS_COMMAND_CONFIG, ImportTestsCommand} from '../import-tests-command' import { CookiesObject, ExecutionRule, + ImportTestsCommandConfig, RunTestsCommandConfig, ServerTest, UploadApplicationCommandConfig, @@ -1592,3 +1594,120 @@ describe('upload-application', () => { }) }) }) + +describe('import-tests', () => { + beforeEach(() => { + process.env = {} + jest.restoreAllMocks() + }) + + describe('resolveConfig', () => { + beforeEach(() => { + process.env = {} + }) + + test('override from ENV', async () => { + const overrideEnv = { + DATADOG_API_KEY: 'fake_api_key', + DATADOG_APP_KEY: 'fake_app_key', + DATADOG_SYNTHETICS_CONFIG_PATH: 'path/to/config.json', + DATADOG_SITE: 'datadoghq.eu', + DATADOG_SYNTHETICS_FILES: 'test-file1;test-file2;test-file3', + DATADOG_SYNTHETICS_PUBLIC_IDS: 'a-public-id;another-public-id', + DATADOG_SYNTHETICS_TEST_SEARCH_QUERY: 'a-search-query', + } + + process.env = overrideEnv + const command = createCommand(ImportTestsCommand) + + await command['resolveConfig']() + expect(command['config']).toEqual({ + ...DEFAULT_IMPORT_TESTS_COMMAND_CONFIG, + apiKey: overrideEnv.DATADOG_API_KEY, + appKey: overrideEnv.DATADOG_APP_KEY, + configPath: overrideEnv.DATADOG_SYNTHETICS_CONFIG_PATH, + datadogSite: overrideEnv.DATADOG_SITE, + files: overrideEnv.DATADOG_SYNTHETICS_FILES?.split(';'), + publicIds: overrideEnv.DATADOG_SYNTHETICS_PUBLIC_IDS?.split(';'), + testSearchQuery: overrideEnv.DATADOG_SYNTHETICS_TEST_SEARCH_QUERY, + }) + }) + + test('override from config file', async () => { + const expectedConfig: ImportTestsCommandConfig = { + apiKey: 'fake_api_key', + appKey: 'fake_app_key', + configPath: 'src/commands/synthetics/__tests__/config-fixtures/import-tests-config-with-all-keys.json', + datadogSite: 'datadoghq.eu', + files: ['my-new-file'], + proxy: {protocol: 'http'}, + publicIds: ['ran-dom-id1'], + testSearchQuery: 'a-search-query', + } + + const command = createCommand(ImportTestsCommand) + command['configPath'] = 'src/commands/synthetics/__tests__/config-fixtures/import-tests-config-with-all-keys.json' + + await command['resolveConfig']() + expect(command['config']).toEqual(expectedConfig) + }) + + test('override from CLI', async () => { + const overrideCLI: Omit = { + apiKey: 'fake_api_key_cli', + appKey: 'fake_app_key_cli', + configPath: 'src/commands/synthetics/__tests__/config-fixtures/empty-config-file.json', + datadogSite: 'datadoghq.cli', + files: ['new-file'], + publicIds: ['ran-dom-id2'], + testSearchQuery: 'a-search-query', + } + + const command = createCommand(ImportTestsCommand) + command['apiKey'] = overrideCLI.apiKey + command['appKey'] = overrideCLI.appKey + command['configPath'] = overrideCLI.configPath + command['datadogSite'] = overrideCLI.datadogSite + command['files'] = overrideCLI.files + command['publicIds'] = overrideCLI.publicIds + command['testSearchQuery'] = overrideCLI.testSearchQuery + + await command['resolveConfig']() + expect(command['config']).toEqual({ + ...DEFAULT_IMPORT_TESTS_COMMAND_CONFIG, + apiKey: 'fake_api_key_cli', + appKey: 'fake_app_key_cli', + configPath: 'src/commands/synthetics/__tests__/config-fixtures/empty-config-file.json', + datadogSite: 'datadoghq.cli', + files: ['new-file'], + publicIds: ['ran-dom-id2'], + testSearchQuery: 'a-search-query', + }) + }) + + test('override from config file < ENV < CLI', async () => { + jest.spyOn(ciUtils, 'resolveConfigFromFile').mockImplementationOnce(async (baseConfig: T) => ({ + ...baseConfig, + apiKey: 'api_key_config_file', + appKey: 'app_key_config_file', + datadogSite: 'us5.datadoghq.com', + })) + + process.env = { + DATADOG_API_KEY: 'api_key_env', + DATADOG_APP_KEY: 'app_key_env', + } + + const command = createCommand(ImportTestsCommand) + command['apiKey'] = 'api_key_cli' + + await command['resolveConfig']() + expect(command['config']).toEqual({ + ...DEFAULT_IMPORT_TESTS_COMMAND_CONFIG, + apiKey: 'api_key_cli', + appKey: 'app_key_env', + datadogSite: 'us5.datadoghq.com', + }) + }) + }) +}) diff --git a/src/commands/synthetics/__tests__/config-fixtures/import-tests-config-with-all-keys.json b/src/commands/synthetics/__tests__/config-fixtures/import-tests-config-with-all-keys.json new file mode 100644 index 000000000..be61c7d6b --- /dev/null +++ b/src/commands/synthetics/__tests__/config-fixtures/import-tests-config-with-all-keys.json @@ -0,0 +1,13 @@ +{ + "apiKey": "fake_api_key", + "appKey": "fake_app_key", + "configPath": "fake-datadog-ci.json", + "datadogSite": "datadoghq.eu", + "files": [ + "my-new-file" + ], + "publicIds": [ + "ran-dom-id1" + ], + "testSearchQuery": "a-search-query" +} \ No newline at end of file diff --git a/src/commands/synthetics/__tests__/fixtures.ts b/src/commands/synthetics/__tests__/fixtures.ts index c3e281561..f0aacba5f 100644 --- a/src/commands/synthetics/__tests__/fixtures.ts +++ b/src/commands/synthetics/__tests__/fixtures.ts @@ -604,6 +604,7 @@ export const mockApi = (override?: Partial): APIHelper => { getBatch: jest.fn(), getMobileApplicationPresignedURLs: jest.fn(), getTest: jest.fn(), + getTestWithType: jest.fn(), getSyntheticsOrgSettings: jest.fn(), getTunnelPresignedURL: jest.fn(), pollResults: jest.fn(), diff --git a/src/commands/synthetics/__tests__/import-tests-lib.test.ts b/src/commands/synthetics/__tests__/import-tests-lib.test.ts new file mode 100644 index 000000000..4bfc50354 --- /dev/null +++ b/src/commands/synthetics/__tests__/import-tests-lib.test.ts @@ -0,0 +1,213 @@ +jest.mock('fs/promises') +import * as fsPromises from 'fs/promises' + +import * as api from '../api' +import {DEFAULT_IMPORT_TESTS_COMMAND_CONFIG} from '../import-tests-command' +import {importTests} from '../import-tests-lib' +import {TriggerConfig} from '../interfaces' +import * as tests from '../test' + +import {getApiTest, getBrowserTest, mockApi, mockReporter} from './fixtures' + +describe('import-tests', () => { + beforeEach(() => { + jest.restoreAllMocks() + jest.mock('fs/promises', () => ({ + writeFile: jest.fn().mockResolvedValue(undefined), + })) + process.env = {} + }) + + describe('importTests', () => { + test('we write imported test to file', async () => { + const filePath = 'test.synthetics.json' + const config = DEFAULT_IMPORT_TESTS_COMMAND_CONFIG + config['files'] = [filePath] + config['publicIds'] = ['123-456-789'] + + const mockTest = getApiTest(config['publicIds'][0]) + // eslint-disable-next-line @typescript-eslint/naming-convention + const {message, monitor_id, status, tags, ...mockTestWithoutUnsupportedFields} = mockTest + const mockLTD = { + tests: [ + { + local_test_definition: { + ...mockTestWithoutUnsupportedFields, + }, + }, + ], + } + // eslint-disable-next-line no-null/no-null + const jsonData = JSON.stringify(mockLTD, null, 2) + + const apiHelper = mockApi({ + getTest: jest.fn(() => { + return Promise.resolve(mockTest) + }), + }) + jest.spyOn(api, 'getApiHelper').mockImplementation(() => apiHelper as any) + jest.spyOn(tests, 'getTestConfigs').mockImplementation(async () => []) + + await importTests(mockReporter, config) + + expect(apiHelper.getTest).toHaveBeenCalledTimes(1) + expect(tests.getTestConfigs).toHaveBeenCalledTimes(1) + expect(fsPromises.writeFile).toHaveBeenCalledTimes(1) + expect(fsPromises.writeFile).toHaveBeenCalledWith(filePath, jsonData, 'utf8') + }) + + test('we can fetch multiple public_ids', async () => { + const filePath = 'test.synthetics.json' + const config = DEFAULT_IMPORT_TESTS_COMMAND_CONFIG + config['files'] = [filePath] + config['publicIds'] = ['123-456-789', '987-654-321'] + + const mockTest = getApiTest(config['publicIds'][0]) + // eslint-disable-next-line @typescript-eslint/naming-convention + const {message, monitor_id, status, tags, ...mockTestWithoutUnsupportedFields} = mockTest + const mockLTD = { + tests: [ + { + local_test_definition: { + ...mockTestWithoutUnsupportedFields, + public_id: config['publicIds'][0], + }, + }, + { + local_test_definition: { + ...mockTestWithoutUnsupportedFields, + public_id: config['publicIds'][1], + }, + }, + ], + } + // eslint-disable-next-line no-null/no-null + const expectedJsonData = JSON.stringify(mockLTD, null, 2) + + const apiHelper = mockApi({ + getTest: jest + .fn() + .mockReturnValueOnce(Promise.resolve(mockTest)) + .mockReturnValueOnce(Promise.resolve({...mockTest, public_id: config['publicIds'][1]})), + }) + jest.spyOn(api, 'getApiHelper').mockImplementation(() => apiHelper as any) + jest.spyOn(tests, 'getTestConfigs').mockImplementation(async () => []) + + await importTests(mockReporter, config) + + expect(apiHelper.getTest).toHaveBeenCalledTimes(2) + expect(tests.getTestConfigs).toHaveBeenCalledTimes(1) + expect(fsPromises.writeFile).toHaveBeenCalledTimes(1) + expect(fsPromises.writeFile).toHaveBeenCalledWith(filePath, expectedJsonData, 'utf8') + }) + + test('we write browser test', async () => { + const filePath = 'test.synthetics.json' + const config = DEFAULT_IMPORT_TESTS_COMMAND_CONFIG + config['files'] = [filePath] + config['publicIds'] = ['123-456-789'] + + const mockTest = getBrowserTest(config['publicIds'][0]) + // eslint-disable-next-line @typescript-eslint/naming-convention + const {message, monitor_id, status, tags, ...mockTestWithoutUnsupportedFields} = mockTest + const mockLTD = { + tests: [ + { + local_test_definition: { + ...mockTestWithoutUnsupportedFields, + }, + }, + ], + } + // eslint-disable-next-line no-null/no-null + const jsonData = JSON.stringify(mockLTD, null, 2) + + const apiHelper = mockApi({ + getTest: jest.fn(() => { + return Promise.resolve(mockTest) + }), + getTestWithType: jest.fn(() => { + return Promise.resolve(mockTest) + }), + }) + jest.spyOn(api, 'getApiHelper').mockImplementation(() => apiHelper as any) + jest.spyOn(tests, 'getTestConfigs').mockImplementation(async () => []) + + await importTests(mockReporter, config) + + expect(apiHelper.getTest).toHaveBeenCalledTimes(1) + expect(apiHelper.getTestWithType).toHaveBeenCalledTimes(1) + expect(tests.getTestConfigs).toHaveBeenCalledTimes(1) + expect(fsPromises.writeFile).toHaveBeenCalledTimes(1) + expect(fsPromises.writeFile).toHaveBeenCalledWith(filePath, jsonData, 'utf8') + }) + + test('we write imported test to already existing file', async () => { + const filePath = 'test.synthetics.json' + const config = DEFAULT_IMPORT_TESTS_COMMAND_CONFIG + config['files'] = [filePath] + config['publicIds'] = ['123-456-789', '987-654-321'] + + const mockTest = getApiTest(config['publicIds'][0]) + + const mockTriggerConfig: TriggerConfig[] = [ + { + local_test_definition: { + ...mockTest, + public_id: 'abc-def-ghi', + }, + }, + { + local_test_definition: { + ...mockTest, + public_id: config['publicIds'][0], + }, + }, + ] + + // eslint-disable-next-line @typescript-eslint/naming-convention + const {message, monitor_id, status, tags, ...mockTestWithoutUnsupportedFields} = mockTest + const expectedLTD = { + tests: [ + { + local_test_definition: { + ...mockTest, + public_id: 'abc-def-ghi', + }, + }, + { + local_test_definition: { + ...mockTestWithoutUnsupportedFields, + public_id: config['publicIds'][0], + name: 'Some other name', + }, + }, + { + local_test_definition: { + ...mockTestWithoutUnsupportedFields, + public_id: config['publicIds'][1], + }, + }, + ], + } + // eslint-disable-next-line no-null/no-null + const expectedJsonData = JSON.stringify(expectedLTD, null, 2) + + const apiHelper = mockApi({ + getTest: jest + .fn() + .mockReturnValueOnce(Promise.resolve({...mockTest, name: 'Some other name'})) + .mockReturnValueOnce(Promise.resolve({...mockTest, public_id: config['publicIds'][1]})), + }) + jest.spyOn(api, 'getApiHelper').mockImplementation(() => apiHelper as any) + jest.spyOn(tests, 'getTestConfigs').mockResolvedValue(mockTriggerConfig) + + await importTests(mockReporter, config) + + expect(apiHelper.getTest).toHaveBeenCalledTimes(2) + expect(tests.getTestConfigs).toHaveBeenCalledTimes(1) + expect(fsPromises.writeFile).toHaveBeenCalledTimes(1) + expect(fsPromises.writeFile).toHaveBeenCalledWith(filePath, expectedJsonData, 'utf8') + }) + }) +}) diff --git a/src/commands/synthetics/api.ts b/src/commands/synthetics/api.ts index 8c179d5ea..af94c863c 100644 --- a/src/commands/synthetics/api.ts +++ b/src/commands/synthetics/api.ts @@ -95,6 +95,21 @@ const getTest = (request: (args: AxiosRequestConfig) => AxiosPromise return resp.data } +const getTestWithType = (request: (args: AxiosRequestConfig) => AxiosPromise) => async ( + testId: string, + testType: string +) => { + const resp = await retryRequest( + { + url: `/synthetics/tests/${testType}/${testId}`, + }, + request, + {retryOn429: true} + ) + + return resp.data +} + const searchTests = (request: (args: AxiosRequestConfig) => AxiosPromise) => async ( query: string ) => { @@ -353,6 +368,7 @@ export const apiConstructor = (configuration: APIConfiguration) => { getBatch: getBatch(request), getMobileApplicationPresignedURLs: getMobileApplicationPresignedURLs(requestUnstable), getTest: getTest(request), + getTestWithType: getTestWithType(request), getSyntheticsOrgSettings: getSyntheticsOrgSettings(request), getTunnelPresignedURL: getTunnelPresignedURL(requestIntake), pollResults: pollResults(request), diff --git a/src/commands/synthetics/cli.ts b/src/commands/synthetics/cli.ts index 4b94cf11c..e4172eff0 100644 --- a/src/commands/synthetics/cli.ts +++ b/src/commands/synthetics/cli.ts @@ -1,4 +1,5 @@ +import {ImportTestsCommand} from './import-tests-command' import {RunTestsCommand} from './run-tests-command' import {UploadApplicationCommand} from './upload-application-command' -module.exports = [RunTestsCommand, UploadApplicationCommand] +module.exports = [RunTestsCommand, UploadApplicationCommand, ImportTestsCommand] diff --git a/src/commands/synthetics/import-tests-command.ts b/src/commands/synthetics/import-tests-command.ts new file mode 100644 index 000000000..c3fcef5c9 --- /dev/null +++ b/src/commands/synthetics/import-tests-command.ts @@ -0,0 +1,140 @@ +import {Command, Option} from 'clipanion' +import deepExtend from 'deep-extend' +import terminalLink from 'terminal-link' + +import {FIPS_ENV_VAR, FIPS_IGNORE_ERROR_ENV_VAR} from '../../constants' +import {toBoolean} from '../../helpers/env' +import {enableFips} from '../../helpers/fips' +import {LogLevel, Logger} from '../../helpers/logger' +import {removeUndefinedValues, resolveConfigFromFile} from '../../helpers/utils' + +import {importTests} from './import-tests-lib' +import {ImportTestsCommandConfig, MainReporter, Reporter} from './interfaces' +import {DefaultReporter} from './reporters/default' +import {getReporter} from './utils/public' + +export const DEFAULT_IMPORT_TESTS_COMMAND_CONFIG: ImportTestsCommandConfig = { + apiKey: '', + appKey: '', + configPath: 'datadog-ci.json', + datadogSite: 'datadoghq.com', + files: [], + proxy: {protocol: 'http'}, + publicIds: [], + testSearchQuery: '', +} + +const configurationLink = 'https://docs.datadoghq.com/continuous_testing/cicd_integrations/configuration' + +const $1 = (text: string) => terminalLink(text, `${configurationLink}#global-configuration-file-options`) +const $2 = (text: string) => terminalLink(text, `${configurationLink}#test-files`) + +export class ImportTestsCommand extends Command { + public static paths = [['synthetics', 'import-tests']] + + public static usage = Command.Usage({ + category: 'Synthetics', + description: 'Import the Main Test Definition from a Datadog scheduled tests as a Local Test Definitions.', + details: ` + This command imports a Main Test Definition from a Datadog scheduled tests as a Local Test Definitions to be used in local development. + `, + examples: [ + [ + 'Explicitly specify multiple tests to run', + 'datadog-ci synthetics import-tests --public-id pub-lic-id1 --public-id pub-lic-id2', + ], + ['Override the default glob pattern', 'datadog-ci synthetics import-tests -f test-file.synthetics.json'], + ], + }) + + private apiKey = Option.String('--apiKey', {description: 'The API key used to query the Datadog API.'}) + private appKey = Option.String('--appKey', {description: 'The application key used to query the Datadog API.'}) + private configPath = Option.String('--config', {description: `Pass a path to a ${$1('global configuration file')}.`}) + private datadogSite = Option.String('--datadogSite', {description: 'The Datadog instance to which request is sent.'}) + private files = Option.Array('-f,--files', { + description: `Glob pattern to detect Synthetic test files ${$2('configuration files')}} and write to this file.`, + }) + private publicIds = Option.Array('-p,--public-id', {description: 'Specify a test to import.'}) + private testSearchQuery = Option.String('-s,--search', { + description: 'Pass a query to select which Synthetic tests to run.', + }) + + private reporter!: MainReporter + private config: ImportTestsCommandConfig = JSON.parse(JSON.stringify(DEFAULT_IMPORT_TESTS_COMMAND_CONFIG)) // Deep copy to avoid mutation + + private logger: Logger = new Logger((s: string) => { + this.context.stdout.write(s) + }, LogLevel.INFO) + + private fips = Option.Boolean('--fips', false) + private fipsIgnoreError = Option.Boolean('--fips-ignore-error', false) + private fipsConfig = { + fips: toBoolean(process.env[FIPS_ENV_VAR]) ?? false, + fipsIgnoreError: toBoolean(process.env[FIPS_IGNORE_ERROR_ENV_VAR]) ?? false, + } + + public async execute() { + const reporters: Reporter[] = [new DefaultReporter(this)] + this.reporter = getReporter(reporters) + enableFips(this.fips || this.fipsConfig.fips, this.fipsIgnoreError || this.fipsConfig.fipsIgnoreError) + + try { + await this.resolveConfig() + } catch (error) { + this.logger.error(`Error: invalid config`) + + return 1 + } + + try { + await importTests(this.reporter, this.config) + } catch (error) { + this.logger.error(`Error: ${error.message}`) + + return 1 + } + } + + private async resolveConfig() { + // Defaults < file < ENV < CLI + try { + // Override Config Path with ENV variables + const overrideConfigPath = this.configPath ?? process.env.DATADOG_SYNTHETICS_CONFIG_PATH ?? 'datadog-ci.json' + this.config = await resolveConfigFromFile(this.config, { + configPath: overrideConfigPath, + defaultConfigPaths: [this.config.configPath], + }) + } catch (error) { + if (this.configPath) { + throw error + } + } + + this.config = deepExtend( + this.config, + removeUndefinedValues({ + apiKey: process.env.DATADOG_API_KEY, + appKey: process.env.DATADOG_APP_KEY, + configPath: process.env.DATADOG_SYNTHETICS_CONFIG_PATH, + datadogSite: process.env.DATADOG_SITE, + files: process.env.DATADOG_SYNTHETICS_FILES?.split(';'), + publicIds: process.env.DATADOG_SYNTHETICS_PUBLIC_IDS?.split(';'), + testSearchQuery: process.env.DATADOG_SYNTHETICS_TEST_SEARCH_QUERY, + }) + ) + + // Override with CLI parameters + this.config = deepExtend( + this.config, + removeUndefinedValues({ + apiKey: this.apiKey, + appKey: this.appKey, + configPath: this.configPath, + datadogSite: this.datadogSite, + files: this.files, + publicIds: this.publicIds, + testSearchQuery: this.testSearchQuery, + }) + ) + } +} diff --git a/src/commands/synthetics/import-tests-lib.ts b/src/commands/synthetics/import-tests-lib.ts new file mode 100644 index 000000000..7f491aa50 --- /dev/null +++ b/src/commands/synthetics/import-tests-lib.ts @@ -0,0 +1,141 @@ +import {writeFile} from 'fs/promises' + +import {getApiHelper} from './api' +import { + ImportTestsCommandConfig, + LocalTriggerConfig, + MainReporter, + ServerTest, + TestConfig, + TestStepWithUnsupportedFields, +} from './interfaces' +import {getTestConfigs} from './test' +import {isLocalTriggerConfig} from './utils/internal' + +const BASE_FIELDS_TRIM: (keyof ServerTest)[] = [ + 'created_at', + 'created_by', + 'creator', + 'message', + 'modified_at', + 'modified_by', + 'monitor_id', + 'overall_state', + 'overall_state_modified', + 'status', + 'stepCount', + 'tags', + 'version', + 'version_uuid', +] + +const OPTIONS_FIELDS_TRIM: (keyof ServerTest['options'])[] = [ + 'min_failure_duration', + 'min_location_failed', + 'monitor_name', + 'monitor_options', + 'monitor_priority', + 'tick_every', +] + +const STEP_FIELDS_TRIM: (keyof TestStepWithUnsupportedFields)[] = ['public_id'] + +export const importTests = async (reporter: MainReporter, config: ImportTestsCommandConfig): Promise => { + const api = getApiHelper(config) + console.log('Importing tests...') + const testConfigFromBackend: TestConfig = { + tests: [], + } + + for (const publicId of config.publicIds) { + console.log(`Fetching test with public_id: ${publicId}`) + let localTriggerConfig: LocalTriggerConfig + const test = await api.getTest(publicId) + + if (test.type === 'browser') { + const testWithSteps = await api.getTestWithType(publicId, test.type) + localTriggerConfig = {local_test_definition: removeUnsupportedLTDFields(testWithSteps)} + } else if (test.type === 'mobile') { + console.error('Unsupported test type: mobile') + + return + } else { + localTriggerConfig = {local_test_definition: removeUnsupportedLTDFields(test)} + } + testConfigFromBackend.tests.push(localTriggerConfig) + } + + const testConfigFromFile: TestConfig = { + tests: await getTestConfigs(config, reporter), + } + + const testConfig = overwriteTestConfig(testConfigFromBackend, testConfigFromFile) + + // eslint-disable-next-line no-null/no-null + const jsonString = JSON.stringify(testConfig, null, 2) + try { + await writeFile(config.files[0], jsonString, 'utf8') + console.log(`Object has been written to ${config.files[0]}`) + } catch (error) { + console.error('Error writing file:', error) + } +} + +const overwriteTestConfig = (testConfigFromBackend: TestConfig, testConfigFromFile: TestConfig): TestConfig => { + for (const test of testConfigFromBackend.tests) { + const index = testConfigFromFile.tests.findIndex( + (t) => + isLocalTriggerConfig(t) && + isLocalTriggerConfig(test) && + t.local_test_definition.public_id === test.local_test_definition.public_id + ) + + if (index !== -1) { + testConfigFromFile.tests[index] = test + } else { + testConfigFromFile.tests.push(test) + } + } + + return testConfigFromFile +} + +const removeUnsupportedLTDFields = (testConfig: ServerTest): ServerTest => { + for (const field of BASE_FIELDS_TRIM) { + delete testConfig[field] + } + for (const field of OPTIONS_FIELDS_TRIM) { + delete testConfig.options[field] + } + + for (const step of testConfig.steps || []) { + if ('element' in step.params && !!step.params.element) { + if ('multiLocator' in step.params.element && !!step.params.element.multiLocator) { + if ('ab' in step.params.element.multiLocator && !!step.params.element.multiLocator.ab) { + if (!step.params.element.userLocator) { + step.params.element.userLocator = { + values: [ + { + type: 'xpath', + value: step.params.element.multiLocator.ab, + }, + ], + failTestOnCannotLocate: true, + } + } + delete step.params.element.multiLocator + } + if ('bucketKey' in step.params.element) { + delete step.params.element['bucketKey'] + } + } + } + for (const field of STEP_FIELDS_TRIM) { + if (field in step) { + delete step[field] + } + } + } + + return testConfig +} diff --git a/src/commands/synthetics/interfaces.ts b/src/commands/synthetics/interfaces.ts index 905a79e29..74f4acad9 100644 --- a/src/commands/synthetics/interfaces.ts +++ b/src/commands/synthetics/interfaces.ts @@ -232,6 +232,13 @@ export interface Step { }[] } +// TODO SYNTH-17944 Remove unsupported fields + +export interface TestStepWithUnsupportedFields { + public_id?: string + params: any +} + export interface LocalTestDefinition { config: { assertions: Assertion[] @@ -248,32 +255,63 @@ export interface LocalTestDefinition { variables: string[] } locations: string[] - message: string name: string - options: { - ci?: { - executionRule: ExecutionRule - } - device_ids?: string[] - mobileApplication?: MobileApplication - retry?: { - count?: number - } - } + options: OptionsWithUnsupportedFields /** Can be used to link to an existing remote test. */ public_id?: string - subtype: string - tags: string[] + subtype: string // This is optional in the browser and api schemas + steps?: TestStepWithUnsupportedFields[] // From browser schema type: string } -export interface ServerTest extends LocalTestDefinition { +interface Options { + ci?: { + executionRule: ExecutionRule + } + device_ids?: string[] + mobileApplication?: MobileApplication + retry?: { + count?: number + } +} + +// TODO SYNTH-17944 Remove unsupported fields + +export interface OptionsWithUnsupportedFields extends Options { + min_failure_duration?: number + min_location_failed?: any + monitor_name?: string + monitor_options?: any + monitor_priority?: number + tick_every?: number +} + +// TODO SYNTH-17944 Remove unsupported fields +// I think a bunch of these are front-end specific fields +interface LocalTestDefinitionWithUnsupportedFields extends LocalTestDefinition { + created_at?: any + created_by?: any + creator?: any + creation_source?: string + message?: string + modified_at?: any + modified_by?: any + monitor_id?: number + overall_state?: any + overall_state_modified?: any + status?: string + stepCount?: any + tags?: string[] + version?: any + version_uuid?: any +} +export interface ServerTest extends LocalTestDefinitionWithUnsupportedFields { monitor_id: number status: 'live' | 'paused' public_id: string } -export type Test = (ServerTest | LocalTestDefinition) & { +export type Test = (ServerTest | LocalTestDefinitionWithUnsupportedFields) & { suite?: string } @@ -450,6 +488,10 @@ export interface Suite { name?: string } +export interface TestConfig { + tests: TriggerConfig[] +} + export interface Summary { // The batchId is associated to a full run of datadog-ci: multiple suites will be in the same batch. batchId: string @@ -602,3 +644,10 @@ export interface MobileApplicationVersion { version_name: string created_at?: string } + +export interface ImportTestsCommandConfig extends SyntheticsCIConfig { + configPath: string + files: string[] + publicIds: string[] + testSearchQuery?: string +} diff --git a/src/commands/synthetics/reporters/junit.ts b/src/commands/synthetics/reporters/junit.ts index 9fc500c40..d65bbecbc 100644 --- a/src/commands/synthetics/reporters/junit.ts +++ b/src/commands/synthetics/reporters/junit.ts @@ -471,7 +471,7 @@ export class JUnitReporter implements Reporter { ...('monitor_id' in test ? [{$: {name: 'monitor_id', value: test.monitor_id}}] : []), {$: {name: 'public_id', value: publicId}}, ...('status' in test ? [{$: {name: 'status', value: test.status}}] : []), - {$: {name: 'tags', value: test.tags.join(',')}}, + {$: {name: 'tags', value: (test.tags ?? []).join(',')}}, {$: {name: 'type', value: test.type}}, ].filter((prop) => prop.$.value !== undefined), }, @@ -556,7 +556,7 @@ export class JUnitReporter implements Reporter { }, }, ...('status' in test ? [{$: {name: 'status', value: test.status}}] : []), - {$: {name: 'tags', value: test.tags.join(',')}}, + {$: {name: 'tags', value: (test.tags ?? []).join(',')}}, {$: {name: 'timeout', value: String(result.timedOut)}}, {$: {name: 'type', value: test.type}}, ].filter((prop) => prop.$.value !== undefined), diff --git a/src/commands/synthetics/test.ts b/src/commands/synthetics/test.ts index b2e163894..08b061cb8 100644 --- a/src/commands/synthetics/test.ts +++ b/src/commands/synthetics/test.ts @@ -14,13 +14,14 @@ import { Test, TriggerConfig, LocalTestDefinition, + ImportTestsCommandConfig, } from './interfaces' import {DEFAULT_TEST_CONFIG_FILES_GLOB} from './run-tests-command' import {isLocalTriggerConfig} from './utils/internal' import {getSuites, normalizePublicId} from './utils/public' export const getTestConfigs = async ( - config: RunTestsCommandConfig, + config: RunTestsCommandConfig | ImportTestsCommandConfig, reporter: MainReporter, suites: Suite[] = [] ): Promise => {