Repository: elastic/synthetics (ts)

Issue:
Support for lightweight monitors via projects
With project monitors we now let users manage browser based monitors easily from the CLI, but that story does not extend to lightweight monitors. This limits the utility of our overall solution since users need multiple workflows, when they ideally could use project monitors for everything if desired.

This issue proposes supporting the YAML format used in heartbeat's `monitor.d` directory with project monitors. These would act the same as browser based monitors, only their definitions would be YAML based and included in the source tree. An example is listed below.

```
- type: http 
  id: my-http-monitor
  name: Lightweight HTTP Monitor
  schedule: '@every 5s' 
  hosts: ["https://www.elastic.co"]
  # New keys for service listed below
  locations: ["us-west", "japan"]
```

## Why YAML?

- Users will need to migrate existing configs, this being the exact same format they already use makes migration a breeze. 
- Lightweight monitors are more frequently used by administrators who are more comfortable with YAML than JS/TS and Node. Asking users with those personas to use JS/TS may hinder adoption.
- Ease of implementation, should be a simple translation to the API

## Gotchas / Known issues

- The IDs are not exactly portable since project monitors include the space and journey in the monitor ID. We could add a special config key `preserve_exact_monitor_id: true|false` to handle this situation.
- We won't be able to support cron syntax, or, if we do, we should support the subset that is translatable to `@every` syntax (the regex `(\*(\/\d)? )(\*(\/\d)? )\* \* \*` should match and capture cron jobs meant to run every N minutes or every N hours.
- Running locally is something we'll need to determine in the future. I propose that we initially just validate these and allow them to be pushed. Later we can determine a strategy for running these locally. Perhaps the simplest answer would be to just invoke `docker run` in `run_once` mode. For now, we could just validate the syntax of these monitors locally and mark them in some sort of `skipped` status.

Pull request description:
feat: support pushing lightweight monitors
+ fix #542 
+ Adds support for pushing lightweight monitors via `.(yml|yaml)` files pattern to Kibana endpoint when invoked using `npx @elastic/synthetics push` command. 
+ Works by checking all the yml files inside the synthetics project and creates a Monitor object that matches with browser monitors to be able to push to the Kibana endpoint. Also performs duplication and error reporting as we extract the line number and other information to go back to the source location. 
+ Supports only `@every <1m>` syntax for scheduling lightweight monitors, Throws Error when it sees other CRON format for schedules. 
+ Uses location information from two places
  - Default locations from Synthetics.config.ts when project is initialized using `npx @elastic/synthetics init` command
  - Allows overriding for individual monitors via `locations` and `private_locations` fields in Heartbeat yml files.
+ Added couple of validations 
    - Ignores pushing `browser` and `disabled` monitors
    - Aborts if monitor `id | name` is not provided. 


### Testing
- Build the local project using - `npm run build`
- Run `npm link`
- Setup a brand new Synthetics project - node dist/cli.js init [optional-dir]
- Navigate to the new directory created with `init` and run `npm link @elastic/synthetics`
- Follow along and check if project settings have been initialized properly
- Create Heartbeat yaml files, copy over existing ones or use the below (some additional heartbeat yaml is included in the linked Kibana PR)
```yml
- type: http
  enabled: true
  id: my-monitor
  name: My Monitor
  urls: ["http://localhost:9200"]
  schedule: @every 10m
  locations: ["singapore"]
  private_locations: ["private-1"]
  timeout: 16s
  check.request:
    method: POST
    headers:
      'Content-Type': 'application/x-www-form-urlencoded'
  check.response:
    status: [200]
    body:
      - Saved
      - saved
```
- Validate by pushing with auth specified `SYNTHETICS_API_KEY=<key> npm run push`. 


Patch:
diff --git a/package-lock.json b/package-lock.json
index 39161d2..fe44083 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -27,7 +27,8 @@
         "sonic-boom": "^3.2.0",
         "ts-node": "^10.9.1",
         "typescript": "^4.8.3",
