From eda80d8063b840e4e9535dd720bdc59038d5fc7b Mon Sep 17 00:00:00 2001 From: Teodor Todorov Date: Mon, 13 Jan 2025 17:36:12 +0100 Subject: [PATCH 01/18] add new import-tests command --- src/commands/synthetics/cli.ts | 3 +- .../synthetics/import-tests-command.ts | 130 ++++++++++++++++++ src/commands/synthetics/interfaces.ts | 5 + 3 files changed, 137 insertions(+), 1 deletion(-) create mode 100644 src/commands/synthetics/import-tests-command.ts 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..454b222e2 --- /dev/null +++ b/src/commands/synthetics/import-tests-command.ts @@ -0,0 +1,130 @@ +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 {EndpointError} from './api' +// import {CiError, CriticalError} from './errors' +import {importTests} from './import-tests-lib' +import {ImportTestsCommandConfig} from './interfaces' +// import {uploadMobileApplicationVersion} from './mobile' +// import {AppUploadReporter} from './reporters/mobile/app-upload' + +export const DEFAULT_IMPORT_TESTS_COMMAND_CONFIG: ImportTestsCommandConfig = { + apiKey: '', + appKey: '', + configPath: 'datadog-ci.json', + datadogSite: 'datadoghq.com', + proxy: {protocol: 'http'}, + publicIds: [], + // subdomain: '', +} + +const configurationLink = 'https://docs.datadoghq.com/continuous_testing/cicd_integrations/configuration' + +const $1 = (text: string) => terminalLink(text, `${configurationLink}#global-configuration-file-options`) + +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 ./component-1/**/*.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 publicIds = Option.Array('-p,--public-id', {description: 'Specify a test to import.'}) + + 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() { + 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 + } + + // const appUploadReporter = new AppUploadReporter(this.context) + try { + await importTests(this.config) + } catch (error) { + // if (error instanceof CiError || error instanceof EndpointError || error instanceof CriticalError) { + // 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, + publicIds: process.env.DATADOG_SYNTHETICS_PUBLIC_IDS?.split(';'), + }) + ) + + // Override with CLI parameters + this.config = deepExtend( + this.config, + removeUndefinedValues({ + apiKey: this.apiKey, + appKey: this.appKey, + configPath: this.configPath, + datadogSite: this.datadogSite, + publicIds: this.publicIds, + }) + ) + } +} diff --git a/src/commands/synthetics/interfaces.ts b/src/commands/synthetics/interfaces.ts index 905a79e29..ce75028db 100644 --- a/src/commands/synthetics/interfaces.ts +++ b/src/commands/synthetics/interfaces.ts @@ -602,3 +602,8 @@ export interface MobileApplicationVersion { version_name: string created_at?: string } + +export interface ImportTestsCommandConfig extends SyntheticsCIConfig { + configPath: string + publicIds: string[] +} From 6d2ebb4637573b049da81e652cd962b2c686fe06 Mon Sep 17 00:00:00 2001 From: Teodor Todorov Date: Mon, 13 Jan 2025 17:38:00 +0100 Subject: [PATCH 02/18] add lib to get test --- src/commands/synthetics/import-tests-lib.ts | 11 +++++++++++ 1 file changed, 11 insertions(+) create mode 100644 src/commands/synthetics/import-tests-lib.ts diff --git a/src/commands/synthetics/import-tests-lib.ts b/src/commands/synthetics/import-tests-lib.ts new file mode 100644 index 000000000..b4ca77aa9 --- /dev/null +++ b/src/commands/synthetics/import-tests-lib.ts @@ -0,0 +1,11 @@ +import {getApiHelper} from './api' +import {ImportTestsCommandConfig} from './interfaces' + +export const importTests = async (config: ImportTestsCommandConfig) => { + const api = getApiHelper(config) + console.log('Importing tests...') + for (const publicId of config.publicIds) { + const test = await api.getTest(publicId) + console.log(test) + } +} From 597995c87d9ee024d44ff4983b15ef81db7e5fde Mon Sep 17 00:00:00 2001 From: Teodor Todorov Date: Mon, 13 Jan 2025 18:30:53 +0100 Subject: [PATCH 03/18] add writing tests to file --- .../synthetics/import-tests-command.ts | 27 ++++++--- src/commands/synthetics/import-tests-lib.ts | 56 +++++++++++++++++-- src/commands/synthetics/interfaces.ts | 6 ++ src/commands/synthetics/test.ts | 3 +- 4 files changed, 80 insertions(+), 12 deletions(-) diff --git a/src/commands/synthetics/import-tests-command.ts b/src/commands/synthetics/import-tests-command.ts index 454b222e2..f31b54038 100644 --- a/src/commands/synthetics/import-tests-command.ts +++ b/src/commands/synthetics/import-tests-command.ts @@ -8,26 +8,27 @@ import {enableFips} from '../../helpers/fips' import {LogLevel, Logger} from '../../helpers/logger' import {removeUndefinedValues, resolveConfigFromFile} from '../../helpers/utils' -// import {EndpointError} from './api' -// import {CiError, CriticalError} from './errors' import {importTests} from './import-tests-lib' -import {ImportTestsCommandConfig} from './interfaces' -// import {uploadMobileApplicationVersion} from './mobile' -// import {AppUploadReporter} from './reporters/mobile/app-upload' +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: [], // subdomain: '', + 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']] @@ -51,8 +52,15 @@ export class ImportTestsCommand extends Command { 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 ${$2('configuration files')}}.`, + }) 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) => { @@ -67,6 +75,8 @@ export class ImportTestsCommand extends Command { } 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 { @@ -77,9 +87,8 @@ export class ImportTestsCommand extends Command { return 1 } - // const appUploadReporter = new AppUploadReporter(this.context) try { - await importTests(this.config) + await importTests(this.reporter, this.config) } catch (error) { // if (error instanceof CiError || error instanceof EndpointError || error instanceof CriticalError) { // this.logger.error(`Error: ${error.message}`) @@ -111,7 +120,9 @@ export class ImportTestsCommand extends Command { 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, }) ) @@ -123,7 +134,9 @@ export class ImportTestsCommand extends Command { 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 index b4ca77aa9..175697843 100644 --- a/src/commands/synthetics/import-tests-lib.ts +++ b/src/commands/synthetics/import-tests-lib.ts @@ -1,11 +1,59 @@ +import {writeFile} from 'fs/promises' + import {getApiHelper} from './api' -import {ImportTestsCommandConfig} from './interfaces' +import {ImportTestsCommandConfig, LocalTriggerConfig, MainReporter, TestConfig} from './interfaces' +import {getTestConfigs} from './test' -export const importTests = async (config: ImportTestsCommandConfig) => { +export const importTests = async (reporter: MainReporter, config: ImportTestsCommandConfig) => { const api = getApiHelper(config) console.log('Importing tests...') + const testConfigFromBackend: TestConfig = { + tests: [], + } + + // TODO fetch public ids from search query of it exists for (const publicId of config.publicIds) { - const test = await api.getTest(publicId) - console.log(test) + const test: LocalTriggerConfig = {local_test_definition: await api.getTest(publicId)} + testConfigFromBackend.tests.push(test) + // console.log(test) } + // console.log('testConfigFromBackend ', testConfigFromBackend) + + // TODO get steps + + // TODO (answer later) what if there's more than one test file in which the public_ids exist? + const testConfigFromFile: TestConfig = { + tests: await getTestConfigs(config, reporter), + } + // console.log('testConfigFromFile ', testConfigFromFile) + // TODO remove unsupported fields + + const testConfig = overwriteTestConfig(testConfigFromBackend, testConfigFromFile) + // console.log('testConfig ', testConfig) + + // 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 = (testConfig: TestConfig, testConfigFromFile: TestConfig): TestConfig => { + for (const test of testConfig.tests) { + // TODO (answer later) what if there's more than 1 test with this public_id? + const index = testConfigFromFile.tests.findIndex( + (t) => t.local_test_definition.public_id === test.local_test_definition.public_id + ) + + if (index !== -1) { + // TODO (answer later) we can maybe ask the user here if they are sure they want to override the test or extend it + testConfigFromFile.tests[index] = testConfig.tests[index] + } else { + testConfigFromFile.tests.push(test) + } + } + + return testConfigFromFile } diff --git a/src/commands/synthetics/interfaces.ts b/src/commands/synthetics/interfaces.ts index ce75028db..29bd1d9e7 100644 --- a/src/commands/synthetics/interfaces.ts +++ b/src/commands/synthetics/interfaces.ts @@ -450,6 +450,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 @@ -605,5 +609,7 @@ export interface MobileApplicationVersion { export interface ImportTestsCommandConfig extends SyntheticsCIConfig { configPath: string + files: string[] publicIds: string[] + testSearchQuery?: string } 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 => { From 5705871463d2330179a63b3aaf3927b468e1b492 Mon Sep 17 00:00:00 2001 From: Teodor Todorov Date: Tue, 14 Jan 2025 17:55:04 +0100 Subject: [PATCH 04/18] get steps --- src/commands/synthetics/api.ts | 16 ++++++++++++++++ src/commands/synthetics/import-tests-lib.ts | 18 ++++++++++++++---- 2 files changed, 30 insertions(+), 4 deletions(-) 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/import-tests-lib.ts b/src/commands/synthetics/import-tests-lib.ts index 175697843..7e7159705 100644 --- a/src/commands/synthetics/import-tests-lib.ts +++ b/src/commands/synthetics/import-tests-lib.ts @@ -11,11 +11,21 @@ export const importTests = async (reporter: MainReporter, config: ImportTestsCom tests: [], } - // TODO fetch public ids from search query of it exists + // TODO (later) fetch public ids from search query if it exists for (const publicId of config.publicIds) { - const test: LocalTriggerConfig = {local_test_definition: await api.getTest(publicId)} - testConfigFromBackend.tests.push(test) - // console.log(test) + let localTriggerConfig: LocalTriggerConfig + const test = await api.getTest(publicId) + // TODO (answer later) we need the 2nd call because we learn the type from the first one but maybe we can improve in the future + if (test.type === 'browser' || test.type === 'mobile') { + console.log('test.type ', test.type) + const testWithSteps = await api.getTestWithType(publicId, test.type) + console.log(testWithSteps) + localTriggerConfig = {local_test_definition: testWithSteps} + } else { + console.log(test) + localTriggerConfig = {local_test_definition: test} + } + testConfigFromBackend.tests.push(localTriggerConfig) } // console.log('testConfigFromBackend ', testConfigFromBackend) From fd8a7025cc8a8b08c39fddf44c86c941a868dcc0 Mon Sep 17 00:00:00 2001 From: Teodor Todorov Date: Mon, 20 Jan 2025 13:06:12 +0100 Subject: [PATCH 05/18] add interfaces for steps --- src/commands/synthetics/interfaces.ts | 72 ++++++++++++++++++++++++++- 1 file changed, 70 insertions(+), 2 deletions(-) diff --git a/src/commands/synthetics/interfaces.ts b/src/commands/synthetics/interfaces.ts index 29bd1d9e7..36c75092f 100644 --- a/src/commands/synthetics/interfaces.ts +++ b/src/commands/synthetics/interfaces.ts @@ -232,6 +232,71 @@ export interface Step { }[] } +interface TestStep { + allowFailure?: boolean + alwaysExecute?: boolean + exitIfSucceed?: boolean + isCritical?: boolean + name: string + noScreenshot?: boolean + params: StepParams + public_id?: string + timeout?: number + type: string +} + +interface StepParams { + attribute?: string + check?: string + click_type?: string + code?: string + delay?: number + element?: ParamsElement + element_user_locator?: UserLocator + email?: string + file?: string + files?: string + modifiers?: number + playing_tab_id?: string + request?: string + subtest_public_id?: string + value?: string + variable?: ParamVariable[] + with_click?: boolean + x?: number + y?: number +} + +interface ParamsElement { + bucketKe?: string + html?: string + multiLocator?: MultiLocator + targetOuterHTML?: string + url?: string + userLocator?: UserLocator + shadowHtmls?: string[] +} + +interface MultiLocator { + ab?: string + at?: string + cl?: string + clt?: string + co?: string + ro?: string +} + +interface UserLocator { + failTestOnCannotLocate: boolean + values: {type: string; value: string}[] +} + +interface ParamVariable { + name: string + example: string + secure?: boolean +} + export interface LocalTestDefinition { config: { assertions: Assertion[] @@ -247,6 +312,7 @@ export interface LocalTestDefinition { steps?: {subtype: string}[] // For multistep API tests variables: string[] } + creation_source?: string locations: string[] message: string name: string @@ -262,8 +328,10 @@ export interface LocalTestDefinition { } /** Can be used to link to an existing remote test. */ public_id?: string - subtype: string - tags: string[] + subtype?: string + steps?: TestStep[] // From browser schema + status?: string + tags?: string[] type: string } From b8b2dd10af813a2096f5dc3c1c2b2f0bdf2a2d58 Mon Sep 17 00:00:00 2001 From: Teodor Todorov Date: Mon, 20 Jan 2025 14:55:19 +0100 Subject: [PATCH 06/18] remove fields that would fail the validation on dogweb side --- src/commands/synthetics/import-tests-lib.ts | 87 +++++++++++++++++++-- 1 file changed, 81 insertions(+), 6 deletions(-) diff --git a/src/commands/synthetics/import-tests-lib.ts b/src/commands/synthetics/import-tests-lib.ts index 7e7159705..adb099c40 100644 --- a/src/commands/synthetics/import-tests-lib.ts +++ b/src/commands/synthetics/import-tests-lib.ts @@ -1,9 +1,39 @@ import {writeFile} from 'fs/promises' import {getApiHelper} from './api' -import {ImportTestsCommandConfig, LocalTriggerConfig, MainReporter, TestConfig} from './interfaces' +import {ImportTestsCommandConfig, LocalTriggerConfig, MainReporter, ServerTest, TestConfig} from './interfaces' import {getTestConfigs} from './test' +const BASE_FIELDS_TRIM = [ + '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 = [ + 'min_failure_duration', + 'min_location_failed', + 'monitor_name', + 'monitor_options', + 'monitor_priority', + 'tick_every', +] + +const CONFIG_FIELDS_TRIM = ['oneClickCreationClassification'] + +const STEP_FIELDS_TRIM = ['position', 'public_id'] + export const importTests = async (reporter: MainReporter, config: ImportTestsCommandConfig) => { const api = getApiHelper(config) console.log('Importing tests...') @@ -20,23 +50,23 @@ export const importTests = async (reporter: MainReporter, config: ImportTestsCom console.log('test.type ', test.type) const testWithSteps = await api.getTestWithType(publicId, test.type) console.log(testWithSteps) - localTriggerConfig = {local_test_definition: testWithSteps} + // localTriggerConfig = {local_test_definition: testWithSteps} + localTriggerConfig = {local_test_definition: removeUnsupportedLTDFields(testWithSteps)} } else { console.log(test) - localTriggerConfig = {local_test_definition: test} + // localTriggerConfig = {local_test_definition: test} + localTriggerConfig = {local_test_definition: removeUnsupportedLTDFields(test)} } + // TODO remove unsupported fields testConfigFromBackend.tests.push(localTriggerConfig) } // console.log('testConfigFromBackend ', testConfigFromBackend) - // TODO get steps - // TODO (answer later) what if there's more than one test file in which the public_ids exist? const testConfigFromFile: TestConfig = { tests: await getTestConfigs(config, reporter), } // console.log('testConfigFromFile ', testConfigFromFile) - // TODO remove unsupported fields const testConfig = overwriteTestConfig(testConfigFromBackend, testConfigFromFile) // console.log('testConfig ', testConfig) @@ -67,3 +97,48 @@ const overwriteTestConfig = (testConfig: TestConfig, testConfigFromFile: TestCon return testConfigFromFile } + +const removeUnsupportedLTDFields = (testConfig: ServerTest): ServerTest => { + for (const field of BASE_FIELDS_TRIM) { + delete testConfig[field as keyof ServerTest] + } + for (const field of OPTIONS_FIELDS_TRIM) { + delete testConfig.options[field as keyof ServerTest['options']] + } + for (const field of CONFIG_FIELDS_TRIM) { + if (field in testConfig.config) { + delete testConfig.config[field as keyof ServerTest['config']] + } + } + + 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 as keyof ServerTest['steps'][0]] + } + } + } + + return testConfig +} From fe2085a4f5484e15e470d7f95087d938f1e895b0 Mon Sep 17 00:00:00 2001 From: Teodor Todorov Date: Mon, 20 Jan 2025 15:26:02 +0100 Subject: [PATCH 07/18] do some clean-up --- src/commands/synthetics/import-tests-lib.ts | 20 ++++++++++++++------ src/commands/synthetics/interfaces.ts | 2 +- 2 files changed, 15 insertions(+), 7 deletions(-) diff --git a/src/commands/synthetics/import-tests-lib.ts b/src/commands/synthetics/import-tests-lib.ts index adb099c40..5dc9fc1ec 100644 --- a/src/commands/synthetics/import-tests-lib.ts +++ b/src/commands/synthetics/import-tests-lib.ts @@ -1,8 +1,16 @@ import {writeFile} from 'fs/promises' import {getApiHelper} from './api' -import {ImportTestsCommandConfig, LocalTriggerConfig, MainReporter, ServerTest, TestConfig} from './interfaces' +import { + ImportTestsCommandConfig, + LocalTriggerConfig, + MainReporter, + ServerTest, + TestConfig, + TestStep, +} from './interfaces' import {getTestConfigs} from './test' +import {isLocalTriggerConfig} from './utils/internal' const BASE_FIELDS_TRIM = [ 'created_at', @@ -60,16 +68,13 @@ export const importTests = async (reporter: MainReporter, config: ImportTestsCom // TODO remove unsupported fields testConfigFromBackend.tests.push(localTriggerConfig) } - // console.log('testConfigFromBackend ', testConfigFromBackend) // TODO (answer later) what if there's more than one test file in which the public_ids exist? const testConfigFromFile: TestConfig = { tests: await getTestConfigs(config, reporter), } - // console.log('testConfigFromFile ', testConfigFromFile) const testConfig = overwriteTestConfig(testConfigFromBackend, testConfigFromFile) - // console.log('testConfig ', testConfig) // eslint-disable-next-line no-null/no-null const jsonString = JSON.stringify(testConfig, null, 2) @@ -84,7 +89,10 @@ const overwriteTestConfig = (testConfig: TestConfig, testConfigFromFile: TestCon for (const test of testConfig.tests) { // TODO (answer later) what if there's more than 1 test with this public_id? const index = testConfigFromFile.tests.findIndex( - (t) => t.local_test_definition.public_id === test.local_test_definition.public_id + (t) => + isLocalTriggerConfig(t) && + isLocalTriggerConfig(test) && + t.local_test_definition.public_id === test.local_test_definition.public_id ) if (index !== -1) { @@ -135,7 +143,7 @@ const removeUnsupportedLTDFields = (testConfig: ServerTest): ServerTest => { } for (const field of STEP_FIELDS_TRIM) { if (field in step) { - delete step[field as keyof ServerTest['steps'][0]] + delete step[field as keyof TestStep] } } } diff --git a/src/commands/synthetics/interfaces.ts b/src/commands/synthetics/interfaces.ts index 36c75092f..b83948968 100644 --- a/src/commands/synthetics/interfaces.ts +++ b/src/commands/synthetics/interfaces.ts @@ -232,7 +232,7 @@ export interface Step { }[] } -interface TestStep { +export interface TestStep { allowFailure?: boolean alwaysExecute?: boolean exitIfSucceed?: boolean From 852a7d411b2e7f6a10249279f9f5493440ea2259 Mon Sep 17 00:00:00 2001 From: Teodor Todorov Date: Mon, 20 Jan 2025 17:53:38 +0100 Subject: [PATCH 08/18] fix build --- src/commands/synthetics/__tests__/fixtures.ts | 1 + src/commands/synthetics/reporters/default.ts | 2 +- src/commands/synthetics/reporters/junit.ts | 4 ++-- 3 files changed, 4 insertions(+), 3 deletions(-) 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/reporters/default.ts b/src/commands/synthetics/reporters/default.ts index 005cf6798..1b2a57008 100644 --- a/src/commands/synthetics/reporters/default.ts +++ b/src/commands/synthetics/reporters/default.ts @@ -160,7 +160,7 @@ const renderResultOutcome = ( } } -const renderApiRequestDescription = (subType: string, config: Test['config']): string => { +const renderApiRequestDescription = (subType = '', config: Test['config']): string => { const {request, steps} = config if (subType === 'dns') { const text = `Query for ${request.host}` 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), From 8e0843257eecf103cea55b3de388a6fae9aab778 Mon Sep 17 00:00:00 2001 From: Teodor Todorov Date: Thu, 23 Jan 2025 12:27:39 +0100 Subject: [PATCH 09/18] add tests --- src/commands/synthetics/__tests__/cli.test.ts | 119 ++++++++++ .../import-tests-config-with-all-keys.json | 13 ++ .../__tests__/import-tests-lib.test.ts | 220 ++++++++++++++++++ 3 files changed, 352 insertions(+) create mode 100644 src/commands/synthetics/__tests__/config-fixtures/import-tests-config-with-all-keys.json create mode 100644 src/commands/synthetics/__tests__/import-tests-lib.test.ts 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__/import-tests-lib.test.ts b/src/commands/synthetics/__tests__/import-tests-lib.test.ts new file mode 100644 index 000000000..b4c186825 --- /dev/null +++ b/src/commands/synthetics/__tests__/import-tests-lib.test.ts @@ -0,0 +1,220 @@ +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), + })) + // jest.spyOn(ciUtils, 'getConfig').mockImplementation(async () => ({})) + process.env = {} + }) + + describe('importTests', () => { + // test is written to new file + // test multiple public_ids + // test browser test has steps + // test already existing file is edited + // test unsupported fields are not present + + 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') + }) + }) +}) From 34e746361a0742866365f54b11fb4cd9e53c5076 Mon Sep 17 00:00:00 2001 From: Teodor Todorov Date: Thu, 23 Jan 2025 12:28:23 +0100 Subject: [PATCH 10/18] fixes and clean-up --- src/commands/synthetics/import-tests-command.ts | 5 +---- src/commands/synthetics/import-tests-lib.ts | 11 ++++------- src/commands/synthetics/run-tests-command.ts | 1 - 3 files changed, 5 insertions(+), 12 deletions(-) diff --git a/src/commands/synthetics/import-tests-command.ts b/src/commands/synthetics/import-tests-command.ts index f31b54038..d7954f4d3 100644 --- a/src/commands/synthetics/import-tests-command.ts +++ b/src/commands/synthetics/import-tests-command.ts @@ -21,7 +21,6 @@ export const DEFAULT_IMPORT_TESTS_COMMAND_CONFIG: ImportTestsCommandConfig = { files: [], proxy: {protocol: 'http'}, publicIds: [], - // subdomain: '', testSearchQuery: '', } @@ -90,9 +89,7 @@ export class ImportTestsCommand extends Command { try { await importTests(this.reporter, this.config) } catch (error) { - // if (error instanceof CiError || error instanceof EndpointError || error instanceof CriticalError) { - // this.logger.error(`Error: ${error.message}`) - // } + this.logger.error(`Error: ${error.message}`) return 1 } diff --git a/src/commands/synthetics/import-tests-lib.ts b/src/commands/synthetics/import-tests-lib.ts index 5dc9fc1ec..7fd589adb 100644 --- a/src/commands/synthetics/import-tests-lib.ts +++ b/src/commands/synthetics/import-tests-lib.ts @@ -42,7 +42,7 @@ const CONFIG_FIELDS_TRIM = ['oneClickCreationClassification'] const STEP_FIELDS_TRIM = ['position', 'public_id'] -export const importTests = async (reporter: MainReporter, config: ImportTestsCommandConfig) => { +export const importTests = async (reporter: MainReporter, config: ImportTestsCommandConfig): Promise => { const api = getApiHelper(config) console.log('Importing tests...') const testConfigFromBackend: TestConfig = { @@ -51,18 +51,15 @@ export const importTests = async (reporter: MainReporter, config: ImportTestsCom // TODO (later) fetch public ids from search query if it exists for (const publicId of config.publicIds) { + console.log(`Fetching test with public_id: ${publicId}`) let localTriggerConfig: LocalTriggerConfig const test = await api.getTest(publicId) + // TODO (answer later) we need the 2nd call because we learn the type from the first one but maybe we can improve in the future if (test.type === 'browser' || test.type === 'mobile') { - console.log('test.type ', test.type) const testWithSteps = await api.getTestWithType(publicId, test.type) - console.log(testWithSteps) - // localTriggerConfig = {local_test_definition: testWithSteps} localTriggerConfig = {local_test_definition: removeUnsupportedLTDFields(testWithSteps)} } else { - console.log(test) - // localTriggerConfig = {local_test_definition: test} localTriggerConfig = {local_test_definition: removeUnsupportedLTDFields(test)} } // TODO remove unsupported fields @@ -97,7 +94,7 @@ const overwriteTestConfig = (testConfig: TestConfig, testConfigFromFile: TestCon if (index !== -1) { // TODO (answer later) we can maybe ask the user here if they are sure they want to override the test or extend it - testConfigFromFile.tests[index] = testConfig.tests[index] + testConfigFromFile.tests[index] = test } else { testConfigFromFile.tests.push(test) } diff --git a/src/commands/synthetics/run-tests-command.ts b/src/commands/synthetics/run-tests-command.ts index b370acc19..b3bbe81d3 100644 --- a/src/commands/synthetics/run-tests-command.ts +++ b/src/commands/synthetics/run-tests-command.ts @@ -292,7 +292,6 @@ export class RunTestsCommand extends Command { jUnitReport: process.env.DATADOG_SYNTHETICS_JUNIT_REPORT, publicIds: process.env.DATADOG_SYNTHETICS_PUBLIC_IDS?.split(';'), selectiveRerun: toBoolean(process.env.DATADOG_SYNTHETICS_SELECTIVE_RERUN), - subdomain: process.env.DATADOG_SUBDOMAIN, testSearchQuery: process.env.DATADOG_SYNTHETICS_TEST_SEARCH_QUERY, tunnel: toBoolean(process.env.DATADOG_SYNTHETICS_TUNNEL), }) From f2a5272aed3ba4a01c3ba62637f0b77a4efd4517 Mon Sep 17 00:00:00 2001 From: Teodor Todorov Date: Thu, 23 Jan 2025 13:00:32 +0100 Subject: [PATCH 11/18] fix build --- src/commands/synthetics/run-tests-command.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/src/commands/synthetics/run-tests-command.ts b/src/commands/synthetics/run-tests-command.ts index b3bbe81d3..b370acc19 100644 --- a/src/commands/synthetics/run-tests-command.ts +++ b/src/commands/synthetics/run-tests-command.ts @@ -292,6 +292,7 @@ export class RunTestsCommand extends Command { jUnitReport: process.env.DATADOG_SYNTHETICS_JUNIT_REPORT, publicIds: process.env.DATADOG_SYNTHETICS_PUBLIC_IDS?.split(';'), selectiveRerun: toBoolean(process.env.DATADOG_SYNTHETICS_SELECTIVE_RERUN), + subdomain: process.env.DATADOG_SUBDOMAIN, testSearchQuery: process.env.DATADOG_SYNTHETICS_TEST_SEARCH_QUERY, tunnel: toBoolean(process.env.DATADOG_SYNTHETICS_TUNNEL), }) From 6f83d86ce59bd04213d3d482f47e1019bf657415 Mon Sep 17 00:00:00 2001 From: Teodor Todorov Date: Thu, 23 Jan 2025 13:17:49 +0100 Subject: [PATCH 12/18] remove comments with topics for further discussion --- src/commands/synthetics/import-tests-lib.ts | 6 ------ 1 file changed, 6 deletions(-) diff --git a/src/commands/synthetics/import-tests-lib.ts b/src/commands/synthetics/import-tests-lib.ts index 7fd589adb..113823426 100644 --- a/src/commands/synthetics/import-tests-lib.ts +++ b/src/commands/synthetics/import-tests-lib.ts @@ -49,24 +49,20 @@ export const importTests = async (reporter: MainReporter, config: ImportTestsCom tests: [], } - // TODO (later) fetch public ids from search query if it exists for (const publicId of config.publicIds) { console.log(`Fetching test with public_id: ${publicId}`) let localTriggerConfig: LocalTriggerConfig const test = await api.getTest(publicId) - // TODO (answer later) we need the 2nd call because we learn the type from the first one but maybe we can improve in the future if (test.type === 'browser' || test.type === 'mobile') { const testWithSteps = await api.getTestWithType(publicId, test.type) localTriggerConfig = {local_test_definition: removeUnsupportedLTDFields(testWithSteps)} } else { localTriggerConfig = {local_test_definition: removeUnsupportedLTDFields(test)} } - // TODO remove unsupported fields testConfigFromBackend.tests.push(localTriggerConfig) } - // TODO (answer later) what if there's more than one test file in which the public_ids exist? const testConfigFromFile: TestConfig = { tests: await getTestConfigs(config, reporter), } @@ -84,7 +80,6 @@ export const importTests = async (reporter: MainReporter, config: ImportTestsCom } const overwriteTestConfig = (testConfig: TestConfig, testConfigFromFile: TestConfig): TestConfig => { for (const test of testConfig.tests) { - // TODO (answer later) what if there's more than 1 test with this public_id? const index = testConfigFromFile.tests.findIndex( (t) => isLocalTriggerConfig(t) && @@ -93,7 +88,6 @@ const overwriteTestConfig = (testConfig: TestConfig, testConfigFromFile: TestCon ) if (index !== -1) { - // TODO (answer later) we can maybe ask the user here if they are sure they want to override the test or extend it testConfigFromFile.tests[index] = test } else { testConfigFromFile.tests.push(test) From 33436777271b064b2f4c234fd245c6da11b97c2b Mon Sep 17 00:00:00 2001 From: Teodor Todorov Date: Thu, 23 Jan 2025 13:20:42 +0100 Subject: [PATCH 13/18] clean up --- src/commands/synthetics/__tests__/import-tests-lib.test.ts | 7 ------- 1 file changed, 7 deletions(-) diff --git a/src/commands/synthetics/__tests__/import-tests-lib.test.ts b/src/commands/synthetics/__tests__/import-tests-lib.test.ts index b4c186825..4bfc50354 100644 --- a/src/commands/synthetics/__tests__/import-tests-lib.test.ts +++ b/src/commands/synthetics/__tests__/import-tests-lib.test.ts @@ -15,17 +15,10 @@ describe('import-tests', () => { jest.mock('fs/promises', () => ({ writeFile: jest.fn().mockResolvedValue(undefined), })) - // jest.spyOn(ciUtils, 'getConfig').mockImplementation(async () => ({})) process.env = {} }) describe('importTests', () => { - // test is written to new file - // test multiple public_ids - // test browser test has steps - // test already existing file is edited - // test unsupported fields are not present - test('we write imported test to file', async () => { const filePath = 'test.synthetics.json' const config = DEFAULT_IMPORT_TESTS_COMMAND_CONFIG From 20f09c745674e476d0edf086a17d4adb0aaddbb9 Mon Sep 17 00:00:00 2001 From: Teodor Todorov Date: Mon, 27 Jan 2025 12:33:30 +0100 Subject: [PATCH 14/18] trim the interfce definitions --- src/commands/synthetics/import-tests-lib.ts | 4 +- src/commands/synthetics/interfaces.ts | 71 ++++----------------- 2 files changed, 14 insertions(+), 61 deletions(-) diff --git a/src/commands/synthetics/import-tests-lib.ts b/src/commands/synthetics/import-tests-lib.ts index 113823426..ff2923ff5 100644 --- a/src/commands/synthetics/import-tests-lib.ts +++ b/src/commands/synthetics/import-tests-lib.ts @@ -78,8 +78,8 @@ export const importTests = async (reporter: MainReporter, config: ImportTestsCom console.error('Error writing file:', error) } } -const overwriteTestConfig = (testConfig: TestConfig, testConfigFromFile: TestConfig): TestConfig => { - for (const test of testConfig.tests) { +const overwriteTestConfig = (testConfigFromBackend: TestConfig, testConfigFromFile: TestConfig): TestConfig => { + for (const test of testConfigFromBackend.tests) { const index = testConfigFromFile.tests.findIndex( (t) => isLocalTriggerConfig(t) && diff --git a/src/commands/synthetics/interfaces.ts b/src/commands/synthetics/interfaces.ts index b83948968..8610c9207 100644 --- a/src/commands/synthetics/interfaces.ts +++ b/src/commands/synthetics/interfaces.ts @@ -239,62 +239,13 @@ export interface TestStep { isCritical?: boolean name: string noScreenshot?: boolean - params: StepParams - public_id?: string + params: any timeout?: number type: string } -interface StepParams { - attribute?: string - check?: string - click_type?: string - code?: string - delay?: number - element?: ParamsElement - element_user_locator?: UserLocator - email?: string - file?: string - files?: string - modifiers?: number - playing_tab_id?: string - request?: string - subtest_public_id?: string - value?: string - variable?: ParamVariable[] - with_click?: boolean - x?: number - y?: number -} - -interface ParamsElement { - bucketKe?: string - html?: string - multiLocator?: MultiLocator - targetOuterHTML?: string - url?: string - userLocator?: UserLocator - shadowHtmls?: string[] -} - -interface MultiLocator { - ab?: string - at?: string - cl?: string - clt?: string - co?: string - ro?: string -} - -interface UserLocator { - failTestOnCannotLocate: boolean - values: {type: string; value: string}[] -} - -interface ParamVariable { - name: string - example: string - secure?: boolean +interface TestStepWithUnsupportedFields extends TestStep { + public_id?: string } export interface LocalTestDefinition { @@ -312,9 +263,7 @@ export interface LocalTestDefinition { steps?: {subtype: string}[] // For multistep API tests variables: string[] } - creation_source?: string locations: string[] - message: string name: string options: { ci?: { @@ -329,19 +278,23 @@ export interface LocalTestDefinition { /** Can be used to link to an existing remote test. */ public_id?: string subtype?: string - steps?: TestStep[] // From browser schema - status?: string - tags?: string[] + steps?: TestStepWithUnsupportedFields[] // From browser schema type: string } -export interface ServerTest extends LocalTestDefinition { +interface LocalTestDefinitionWithUnsupportedFields extends LocalTestDefinition { + creation_source?: string + status?: string + tags?: string[] + message?: string +} +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 } From 6b24e162ddd1f6cd2fdd0c7654dd7d2047607e12 Mon Sep 17 00:00:00 2001 From: Teodor Todorov Date: Mon, 27 Jan 2025 12:37:59 +0100 Subject: [PATCH 15/18] improve descriptions for -f --- src/commands/synthetics/import-tests-command.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/commands/synthetics/import-tests-command.ts b/src/commands/synthetics/import-tests-command.ts index d7954f4d3..c3fcef5c9 100644 --- a/src/commands/synthetics/import-tests-command.ts +++ b/src/commands/synthetics/import-tests-command.ts @@ -43,7 +43,7 @@ export class ImportTestsCommand extends Command { '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 ./component-1/**/*.synthetics.json'], + ['Override the default glob pattern', 'datadog-ci synthetics import-tests -f test-file.synthetics.json'], ], }) @@ -52,7 +52,7 @@ export class ImportTestsCommand extends Command { 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 ${$2('configuration 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', { From 3a597ff112b4d66774c83380d8cce0b0b6c9c1fe Mon Sep 17 00:00:00 2001 From: Teodor Todorov Date: Mon, 27 Jan 2025 14:03:17 +0100 Subject: [PATCH 16/18] fix build --- src/commands/synthetics/interfaces.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/commands/synthetics/interfaces.ts b/src/commands/synthetics/interfaces.ts index 8610c9207..76a435d02 100644 --- a/src/commands/synthetics/interfaces.ts +++ b/src/commands/synthetics/interfaces.ts @@ -244,7 +244,7 @@ export interface TestStep { type: string } -interface TestStepWithUnsupportedFields extends TestStep { +export interface TestStepWithUnsupportedFields extends TestStep { public_id?: string } From bd91bca5eeaf1190087d1f76f6d653cb7e0021e9 Mon Sep 17 00:00:00 2001 From: Teodor Todorov Date: Tue, 28 Jan 2025 19:10:36 +0100 Subject: [PATCH 17/18] address comments --- src/commands/synthetics/import-tests-lib.ts | 28 ++++----- src/commands/synthetics/interfaces.ts | 65 ++++++++++++-------- src/commands/synthetics/reporters/default.ts | 2 +- 3 files changed, 55 insertions(+), 40 deletions(-) diff --git a/src/commands/synthetics/import-tests-lib.ts b/src/commands/synthetics/import-tests-lib.ts index ff2923ff5..7f491aa50 100644 --- a/src/commands/synthetics/import-tests-lib.ts +++ b/src/commands/synthetics/import-tests-lib.ts @@ -7,12 +7,12 @@ import { MainReporter, ServerTest, TestConfig, - TestStep, + TestStepWithUnsupportedFields, } from './interfaces' import {getTestConfigs} from './test' import {isLocalTriggerConfig} from './utils/internal' -const BASE_FIELDS_TRIM = [ +const BASE_FIELDS_TRIM: (keyof ServerTest)[] = [ 'created_at', 'created_by', 'creator', @@ -29,7 +29,7 @@ const BASE_FIELDS_TRIM = [ 'version_uuid', ] -const OPTIONS_FIELDS_TRIM = [ +const OPTIONS_FIELDS_TRIM: (keyof ServerTest['options'])[] = [ 'min_failure_duration', 'min_location_failed', 'monitor_name', @@ -38,9 +38,7 @@ const OPTIONS_FIELDS_TRIM = [ 'tick_every', ] -const CONFIG_FIELDS_TRIM = ['oneClickCreationClassification'] - -const STEP_FIELDS_TRIM = ['position', 'public_id'] +const STEP_FIELDS_TRIM: (keyof TestStepWithUnsupportedFields)[] = ['public_id'] export const importTests = async (reporter: MainReporter, config: ImportTestsCommandConfig): Promise => { const api = getApiHelper(config) @@ -54,9 +52,13 @@ export const importTests = async (reporter: MainReporter, config: ImportTestsCom let localTriggerConfig: LocalTriggerConfig const test = await api.getTest(publicId) - if (test.type === 'browser' || test.type === 'mobile') { + 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)} } @@ -78,6 +80,7 @@ export const importTests = async (reporter: MainReporter, config: ImportTestsCom console.error('Error writing file:', error) } } + const overwriteTestConfig = (testConfigFromBackend: TestConfig, testConfigFromFile: TestConfig): TestConfig => { for (const test of testConfigFromBackend.tests) { const index = testConfigFromFile.tests.findIndex( @@ -99,15 +102,10 @@ const overwriteTestConfig = (testConfigFromBackend: TestConfig, testConfigFromFi const removeUnsupportedLTDFields = (testConfig: ServerTest): ServerTest => { for (const field of BASE_FIELDS_TRIM) { - delete testConfig[field as keyof ServerTest] + delete testConfig[field] } for (const field of OPTIONS_FIELDS_TRIM) { - delete testConfig.options[field as keyof ServerTest['options']] - } - for (const field of CONFIG_FIELDS_TRIM) { - if (field in testConfig.config) { - delete testConfig.config[field as keyof ServerTest['config']] - } + delete testConfig.options[field] } for (const step of testConfig.steps || []) { @@ -134,7 +132,7 @@ const removeUnsupportedLTDFields = (testConfig: ServerTest): ServerTest => { } for (const field of STEP_FIELDS_TRIM) { if (field in step) { - delete step[field as keyof TestStep] + delete step[field] } } } diff --git a/src/commands/synthetics/interfaces.ts b/src/commands/synthetics/interfaces.ts index 76a435d02..de0c3dea9 100644 --- a/src/commands/synthetics/interfaces.ts +++ b/src/commands/synthetics/interfaces.ts @@ -232,20 +232,11 @@ export interface Step { }[] } -export interface TestStep { - allowFailure?: boolean - alwaysExecute?: boolean - exitIfSucceed?: boolean - isCritical?: boolean - name: string - noScreenshot?: boolean - params: any - timeout?: number - type: string -} +// TODO SYNTH-17944 Remove unsupported fields -export interface TestStepWithUnsupportedFields extends TestStep { +export interface TestStepWithUnsupportedFields { public_id?: string + params: any } export interface LocalTestDefinition { @@ -265,28 +256,54 @@ export interface LocalTestDefinition { } locations: 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 + subtype: string // This is optional in the browser and api schemas steps?: TestStepWithUnsupportedFields[] // From browser schema type: string } +interface Options { + ci?: { + executionRule: ExecutionRule + } + device_ids?: string[] + mobileApplication?: MobileApplication + retry?: { + count?: number + } +} + +// TODO SYNTH-17944 Remove unsupported fields + +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[] - message?: string + version?: any + version_uuid?: any } export interface ServerTest extends LocalTestDefinitionWithUnsupportedFields { monitor_id: number diff --git a/src/commands/synthetics/reporters/default.ts b/src/commands/synthetics/reporters/default.ts index 1b2a57008..005cf6798 100644 --- a/src/commands/synthetics/reporters/default.ts +++ b/src/commands/synthetics/reporters/default.ts @@ -160,7 +160,7 @@ const renderResultOutcome = ( } } -const renderApiRequestDescription = (subType = '', config: Test['config']): string => { +const renderApiRequestDescription = (subType: string, config: Test['config']): string => { const {request, steps} = config if (subType === 'dns') { const text = `Query for ${request.host}` From 79d81ed8b78893a6aed984aa0da839d344acb94d Mon Sep 17 00:00:00 2001 From: Teodor Todorov Date: Wed, 29 Jan 2025 11:10:19 +0100 Subject: [PATCH 18/18] fix build --- src/commands/synthetics/interfaces.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/commands/synthetics/interfaces.ts b/src/commands/synthetics/interfaces.ts index de0c3dea9..74f4acad9 100644 --- a/src/commands/synthetics/interfaces.ts +++ b/src/commands/synthetics/interfaces.ts @@ -277,7 +277,7 @@ interface Options { // TODO SYNTH-17944 Remove unsupported fields -interface OptionsWithUnsupportedFields extends Options { +export interface OptionsWithUnsupportedFields extends Options { min_failure_duration?: number min_location_failed?: any monitor_name?: string