Repository: elastic/synthetics
Pull request #991 (2024-12-19 02:30:51)

feat: run pushed monitors locally in dry-run mode
+ fix https://github.com/elastic/synthetics/issues/973 and https://github.com/elastic/synthetics/issues/929
+ Setting `DEBUG=synthetics` will provides detailed information like Monitor size, overall project size and monitors that were added/deleted/updated etc. 
+ With `--dry-run`, we have the ability to now run these monitors similar to how it would be run inside the Synthetics Public/Private managed locations. 

<img width="841" alt="Screenshot 2024-12-19 at 4 17 52 PM" src="https://github.com/user-attachments/assets/49174ab1-b58b-466b-ab1e-fc41814798f4" />




Patch:
diff --git a/package-lock.json b/package-lock.json
index 84cab91..f18b5fc 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -30,6 +30,7 @@
         "source-map-support": "^0.5.21",
         "stack-utils": "^2.0.6",
         "undici": "^5.28.3",
+        "unzip-stream": "^0.3.4",
         "yaml": "^2.2.2"
       },
       "bin": {
@@ -58,8 +59,7 @@
         "rimraf": "^3.0.2",
         "semantic-release": "^21.1.1",
         "ts-jest": "^29.1.1",
-        "typescript": "^5.1.6",
-        "unzipper": "^0.10.11"
+        "typescript": "^5.1.6"
       },
       "engines": {
         "node": ">=18.20.3"
@@ -5123,20 +5123,10 @@
       "integrity": "sha512-NzUnlZexiaH/46WDhANlyR2bXRopNg4F/zuSA3OpZnllCUgRaOF2znDioDWrmbNVsuZk6l9pMquQB38cfBZwkQ==",
       "dev": true
     },
-    "node_modules/big-integer": {
-      "version": "1.6.51",
-      "resolved": "https://registry.npmjs.org/big-integer/-/big-integer-1.6.51.tgz",
-      "integrity": "sha512-GPEid2Y9QU1Exl1rpO9B2IPJGHPSupF5GnVIP0blYvNOMer2bTvSWs1jGOUg04hTmu67nmLsQ9TBo1puaotBHg==",
-      "dev": true,
-      "engines": {
-        "node": ">=0.6"
-      }
-    },
     "node_modules/binary": {
       "version": "0.3.0",
       "resolved": "https://registry.npmjs.org/binary/-/binary-0.3.0.tgz",
       "integrity": "sha512-D4H1y5KYwpJgK8wk1Cue5LLPgmwHKYSChkbspQg5JtVuR5ulGckxfR62H3AE9UDkdMC8yyXlqYihuz3Aqg2XZg==",
-      "dev": true,
       "dependencies": {
         "buffers": "~0.1.1",
         "chainsaw": "~0.1.0"
@@ -5145,12 +5135,6 @@
         "node": "*"
       }
     },
-    "node_modules/bluebird": {
-      "version": "3.4.7",
-      "resolved": "https://registry.npmjs.org/bluebird/-/bluebird-3.4.7.tgz",
-      "integrity": "sha512-iD3898SR7sWVRHbiQv+sHUtHnMvC1o3nW5rAcqnq3uOn07DSAppZYUkIGslDz6gXC7HfunPe7YVBgoEJASPcHA==",
-      "dev": true
-    },
     "node_modules/bottleneck": {
       "version": "2.19.5",
       "resolved": "https://registry.npmjs.org/bottleneck/-/bottleneck-2.19.5.tgz",
@@ -5244,20 +5228,10 @@
       "resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz",
       "integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ=="
     },
