Repository: elastic/synthetics (ts)

Issue:
Add tags, filtering
It should be possible to tag journeys, and, when running a suite, filter by tag name.

I propose we add `--include-tags tag1,tag2` and `--exclude-tags tag1,tag2` as options.

Pull request:
feat: add filtering by tags and names
+ fix #26 
+ Added support for glob pattern matching for both journey name and tags. 
+ Names and tags can be matched with multiple journeys that has the same name and tags spawned across multiple files. 
```
elastic-synthetics examples/todos/* --match 'check*' 
// will match all the journey that has `check` in their name and tags. 
```
This aligns with `RegExp` or glob matching found in major libraries like jest, folio, mocha etc and we don't support variadiac options for name matching as majority of the cases can be solved with a mix of both.

+ Tags matching which is a variadic option meaning you can pass multiple options in the CLI to match a group of journeys.
```ts
// will run the journeys that are tagged with the respective teams in the kibana repo. 
elastic-synthetics <dir> --tags 'kibana:uptime*'  'kibana:apm*'

journey({ name: "elastic uptime app", tags: ["kibana:uptime", "kibana:apm" }, () => {})
```

**Note: `match` pattern that matches both name and tags, `tags` pattern that matches only tags**


Patch:
diff --git a/package-lock.json b/package-lock.json
index 15a595a..ff914ae 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -833,6 +833,12 @@
         "@babel/types": "^7.3.0"
       }
     },
+    "@types/braces": {
+      "version": "3.0.0",
+      "resolved": "https://registry.npmjs.org/@types/braces/-/braces-3.0.0.tgz",
+      "integrity": "sha512-TbH79tcyi9FHwbyboOKeRachRq63mSuWYXOflsNO9ZyE5ClQ/JaozNKl+aWUq87qPNsXasXxi2AbgfwIJ+8GQw==",
+      "dev": true
+    },
     "@types/eslint-visitor-keys": {
       "version": "1.0.0",
       "resolved": "https://registry.npmjs.org/@types/eslint-visitor-keys/-/eslint-visitor-keys-1.0.0.tgz",
@@ -888,6 +894,15 @@
       "integrity": "sha512-cxWFQVseBm6O9Gbw1IWb8r6OS4OhSt3hPZLkFApLjM8TEXROBuQGLAH2i2gZpcXdLBIrpXuTDhH7Vbm1iXmNGA==",
       "dev": true
     },
