Repository: elastic/synthetics (ts)

Issue:
Duplicate journey id, silently groups synthetic pings under the last journey in list
If an id is repeated across journeys in a suite, in Uptime, all the pings of the journeys with the same id will go under the journey occurring last in order. Uptime will show only one monitor in monitors overview page, with monitor title as the title of the journey read last.

No warning/error is reported in logs.

Correct data and screenshots are shown under each ping though.

Example:
A journey script as:
```typescript
import { journey, step, expect } from '@elastic/synthetics';

journey({id: 'todo-journey-id', name: 'check if title is present'}, ({ page, params }) => {
  step('launch app', async () => {
    await page.goto(params.url);
  });

  step('assert title', async () => {
    const header = await page.$('h1');
    expect(await header.textContent()).toBe('todos');
    await page.type('input.new-todo', 'New Title');
  });
});

journey({id: 'todo-journey-id', name: 'check if input placeholder is correct'}, ({ page, params }) => {
  step('launch app', async () => {
    await page.goto(params.url);
  });

  step('assert placeholder value', async () => {
    const input = await page.$('input.new-todo');
    expect(await input.getAttribute('placeholder')).toBe(
      'What needs to be done?'
    );
  });
});
```

Will result in Uptime showing:
![image](https://user-images.githubusercontent.com/2748376/149372258-36fd697a-4f81-45b1-bf1f-cee9161109ae.png)

Whereas the the list of pings contains pings of both monitors with correct data and screenshots:
![image](https://user-images.githubusercontent.com/2748376/149372311-33953702-d350-4b6e-b6f6-759c687eb427.png)


**Heartbeat version**: `heartbeat version 8.1.0 (amd64), libbeat 8.1.0 [84924e9f32d018e360303714c26f754dbab34f93 built 2021-12-30 16:14:09 +0000 UTC]`

**Synthetics version**:  `1.0.0-beta.17`

Pull request description:
feat: abort push on duplicate monitors
+ fix #442 
+ Aborts the push command if any of the monitors contain duplicate ids with a clean error message and also linking users to the duplicate monitor files where required. 
+ Decided to handle the logic here as Kibana currently does not have any way to know if there are any duplicates without looking up the saved objects which is not performant when we have all the list already available via the agent. 

<img width="790" alt="Screen Shot 2022-08-01 at 3 30 41 PM" src="https://user-images.githubusercontent.com/3902525/182256462-1b0c50cb-ebd2-4033-a7bf-227e7f6d60e3.png">



### Testing.
- Build the local project using - npm run build
2. Setup a brand new Synthetics project - node dist/cli.js init [optional-dir]
3. Change the monitior ids for some of the monitors. 
4. Run push command - node dist/cli.js push --auth <> 


Patch:
diff --git a/src/push/index.ts b/src/push/index.ts
index 4e31160..b68eb6b 100644
--- a/src/push/index.ts
+++ b/src/push/index.ts
@@ -33,9 +33,19 @@ import {
 import { Monitor } from '../dsl/monitor';
 import { PushOptions } from '../common_types';
 import { buildMonitorSchema } from './monitor';
-import { progress, error, done } from '../helpers';
+import { progress, error, done, indent } from '../helpers';
+import { bold } from 'kleur/colors';
 
 export async function push(monitors: Monitor[], options: PushOptions) {
+  if (monitors.length === 0) {
+    throw 'No Monitors found';
+  }
+
+  const duplicates = trackDuplicates(monitors);
+  if (duplicates.size > 0) {
+    throw error(formatDuplicateError(duplicates));
+  }
+
   progress(`preparing all monitors`);
   const schemas = await buildMonitorSchema(monitors);
   try {
@@ -57,3 +67,29 @@ export async function push(monitors: Monitor[], options: PushOptions) {
     error(e);
   }
 }
+
+function trackDuplicates(monitors: Monitor[]) {
+  const monitorMap: Map<string, Monitor> = new Map();
+  const duplicates: Set<Monitor> = new Set();
+  for (const monitor of monitors) {
+    const id = monitor.config.id;
+    if (monitorMap.has(id)) {
+      duplicates.add(monitorMap.get(id));
+      duplicates.add(monitor);
+    }
+    monitorMap.set(id, monitor);
+  }
+  return duplicates;
+}
+
+export function formatDuplicateError(monitors: Set<Monitor>) {
+  let outer = bold(`Aborted: Duplicate monitors found\n`);
+
+  let inner = '';
+  for (const monitor of monitors) {
+    const { config, source } = monitor;
+    inner += `* ${config.id} - ${source.file}:${source.line}:${source.column}\n`;
+  }
+  outer += indent(inner);
+  return outer;
+}
diff --git a/src/push/monitor.ts b/src/push/monitor.ts
index 262ebdb..de7a1b3 100644
--- a/src/push/monitor.ts
+++ b/src/push/monitor.ts
@@ -42,9 +42,6 @@ function translateLocation(locations?: MonitorConfig['locations']) {
 }
 
 export async function buildMonitorSchema(monitors: Monitor[]) {
-  if (monitors.length == 0) {
-    throw new Error('No Monitors found');
-  }
   /**
    * Set up the bundle artifacts path which can be used to
    * create the bundles required for uploading journeys


Test patch:
diff --git a/__tests__/fixtures/duplicate.journey.ts b/__tests__/fixtures/duplicate.journey.ts
new file mode 100644
index 0000000..903189b
--- /dev/null
+++ b/__tests__/fixtures/duplicate.journey.ts
@@ -0,0 +1,32 @@
+/**
+ * MIT License
+ *
+ * Copyright (c) 2020-present, Elastic NV
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to deal
+ * in the Software without restriction, including without limitation the rights
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons to whom the Software is
+ * furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in
+ * all copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+ * THE SOFTWARE.
+ *
+ */
+
+import { journey, monitor } from '../../src';
+
+journey('journey 1', () => monitor.use({ id: 'duplicate id' }));
+journey('journey 2', () => monitor.use({ id: 'duplicate id' }));
+
+journey('duplicate name', () => monitor.use({ schedule: 10 }));
+journey('duplicate name', () => monitor.use({ schedule: 20 }));
diff --git a/__tests__/push/__snapshots__/index.test.ts.snap b/__tests__/push/__snapshots__/index.test.ts.snap
new file mode 100644
index 0000000..a98b970
--- /dev/null
+++ b/__tests__/push/__snapshots__/index.test.ts.snap
@@ -0,0 +1,8 @@
+// Jest Snapshot v1, https://goo.gl/fbAQLP
+
+exports[`Push format duplicate monitors 1`] = `
+"Aborted: Duplicate monitors found
+   * test - journey1.ts:2:5
+   * test - journey2.ts:10:5
+   "
+`;
diff --git a/__tests__/push/index.test.ts b/__tests__/push/index.test.ts
index 685c295..fcaf6ad 100644
--- a/__tests__/push/index.test.ts
+++ b/__tests__/push/index.test.ts
@@ -23,12 +23,18 @@
  *
  */
 
+process.env.NO_COLOR = '1';
+
 import { join } from 'path';
+import { Monitor } from '../../src/dsl/monitor';
+import { formatDuplicateError } from '../../src/push';
 import { CLIMock } from '../utils/test-config';
 
 const FIXTURES_DIR = join(__dirname, '..', 'fixtures');
 
-describe('Push CLI', () => {
+describe('Push', () => {
+  afterAll(() => (process.env.NO_COLOR = ''));
+
   const args = [
     '--url',
     'http://localhost:8000',
@@ -58,4 +64,29 @@ describe('Push CLI', () => {
 
     expect(cli.stderr()).toContain(`Set default location for all monitors`);
   });
+
+  it('errors on duplicate monitors', async () => {
+    const cli = new CLIMock()
+      .args(['push', ...args, '--schedule', '20', '--locations', 'us_east'])
+      .run({ cwd: FIXTURES_DIR });
+    expect(await cli.exitCode).toBe(1);
+    const error = cli.stderr();
+    expect(error).toContain(`Aborted: Duplicate monitors found`);
+    expect(error).toContain(`duplicate id`);
+    expect(error).toContain(`duplicate name`);
+  });
+
+  it('format duplicate monitors', () => {
+    const duplicates = new Set([
+      {
+        config: { id: 'test' },
+        source: { file: 'journey1.ts', line: 2, column: 5 },
+      },
+      {
+        config: { id: 'test' },
+        source: { file: 'journey2.ts', line: 10, column: 5 },
+      },
+    ]);
+    expect(formatDuplicateError(duplicates as Set<Monitor>)).toMatchSnapshot();
+  });
 });