-    "node_modules/buffer-indexof-polyfill": {
-      "version": "1.0.2",
-      "resolved": "https://registry.npmjs.org/buffer-indexof-polyfill/-/buffer-indexof-polyfill-1.0.2.tgz",
-      "integrity": "sha512-I7wzHwA3t1/lwXQh+A5PbNvJxgfo5r3xulgpYDB5zckTu/Z9oUK9biouBKQUjEqzaz3HnAT6TYoovmE+GqSf7A==",
-      "dev": true,
-      "engines": {
-        "node": ">=0.10"
-      }
-    },
     "node_modules/buffers": {
       "version": "0.1.1",
       "resolved": "https://registry.npmjs.org/buffers/-/buffers-0.1.1.tgz",
       "integrity": "sha512-9q/rDEGSb/Qsvv2qvzIzdluL5k7AaJOTrw23z9reQthrbF7is4CtlT0DXyO1oei2DCp4uojjzQ7igaSHp1kAEQ==",
-      "dev": true,
       "engines": {
         "node": ">=0.2.0"
       }
@@ -5334,7 +5308,6 @@
       "version": "0.1.0",
       "resolved": "https://registry.npmjs.org/chainsaw/-/chainsaw-0.1.0.tgz",
       "integrity": "sha512-75kWfWt6MEKNC8xYXIdRpDehRYY/tNSgwKaJq+dbbDcxORuVrrQ+SEHoWsniVn9XPYfP4gmdWIeDk/4YNp1rNQ==",
-      "dev": true,
       "dependencies": {
         "traverse": ">=0.3.0 <0.4"
       },
@@ -5343,10 +5316,11 @@
       }
     },
     "node_modules/chalk": {
-      "version": "4.1.1",
-      "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.1.tgz",
-      "integrity": "sha512-diHzdDKxcU+bAsUboHLPEDQiw0qEe0qd7SYUn3HgcFlWgbDcfLGswOHYeGrHKzG9z6UYf01d9VFMfZxPM1xZSg==",
+      "version": "4.1.2",
+      "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz",
+      "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==",
       "dev": true,
+      "license": "MIT",
       "dependencies": {
         "ansi-styles": "^4.1.0",
         "supports-color": "^7.1.0"
@@ -5401,10 +5375,11 @@
       }
     },
     "node_modules/cli-table3": {
-      "version": "0.6.3",
-      "resolved": "https://registry.npmjs.org/cli-table3/-/cli-table3-0.6.3.tgz",
-      "integrity": "sha512-w5Jac5SykAeZJKntOxJCrm63Eg5/4dhMWIcuTbo9rpE+brgaSZo0RuNJZeOyMgsUdhDeojvgyQLmjI+K50ZGyg==",
+      "version": "0.6.5",
+      "resolved": "https://registry.npmjs.org/cli-table3/-/cli-table3-0.6.5.tgz",
+      "integrity": "sha512-+W/5efTR7y5HRD7gACw9yQjqMVvEMLBHmboM/kPWam+H+Hmyrgjh6YncVKK122YZkXrLudzTuAukUw9FnMf7IQ==",
       "dev": true,
+      "license": "MIT",
       "dependencies": {
         "string-width": "^4.2.0"
       },
@@ -6941,33 +6916,6 @@
         "node": "^8.16.0 || ^10.6.0 || >=11.0.0"
       }
     },
-    "node_modules/fstream": {
-      "version": "1.0.12",
-      "resolved": "https://registry.npmjs.org/fstream/-/fstream-1.0.12.tgz",
-      "integrity": "sha512-WvJ193OHa0GHPEL+AycEJgxvBEwyfRkN1vhjca23OaPVMCaLCXTd5qAu82AjTcgP1UJmytkOKb63Ypde7raDIg==",
-      "dev": true,
-      "dependencies": {
-        "graceful-fs": "^4.1.2",
-        "inherits": "~2.0.0",
-        "mkdirp": ">=0.5 0",
-        "rimraf": "2"
-      },
-      "engines": {
-        "node": ">=0.6"
-      }
-    },
-    "node_modules/fstream/node_modules/rimraf": {
-      "version": "2.7.1",
-      "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-2.7.1.tgz",
-      "integrity": "sha512-uWjbaKIK3T1OSVptzX7Nl6PvQ3qAGtKEtVRjRuazjfL3Bx5eI409VZSqgND+4UNnmzLVdPj9FqFJNPqBZFve4w==",
-      "dev": true,
-      "dependencies": {
-        "glob": "^7.1.3"
-      },
-      "bin": {
-        "rimraf": "bin.js"
-      }
-    },
     "node_modules/function-bind": {
       "version": "1.1.1",
       "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.1.tgz",
@@ -9668,12 +9616,6 @@
         "url": "https://github.com/sindresorhus/execa?sponsor=1"
       }
     },