+    "@types/micromatch": {
+      "version": "4.0.1",
+      "resolved": "https://registry.npmjs.org/@types/micromatch/-/micromatch-4.0.1.tgz",
+      "integrity": "sha512-my6fLBvpY70KattTNzYOK6KU1oR1+UCz9ug/JbcF5UrEmeCt9P7DV2t7L8+t18mMPINqGQCE4O8PLOPbI84gxw==",
+      "dev": true,
+      "requires": {
+        "@types/braces": "*"
+      }
+    },
     "@types/node": {
       "version": "14.14.45",
       "resolved": "https://registry.npmjs.org/@types/node/-/node-14.14.45.tgz",
@@ -1421,7 +1436,6 @@
       "version": "3.0.2",
       "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.2.tgz",
       "integrity": "sha512-b8um+L1RzM3WDSzvhm6gIz1yfTbBt6YTlcEKAvsmqCZZFw46z626lVj9j1yEPW33H5H+lBQpZMP1k8l+78Ha0A==",
-      "dev": true,
       "requires": {
         "fill-range": "^7.0.1"
       }
@@ -2602,7 +2616,6 @@
       "version": "7.0.1",
       "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.0.1.tgz",
       "integrity": "sha512-qOo9F+dMUmC2Lcb4BbVvnKJxTPjCm+RRpe4gDuGrzkL7mEVl/djYSu2OdQ2Pa302N4oqkSg9ir6jaLWJ2USVpQ==",
-      "dev": true,
       "requires": {
         "to-regex-range": "^5.0.1"
       }
@@ -3278,8 +3291,7 @@
     "is-number": {
       "version": "7.0.0",
       "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz",
-      "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==",
-      "dev": true
+      "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng=="
     },
     "is-obj": {
       "version": "1.0.1",
@@ -4262,7 +4274,6 @@
       "version": "4.0.4",
       "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.4.tgz",
       "integrity": "sha512-pRmzw/XUcwXGpD9aI9q/0XOwLNygjETJ8y0ao0wdqprrzDa4YnxLcz7fQRZr8voh8V10kGhABbNcHVk5wHgWwg==",
-      "dev": true,
       "requires": {
         "braces": "^3.0.1",
         "picomatch": "^2.2.3"
@@ -4726,8 +4737,7 @@
     "picomatch": {
       "version": "2.2.3",
       "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.2.3.tgz",
-      "integrity": "sha512-KpELjfwcCDUb9PeigTs2mBJzXUPzAuP2oPcA989He8Rte0+YUAjw1JVedDhuTKPkHjSYzMN3npC9luThGYEKdg==",
-      "dev": true
+      "integrity": "sha512-KpELjfwcCDUb9PeigTs2mBJzXUPzAuP2oPcA989He8Rte0+YUAjw1JVedDhuTKPkHjSYzMN3npC9luThGYEKdg=="
     },
     "pirates": {
       "version": "4.0.1",
@@ -6092,7 +6102,6 @@
       "version": "5.0.1",
       "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz",
       "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==",
-      "dev": true,
       "requires": {
         "is-number": "^7.0.0"
       }
diff --git a/package.json b/package.json
index 5ff0341..f6e0c3a 100644
--- a/package.json
+++ b/package.json
@@ -42,6 +42,7 @@
     "deepmerge": "^4.2.2",
     "http-proxy": "^1.18.1",
     "kleur": "^4.1.3",
+    "micromatch": "^4.0.4",
     "playwright-chromium": "=1.11.0",
     "sharp": "^0.28.3",
     "snakecase-keys": "^3.2.1",
@@ -52,6 +53,7 @@
   },
   "devDependencies": {
     "@types/jest": "^26.0.19",
+    "@types/micromatch": "^4.0.1",
     "@types/node": "^14.14.14",
     "@types/sharp": "^0.28.2",
     "@typescript-eslint/eslint-plugin": "^3.10.1",
diff --git a/src/common_types.ts b/src/common_types.ts
index 36b01fc..77bc4c2 100644
--- a/src/common_types.ts
+++ b/src/common_types.ts
@@ -125,7 +125,6 @@ export type CliArgs = {
   filmstrips?: boolean;
   trace?: boolean;
   dryRun?: boolean;
-  journeyName?: string;
   network?: boolean;
   pauseOnError?: boolean;
   reporter?: Reporters;
@@ -134,6 +133,8 @@ export type CliArgs = {
   json?: boolean;
   pattern?: string;
   inline: boolean;
+  match?: string;
+  tags?: Array<string>;
   require: Array<string>;
   debug?: boolean;
   suiteParams?: string;
diff --git a/src/core/runner.ts b/src/core/runner.ts
index 8214f77..16996ad 100644
--- a/src/core/runner.ts
+++ b/src/core/runner.ts
@@ -24,6 +24,8 @@
  */
 
 import { once, EventEmitter } from 'events';
+import { join } from 'path';
+import { mkdirSync, rmdirSync, writeFileSync } from 'fs';
 import { Journey } from '../dsl/journey';
 import { Step } from '../dsl/step';
 import { reporters, Reporter } from '../reporters';
@@ -46,8 +48,6 @@ import { PluginManager } from '../plugins';
 import { PerformanceManager, Metrics } from '../plugins';
 import { Driver, Gatherer } from './gatherer';
 import { log } from './logger';
-import { mkdirSync, rmdirSync, writeFileSync } from 'fs';
-import { join } from 'path';
 
 export type RunOptions = Omit<
   CliArgs,
@@ -369,8 +369,8 @@ export default class Runner extends EventEmitter {
       env: options.environment,
       params: options.params,
     });
+    const { dryRun, match, tags } = options;
     for (const journey of this.journeys) {
-      const { dryRun, journeyName } = options;
       /**
        * Used by heartbeat to gather all registered journeys
        */
@@ -378,8 +378,7 @@ export default class Runner extends EventEmitter {
         this.emit('journey:register', { journey });
         continue;
       }
-      // TODO: Replace functionality with journey.only
-      if (journeyName && journey.name != journeyName) {
+      if (!journey.isMatch(match, tags)) {
         continue;
       }
       const journeyResult = await this.runJourney(journey, options);
diff --git a/src/dsl/journey.ts b/src/dsl/journey.ts
index 352947b..b613731 100644
--- a/src/dsl/journey.ts
+++ b/src/dsl/journey.ts
@@ -24,12 +24,14 @@
  */
 
 import { Browser, Page, BrowserContext, CDPSession } from 'playwright-chromium';
+import micromatch, { isMatch } from 'micromatch';
 import { Step } from './step';
 import { VoidCallback, HooksCallback, Params } from '../common_types';
 
 export type JourneyOptions = {
   name: string;
   id?: string;
+  tags?: string[];
 };
 
 type HookType = 'before' | 'after';
@@ -45,6 +47,7 @@ export type JourneyCallback = (options: {
 export class Journey {
   name: string;
   id?: string;
+  tags?: string[];
   callback: JourneyCallback;
   steps: Step[] = [];
   hooks: Hooks = { before: [], after: [] };
@@ -52,6 +55,7 @@ export class Journey {
   constructor(options: JourneyOptions, callback: JourneyCallback) {
     this.name = options.name;
     this.id = options.id;
+    this.tags = options.tags;
     this.callback = callback;
   }
 
@@ -64,4 +68,23 @@ export class Journey {
   addHook(type: HookType, callback: HooksCallback) {
     this.hooks[type].push(callback);
   }
+  /**
+   * Matches journeys based on the provided args. Proitize tags over match
+   * - tags pattern that matches only tags
+   * - match pattern that matches both name and tags
+   */
+  isMatch(matchPattern: string, tagsPattern: Array<string>) {
+    if (tagsPattern) {
+      return this.tagsMatch(tagsPattern);
+    }
+    if (matchPattern) {
+      return isMatch(this.name, matchPattern) || this.tagsMatch(matchPattern);
+    }
+    return true;
+  }
+
+  tagsMatch(pattern) {
+    const matchess = micromatch(this.tags || ['*'], pattern);
+    return matchess.length > 0;
+  }
 }
diff --git a/src/parse_args.ts b/src/parse_args.ts
index fe78c8e..d8f33f0 100644
--- a/src/parse_args.ts
+++ b/src/parse_args.ts
@@ -65,7 +65,14 @@ program
     '--dry-run',
     "don't actually execute anything, report only registered journeys"
   )
-  .option('--journey-name <name>', 'only run the journey with the given name')
+  .option(
+    '--match <name>',
+    'run only journeys with a name or tags that matches the glob'
+  )
+  .option(
+    '--tags <name...>',
+    'run only journeys with the given tag(s), or globs'
+  )
   .option(
     '--outfd <fd>',
     'specify a file descriptor for logs. Default is stdout',
diff --git a/src/reporters/json.ts b/src/reporters/json.ts
index a7c5a13..93002d4 100644
--- a/src/reporters/json.ts
+++ b/src/reporters/json.ts
@@ -244,6 +244,7 @@ function journeyInfo(
   return {
     name: journey.name,
     id: journey.id,
+    tags: journey.tags,
     status: type === 'journey/end' ? status : undefined,
   };
 }


Test patch:
diff --git a/__tests__/core/runner.test.ts b/__tests__/core/runner.test.ts
index 4c5ad8d..a60b6ed 100644
--- a/__tests__/core/runner.test.ts
+++ b/__tests__/core/runner.test.ts
@@ -265,16 +265,83 @@ describe('runner', () => {
     });
   });
 
-  it('run api - only runs specified journeyName', async () => {
+  it('run api - match journey name explict', async () => {
     runner.addJourney(new Journey({ name: 'j1' }, noop));
     runner.addJourney(new Journey({ name: 'j2' }, noop));
-    const result = await runner.run({
-      wsEndpoint,
-      outfd: fs.openSync(dest, 'w'),
-      journeyName: 'j2',
+    expect(
+      await runner.run({
+        wsEndpoint,
+        outfd: fs.openSync(dest, 'w'),
+        match: 'j2',
+      })
+    ).toEqual({
+      j2: { status: 'succeeded' },
     });
-    expect(result).toEqual({
+  });
+
+  it('run api - match journey name and tag globs', async () => {
+    runner.addJourney(new Journey({ name: 'j1' }, noop));
+    runner.addJourney(new Journey({ name: 'tagj2', tags: ['j2'] }, noop));
+    expect(
+      await runner.run({
+        wsEndpoint,
+        outfd: fs.openSync(dest, 'w'),
+        match: 'j*',
+      })
+    ).toEqual({
+      j1: { status: 'succeeded' },
+      tagj2: { status: 'succeeded' },
+    });
+  });
+
+  it('run api - prefer tags glob matching', async () => {
+    runner.addJourney(new Journey({ name: 'j1', tags: ['foo'] }, noop));
+    runner.addJourney(new Journey({ name: 'j2', tags: ['bar'] }, noop));
+    runner.addJourney(new Journey({ name: 'j3', tags: ['foo:test'] }, noop));
+    runner.addJourney(new Journey({ name: 'j4', tags: ['baz'] }, noop));
+    runner.addJourney(new Journey({ name: 'j5', tags: ['foo'] }, noop));
+    expect(
+      await runner.run({
+        wsEndpoint,
+        outfd: fs.openSync(dest, 'w'),
+        tags: ['foo*'],
+        match: 'j*',
+      })
+    ).toEqual({
+      j1: { status: 'succeeded' },
+      j3: { status: 'succeeded' },
+      j5: { status: 'succeeded' },
+    });
+  });
+
+  it('run api - support multiple tags', async () => {
+    runner.addJourney(new Journey({ name: 'j1', tags: ['hello:foo'] }, noop));
+    runner.addJourney(new Journey({ name: 'j2', tags: ['hello:bar'] }, noop));
+    runner.addJourney(new Journey({ name: 'j3', tags: ['hello:baz'] }, noop));
+    expect(
+      await runner.run({
+        wsEndpoint,
+        outfd: fs.openSync(dest, 'w'),
+        tags: ['hello:b*'],
+      })
+    ).toEqual({
       j2: { status: 'succeeded' },
+      j3: { status: 'succeeded' },
+    });
+  });
+
+  it('run api - support negation tags', async () => {
+    runner.addJourney(new Journey({ name: 'j1', tags: ['hello:foo'] }, noop));
+    runner.addJourney(new Journey({ name: 'j2', tags: ['hello:bar'] }, noop));
+    runner.addJourney(new Journey({ name: 'j3', tags: ['hello:baz'] }, noop));
+    expect(
+      await runner.run({
+        wsEndpoint,
+        outfd: fs.openSync(dest, 'w'),
+        tags: ['!hello:b*'],
+      })
+    ).toEqual({
+      j1: { status: 'succeeded' },
     });
   });
 
diff --git a/__tests__/index.test.ts b/__tests__/index.test.ts
index cc7611a..3da1a97 100644
--- a/__tests__/index.test.ts
+++ b/__tests__/index.test.ts
@@ -70,7 +70,7 @@ describe('Run', () => {
       filmstrips: false,
       trace: false,
       dryRun: true,
-      journeyName: 'There and Back Again',
+      match: 'check*',
       network: true,
       pauseOnError: true,
       reporter: 'json',
diff --git a/__tests__/reporters/json.test.ts b/__tests__/reporters/json.test.ts
index 9e9f71c..e510e71 100644
--- a/__tests__/reporters/json.test.ts
+++ b/__tests__/reporters/json.test.ts
@@ -203,6 +203,21 @@ describe('json reporter', () => {
     expect(journeyEnd.error).toEqual(helpers.formatError(myErr));
   });
 
+  it('writes full journey info if present', async () => {
+    const journeyOpts = { name: 'name', id: 'id', tags: ['tag1', 'tag2'] };
+    runner.emit('journey:end', {
+      journey: journey(journeyOpts, () => {}),
+      start: 0,
+      end: 1,
+      status: 'skipped',
+    });
+
+    const journeyEnd = (await readAndCloseStreamJson()).find(
+      json => json.type == 'journey/end'
+    );
+    expect(journeyEnd.journey).toEqual({ ...journeyOpts, status: 'skipped' });
+  });
+
   it('captures number of journeys as metadata event', async () => {
     runner.emit('start', {
       numJourneys: 10,