From 13813d44933f40434b2c3ba3da55cdf722daf999 Mon Sep 17 00:00:00 2001 From: Vignesh Shanmugam Date: Fri, 5 Apr 2024 08:03:26 -0700 Subject: [PATCH] feat: support tags/match filtering in push (#913) * feat: support tags/match filtering in push * fix options test * rename to grep options and add more tests --- __tests__/core/runner.test.ts | 64 ++++++++- __tests__/fixtures/synthetics.config.ts | 1 + __tests__/options.test.ts | 3 +- .../push/__snapshots__/index.test.ts.snap | 7 - __tests__/push/index.test.ts | 6 - __tests__/push/monitor.test.ts | 69 ++++++++- src/cli.ts | 26 ++-- src/common_types.ts | 16 ++- src/core/runner.ts | 23 ++- src/dsl/monitor.ts | 14 +- src/options.ts | 135 ++++++++++-------- src/push/monitor.ts | 15 +- 12 files changed, 277 insertions(+), 102 deletions(-) diff --git a/__tests__/core/runner.test.ts b/__tests__/core/runner.test.ts index b642c10c..f3301b5c 100644 --- a/__tests__/core/runner.test.ts +++ b/__tests__/core/runner.test.ts @@ -338,7 +338,7 @@ describe('runner', () => { expect( await runner.run({ ...defaultRunOptions, - match: 'j2', + grepOpts: { match: 'j2' }, }) ).toMatchObject({ j2: { status: 'succeeded' }, @@ -351,7 +351,7 @@ describe('runner', () => { expect( await runner.run({ ...defaultRunOptions, - match: 'j*', + grepOpts: { match: 'j*' }, }) ).toMatchObject({ j1: { status: 'succeeded' }, @@ -368,8 +368,7 @@ describe('runner', () => { expect( await runner.run({ ...defaultRunOptions, - tags: ['foo*'], - match: 'j*', + grepOpts: { tags: ['foo*'], match: 'j*' }, }) ).toMatchObject({ j1: { status: 'succeeded' }, @@ -385,7 +384,7 @@ describe('runner', () => { expect( await runner.run({ ...defaultRunOptions, - tags: ['hello:b*'], + grepOpts: { tags: ['hello:b*'] }, }) ).toMatchObject({ j2: { status: 'succeeded' }, @@ -400,7 +399,7 @@ describe('runner', () => { expect( await runner.run({ ...defaultRunOptions, - tags: ['!hello:b*'], + grepOpts: { tags: ['!hello:b*'] }, }) ).toMatchObject({ j1: { status: 'succeeded' }, @@ -750,6 +749,7 @@ describe('runner', () => { throttling: { latency: 1000 }, schedule: 1, alert: { status: { enabled: false } }, + tags: [], }); }); @@ -810,6 +810,58 @@ describe('runner', () => { alert: { tls: { enabled: true } }, }); }); + + it('runner - build monitors filtered via "match"', async () => { + const j1 = new Journey({ name: 'j1' }, noop); + const j2 = new Journey({ name: 'j2' }, noop); + runner.addJourney(j1); + runner.addJourney(j2); + + const monitors = runner.buildMonitors({ + ...options, + grepOpts: { match: 'j1' }, + schedule: 1, + }); + expect(monitors.length).toBe(1); + expect(monitors[0].config.name).toBe('j1'); + }); + + it('runner - build monitors with via "tags"', async () => { + const j1 = new Journey({ name: 'j1', tags: ['first'] }, noop); + const j2 = new Journey({ name: 'j2', tags: ['second'] }, noop); + const j3 = new Journey({ name: 'j3' }, noop); + runner.addJourney(j1); + runner.addJourney(j2); + runner.addJourney(j3); + + const monitors = runner.buildMonitors({ + ...options, + grepOpts: { tags: ['first'] }, + schedule: 1, + }); + expect(monitors.length).toBe(1); + expect(monitors[0].config.name).toBe('j1'); + }); + + it('runner - build monitors with config and filter via "tags"', async () => { + const j1 = new Journey({ name: 'j1', tags: ['first'] }, noop); + const j2 = new Journey({ name: 'j2', tags: ['second'] }, noop); + const j3 = new Journey({ name: 'j3' }, noop); + runner.addJourney(j1); + runner.addJourney(j2); + runner.addJourney(j3); + // using monitor.use + j2.updateMonitor({ tags: ['newtag'] }); + + const monitors = runner.buildMonitors({ + ...options, + tags: ['newtag'], + grepOpts: { tags: ['newtag'] }, + schedule: 1, + }); + expect(monitors.length).toBe(2); + expect(monitors.map(m => m.config.name)).toEqual(['j2', 'j3']); + }); }); describe('journey and step annotations', () => { diff --git a/__tests__/fixtures/synthetics.config.ts b/__tests__/fixtures/synthetics.config.ts index 7319928e..d202443e 100644 --- a/__tests__/fixtures/synthetics.config.ts +++ b/__tests__/fixtures/synthetics.config.ts @@ -38,6 +38,7 @@ module.exports = env => { screenshot: 'off', schedule: 10, locations: ['us_east'], + tags: ['foo', 'bar'], privateLocations: ['test-location'], alert: { status: { diff --git a/__tests__/options.test.ts b/__tests__/options.test.ts index cd2a83fe..6ad35f8c 100644 --- a/__tests__/options.test.ts +++ b/__tests__/options.test.ts @@ -51,7 +51,7 @@ describe('options', () => { expect(await normalizeOptions(cliArgs)).toMatchObject({ dryRun: true, environment: 'test', - match: 'check*', + grepOpts: { match: 'check*' }, params: { foo: 'bar', url: 'non-dev', @@ -108,6 +108,7 @@ describe('options', () => { screenshots: 'only-on-failure', schedule: 3, privateLocations: ['test'], + tags: ['foo', 'bar'], locations: ['australia_east'], alert: { status: { diff --git a/__tests__/push/__snapshots__/index.test.ts.snap b/__tests__/push/__snapshots__/index.test.ts.snap index c4d8e0b9..1ec22f7b 100644 --- a/__tests__/push/__snapshots__/index.test.ts.snap +++ b/__tests__/push/__snapshots__/index.test.ts.snap @@ -1,12 +1,5 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP -exports[`Push abort on tags and match 1`] = ` -"Aborted. Invalid CLI flags. - -Tags and Match are not supported in push command. -" -`; - exports[`Push error on empty project id 1`] = ` "Aborted. Invalid synthetics project settings. diff --git a/__tests__/push/index.test.ts b/__tests__/push/index.test.ts index 2748b2d6..b79e9ad0 100644 --- a/__tests__/push/index.test.ts +++ b/__tests__/push/index.test.ts @@ -123,12 +123,6 @@ describe('Push', () => { expect(output).toContain('Push command Aborted'); }); - it('abort on tags and match', async () => { - await fakeProjectSetup({}, {}); - const output = await runPush([...DEFAULT_ARGS, '--tags', 'foo:*']); - expect(output).toMatchSnapshot(); - }); - it('error on invalid schedule in monitor DSL', async () => { await fakeProjectSetup( { id: 'test-project', space: 'dummy', url: 'http://localhost:8080' }, diff --git a/__tests__/push/monitor.test.ts b/__tests__/push/monitor.test.ts index eabb026b..34bd1f52 100644 --- a/__tests__/push/monitor.test.ts +++ b/__tests__/push/monitor.test.ts @@ -207,7 +207,7 @@ heartbeat.monitors: `); const monitors = await createLightweightMonitors(PROJECT_DIR, { ...opts, - pattern: '.yaml$', + grepOpts: { pattern: '.yaml$' }, }); expect(monitors.length).toBe(0); }); @@ -236,6 +236,73 @@ heartbeat.monitors: expect(monitors.length).toBe(1); }); + it('push - match filter', async () => { + await writeHBFile(` +heartbeat.monitors: +- type: http + name: "m1" + id: "mon1" + tags: "tag1" +- type: http + name: "m2" + id: "mon2" + tags: "tag2" + `); + const monitors = await createLightweightMonitors(PROJECT_DIR, { + ...opts, + grepOpts: { match: 'm1' }, + }); + expect(monitors.length).toBe(1); + expect(monitors[0].config.name).toEqual('m1'); + }); + + it('push - tags filter', async () => { + await writeHBFile(` +heartbeat.monitors: +- type: http + name: "m1" + id: "mon1" + tags: ["foo", "bar"] +- type: http + name: "m2" + id: "mon2" + tags: ["bar", "baz"] +- type: http + name: "m3" + id: "mon3" + tags: ["baz", "boom"] + `); + const monitors = await createLightweightMonitors(PROJECT_DIR, { + ...opts, + grepOpts: { tags: ['bar'] }, + }); + expect(monitors.length).toBe(2); + expect(monitors.map(m => m.config.name)).toEqual(['m1', 'm2']); + }); + + it('push - apply tags config and also filter', async () => { + await writeHBFile(` +heartbeat.monitors: +- type: http + name: "m1" + id: "mon1" + tags: ["foo"] +- type: http + name: "m2" + id: "mon2" +- type: http + name: "m3" + id: "mon3" + `); + const monitors = await createLightweightMonitors(PROJECT_DIR, { + ...opts, + tags: ['ltag'], + grepOpts: { tags: ['ltag'] }, + }); + expect(monitors.length).toBe(2); + expect(monitors.map(m => m.config.name)).toEqual(['m2', 'm3']); + }); + it('prefer local monitor config', async () => { await writeHBFile(` heartbeat.monitors: diff --git a/src/cli.ts b/src/cli.ts index 932b2b27..19be1523 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -56,20 +56,25 @@ import { installTransform } from './core/transform'; /* eslint-disable-next-line @typescript-eslint/no-var-requires */ const { name, version } = require('../package.json'); -const { params, pattern, playwrightOpts, auth, authMandatory, configOpt } = - getCommonCommandOpts(); +const { + params, + pattern, + playwrightOpts, + auth, + authMandatory, + configOpt, + tags, + match, +} = getCommonCommandOpts(); program .name(`npx ${name}`) .usage('[options] [dir] [files] file') .addOption(configOpt) .addOption(pattern) + .addOption(tags) + .addOption(match) .addOption(params) - .option('--tags ', 'run tests with a tag that matches the glob') - .option( - '--match ', - 'run tests with a name or tags that matches the glob' - ) .addOption( new Option('--reporter ', `output reporter format`).choices( Object.keys(reporters) @@ -157,6 +162,7 @@ program .description( 'Push all journeys in the current directory to create monitors within the Kibana monitor management UI' ) + .addOption(authMandatory) .option( '--schedule ', "schedule in minutes for the pushed monitors. Setting `10`, for example, configures monitors which don't have an interval defined to run every 10 minutes.", @@ -172,7 +178,7 @@ program '--private-locations ', 'default list of private locations from which your monitors will run.' ) - .option('--url ', 'Kibana URL to upload the monitors') + .option('--url ', 'Kibana URL to upload the project monitors') .option( '--id ', 'project id that will be used for logically grouping monitors' @@ -182,8 +188,10 @@ program 'the target Kibana spaces for the pushed monitors — spaces help you organise pushed monitors.' ) .option('-y, --yes', 'skip all questions and run non-interactively') - .addOption(authMandatory) + .addOption(pattern) + .addOption(tags) + .addOption(match) .addOption(params) .addOption(playwrightOpts) .addOption(configOpt) diff --git a/src/common_types.ts b/src/common_types.ts index d904cf67..21c2df2e 100644 --- a/src/common_types.ts +++ b/src/common_types.ts @@ -200,14 +200,18 @@ export type ThrottlingOptions = { latency?: number; }; +type GrepOptions = { + pattern?: string; + tags?: Array; + match?: string; +}; + type BaseArgs = { params?: Params; screenshots?: ScreenshotOptions; dryRun?: boolean; config?: string; - pattern?: string; - match?: string; - tags?: Array; + auth?: string; outfd?: number; wsEndpoint?: string; pauseOnError?: boolean; @@ -220,6 +224,9 @@ type BaseArgs = { }; export type CliArgs = BaseArgs & { + pattern?: string; + match?: string; + tags?: Array; reporter?: BuiltInReporterName; inline?: boolean; require?: Array; @@ -239,6 +246,7 @@ export type RunOptions = BaseArgs & { environment?: string; networkConditions?: NetworkConditions; reporter?: BuiltInReporterName | ReporterInstance; + grepOpts?: GrepOptions; }; export type PushOptions = Partial & @@ -246,9 +254,11 @@ export type PushOptions = Partial & auth: string; kibanaVersion?: string; yes?: boolean; + tags?: Array; alert?: AlertConfig; retestOnFailure?: MonitorConfig['retestOnFailure']; enabled?: boolean; + grepOpts?: GrepOptions; }; export type ProjectSettings = { diff --git a/src/core/runner.ts b/src/core/runner.ts index 3d28737c..c30ce385 100644 --- a/src/core/runner.ts +++ b/src/core/runner.ts @@ -404,8 +404,7 @@ export default class Runner { buildMonitors(options: PushOptions) { /** - * Update the global monitor configuration required for - * setting defaults + * Update the global monitor configuration required for setting defaults */ this.updateMonitor({ throttling: options.throttling, @@ -430,11 +429,23 @@ export default class Runner { ); } /** - * Execute dummy callback to get all monitor specific - * configurations for the current journey + * Before pushing a browser monitor, three things need to be done: + * + * - execute callback `monitor.use` in particular to get monitor configurations + * - update the monitor config with global configuration + * - filter out monitors based on matched tags and name after applying both + * global and local monitor configurations */ journey.callback({ params: options.params } as any); journey.monitor.update(this.monitor?.config); + if ( + !journey.monitor.isMatch( + options.grepOpts?.match, + options.grepOpts?.tags + ) + ) { + continue; + } journey.monitor.validate(); monitors.push(journey.monitor); } @@ -454,7 +465,7 @@ export default class Runner { params: options.params, }).catch(e => (this.hookError = e)); - const { dryRun, match, tags } = options; + const { dryRun, grepOpts } = options; /** * Skip other journeys when using `.only` */ @@ -471,7 +482,7 @@ export default class Runner { this.#reporter.onJourneyRegister?.(journey); continue; } - if (!journey.isMatch(match, tags) || journey.skip) { + if (!journey.isMatch(grepOpts?.match, grepOpts?.tags) || journey.skip) { continue; } const journeyResult: JourneyResult = this.hookError diff --git a/src/dsl/monitor.ts b/src/dsl/monitor.ts index 9f3411fa..e0071534 100644 --- a/src/dsl/monitor.ts +++ b/src/dsl/monitor.ts @@ -33,7 +33,7 @@ import { Params, PlaywrightOptions, } from '../common_types'; -import { indent } from '../helpers'; +import { indent, isMatch } from '../helpers'; import { LocationsMap } from '../locations/public-locations'; export type SyntheticsLocationsType = keyof typeof LocationsMap; @@ -129,6 +129,18 @@ export class Monitor { this.filter = filter; } + /** + * Matches monitors based on the provided args. Proitize tags over match + */ + isMatch(matchPattern: string, tagsPattern: Array) { + return isMatch( + this.config.tags, + this.config.name, + tagsPattern, + matchPattern + ); + } + /** * Hash is used to identify if the monitor has changed since the last time * it was pushed to Kibana. Change is based on three factors: diff --git a/src/options.ts b/src/options.ts index 789373f1..a37d900f 100644 --- a/src/options.ts +++ b/src/options.ts @@ -26,74 +26,49 @@ import merge from 'deepmerge'; import { createOption } from 'commander'; import { readConfig } from './config'; -import type { CliArgs, PushOptions, RunOptions } from './common_types'; -import { THROTTLING_WARNING_MSG, error, warn } from './helpers'; +import type { CliArgs, RunOptions } from './common_types'; +import { THROTTLING_WARNING_MSG, warn } from './helpers'; type Mode = 'run' | 'push'; +/** + * Normalize the options passed via CLI and Synthetics config file + * + * Order of preference for options: + * 1. Local options configured via Runner API + * 2. CLI flags + * 3. Configuration file + */ export async function normalizeOptions( cliArgs: CliArgs, mode: Mode = 'run' ): Promise { + /** + * Move filtering flags from the top level to filter object + * and delete the old keys + */ + const grepOpts = { + pattern: cliArgs.pattern, + tags: cliArgs.tags, + match: cliArgs.match, + }; + delete cliArgs.pattern; + delete cliArgs.tags; + delete cliArgs.match; + const options: RunOptions = { ...cliArgs, + grepOpts, environment: process.env['NODE_ENV'] || 'development', }; /** - * Group all events that can be consumed by heartbeat and - * eventually by the Synthetics UI. - */ - if (cliArgs.richEvents) { - options.reporter = cliArgs.reporter ?? 'json'; - options.ssblocks = true; - options.network = true; - options.trace = true; - options.quietExitCode = true; - } - - if (cliArgs.capability) { - const supportedCapabilities = [ - 'trace', - 'network', - 'filmstrips', - 'metrics', - 'ssblocks', - ]; - /** - * trace - record chrome trace events(LCP, FCP, CLS, etc.) for all journeys - * network - capture network information for all journeys - * filmstrips - record detailed filmstrips for all journeys - * metrics - capture performance metrics (DOM Nodes, Heap size, etc.) for each step - * ssblocks - Dedupes the screenshots in to blocks to save storage space - */ - for (const flag of cliArgs.capability) { - if (supportedCapabilities.includes(flag)) { - options[flag] = true; - } else { - console.warn( - `Missing capability "${flag}", current supported capabilities are ${supportedCapabilities.join( - ', ' - )}` - ); - } - } - } - - /** - * Validate and read synthetics config file - * based on the environment + * Validate and read synthetics config file based on the environment */ const config = cliArgs.config || !cliArgs.inline ? await readConfig(options.environment, cliArgs.config) : {}; - /** - * Order of preference for options that are used while running are - * 1. Local options configured via Runner API - * 2. CLI flags - * 3. Configuration file - */ options.params = Object.freeze(merge(config.params, cliArgs.params || {})); /** @@ -118,10 +93,49 @@ export async function normalizeOptions( */ switch (mode) { case 'run': + if (cliArgs.capability) { + const supportedCapabilities = [ + 'trace', + 'network', + 'filmstrips', + 'metrics', + 'ssblocks', + ]; + /** + * trace - record chrome trace events(LCP, FCP, CLS, etc.) for all journeys + * network - capture network information for all journeys + * filmstrips - record detailed filmstrips for all journeys + * metrics - capture performance metrics (DOM Nodes, Heap size, etc.) for each step + * ssblocks - Dedupes the screenshots in to blocks to save storage space + */ + for (const flag of cliArgs.capability) { + if (supportedCapabilities.includes(flag)) { + options[flag] = true; + } else { + console.warn( + `Missing capability "${flag}", current supported capabilities are ${supportedCapabilities.join( + ', ' + )}` + ); + } + } + } + + /** + * Group all events that can be consumed by heartbeat and + * eventually by the Synthetics UI. + */ + if (cliArgs.richEvents) { + options.reporter = cliArgs.reporter ?? 'json'; + options.ssblocks = true; + options.network = true; + options.trace = true; + options.quietExitCode = true; + } + options.screenshots = cliArgs.screenshots ?? 'on'; break; case 'push': - validatePushOptions(options as PushOptions); /** * Merge the default monitor config from synthetics.config.ts file * with the CLI options passed via push command @@ -152,14 +166,6 @@ export function getHeadlessFlag( return configHeadless ?? true; } -export function validatePushOptions(opts: PushOptions) { - if (opts.tags || opts.match) { - throw error(`Aborted. Invalid CLI flags. - -Tags and Match are not supported in push command.`); - } -} - /* eslint-disable-next-line @typescript-eslint/no-unused-vars */ function toObject(value: boolean | Record): Record { const defaulVal = {}; @@ -211,6 +217,15 @@ export function getCommonCommandOpts() { 'configuration path (default: synthetics.config.js)' ); + const tags = createOption( + '--tags ', + 'run/push tests with a tag that matches the glob' + ); + const match = createOption( + '--match ', + 'run/push tests with a name or tags that matches the glob' + ); + return { auth, authMandatory, @@ -218,5 +233,7 @@ export function getCommonCommandOpts() { playwrightOpts, pattern, configOpt, + tags, + match, }; } diff --git a/src/push/monitor.ts b/src/push/monitor.ts index f1683e8e..b7982c70 100644 --- a/src/push/monitor.ts +++ b/src/push/monitor.ts @@ -167,8 +167,8 @@ export async function createLightweightMonitors( ) { const lwFiles = new Set(); // Filter monitor files based on the provided pattern - const pattern = options.pattern - ? new RegExp(options.pattern, 'i') + const pattern = options.grepOpts?.pattern + ? new RegExp(options.grepOpts?.pattern, 'i') : /.(yml|yaml)$/; const ignore = /(node_modules|.github)/; await totalist(workDir, (rel, abs) => { @@ -211,7 +211,9 @@ export async function createLightweightMonitors( offsets.push(monNode.srcToken.offset); } - const mergedConfig = parsedDoc.toJS()['heartbeat.monitors']; + const mergedConfig = parsedDoc.toJS()[ + 'heartbeat.monitors' + ] as Array; for (let i = 0; i < mergedConfig.length; i++) { const monitor = mergedConfig[i]; // Skip browser monitors from the YML files @@ -220,7 +222,14 @@ export async function createLightweightMonitors( } const { line, col } = lineCounter.linePos(offsets[i]); try { + /** + * Build the monitor object from the yaml config along with global configuration + * and perform the match based on the provided filters + */ const mon = buildMonitorFromYaml(monitor, options); + if (!mon.isMatch(options.grepOpts?.match, options.grepOpts?.tags)) { + continue; + } mon.setSource({ file, line, column: col }); monitors.push(mon); } catch (e) {