-    "node_modules/listenercount": {
-      "version": "1.0.1",
-      "resolved": "https://registry.npmjs.org/listenercount/-/listenercount-1.0.1.tgz",
-      "integrity": "sha1-hMinKrWcRyUyFIDJdeZQg0LnCTc=",
-      "dev": true
-    },
     "node_modules/listr2": {
       "version": "3.8.2",
       "resolved": "https://registry.npmjs.org/listr2/-/listr2-3.8.2.tgz",
@@ -10157,8 +10099,7 @@
     "node_modules/minimist": {
       "version": "1.2.6",
       "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.6.tgz",
-      "integrity": "sha512-Jsjnk4bw3YJqYzbdyBiNsPWHPfO++UGG749Cxs6peCu5Xg4nrena6OVxOYxrQTqww0Jmwt+Ref8rggumkTLz9Q==",
-      "dev": true
+      "integrity": "sha512-Jsjnk4bw3YJqYzbdyBiNsPWHPfO++UGG749Cxs6peCu5Xg4nrena6OVxOYxrQTqww0Jmwt+Ref8rggumkTLz9Q=="
     },
     "node_modules/minimist-options": {
       "version": "4.1.0",
@@ -10186,7 +10127,6 @@
       "version": "0.5.6",
       "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-0.5.6.tgz",
       "integrity": "sha512-FP+p8RB8OWpF3YZBCrP5gtADmtXApB5AMLn+vdyA+PyxCjrCs00mjyUozssO33cwDeT3wNGdLxJ5M//YqtHAJw==",
-      "dev": true,
       "dependencies": {
         "minimist": "^1.2.6"
       },
@@ -15106,12 +15046,6 @@
         "url": "https://github.com/sponsors/sindresorhus"
       }
     },
-    "node_modules/setimmediate": {
-      "version": "1.0.5",
-      "resolved": "https://registry.npmjs.org/setimmediate/-/setimmediate-1.0.5.tgz",
-      "integrity": "sha1-KQy7Iy4waULX1+qbg3Mqt4VvgoU=",
-      "dev": true
-    },
     "node_modules/sharp": {
       "version": "0.33.5",
       "resolved": "https://registry.npmjs.org/sharp/-/sharp-0.33.5.tgz",
@@ -15807,7 +15741,6 @@
       "version": "0.3.9",
       "resolved": "https://registry.npmjs.org/traverse/-/traverse-0.3.9.tgz",
       "integrity": "sha1-cXuPIgzAu3tE5AUUwisui7xw2Lk=",
-      "dev": true,
       "engines": {
         "node": "*"
       }
@@ -16066,22 +15999,14 @@
         "node": ">= 10.0.0"
       }
     },