-        "undici": "^5.10.0"
+        "undici": "^5.10.0",
+        "yaml": "^2.1.1"
       },
       "bin": {
         "elastic-synthetics": "dist/cli.js",
@@ -2443,6 +2444,15 @@
         "node": ">=10"
       }
     },
+    "node_modules/cosmiconfig/node_modules/yaml": {
+      "version": "1.10.2",
+      "resolved": "https://registry.npmjs.org/yaml/-/yaml-1.10.2.tgz",
+      "integrity": "sha512-r3vXyErRCYJ7wg28yvBY5VSoAF8ZvlcW9/BwUzEtUsjvX/DKs24dIkuwjtuprwJJHsbyUbLApepYTR1BN4uHrg==",
+      "dev": true,
+      "engines": {
+        "node": ">= 6"
+      }
+    },
     "node_modules/crc-32": {
       "version": "1.2.1",
       "resolved": "https://registry.npmjs.org/crc-32/-/crc-32-1.2.1.tgz",
@@ -6929,12 +6939,11 @@
       "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A=="
     },
     "node_modules/yaml": {
-      "version": "1.10.2",
-      "resolved": "https://registry.npmjs.org/yaml/-/yaml-1.10.2.tgz",
-      "integrity": "sha512-r3vXyErRCYJ7wg28yvBY5VSoAF8ZvlcW9/BwUzEtUsjvX/DKs24dIkuwjtuprwJJHsbyUbLApepYTR1BN4uHrg==",
-      "dev": true,
+      "version": "2.1.1",
+      "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.1.1.tgz",
+      "integrity": "sha512-o96x3OPo8GjWeSLF+wOAbrPfhFOGY0W00GNaxCDv+9hkcDJEnev1yh8S7pgHF0ik6zc8sQLuL8hjHjJULZp8bw==",
       "engines": {
-        "node": ">= 6"
+        "node": ">= 14"
       }
     },
     "node_modules/yargs": {
@@ -8837,6 +8846,14 @@
         "parse-json": "^5.0.0",
         "path-type": "^4.0.0",
         "yaml": "^1.10.0"
+      },
+      "dependencies": {
+        "yaml": {
+          "version": "1.10.2",
+          "resolved": "https://registry.npmjs.org/yaml/-/yaml-1.10.2.tgz",
+          "integrity": "sha512-r3vXyErRCYJ7wg28yvBY5VSoAF8ZvlcW9/BwUzEtUsjvX/DKs24dIkuwjtuprwJJHsbyUbLApepYTR1BN4uHrg==",
+          "dev": true
+        }
       }
     },
     "crc-32": {
@@ -12025,10 +12042,9 @@
       "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A=="
     },
     "yaml": {
-      "version": "1.10.2",
-      "resolved": "https://registry.npmjs.org/yaml/-/yaml-1.10.2.tgz",
-      "integrity": "sha512-r3vXyErRCYJ7wg28yvBY5VSoAF8ZvlcW9/BwUzEtUsjvX/DKs24dIkuwjtuprwJJHsbyUbLApepYTR1BN4uHrg==",
-      "dev": true
+      "version": "2.1.1",
+      "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.1.1.tgz",
+      "integrity": "sha512-o96x3OPo8GjWeSLF+wOAbrPfhFOGY0W00GNaxCDv+9hkcDJEnev1yh8S7pgHF0ik6zc8sQLuL8hjHjJULZp8bw=="
     },
     "yargs": {
       "version": "17.5.1",
diff --git a/package.json b/package.json
index c5b77b9..18809da 100644
--- a/package.json
+++ b/package.json
@@ -58,7 +58,8 @@
     "sonic-boom": "^3.2.0",
     "ts-node": "^10.9.1",
     "typescript": "^4.8.3",
-    "undici": "^5.10.0"
+    "undici": "^5.10.0",
+    "yaml": "^2.1.1"
   },
   "devDependencies": {
     "@types/jest": "^28.1.8",
diff --git a/src/cli.ts b/src/cli.ts
index da75dc8..0310aa4 100644
--- a/src/cli.ts
+++ b/src/cli.ts
@@ -54,6 +54,7 @@ import { resolve } from 'path';
 import { Generator } from './generator';
 import { error } from './helpers';
 import { LocationsMap } from './locations/public-locations';
+import { createLightweightMonitors } from './push/monitor';
 
 /* eslint-disable-next-line @typescript-eslint/no-var-requires */
 const { name, version } = require('../package.json');
@@ -192,7 +193,8 @@ program
   .addOption(playwrightOpts)
   .action(async (cmdOpts: PushOptions) => {
     try {
-      await loadTestFiles({ inline: false }, [cwd()]);
+      const workDir = cwd();
+      await loadTestFiles({ inline: false, ...program.opts() }, [workDir]);
       const settings = await loadSettings();
       const options = normalizeOptions({
         ...program.opts(),
@@ -202,6 +204,7 @@ program
       validateSettings(options);
       await catchIncorrectSettings(settings, options);
       const monitors = runner.buildMonitors(options);
+      monitors.push(...(await createLightweightMonitors(workDir, options)));
       await push(monitors, options);
     } catch (e) {
       e && console.error(e);
diff --git a/src/common_types.ts b/src/common_types.ts
index b9884ee..3a1d292 100644
--- a/src/common_types.ts
+++ b/src/common_types.ts
@@ -241,7 +241,7 @@ export type RunOptions = BaseArgs & {
   reporter?: BuiltInReporterName | ReporterInstance;
 };
 
-export type PushOptions = ProjectSettings & {
+export type PushOptions = Partial<ProjectSettings> & {
   auth: string;
   schedule?: MonitorConfig['schedule'];
   locations?: MonitorConfig['locations'];
diff --git a/src/dsl/journey.ts b/src/dsl/journey.ts
index a78dd08..f964823 100644
--- a/src/dsl/journey.ts
+++ b/src/dsl/journey.ts
@@ -92,6 +92,7 @@ export class Journey {
     this.monitor = new Monitor({
       name: this.name,
       id: this.id,
+      type: 'browser',
       tags: this.tags ?? [],
       ...config,
     });
diff --git a/src/dsl/monitor.ts b/src/dsl/monitor.ts
index 195ba33..47e3aeb 100644
--- a/src/dsl/monitor.ts
+++ b/src/dsl/monitor.ts
@@ -41,6 +41,7 @@ export const SyntheticsLocations = Object.keys(
 export type MonitorConfig = {
   id?: string;
   name?: string;
+  type?: string;
   tags?: string[];
   schedule?: number;
   enabled?: boolean;
@@ -73,6 +74,10 @@ export class Monitor {
     });
   }
 
+  get type() {
+    return this.config.type;
+  }
+
   setSource(source: Location) {
     this.source = source;
   }
diff --git a/src/push/monitor.ts b/src/push/monitor.ts
index 35a4120..fb0b022 100644
--- a/src/push/monitor.ts
+++ b/src/push/monitor.ts
@@ -23,19 +23,26 @@
  *
  */
 
-import { mkdir, rm } from 'fs/promises';
+import { mkdir, rm, readFile } from 'fs/promises';
 import { join } from 'path';
+import { LineCounter, parseDocument, YAMLSeq, YAMLMap } from 'yaml';
+import { bold, red } from 'kleur/colors';
 import { Bundler } from './bundler';
 import { sendRequest } from './request';
-import { removeTrailingSlash, SYNTHETICS_PATH } from '../helpers';
+import {
+  removeTrailingSlash,
+  SYNTHETICS_PATH,
+  totalist,
+  indent,
+} from '../helpers';
 import { LocationsMap } from '../locations/public-locations';
 import { Monitor, MonitorConfig } from '../dsl/monitor';
 import { PushOptions } from '../common_types';
 
 export type MonitorSchema = Omit<MonitorConfig, 'locations'> & {
-  content: string;
   locations: string[];
-  filter: Monitor['filter'];
+  content?: string;
+  filter?: Monitor['filter'];
 };
 
 export type APISchema = {
@@ -60,21 +67,123 @@ export async function buildMonitorSchema(monitors: Monitor[]) {
   const schemas: MonitorSchema[] = [];
 
   for (const monitor of monitors) {
-    const { source, config, filter } = monitor;
-    const outPath = join(bundlePath, config.name + '.zip');
-    const content = await bundler.build(source.file, outPath);
-    schemas.push({
+    const { source, config, filter, type } = monitor;
+    const schema = {
       ...config,
-      content,
       locations: translateLocation(config.locations),
-      filter: filter,
-    });
+    };
+    if (type === 'browser') {
+      const outPath = join(bundlePath, config.name + '.zip');
+      const content = await bundler.build(source.file, outPath);
+      Object.assign(schema, { content, filter });
+    }
+    schemas.push(schema);
   }
 
   await rm(bundlePath, { recursive: true });
   return schemas;
 }
 
+export async function createLightweightMonitors(
+  workDir: string,
+  options: PushOptions
+) {
+  const lwFiles = new Set<string>();
+  await totalist(workDir, (rel, abs) => {
+    if (/.(yml|yaml)$/.test(rel)) {
+      lwFiles.add(abs);
+    }
+  });
+
+  const monitors: Monitor[] = [];
+  for (const file of lwFiles.values()) {
+    const content = await readFile(file, 'utf-8');
+    const lineCounter = new LineCounter();
+    const parsedDoc = parseDocument(content, {
+      lineCounter,
+      keepSourceTokens: true,
+    });
+    // Skip other yml files that are not relevant
+    const monitorSeq = parsedDoc.get('heartbeat.monitors') as YAMLSeq<YAMLMap>;
+    if (!monitorSeq) {
+      continue;
+    }
+
+    for (const monNode of monitorSeq.items) {
+      // Skip browser monitors and disabled monitors from pushing
+      if (
+        monNode.get('type') === 'browser' ||
+        monNode.get('enabled') === false
+      ) {
+        continue;
+      }
+      const config = monNode.toJSON();
+      const { line, col } = lineCounter.linePos(monNode.srcToken.offset);
+      try {
+        const mon = buildMonitorFromYaml(config, options);
+        mon.setSource({ file, line, column: col });
+        monitors.push(mon);
+      } catch (e) {
+        let outer = bold(`Aborted: ${e}\n`);
+        outer += indent(
+          `* ${config.id || config.name} - ${file}:${line}:${col}\n`
+        );
+        throw red(outer);
+      }
+    }
+  }
+  return monitors;
+}
+
+const REQUIRED_MONITOR_FIELDS = ['id', 'name'];
+export function buildMonitorFromYaml(
+  config: MonitorConfig,
+  options: PushOptions
+) {
+  // Validate required fields
+  for (const field of REQUIRED_MONITOR_FIELDS) {
+    if (!config[field]) {
+      throw `Monitor ${field} is required`;
+    }
+  }
+  const schedule = parseSchedule(String(config.schedule));
+  const privateLocations =
+    config['private_locations'] || options.privateLocations;
+  delete config['private_locations'];
+
+  return new Monitor({
+    locations: options.locations,
+    ...config,
+    privateLocations,
+    schedule: schedule || options.schedule,
+  });
+}
+
+export function parseSchedule(schedule: string) {
+  const EVERY_SYNTAX = '@every';
+  if (!(schedule + '').startsWith(EVERY_SYNTAX)) {
+    throw `Monitor schedule format(${schedule}) not supported: use '@every' syntax instead`;
+  }
+
+  const duration = schedule.substring(EVERY_SYNTAX.length + 1);
+  // split between non-digit (\D) and a digit (\d)
+  const durations = duration.split(/(?<=\D)(?=\d)/g);
+  let minutes = 0;
+  for (const dur of durations) {
+    // split between a digit and non-digit
+    const [value, format] = dur.split(/(?<=\d)(?=\D)/g);
+    // Calculate
+    if (format === 's') {
+      minutes++;
+    } else if (format === 'm') {
+      minutes += Number(value);
+    } else if (format === 'h') {
+      minutes += Number(value) * 60;
+    }
+  }
+  return minutes;
+}
+
 export async function createMonitors(
   monitors: MonitorSchema[],
   options: PushOptions,


Test patch:
diff --git a/__tests__/core/runner.test.ts b/__tests__/core/runner.test.ts
index 48943aa..41fc56f 100644
--- a/__tests__/core/runner.test.ts
+++ b/__tests__/core/runner.test.ts
@@ -726,8 +726,10 @@ describe('runner', () => {
     expect(monitors[0].config).toEqual({
       id: 'test-j1',
       name: 'j1',
+      type: 'browser',
       tags: [],
       locations: ['united_kingdom'],
+      privaateLocations: undefined,
       schedule: 2,
       params: undefined,
       playwrightOptions: undefined,
@@ -767,6 +769,7 @@ describe('runner', () => {
     expect(monitors[0].config).toEqual({
       id: 'test-j1',
       name: 'j1',
+      type: 'browser',
       tags: ['foo*'],
       locations: ['united_kingdom'],
       privateLocations: ['spain'],
diff --git a/__tests__/push/index.test.ts b/__tests__/push/index.test.ts
index 3267fbd..13055f6 100644
--- a/__tests__/push/index.test.ts
+++ b/__tests__/push/index.test.ts
@@ -126,7 +126,7 @@ journey('journey 1', () => monitor.use({ id: 'j1' }));`
     await rm(testJourney, { force: true });
   });
 
-  it('errors on duplicate monitors', async () => {
+  it('errors on duplicate browser monitors', async () => {
     await fakeProjectSetup(
       { id: 'test-project' },
       { locations: ['test-loc'], schedule: 2 }
@@ -149,6 +149,32 @@ journey('duplicate name', () => monitor.use({ schedule: 20 }));`
     await rm(dupJourney, { force: true });
   });
 
+  it('errors on duplicate lightweight monitors', async () => {
+    await fakeProjectSetup(
+      { id: 'test-project' },
+      { locations: ['test-loc'], schedule: 2 }
+    );
+
+    const heartbeatYml = join(PROJECT_DIR, 'hearbeat.duplicate.yml');
+    await writeFile(
+      heartbeatYml,
+      `
+heartbeat.monitors:
+- type: http
+  schedule: @every 1m
+  id: foo
+  name: foo1
+- type: http
+  schedule: @every 2m
+  id: foo
+  name: foo2
+    `
+    );
+    const output = await runPush();
+    expect(output).toContain(`Aborted: Duplicate monitors found`);
+    await rm(heartbeatYml, { force: true });
+  });
+
   it('format duplicate monitors', () => {
     const duplicates = new Set([
       {
diff --git a/__tests__/push/monitor.test.ts b/__tests__/push/monitor.test.ts
index 4210bd1..7d00ef6 100644
--- a/__tests__/push/monitor.test.ts
+++ b/__tests__/push/monitor.test.ts
@@ -23,7 +23,15 @@
  *
  */
 
-import { buildMonitorSchema, createMonitors } from '../../src/push/monitor';
+import { mkdir, rm, writeFile } from 'fs/promises';
+import { join } from 'path';
+import { generateTempPath } from '../../src/helpers';
+import {
+  buildMonitorSchema,
+  createMonitors,
+  createLightweightMonitors,
+  parseSchedule,
+} from '../../src/push/monitor';
 import { Server } from '../utils/server';
 import { createTestMonitor } from '../utils/test-config';
 
@@ -38,12 +46,28 @@ describe('Monitors', () => {
     process.env.NO_COLOR = '';
   });
 
-  it('build monitor schema monitor', async () => {
+  it('build lightweight monitor schema', async () => {
+    const schema = await buildMonitorSchema([
+      createTestMonitor('heartbeat.yml', 'http'),
+    ]);
+    expect(schema[0]).toEqual({
+      id: 'test-monitor',
+      name: 'test',
+      schedule: 10,
+      type: 'http',
+      enabled: true,
+      locations: ['europe-west2-a', 'australia-southeast1-a'],
+      privateLocations: ['germany'],
+    });
+  });
+
+  it('build browser monitor schema', async () => {
     const schema = await buildMonitorSchema([monitor]);
     expect(schema[0]).toEqual({
       id: 'test-monitor',
       name: 'test',
       schedule: 10,
+      type: 'browser',
       enabled: true,
       locations: ['europe-west2-a', 'australia-southeast1-a'],
       privateLocations: ['germany'],
@@ -54,6 +78,19 @@ describe('Monitors', () => {
     });
   });
 
+  it('parse @every schedule format', async () => {
+    expect(() => parseSchedule('* * * *')).toThrowError(
+      `Monitor schedule format(* * * *) not supported: use '@every' syntax instead`
+    );
+    expect(parseSchedule('@every 4s')).toBe(1);
+    expect(parseSchedule('@every 60s')).toBe(1);
+    expect(parseSchedule('@every 1m')).toBe(1);
+    expect(parseSchedule('@every 1m10s')).toBe(2);
+    expect(parseSchedule('@every 2m')).toBe(2);
+    expect(parseSchedule('@every 1h2m')).toBe(62);
+    expect(parseSchedule('@every 1h2m10s')).toBe(63);
+  });
+
   it('api schema', async () => {
     server.route(
       '/s/dummy/api/synthetics/service/project/monitors',
@@ -87,4 +124,167 @@ describe('Monitors', () => {
       monitors: schema,
     });
   });
+
+  describe('Lightweight monitors', () => {
+    const PROJECT_DIR = generateTempPath();
+    const HB_SOURCE = join(PROJECT_DIR, 'heartbeat.yml');
+    const opts = { auth: 'foo' };
+    beforeEach(async () => {
+      await mkdir(PROJECT_DIR, { recursive: true });
+    });
+    afterEach(async () => {
+      await rm(PROJECT_DIR, { recursive: true });
+    });
+
+    const writeHBFile = async data => {
+      await writeFile(HB_SOURCE, data, 'utf-8');
+    };
+
+    it('when no yml files are present', async () => {
+      const monitors = await createLightweightMonitors(PROJECT_DIR, opts);
+      expect(monitors.length).toBe(0);
+    });
+
+    it('when no monitors are present', async () => {
+      await writeHBFile(`
+heartbeat.monitors:
+
+heartbeat.run_once: true
+      `);
+      const monitors = await createLightweightMonitors(PROJECT_DIR, opts);
+      expect(monitors.length).toBe(0);
+    });
+
+    it('abort on schedule format error', async () => {
+      await writeHBFile(`
+heartbeat.monitors:
+- type: http
+  schedule: "* * * *"
+  id: "foo"
+  name: "foo"
+      `);
+      expect(createLightweightMonitors(PROJECT_DIR, opts)).rejects.toContain(
+        `Aborted: Monitor schedule format(* * * *) not supported: use '@every' syntax instead`
+      );
+    });
+
+    it('validate id check', async () => {
+      await writeHBFile(`
+heartbeat.monitors:
+- type: http
+  name: "foo"
+      `);
+      expect(createLightweightMonitors(PROJECT_DIR, opts)).rejects.toContain(
+        `Aborted: Monitor id is required`
+      );
+    });
+
+    it('validate name check', async () => {
+      await writeHBFile(`
+heartbeat.monitors:
+- type: http
+  id: "foo"
+      `);
+      expect(createLightweightMonitors(PROJECT_DIR, opts)).rejects.toContain(
+        `Aborted: Monitor name is required`
+      );
+    });
+
+    it('skip browser monitors', async () => {
+      await writeHBFile(`
+heartbeat.monitors:
+- type: browser
+  schedule: "@every 1m"
+  id: "browser-mon"
+      `);
+      const monitors = await createLightweightMonitors(PROJECT_DIR, opts);
+      expect(monitors.length).toBe(0);
+    });
+
+    it('skip disabled monitors', async () => {
+      await writeHBFile(`
+heartbeat.monitors:
+- type: http
+  schedule: "@every 1m"
+  id: "http"
+  enabled: false
+      `);
+      const monitors = await createLightweightMonitors(PROJECT_DIR, opts);
+      expect(monitors.length).toBe(0);
+    });
+
+    it('parses monitor config correctly', async () => {
+      await writeHBFile(`
+heartbeat.monitors:
+- type: icmp
+  schedule: @every 5m
+  id: "foo"
+  name: "with-loc"
+  private_locations:
+    - baz
+      `);
+      const [monitor] = await createLightweightMonitors(PROJECT_DIR, {
+        locations: ['australia_east'],
+      } as any);
+      expect(monitor.config).toEqual({
+        id: 'foo',
+        name: 'with-loc',
+        type: 'icmp',
+        locations: ['australia_east'],
+        privateLocations: ['baz'],
+        schedule: 5,
+      });
+      expect(monitor.source).toEqual({
+        file: HB_SOURCE,
+        column: 3,
+        line: 3,
+      });
+    });
+
+    it('pass all monitor config as it is', async () => {
+      await writeHBFile(`
+heartbeat.monitors:
+- type: http
+  schedule: @every 5m
+  id: "foo"
+  name: "with-fields"
+  ssl:
+    certificate_authorities: ['/etc/ca.crt']
+  check.response:
+    status: [200]
+    body:
+      - Saved
+      - saved
+  check.request:
+    method: POST
+    headers:
+      'Content-Type': 'application/x-www-form-urlencoded'
+      `);
+
+      const [monitor] = await createLightweightMonitors(PROJECT_DIR, {
+        locations: ['australia_east'],
+      } as any);
+      expect(monitor.config).toEqual({
+        id: 'foo',
+        name: 'with-fields',
+        type: 'http',
+        locations: ['australia_east'],
+        privateLocations: undefined,
+        schedule: 5,
+        'check.request': {
+          headers: {
+            'Content-Type': 'application/x-www-form-urlencoded',
+          },
+          method: 'POST',
+        },
+        'check.response': {
+          body: ['Saved', 'saved'],
+          status: [200],
+        },
+        ssl: {
+          certificate_authorities: ['/etc/ca.crt'],
+        },
+      });
+    });
+  });
 });
diff --git a/__tests__/utils/test-config.ts b/__tests__/utils/test-config.ts
index bbb10e3..99f3b81 100644
--- a/__tests__/utils/test-config.ts
+++ b/__tests__/utils/test-config.ts
@@ -25,15 +25,16 @@
 
 import { ChildProcess, spawn } from 'child_process';
 import { join } from 'path';
-import { Monitor } from '../../dist/dsl/monitor';
+import { Monitor } from '../../src/dsl/monitor';
 
 export const wsEndpoint = process.env.WSENDPOINT;
 
 const FIXTURES_DIR = join(__dirname, '..', 'fixtures');
-export function createTestMonitor(filename: string) {
+export function createTestMonitor(filename: string, type = 'browser') {
   const monitor = new Monitor({
     id: 'test-monitor',
     name: 'test',
+    type,
     schedule: 10,
     enabled: true,
     locations: ['united_kingdom', 'australia_east'],