-    "node_modules/unzipper": {
-      "version": "0.10.11",
-      "resolved": "https://registry.npmjs.org/unzipper/-/unzipper-0.10.11.tgz",
-      "integrity": "sha512-+BrAq2oFqWod5IESRjL3S8baohbevGcVA+teAIOYWM3pDVdseogqbzhhvvmiyQrUNKFUnDMtELW3X8ykbyDCJw==",
-      "dev": true,
+    "node_modules/unzip-stream": {
+      "version": "0.3.4",
+      "resolved": "https://registry.npmjs.org/unzip-stream/-/unzip-stream-0.3.4.tgz",
+      "integrity": "sha512-PyofABPVv+d7fL7GOpusx7eRT9YETY2X04PhwbSipdj6bMxVCFJrr+nm0Mxqbf9hUiTin/UsnuFWBXlDZFy0Cw==",
+      "license": "MIT",
       "dependencies": {
-        "big-integer": "^1.6.17",
-        "binary": "~0.3.0",
-        "bluebird": "~3.4.1",
-        "buffer-indexof-polyfill": "~1.0.0",
-        "duplexer2": "~0.1.4",
-        "fstream": "^1.0.12",
-        "graceful-fs": "^4.2.2",
-        "listenercount": "~1.0.1",
-        "readable-stream": "~2.3.6",
-        "setimmediate": "~1.0.4"
+        "binary": "^0.3.0",
+        "mkdirp": "^0.5.1"
       }
     },
     "node_modules/update-browserslist-db": {
diff --git a/package.json b/package.json
index 86888b1..ed28467 100644
--- a/package.json
+++ b/package.json
@@ -75,6 +75,7 @@
     "source-map-support": "^0.5.21",
     "stack-utils": "^2.0.6",
     "undici": "^5.28.3",
+    "unzip-stream": "^0.3.4",
     "yaml": "^2.2.2"
   },
   "devDependencies": {
@@ -99,8 +100,7 @@
     "rimraf": "^3.0.2",
     "semantic-release": "^21.1.1",
     "ts-jest": "^29.1.1",
-    "typescript": "^5.1.6",
-    "unzipper": "^0.10.11"
+    "typescript": "^5.1.6"
   },
   "overrides": {
     "follow-redirects": "^1.15.6",
diff --git a/src/core/globals.ts b/src/core/globals.ts
index fa615a8..77f80b6 100644
--- a/src/core/globals.ts
+++ b/src/core/globals.ts
@@ -34,4 +34,16 @@ if (!global[SYNTHETICS_RUNNER]) {
   global[SYNTHETICS_RUNNER] = new Runner();
 }
 
+/**
+ * Set debug based on DEBUG ENV and namespace - synthetics
+ */
+if (process.env.DEBUG && process.env.DEBUG.includes('synthetics')) {
+  process.env['__SYNTHETICS__DEBUG__'] = '1';
+}
+
 export const runner: Runner = global[SYNTHETICS_RUNNER];
+
+// is Debug mode enabled
+export function inDebugMode() {
+  return !!process.env['__SYNTHETICS__DEBUG__'];
+}
diff --git a/src/core/logger.ts b/src/core/logger.ts
index bb28e4d..c3713c0 100644
--- a/src/core/logger.ts
+++ b/src/core/logger.ts
@@ -25,16 +25,10 @@
 
 import { grey, cyan, dim, italic } from 'kleur/colors';
 import { now } from '../helpers';
-
-/**
- * Set debug based on DEBUG ENV and namespace - synthetics
- */
-if (process.env.DEBUG && process.env.DEBUG.includes('synthetics')) {
-  process.env['__SYNTHETICS__DEBUG__'] = '1';
-}
+import { inDebugMode } from './globals';
 
 export function log(msg) {
-  if (!process.env['__SYNTHETICS__DEBUG__'] || !msg) {
+  if (!inDebugMode() || !msg) {
     return;
   }
   if (typeof msg === 'object') {
diff --git a/src/dsl/monitor.ts b/src/dsl/monitor.ts
index 0e2b943..87d5628 100644
--- a/src/dsl/monitor.ts
+++ b/src/dsl/monitor.ts
@@ -158,6 +158,13 @@ export class Monitor {
       .digest('base64');
   }
 
+  /**
+   * Returns the size of the monitor in bytes which is sent as payload to Kibana
+   */
+  size() {
+    return JSON.stringify(this).length;
+  }
+
   validate() {
     const schedule = this.config.schedule;
     if (ALLOWED_SCHEDULES.includes(schedule)) {
diff --git a/src/push/bundler.ts b/src/push/bundler.ts
index d2b9458..6f009d9 100644
--- a/src/push/bundler.ts
+++ b/src/push/bundler.ts
@@ -24,25 +24,19 @@
  */
 
 import path from 'path';
-import { stat, unlink, readFile } from 'fs/promises';
+import { unlink, readFile } from 'fs/promises';
 import { createWriteStream } from 'fs';
 import * as esbuild from 'esbuild';
 import archiver from 'archiver';
 import { commonOptions } from '../core/transform';
 import { SyntheticsBundlePlugin } from './plugin';
 
-// 1500KB Max Gzipped limit for bundled code to be pushed as Kibana project monitors.
-const SIZE_LIMIT_KB = 1500;
-
 function relativeToCwd(entry: string) {
   return path.relative(process.cwd(), entry);
 }
 
 export class Bundler {
-  moduleMap = new Map<string, string>();
-  constructor() {}
-
-  async prepare(absPath: string) {
+  async bundle(absPath: string) {
     const options: esbuild.BuildOptions = {
       ...commonOptions(),
       ...{
@@ -59,56 +53,42 @@ export class Bundler {
     if (result.errors.length > 0) {
       throw result.errors;
     }
-    this.moduleMap.set(absPath, result.outputFiles[0].text);
+    return result.outputFiles[0].text;
   }
 
-  async zip(outputPath: string) {
+  async zip(source: string, code: string, dest: string) {
     return new Promise((fulfill, reject) => {
-      const output = createWriteStream(outputPath);
+      const output = createWriteStream(dest);
       const archive = archiver('zip', {
         zlib: { level: 9 },
       });
       archive.on('error', reject);
       output.on('close', fulfill);
       archive.pipe(output);
-      for (const [path, content] of this.moduleMap.entries()) {
-        const relativePath = relativeToCwd(path);
-        // Date is fixed to Unix epoch so the file metadata is
-        // not modified everytime when files are bundled
-        archive.append(content, {
-          name: relativePath,
-          date: new Date('1970-01-01'),
-        });
-      }
+      const relativePath = relativeToCwd(source);
+      // Date is fixed to Unix epoch so the file metadata is
+      // not modified everytime when files are bundled
+      archive.append(code, {
+        name: relativePath,
+        date: new Date('1970-01-01'),
+      });
       archive.finalize();
     });
   }
 
   async build(entry: string, output: string) {
-    await this.prepare(entry);
-    await this.zip(output);
-    const data = await this.encode(output);
-    await this.checkSize(output);
+    const code = await this.bundle(entry);
+    await this.zip(entry, code, output);
+    const content = await this.encode(output);
     await this.cleanup(output);
-    return data;
+    return content;
   }
 
   async encode(outputPath: string) {
     return await readFile(outputPath, 'base64');
   }
 
-  async checkSize(outputPath: string) {
-    const { size } = await stat(outputPath);
-    const sizeKb = size / 1024;
-    if (sizeKb > SIZE_LIMIT_KB) {
-      throw new Error(
-        `Bundled monitor code exceeds the recommended ${SIZE_LIMIT_KB}KB limit. Please check your dependencies and try again.`
-      );
-    }
-  }
-
   async cleanup(outputPath: string) {
-    this.moduleMap = new Map<string, string>();
     await unlink(outputPath);
   }
 }
diff --git a/src/push/index.ts b/src/push/index.ts
index 97e0202..25ea0ca 100644
--- a/src/push/index.ts
+++ b/src/push/index.ts
@@ -24,7 +24,7 @@
  */
 import { readFile, writeFile } from 'fs/promises';
 import { prompt } from 'enquirer';
-import { bold, grey } from 'kleur/colors';
+import { bold, underline } from 'kleur/colors';
 import {
   getLocalMonitors,
   buildMonitorSchema,
@@ -57,8 +57,11 @@ import {
   isBulkAPISupported,
   isLightweightMonitorSupported,
   logDiff,
+  logGroups,
+  printBytes,
 } from './utils';
-import { log } from '../core/logger';
+import { runLocal } from './run-local';
+import { inDebugMode } from '../core/globals';
 
 export async function push(monitors: Monitor[], options: PushOptions) {
   if (parseInt(process.env.CHUNK_SIZE) > 250) {
@@ -84,7 +87,7 @@ export async function push(monitors: Monitor[], options: PushOptions) {
   const { monitors: remote } = await bulkGetMonitors(options);
 
   progress(`preparing ${monitors.length} monitors`);
-  const schemas = await buildMonitorSchema(monitors, true);
+  const { schemas, sizes } = await buildMonitorSchema(monitors, true);
   const local = getLocalMonitors(schemas);
 
   const { newIDs, changedIDs, removedIDs, unchangedIDs } = diffMonitorHashIDs(
@@ -92,6 +95,22 @@ export async function push(monitors: Monitor[], options: PushOptions) {
     remote
   );
   logDiff(newIDs, changedIDs, removedIDs, unchangedIDs);
+  if (inDebugMode()) {
+    logGroups(sizes, newIDs, changedIDs, removedIDs, unchangedIDs);
+    // show bundle size for the whole project
+    let totalSize = 0;
+    for (const value of sizes.values()) {
+      totalSize += value;
+    }
+    progress('total size of the monitors payload is ' + printBytes(totalSize));
+  }
+
+  if (options.dryRun) {
+    progress('Running browser monitors in dry run mode');
+    await runLocal(schemas);
+    progress('Dry run completed');
+    return;
+  }
 
   const updatedMonitors = new Set<string>([...changedIDs, ...newIDs]);
   if (updatedMonitors.size > 0) {
@@ -106,7 +125,6 @@ export async function push(monitors: Monitor[], options: PushOptions) {
   }
 
   if (removedIDs.size > 0) {
-    log(`deleting monitor ids: ${Array.from(removedIDs.keys()).join(', ')}`);
     if (updatedMonitors.size === 0 && unchangedIDs.size === 0) {
       await confirmDelete(
         `Pushing without any monitors will delete all monitors associated with the project.\n Do you want to continue?`,
@@ -126,8 +144,7 @@ export async function push(monitors: Monitor[], options: PushOptions) {
       );
     }
   }
-
-  done(`Pushed: ${grey(getMonitorManagementURL(options.url))}`);
+  done(`Pushed: ${underline(getMonitorManagementURL(options.url))} `);
 }
 
 async function confirmDelete(message: string, skip: boolean) {
@@ -302,7 +319,7 @@ export async function pushLegacy(monitors: Monitor[], options: PushOptions) {
   let schemas: MonitorSchema[] = [];
   if (monitors.length > 0) {
     progress(`preparing ${monitors.length} monitors`);
-    schemas = await buildMonitorSchema(monitors, false);
+    ({ schemas } = await buildMonitorSchema(monitors, false));
     const chunks = getChunks(schemas, 10);
     for (const chunk of chunks) {
       await liveProgress(
@@ -321,7 +338,7 @@ export async function pushLegacy(monitors: Monitor[], options: PushOptions) {
     `deleting all stale monitors`
   );
 
-  done(`Pushed: ${grey(getMonitorManagementURL(options.url))}`);
+  done(`Pushed: ${underline(getMonitorManagementURL(options.url))}`);
 }
 
 // prints warning if any of the monitors has throttling settings enabled during push
diff --git a/src/push/monitor.ts b/src/push/monitor.ts
index 85bba7b..421d226 100644
--- a/src/push/monitor.ts
+++ b/src/push/monitor.ts
@@ -42,6 +42,8 @@ import { isParamOptionSupported, normalizeMonitorName } from './utils';
 
 // Allowed extensions for lightweight monitor files
 const ALLOWED_LW_EXTENSIONS = ['.yml', '.yaml'];
+// 1500kB Max Gzipped limit for bundled monitor code to be pushed as Kibana project monitors.
+const SIZE_LIMIT_KB = 1500;
 
 export type MonitorSchema = Omit<MonitorConfig, 'locations'> & {
   locations: string[];
@@ -131,6 +133,7 @@ export async function buildMonitorSchema(monitors: Monitor[], isV2: boolean) {
   await mkdir(bundlePath, { recursive: true });
   const bundler = new Bundler();
   const schemas: MonitorSchema[] = [];
+  const sizes: Map<string, number> = new Map();
 
   for (const monitor of monitors) {
     const { source, config, filter, type } = monitor;
@@ -148,6 +151,17 @@ export async function buildMonitorSchema(monitors: Monitor[], isV2: boolean) {
       monitor.setContent(content);
       Object.assign(schema, { content, filter });
     }
+    const size = monitor.size();
+    const sizeKB = Math.round(size / 1000);
+    if (sizeKB > SIZE_LIMIT_KB) {
+      let outer = bold(
+        `Aborted: Bundled code ${sizeKB}kB exceeds the recommended ${SIZE_LIMIT_KB}kB limit. Please check the dependencies imported.\n`
+      );
+      const inner = `* ${config.id} - ${source.file}:${source.line}:${source.column}\n`;
+      outer += indent(inner);
+      throw red(outer);
+    }
+    sizes.set(config.id, size);
     /**
      * Generate hash only after the bundled content is created
      * to capture code changes in imported files
@@ -159,7 +173,7 @@ export async function buildMonitorSchema(monitors: Monitor[], isV2: boolean) {
   }
 
   await rm(bundlePath, { recursive: true });
-  return schemas;
+  return { schemas, sizes };
 }
 
 export async function createLightweightMonitors(
diff --git a/src/push/run-local.ts b/src/push/run-local.ts
new file mode 100644
index 0000000..7b082c7
--- /dev/null
+++ b/src/push/run-local.ts
@@ -0,0 +1,155 @@
+/**
+ * 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 { execFileSync, spawn } from 'child_process';
+import { rm, writeFile } from 'fs/promises';
+import { createReadStream } from 'fs';
+import { tmpdir } from 'os';
+import { Extract } from 'unzip-stream';
+import { red } from 'kleur/colors';
+import { join } from 'path';
+import { pathToFileURL } from 'url';
+import { MonitorSchema } from './monitor';
+
+async function unzipFile(zipPath, destination) {
+  return new Promise<void>((resolve, reject) => {
+    createReadStream(zipPath)
+      .pipe(Extract({ path: destination }))
+      .on('close', resolve)
+      .on('error', err =>
+        reject(new Error(`failed to extract zip ${zipPath} : ${err.message}`))
+      );
+  });
+}
+
+async function runNpmInstall(directory) {
+  return new Promise<void>((resolve, reject) => {
+    const flags = [
+      '--no-audit', // Prevent audit checks
+      '--no-update-notifier', // Prevent update checks
+      '--no-fund', // No need for package funding messages here
+      '--package-lock=false', // no need to write package lock here
+      '--progress=false', // no need to display progress
+    ];
+
+    const npmInstall = spawn('npm', ['install', ...flags], {
+      cwd: directory,
+      stdio: 'ignore',
+    });
+    npmInstall.on('close', code => {
+      if (code === 0) {
+        resolve();
+      } else {
+        reject(new Error(`npm install failed with exit code ${code}`));
+      }
+    });
+    npmInstall.on('error', err =>
+      reject(new Error(`failed to setup: ${err.message}`))
+    );
+  });
+}
+
+async function runTest(directory, schema: MonitorSchema) {
+  return new Promise<void>((resolve, reject) => {
+    const runTest = spawn(
+      'npx',
+      [
+        '@elastic/synthetics',
+        '.',
+        '--playwright-options',
+        JSON.stringify(schema.playwrightOptions),
+        '--params',
+        JSON.stringify(schema.params),
+      ],
+      {
+        cwd: directory,
+        stdio: 'inherit',
+      }
+    );
+
+    runTest.on('close', resolve);
+    runTest.on('error', err => {
+      reject(
+        new Error(`Failed to execute @elastic/synthetics : ${err.message}`)
+      );
+    });
+  });
+}
+
+async function writePkgJSON(dir: string, synthPath: string) {
+  const packageJsonContent = {
+    name: 'project-journey',
+    private: 'true',
+    dependencies: {
+      '@elastic/synthetics': pathToFileURL(synthPath),
+    },
+  };
+  await writeFile(
+    join(dir, 'package.json'),
+    JSON.stringify(packageJsonContent, null, 2),
+    'utf-8'
+  );
+}
+
+async function extract(
+  schema: MonitorSchema,
+  zipPath: string,
+  unzipPath: string
+) {
+  if (schema.type !== 'browser') {
+    return;
+  }
+  const content = schema.content;
+  await writeFile(zipPath, content, 'base64');
+  await unzipFile(zipPath, unzipPath);
+}
+
+export async function runLocal(schemas: MonitorSchema[]) {
+  // lookup installed bin path of a node module
+  const resolvedPath = execFileSync('which', ['elastic-synthetics'], {
+    encoding: 'utf8',
+  }).trim();
+  const synthPath = resolvedPath.replace(
+    join('bin', 'elastic-synthetics'),
+    join('lib', 'node_modules', '@elastic/synthetics')
+  );
+  const rand = Date.now();
+  const zipPath = join(tmpdir(), `synthetics-zip-${rand}.zip`);
+  const unzipPath = join(tmpdir(), `synthetics-unzip-${rand}`);
+  try {
+    for (const schema of schemas) {
+      await extract(schema, zipPath, unzipPath);
+    }
+    await writePkgJSON(unzipPath, synthPath);
+    await runNpmInstall(unzipPath);
+    // TODO: figure out a way to collect all params and Playwright options
+    await runTest(unzipPath, schemas[0]);
+  } catch (e) {
+    throw red(`Aborted: ${e.message}`);
+  } finally {
+    await rm(zipPath, { recursive: true, force: true });
+    await rm(unzipPath, { recursive: true, force: true });
+  }
+}
diff --git a/src/push/utils.ts b/src/push/utils.ts
index 1072f35..23008ce 100644
--- a/src/push/utils.ts
+++ b/src/push/utils.ts
@@ -25,7 +25,7 @@
 
 import semver from 'semver';
 import { progress, removeTrailingSlash } from '../helpers';
-import { green, red, grey, yellow } from 'kleur/colors';
+import { green, red, grey, yellow, Colorize, bold } from 'kleur/colors';
 import { PushOptions } from '../common_types';
 import { Monitor } from '../dsl/monitor';
 
@@ -44,6 +44,52 @@ export function logDiff<T extends Set<string>>(
   );
 }
 
+export function logGroups<T extends Set<string>>(
+  sizes: Map<string, number>,
+  newIDs: T,
+  changedIDs: T,
+  removedIDs: T,
+  unchangedIDs: T
+) {
+  console.groupCollapsed();
+  logGroup(sizes, 'Added', newIDs, green);
+  logGroup(sizes, 'Updated', changedIDs, yellow);
+  logGroup(sizes, 'Removed', removedIDs, red);
+  logGroup(sizes, 'Unchanged', unchangedIDs, grey);
+  console.groupEnd();
+}
+
+function logGroup(
+  sizes: Map<string, number>,
+  name: string,
+  ids: Set<string>,
+  color: Colorize
+) {
+  if (ids.size === 0) return;
+  // under collapsed group, so giving 2 space for padding
+  printLine(process.stdout.columns - 2);
+  console.groupCollapsed(color(bold(name)));
+  [...ids].forEach(id => {
+    console.log(grey(`- ${id} (${printBytes(sizes.get(id))})`));
+  });
+  console.groupEnd();
+}
+
+function printLine(length: number = process.stdout.columns) {
+  console.log(grey('-').repeat(length));
+}
+
+const BYTE_UNITS = ['B', 'kB', 'MB', 'GB', 'TB', 'PB'];
+export function printBytes(bytes: number) {
+  if (bytes <= 0) return '0 B';
+  const exponent = Math.min(
+    Math.floor(Math.log10(bytes) / 3),
+    BYTE_UNITS.length - 1
+  );
+  bytes /= 1000 ** exponent;
+  return `${bytes.toFixed(1)} ${BYTE_UNITS[exponent]}`;
+}
+
 export function getChunks<T>(arr: Array<T>, size: number): Array<T[]> {
   const chunks = [];
   for (let i = 0; i < arr.length; i += size) {


Test patch:
diff --git a/__tests__/push/bundler.test.ts b/__tests__/push/bundler.test.ts
index fe9d34a..f7636ba 100644
--- a/__tests__/push/bundler.test.ts
+++ b/__tests__/push/bundler.test.ts
@@ -25,7 +25,7 @@
 
 import { createReadStream } from 'fs';
 import { writeFile, unlink, mkdir, rm } from 'fs/promises';
-import unzipper from 'unzipper';
+import unzip from 'unzip-stream';
 import { join } from 'path';
 import { generateTempPath } from '../../src/helpers';
 import { Bundler } from '../../src/push/bundler';
@@ -74,11 +74,10 @@ async function validateZip(content) {
   await writeFile(pathToZip, decoded);
 
   const files: Array<string> = [];
-
   let contents = '';
   await new Promise(r => {
     createReadStream(pathToZip)
-      .pipe(unzipper.Parse())
+      .pipe(unzip.Parse())
       .on('entry', function (entry) {
         files.push(entry.path);
         contents += '\n' + entry.path + '\n';
diff --git a/__tests__/push/monitor.test.ts b/__tests__/push/monitor.test.ts
index 0d890c9..e982ddd 100644
--- a/__tests__/push/monitor.test.ts
+++ b/__tests__/push/monitor.test.ts
@@ -70,11 +70,11 @@ describe('Monitors', () => {
   });
 
   it('build lightweight monitor schema', async () => {
-    const schema = await buildMonitorSchema(
+    const { schemas } = await buildMonitorSchema(
       [createTestMonitor('heartbeat.yml', 'http')],
       true
     );
-    expect(schema[0]).toEqual({
+    expect(schemas[0]).toEqual({
       id: 'test-monitor',
       name: 'test',
       schedule: 10,
@@ -89,8 +89,8 @@ describe('Monitors', () => {
 
   it('build browser monitor schema', async () => {
     const monitor = createTestMonitor('example.journey.ts');
-    const schema = await buildMonitorSchema([monitor], true);
-    expect(schema[0]).toEqual({
+    const { schemas } = await buildMonitorSchema([monitor], true);
+    expect(schemas[0]).toEqual({
       id: 'test-monitor',
       name: 'test',
       schedule: 10,
@@ -106,9 +106,9 @@ describe('Monitors', () => {
       fields: { area: 'website' },
     });
     monitor.update({ locations: ['brazil'], fields: { env: 'dev' } });
-    const schema1 = await buildMonitorSchema([monitor], true);
-    expect(schema1[0].hash).not.toEqual(schema[0].hash);
-    expect(schema1[0].fields).toEqual({
+    const { schemas: schemas1 } = await buildMonitorSchema([monitor], true);
+    expect(schemas1[0].hash).not.toEqual(schemas[0].hash);
+    expect(schemas1[0].fields).toEqual({
       area: 'website',
       env: 'dev',
     });