Repository: elastic/synthetics
Pull request #475 (2022-03-22 05:20:40)

chore: restructure to add sub commands to cli
+ part of #470 
+ Restructure the CLI and commander program to allow adding subcommands like `push` and other commands in the future. 
+ Introduce loader and normalizer concepts to make the boundary clear on how test suites are loaded and how the options are normalized from the CLI args. **No functionality changes has been introduced**. 
+ Removed `--debug` and `--quiet-exit-mode` from CLI options, debug logs can be enabled by setting env value `DEBUG=synthetics` and quiet mode is default enabled for HB using `--rich-events` flag so its not required. 

### How to test
```
npm run build
cat examples/inline/badssl-self-signed.js | node dist/cli.js --inline
```

apmmachine: 
## :broken_heart: Tests Failed


<!-- BUILD BADGES-->
> _the below badges are clickable and redirect to their specific view in the CI or DOCS_
[![Pipeline View](https://img.shields.io/badge/pipeline-pipeline%20-green)](https://apm-ci.elastic.co/blue/organizations/jenkins/apm-agent-rum%2Felastic-synthetics%2FPR-475/detail/PR-475/1//pipeline) [![Test View](https://img.shields.io/badge/test-test-green)](https://apm-ci.elastic.co/blue/organizations/jenkins/apm-agent-rum%2Felastic-synthetics%2FPR-475/detail/PR-475/1//tests) [![Changes](https://img.shields.io/badge/changes-changes-green)](https://apm-ci.elastic.co/blue/organizations/jenkins/apm-agent-rum%2Felastic-synthetics%2FPR-475/detail/PR-475/1//changes) [![Artifacts](https://img.shields.io/badge/artifacts-artifacts-yellow)](https://apm-ci.elastic.co/blue/organizations/jenkins/apm-agent-rum%2Felastic-synthetics%2FPR-475/detail/PR-475/1//artifacts) [![preview](https://img.shields.io/badge/docs-preview-yellowgreen)](http://synthetics_475.docs-preview.app.elstc.co/diff)  [![preview](https://img.shields.io/badge/elastic-observability-blue)](https://ci-stats.elastic.co/app/apm/services/apm-ci/transactions/view?rangeFrom=2022-03-22T05:10:54.393Z&rangeTo=2022-03-22T05:30:54.393Z&transactionName=apm-agent-rum/elastic-synthetics/PR-{number}&transactionType=job&latencyAggregationType=avg&traceId=b3ba2322edf00b173a6e41209f5ac995&transactionId=d1095aa7dc800393)

<!-- BUILD SUMMARY-->
<details><summary>Expand to view the summary</summary>
<p>

#### Build stats




* Start Time: 2022-03-22T05:20:54.393+0000


* Duration: 10 min 53 sec




#### Test stats :test_tube:

| Test         | Results                         |
| ------------ | :-----------------------------: |
| Failed       | 1  |
| Passed       | 144  |
| Skipped      | 2 |
| Total        | 147   |


</p>
</details>

<!-- TEST RESULTS IF ANY-->

  ### Test errors [![1](https://img.shields.io/badge/1%20-red)](https://apm-ci.elastic.co/blue/organizations/jenkins/apm-agent-rum%2Felastic-synthetics%2FPR-475/detail/PR-475/1//tests)
<details><summary>Expand to view the tests failures</summary><p>
    
    
##### `Test / options normalize – options normalize`


<ul>
<details><summary>Expand to view the error details</summary><p>

```
 null 
 ```
</p></details>
<details><summary>Expand to view the stacktrace</summary><p>

```
 Error: expect(received).toMatchSnapshot()

Snapshot name: `options normalize 1`

- Snapshot  - 1
+ Received  + 1

@@ -1,7 +1,7 @@
  Object {
-   "config": "/Users/vigneshh/elastic/synthetics/__tests__/fixtures/synthetics.config.ts",
+   "config": "/var/lib/jenkins/workspace/nt-rum_elastic-synthetics_PR-475/src/github.com/elastic/synthetics/__tests__/fixtures/synthetics.config.ts",
    "dryRun": true,
    "environment": "test",
    "headless": true,
    "match": "check*",
    "params": Object {
    at Object.<anonymous> (/var/lib/jenkins/workspace/nt-rum_elastic-synthetics_PR-475/src/github.com/elastic/synthetics/__tests__/options.test.ts:48:39)
    at Promise.then.completed (/var/lib/jenkins/workspace/nt-rum_elastic-synthetics_PR-475/src/github.com/elastic/synthetics/node_modules/jest-circus/build/utils.js:391:28)
    at new Promise (<anonymous>)
    at callAsyncCircusFn (/var/lib/jenkins/workspace/nt-rum_elastic-synthetics_PR-475/src/github.com/elastic/synthetics/node_modules/jest-circus/build/utils.js:316:10)
    at _callCircusTest (/var/lib/jenkins/workspace/nt-rum_elastic-synthetics_PR-475/src/github.com/elastic/synthetics/node_modules/jest-circus/build/run.js:218:40)
    at processTicksAndRejections (internal/process/task_queues.js:95:5)
    at _runTest (/var/lib/jenkins/workspace/nt-rum_elastic-synthetics_PR-475/src/github.com/elastic/synthetics/node_modules/jest-circus/build/run.js:155:3)
    at _runTestsForDescribeBlock (/var/lib/jenkins/workspace/nt-rum_elastic-synthetics_PR-475/src/github.com/elastic/synthetics/node_modules/jest-circus/build/run.js:66:9)
    at _runTestsForDescribeBlock (/var/lib/jenkins/workspace/nt-rum_elastic-synthetics_PR-475/src/github.com/elastic/synthetics/node_modules/jest-circus/build/run.js:60:9)
    at run (/var/lib/jenkins/workspace/nt-rum_elastic-synthetics_PR-475/src/github.com/elastic/synthetics/node_modules/jest-circus/build/run.js:25:3) 
 ```
</p></details>
</ul>
    
</p></details>


<!-- STEPS ERRORS IF ANY -->












### Steps errors [![1](https://img.shields.io/badge/1%20-red)](https://apm-ci.elastic.co/blue/organizations/jenkins/apm-agent-rum%2Felastic-synthetics%2FPR-475/detail/PR-475/1//pipeline)
  <details><summary>Expand to view the steps failures</summary>
  <p>


  
  
  
  
##### `Runs the tests`
<ul>
<li>Took 1 min 11 sec . View more details <a href="https://apm-ci.elastic.co//blue/rest/organizations/jenkins/pipelines/apm-agent-rum/pipelines/elastic-synthetics/pipelines/PR-475/runs/1/steps/340/log/?start=0">here</a></li>
<li>Description: <code>./node_modules/.bin/jest --ci --reporters=default --reporters=jest-junit</code></l1>
</ul>
  
  </p>
  </details>



## :robot: GitHub comments

To re-run your PR in the CI, just comment with:



- `/test` : Re-trigger the build.




<!--COMMENT_GENERATED_WITH_ID_comment.id-->

Patch:
diff --git a/src/cli.ts b/src/cli.ts
index d4ba124..530015d 100644
--- a/src/cli.ts
+++ b/src/cli.ts
@@ -25,200 +25,126 @@
  *
  */
 
-import { stdin, cwd } from 'process';
-import { resolve } from 'path';
-import merge from 'deepmerge';
-import { step, journey } from './core';
-import { log } from './core/logger';
-import program, { options } from './parse_args';
-import { expect } from './core/expect';
-import {
-  findPkgJsonByTraversing,
-  isDepInstalled,
-  isDirectory,
-  totalist,
-  parseNetworkConditions,
-} from './helpers';
+import { program, Option } from 'commander';
+import { CliArgs } from './common_types';
+import { reporters } from './reporters';
+import { DEFAULT_NETWORK_CONDITIONS_ARG } from './helpers';
+import { normalizeOptions } from './options';
+import { loadTestFiles } from './loader';
 import { run } from './';
-import { readConfig } from './config';
 
-const resolvedCwd = cwd();
-/**
- * Set debug based on DEBUG ENV and -d flags
- * namespace - synthetics
- */
-const namespace = 'synthetics';
-if (process.env.DEBUG === namespace || Boolean(options.debug)) {
-  process.env.DEBUG = '1';
-}
-
-const loadInlineScript = source => {
-  const scriptFn = new Function(
-    'step',
-    'page',
-    'context',
-    'browser',
-    'params',
-    'expect',
-    source
-  );
-  journey('inline', ({ page, context, browser, params }) => {
-    scriptFn.apply(null, [step, page, context, browser, params, expect]);
-  });
-};
-
-async function readStdin() {
-  const chunks = [];
-  stdin.resume();
-  stdin.setEncoding('utf-8');
-  for await (const chunk of stdin) {
-    chunks.push(chunk);
-  }
-  return chunks.join();
-}
-
-function requireSuites(suites: Iterable<string>) {
-  for (const suite of suites) {
-    require(suite);
-  }
-}
-
-/**
- * Handle both directory and files that are passed through TTY
- * and add them to suites
- */
-async function prepareSuites(inputs: string[]) {
-  const suites = new Set<string>();
-  const addSuite = absPath => {
-    log(`Processing file: ${absPath}`);
-    suites.add(require.resolve(absPath));
-  };
-  /**
-   * Match all files inside the directory with the
-   * .journey.{mjs|cjs|js|ts) extensions
-   */
-  const pattern = options.pattern
-    ? new RegExp(options.pattern, 'i')
-    : /.+\.journey\.([mc]js|[jt]s?)$/;
-  /**
-   * Ignore node_modules by default when running suites
-   */
-  const ignored = /node_modules/i;
+/* eslint-disable-next-line @typescript-eslint/no-var-requires */
+const { name, version } = require('../package.json');
 
-  for (const input of inputs) {
-    const absPath = resolve(resolvedCwd, input);
-    /**
-     * Validate for package.json file before running
-     * the suites
-     */
-    findPkgJsonByTraversing(absPath, resolvedCwd);
-    if (isDirectory(absPath)) {
-      await totalist(absPath, (rel, abs) => {
-        if (pattern.test(rel) && !ignored.test(rel)) {
-          addSuite(abs);
+program
+  .name(`npx ${name}`)
+  .usage('[options] [dir] [files] file')
+  .option(
+    '-c, --config <path>',
+    'configuration path (default: synthetics.config.js)'
+  )
+  .option(
+    '-p, --params <jsonstring>',
+    'JSON object that gets injected to all journeys',
+    JSON.parse
+  )
+  .addOption(
+    new Option('--reporter <value>', `output repoter format`).choices(
+      Object.keys(reporters)
+    )
+  )
+  .option(
+    '--pattern <pattern>',
+    'RegExp file patterns to search inside directory'
+  )
+  .option('--inline', 'Run inline journeys from heartbeat')
+  .option('-r, --require <modules...>', 'module(s) to preload')
+  .option('--no-headless', 'run browser in headful mode')
+  .option('--sandbox', 'enable chromium sandboxing', false)
+  .option('--rich-events', 'Mimics a heartbeat run')
+  .option(
+    '--capability <features...>',
+    'Enable capabilities through feature flags'
+  )
+  .addOption(
+    new Option('--screenshots [flag]', 'take screenshots at end of each step')
+      .choices(['on', 'off', 'only-on-failure'])
+      .default('on')
+  )
+  .option(
+    '--dry-run',
+    "don't actually execute anything, report only registered journeys"
+  )
+  .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',
+    parseInt
+  )
+  .option(
+    '--ws-endpoint <endpoint>',
+    'Browser WebSocket endpoint to connect to'
+  )
+  .option(
+    '--pause-on-error',
+    'pause on error until a keypress is made in the console. Useful during development'
+  )
+  .option(
+    '--ignore-https-errors',
+    'ignores any HTTPS errors in sites being tested, including ones related to unrecognized certs or signatures. This can be insecure!'
+  )
+  .option(
+    '--quiet-exit-code',
+    'always return 0 as an exit code status, regardless of test pass / fail. Only return > 0 exit codes on internal errors where the suite could not be run'
+  )
+  .option(
+    '--throttling <d/u/l>',
+    'List of options to throttle network conditions for download throughput (d) in megabits/second, upload throughput (u) in megabits/second and latency (l) in milliseconds.',
+    DEFAULT_NETWORK_CONDITIONS_ARG
+  )
+  .option('--no-throttling', 'Turns off default network throttling.')
+  .option(
+    '--playwright-options <jsonstring>',
+    'JSON object to pass in custom Playwright options for the agent. Options passed will be merged with Playwright options defined in your synthetics.config.js file. Options defined via --playwright-options take precedence.',
+    JSON.parse
+  )
+  .version(version)
+  .description('Run synthetic tests')
+  .action(async (cliArgs: CliArgs) => {
+    try {
+      await loadTestFiles(cliArgs, program.args);
+      const options = normalizeOptions(cliArgs);
+      const results = await run(options);
+      /**
+       * Exit with error status if any journey fails
+       */
+      if (!options.quietExitCode) {
+        for (const result of Object.values(results)) {
+          if (result.status === 'failed') {
+            process.exit(1);
+          }
         }
-      });
-    } else {
-      addSuite(absPath);
-    }
-  }
-  return suites.values();
-}
-
-(async () => {
-  /**
-   * Transform `.ts` files out of the box by invoking
-   * the `ts-node` via `transpile-only` mode that compiles
-   * TS files without doing any extensive type checks.
-   *
-   * We must register `ts-node` _before_ loading inline
-   * scripts too because otherwise we will not be able to
-   * require `.ts` configuration files.
-   */
-  /* eslint-disable-next-line @typescript-eslint/no-var-requires */
-  require('ts-node').register({
-    transpileOnly: true,
-    compilerOptions: {
-      esModuleInterop: true,
-      allowJs: true,
-      target: 'es2020',
-    },
-  });
-
-  if (options.inline) {
-    const source = await readStdin();
-    loadInlineScript(source);
-  } else {
-    /**
-     * Preload modules before running the suites
-     */
-    const modules = [].concat(options.require || []).filter(Boolean);
-    for (const name of modules) {
-      if (isDepInstalled(name)) {
-        require(name);
-      } else {
-        throw new Error(`cannot find module '${name}'`);
       }
+    } catch (e) {
+      console.error(e);
+      process.exit(1);
     }
-    /**
-     * Handle piped files by reading the STDIN
-     * ex: ls example/suites/*.js | npx @elastic/synthetics
-     */
-    const files =
-      program.args.length > 0
-        ? program.args
-        : (await readStdin()).split('\n').filter(Boolean);
-    const suites = await prepareSuites(files);
-    requireSuites(suites);
-  }
-
-  /**
-   * Use the NODE_ENV variable to control the environment
-   */
-  const environment = process.env['NODE_ENV'] || 'development';
-  /**
-   * Validate and handle configs
-   */
-  const config =
-    options.config || !options.inline
-      ? readConfig(environment, options.config)
-      : {};
-  const params = merge(config.params, options.params || {});
-
-  /**
-   * Favor playwright options passed via cli to inline playwright options
-   */
-  const playwrightOptions = merge.all([
-    config.playwrightOptions || {},
-    options.playwrightOptions || {},
-    {
-      headless: options.headless,
-      chromiumSandbox: options.sandbox,
-      ignoreHTTPSErrors: options.ignoreHttpsErrors,
-    },
-  ]);
+  });
 
-  const results = await run({
-    params: Object.freeze(params),
-    networkConditions: options.throttling
-      ? parseNetworkConditions(options.throttling as string)
-      : undefined,
-    environment,
-    playwrightOptions,
-    ...options,
+program
+  .command('push')
+  .description(
+    'Push monitors to create new montors with Kibana monitor management UI'
+  )
+  .action(() => {
+    throw new Error('TODO: Implement me');
   });
 
-  if (!options.quietExitCode) {
-    /**
-     * Exit with error status if any journey fails
-     */
-    for (const result of Object.values(results)) {
-      if (result.status === 'failed') {
-        process.exit(1);
-      }
-    }
-  }
-})().catch(e => {
-  console.error(e);
-  process.exit(1);
-});
+program.parse(process.argv);
diff --git a/src/common_types.ts b/src/common_types.ts
index 956f73a..97feac5 100644
--- a/src/common_types.ts
+++ b/src/common_types.ts
@@ -33,7 +33,7 @@ import {
 } from 'playwright-chromium';
 import { Protocol } from 'playwright-chromium/types/protocol';
 import { Step } from './dsl';
-import { reporters } from './reporters';
+import { Reporter, reporters } from './reporters';
 
 export type VoidCallback = () => void;
 export type Location = {
@@ -193,34 +193,44 @@ export type PluginOutput = {
 
 export type ScreenshotOptions = 'on' | 'off' | 'only-on-failure';
 
-export type CliArgs = {
-  capability?: Array<string>;
-  config?: string;
-  outfd?: number;
-  headless?: boolean;
+type BaseArgs = {
+  params?: Params;
   screenshots?: ScreenshotOptions;
-  ssblocks?: boolean;
-  metrics?: boolean;
-  filmstrips?: boolean;
-  trace?: boolean;
   dryRun?: boolean;
-  network?: boolean;
+  match?: string;
+  tags?: Array<string>;
+  outfd?: number;
+  wsEndpoint?: string;
   pauseOnError?: boolean;
+  ignoreHttpsErrors?: boolean;
+  playwrightOptions?: PlaywrightOptions;
   quietExitCode?: boolean;
+};
+
+export type CliArgs = BaseArgs & {
+  config?: string;
   reporter?: Reporters;
-  wsEndpoint?: string;
-  sandbox?: boolean;
   pattern?: string;
-  inline: boolean;
-  match?: string;
-  tags?: Array<string>;
-  require: Array<string>;
-  debug?: boolean;
+  inline?: boolean;
+  require?: Array<string>;
+  headless?: boolean;
+  sandbox?: boolean;
+  richEvents?: boolean;
+  capability?: Array<string>;
   ignoreHttpsErrors?: boolean;
-  params?: Params;
   throttling?: boolean | string;
-  playwrightOptions?: LaunchOptions & BrowserContextOptions;
-  richEvents?: boolean;
+};
+
+export type RunOptions = BaseArgs & {
+  metrics?: boolean;
+  ssblocks?: boolean;
+  network?: boolean;
+  trace?: boolean;
+  filmstrips?: boolean;
+  environment?: string;
+  playwrightOptions?: PlaywrightOptions;
+  networkConditions?: NetworkConditions;
+  reporter?: CliArgs['reporter'] | Reporter;
 };
 
 export type PlaywrightOptions = LaunchOptions & BrowserContextOptions;
diff --git a/src/core/gatherer.ts b/src/core/gatherer.ts
index 638efc5..f802008 100644
--- a/src/core/gatherer.ts
+++ b/src/core/gatherer.ts
@@ -25,9 +25,8 @@
 
 import { chromium, ChromiumBrowser, BrowserContext } from 'playwright-chromium';
 import { PluginManager } from '../plugins';
-import { RunOptions } from './runner';
 import { log } from './logger';
-import { Driver } from '../common_types';
+import { Driver, RunOptions } from '../common_types';
 
 /**
  * Purpose of the Gatherer is to set up the necessary browser driver
diff --git a/src/core/runner.ts b/src/core/runner.ts
index 6fc3bed..df1394b 100644
--- a/src/core/runner.ts
+++ b/src/core/runner.ts
@@ -28,7 +28,7 @@ import { join } from 'path';
 import { rm } from 'fs/promises';
 import { Journey } from '../dsl/journey';
 import { Step } from '../dsl/step';
-import { reporters, Reporter } from '../reporters';
+import { reporters } from '../reporters';
 import {
   CACHE_PATH,
   monotonicTimeInSeconds,
@@ -44,36 +44,16 @@ import {
   Params,
   NetworkConditions,
   PluginOutput,
-  CliArgs,
   HooksArgs,
-  PlaywrightOptions,
   Driver,
   Screenshot,
+  RunOptions,
 } from '../common_types';
 import { PageMetrics, PluginManager } from '../plugins';
 import { PerformanceManager } from '../plugins';
 import { Gatherer } from './gatherer';
 import { log } from './logger';
 
-export type RunOptions = Omit<
-  CliArgs,
-  | 'debug'
-  | 'pattern'
-  | 'inline'
-  | 'require'
-  | 'reporter'
-  | 'richEvents'
-  | 'capability'
-  | 'sandbox'
-  | 'headless'
-  | 'throttling'
-> & {
-  environment?: string;
-  playwrightOptions?: PlaywrightOptions;
-  networkConditions?: NetworkConditions;
-  reporter?: CliArgs['reporter'] | Reporter;
-};
-
 type HookType = 'beforeAll' | 'afterAll';
 export type SuiteHooks = Record<HookType, Array<HooksCallback>>;
 
diff --git a/src/formatter/javascript.ts b/src/formatter/javascript.ts
index d1932fc..b67484e 100644
--- a/src/formatter/javascript.ts
+++ b/src/formatter/javascript.ts
@@ -28,6 +28,13 @@ import {
   JavaScriptFormatter,
 } from 'playwright-chromium/lib/server/supplements/recorder/javascript';
 
+export type Step = {
+  actions: ActionInContext[];
+  name?: string;
+};
+
+export type Steps = Step[];
+
 export type ActionInContext = {
   pageAlias: string;
   frameName?: string;
@@ -75,8 +82,13 @@ export class SyntheticsGenerator extends JavaScriptLanguageGenerator {
     this.previousContext = undefined;
   }
 
+  /**
+   * Generate code for an action.
+   * @param actionInContext The action to create code for.
+   * @returns the strings generated for the action.
+   */
   generateAction(actionInContext: ActionInContext) {
-    const { action, pageAlias, title } = actionInContext;
+    const { action, pageAlias } = actionInContext;
     if (action.name === 'openPage') {
       return '';
     }
@@ -86,17 +98,9 @@ export class SyntheticsGenerator extends JavaScriptLanguageGenerator {
       return '';
     }
 
-    let formatter = new JavaScriptFormatter(this.isSuite ? 2 : 0);
-    // Check if it's a new step
-    const isNewStep = this.isNewStep(actionInContext);
-    if (isNewStep) {
-      if (this.insideStep) {
-        formatter.add(this.generateStepEnd());
-      }
-      formatter.add(this.generateStepStart(title || actionTitle(action)));
-    } else {
-      formatter = new JavaScriptFormatter(this.isSuite ? 4 : 2);
-    }
+    const stepIndent = this.insideStep ? 2 : 0;
+    const offset = this.isSuite ? 2 + stepIndent : 0 + stepIndent;
+    const formatter = new JavaScriptFormatter(offset);
 
     const subject = actionInContext.isMainFrame
       ? pageAlias
@@ -187,7 +191,9 @@ export class SyntheticsGenerator extends JavaScriptLanguageGenerator {
 
   generateStepStart(name) {
     this.insideStep = true;
-    return `step(${quote(name)}, async () => {`;
+    const formatter = new JavaScriptFormatter(this.isSuite ? 2 : 0);
+    formatter.add(`step(${quote(name)}, async () => {`);
+    return formatter.format();
   }
 
   generateStepEnd() {
@@ -213,19 +219,41 @@ export class SyntheticsGenerator extends JavaScriptLanguageGenerator {
     return `});`;
   }
 
-  generateText(actions: Array<ActionInContext>) {
+  /**
+   * Generates JavaScript code from a custom set of steps and nested actions.
+   *
+   * This function makes no assumptions about where steps should be created,
+   * and instead follows the step definitions the caller has defined.
+   * @param steps IR to use for code generation
+   * @returns a list of the code strings outputted by the generator
+   */
+  generateFromSteps(steps: Steps): string {
     const text = [];
     if (this.isSuite) {
       text.push(this.generateHeader());
     }
-    for (let i = 0; i < actions.length; i++) {
-      text.push(this.generateAction(actions[i]));
-      if (i === actions.length - 1) text.push(this.generateStepEnd());
+    for (const step of steps) {
+      if (step.actions.length === 0)
+        throw Error('Cannot process an empty step');
+      text.push(
+        this.generateStepStart(
+          step.name ??
+            step.actions[0].title ??
+            actionTitle(step.actions[0].action)
+        )
+      );
+
+      for (const action of step.actions) {
+        const actionText = this.generateAction(action);
+        if (actionText.length) text.push(actionText);
+      }
+
+      text.push(this.generateStepEnd());
     }
     if (this.isSuite) {
       text.push(this.generateFooter());
     }
-    return text.filter(t => Boolean(t)).join('\n');
+    return text.filter(s => !!s).join('\n');
   }
 }
 
@@ -299,7 +327,12 @@ function formatObject(value, indent = '  ') {
   return String(value);
 }
 
-function actionTitle(action) {
+/**
+ * Generates an appropriate title string based on the action type/data.
+ * @param action Playwright action IR
+ * @returns title string
+ */
+export function actionTitle(action: Action) {
   switch (action.name) {
     case 'openPage':
       return `Open new page`;
@@ -328,5 +361,7 @@ function actionTitle(action) {
       );
     case 'select':
       return `Select ${action.options.join(', ')}`;
+    case 'assert':
+      return `Assert ${action.selector} ${action.command}`;
   }
 }
diff --git a/src/index.ts b/src/index.ts
index 3965468..0492a7e 100644
--- a/src/index.ts
+++ b/src/index.ts
@@ -24,15 +24,10 @@
  */
 
 import { runner } from './core';
-import { RunOptions } from './core/runner';
+import { RunOptions } from './common_types';
 
 export async function run(options: RunOptions) {
-  try {
-    return await runner.run(options);
-  } catch (e) {
-    console.error('Failed to run the test', e);
-    process.exit(1);
-  }
+  return runner.run(options);
 }
 
 /**
@@ -58,4 +53,11 @@ export type { default as Runner } from './core/runner';
 export type { Reporter, ReporterOptions } from './reporters';
 
 export type { SyntheticsConfig } from './common_types';
-export type { ActionInContext, Action, Signal } from './formatter/javascript';
+export type {
+  Action,
+  ActionInContext,
+  Signal,
+  Step,
+  Steps,
+} from './formatter/javascript';
+export { actionTitle } from './formatter/javascript';
diff --git a/src/loader.ts b/src/loader.ts
new file mode 100644
index 0000000..f9ed36b
--- /dev/null
+++ b/src/loader.ts
@@ -0,0 +1,162 @@
+/**
+ * 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 { stdin, cwd } from 'process';
+import { resolve } from 'path';
+import { CliArgs } from './common_types';
+import { step, journey } from './core';
+import { log } from './core/logger';
+import { expect } from './core/expect';
+import {
+  isDepInstalled,
+  isDirectory,
+  totalist,
+  findPkgJsonByTraversing,
+} from './helpers';
+
+const resolvedCwd = cwd();
+
+export async function loadTestFiles(options: CliArgs, args: string[]) {
+  /**
+   * Transform `.ts` files out of the box by invoking
+   * the `ts-node` via `transpile-only` mode that compiles
+   * TS files without doing any extensive type checks.
+   *
+   * We must register `ts-node` _before_ loading inline
+   * scripts too because otherwise we will not be able to
+   * require `.ts` configuration files.
+   */
+  /* eslint-disable-next-line @typescript-eslint/no-var-requires */
+  require('ts-node').register({
+    transpileOnly: true,
+    compilerOptions: {
+      esModuleInterop: true,
+      allowJs: true,
+      target: 'es2020',
+    },
+  });
+
+  /**
+   * Preload modules before running the tests
+   */
+  const modules = [].concat(options.require || []).filter(Boolean);
+  for (const name of modules) {
+    if (isDepInstalled(name)) {
+      require(name);
+    } else {
+      throw new Error(`cannot find module '${name}'`);
+    }
+  }
+
+  if (options.inline) {
+    const source = await readStdin();
+    loadInlineScript(source);
+    return;
+  }
+  /**
+   * Handle piped files by reading the STDIN
+   * ex: ls example/suites/*.js | npx @elastic/synthetics
+   */
+  const files =
+    args.length > 0 ? args : (await readStdin()).split('\n').filter(Boolean);
+  const suites = await prepareSuites(files, options);
+  requireSuites(suites);
+}
+
+const loadInlineScript = source => {
+  const scriptFn = new Function(
+    'step',
+    'page',
+    'context',
+    'browser',
+    'params',
+    'expect',
+    source
+  );
+  journey('inline', ({ page, context, browser, params }) => {
+    scriptFn.apply(null, [step, page, context, browser, params, expect]);
+  });
+};
+
+/**
+ * Read the input from STDIN and run it as inline journey
+ */
+async function readStdin() {
+  const chunks = [];
+  stdin.resume();
+  stdin.setEncoding('utf-8');
+  for await (const chunk of stdin) {
+    chunks.push(chunk);
+  }
+  return chunks.join();
+}
+
+function requireSuites(suites: Iterable<string>) {
+  for (const suite of suites) {
+    require(suite);
+  }
+}
+
+/**
+ * Handle both directory and files that are passed through TTY
+ * and add them to suites
+ */
+async function prepareSuites(inputs: string[], cliArgs: CliArgs) {
+  const suites = new Set<string>();
+  const addSuite = absPath => {
+    log(`Processing file: ${absPath}`);
+    suites.add(require.resolve(absPath));
+  };
+  /**
+   * Match all files inside the directory with the
+   * .journey.{mjs|cjs|js|ts) extensions
+   */
+  const pattern = cliArgs.pattern
+    ? new RegExp(cliArgs.pattern, 'i')
+    : /.+\.journey\.([mc]js|[jt]s?)$/;
+  /**
+   * Ignore node_modules by default when running suites
+   */
+  const ignored = /node_modules/i;
+
+  for (const input of inputs) {
+    const absPath = resolve(resolvedCwd, input);
+    /**
+     * Validate for package.json file before running
+     * the suites
+     */
+    findPkgJsonByTraversing(absPath, resolvedCwd);
+    if (isDirectory(absPath)) {
+      await totalist(absPath, (rel, abs) => {
+        if (pattern.test(rel) && !ignored.test(rel)) {
+          addSuite(abs);
+        }
+      });
+    } else {
+      addSuite(absPath);
+    }
+  }
+  return suites.values();
+}
diff --git a/src/options.ts b/src/options.ts
new file mode 100644
index 0000000..be42f01
--- /dev/null
+++ b/src/options.ts
@@ -0,0 +1,116 @@
+/**
+ * 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 merge from 'deepmerge';
+import { readConfig } from './config';
+import { parseNetworkConditions } from './helpers';
+import type { CliArgs, RunOptions } from './common_types';
+
+/**
+ * Set debug based on DEBUG ENV and -d flags
+ * namespace - synthetics
+ */
+const namespace = 'synthetics';
+if (process.env.DEBUG && process.env.DEBUG.includes(namespace)) {
+  process.env.DEBUG = '1';
+}
+
+export function normalizeOptions(cliArgs: CliArgs): RunOptions {
+  const options: RunOptions = {
+    ...cliArgs,
+    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
+   */
+  const config =
+    cliArgs.config || !cliArgs.inline
+      ? readConfig(options.environment, cliArgs.config)
+      : {};
+
+  options.params = Object.freeze(merge(config.params, cliArgs.params || {}));
+
+  /**
+   * Favor playwright options passed via cli to inline playwright options
+   */
+  options.playwrightOptions = merge.all([
+    config.playwrightOptions || {},
+    cliArgs.playwrightOptions || {},
+    {
+      headless: cliArgs.headless,
+      chromiumSandbox: cliArgs.sandbox,
+      ignoreHTTPSErrors: cliArgs.ignoreHttpsErrors,
+    },
+  ]);
+
+  if (cliArgs.throttling) {
+    options.networkConditions = parseNetworkConditions(
+      cliArgs.throttling as string
+    );
+  }
+
+  return options;
+}
diff --git a/src/parse_args.ts b/src/parse_args.ts
deleted file mode 100644
index a09e7b1..0000000
--- a/src/parse_args.ts
+++ /dev/null
@@ -1,158 +0,0 @@
-/**
- * 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 { program, Option } from 'commander';
-import { CliArgs } from './common_types';
-import { reporters } from './reporters';
-import { DEFAULT_NETWORK_CONDITIONS_ARG } from './helpers';
-
-/* eslint-disable-next-line @typescript-eslint/no-var-requires */
-const { name, version } = require('../package.json');
-
-program
-  .name(`npx ${name}`)
-  .usage('[options] [dir] [files] file')
-  .option(
-    '-c, --config <path>',
-    'configuration path (default: synthetics.config.js)'
-  )
-  .option(
-    '-p, --params <jsonstring>',
-    'JSON object that gets injected to all journeys',
-    JSON.parse
-  )
-  .addOption(
-    new Option('--reporter <value>', `output repoter format`).choices(
-      Object.keys(reporters)
-    )
-  )
-  .option('-d, --debug', 'print debug logs info')
-  .option(
-    '--pattern <pattern>',
-    'RegExp file patterns to search inside directory'
-  )
-  .option('--inline', 'Run inline journeys from heartbeat')
-  .option('-r, --require <modules...>', 'module(s) to preload')
-  .option('--no-headless', 'run browser in headful mode')
-  .option('--sandbox', 'enable chromium sandboxing', false)
-  .option('--rich-events', 'Mimics a heartbeat run')
-  .option(
-    '--capability <features...>',
-    'Enable capabilities through feature flags'
-  )
-  .addOption(
-    new Option('--screenshots [flag]', 'take screenshots at end of each step')
-      .choices(['on', 'off', 'only-on-failure'])
-      .default('on')
-  )
-  .option(
-    '--dry-run',
-    "don't actually execute anything, report only registered journeys"
-  )
-  .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',
-    parseInt
-  )
-  .option(
-    '--ws-endpoint <endpoint>',
-    'Browser WebSocket endpoint to connect to'
-  )
-  .option(
-    '--pause-on-error',
-    'pause on error until a keypress is made in the console. Useful during development'
-  )
-  .option(
-    '--ignore-https-errors',
-    'ignores any HTTPS errors in sites being tested, including ones related to unrecognized certs or signatures. This can be insecure!'
-  )
-  .option(
-    '--quiet-exit-code',
-    'always return 0 as an exit code status, regardless of test pass / fail. Only return > 0 exit codes on internal errors where the suite could not be run'
-  )
-  .addOption(
-    new Option(
-      '--throttling <d/u/l>',
-      'List of options to throttle network conditions for download throughput (d) in megabits/second, upload throughput (u) in megabits/second and latency (l) in milliseconds.'
-    ).default(DEFAULT_NETWORK_CONDITIONS_ARG)
-  )
-  .option('--no-throttling', 'Turns off default network throttling.')
-  .option('--playwright-options <jsonstring>', 'JSON object to pass in custom Playwright options for the agent. Options passed will be merged with Playwright options defined in your synthetics.config.js file. Options defined via --playwright-options take precedence.', JSON.parse)
-  .version(version)
-  .description('Run synthetic tests');
-
-const command = program.parse(process.argv);
-const options = command.opts() as CliArgs;
-
-/**
- * Group all events that can be consumed by heartbeat and
- * eventually by the Synthetics UI.
- */
-if (options.richEvents) {
-  options.reporter = options.reporter ?? 'json';
-  options.ssblocks = true;
-  options.network = true;
-  options.quietExitCode = true;
-  options.trace = true;
-}
-
-if (options.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 options.capability) {
-    if (supportedCapabilities.includes(flag)) {
-      options[flag] = true;
-    } else {
-      console.warn(
-        `Missing capability "${flag}", current supported capabilities are ${supportedCapabilities.join(
-          ', '
-        )}`
-      );
-    }
-  }
-}
-
-export { options };
-export default command;


Test patch:
diff --git a/__tests__/cli.test.ts b/__tests__/cli.test.ts
index ecc2c81..4251765 100644
--- a/__tests__/cli.test.ts
+++ b/__tests__/cli.test.ts
@@ -29,6 +29,16 @@ import { devices } from 'playwright-chromium';
 import { Server } from './utils/server';
 import { megabitsToBytes, DEFAULT_NETWORK_CONDITIONS } from '../src/helpers';
 
+const safeParse = (chunks: string[]) => {
+  return chunks.map(data => {
+    try {
+      return JSON.parse(data);
+    } catch (e) {
+      throw `Error ${e} could not parse data '${data}'`;
+    }
+  });
+};
+
 describe('CLI', () => {
   let server: Server;
   let serverParams: { url: string };
@@ -178,23 +188,20 @@ describe('CLI', () => {
       .run();
     await cli.waitFor('journey/end');
 
-    const screenshotRef = cli
-      .buffer()
-      .map(data => JSON.parse(data))
-      .find(({ type }) => type === 'step/screenshot_ref');
+    const data = safeParse(cli.buffer());
+    const screenshotRef = data.find(
+      ({ type }) => type === 'step/screenshot_ref'
+    );
     expect(screenshotRef).toBeDefined();
 
-    const networkData = cli
-      .buffer()
-      .map(data => JSON.parse(data))
-      .find(({ type }) => type === 'journey/network_info');
+    const networkData = data.find(
+      ({ type }) => type === 'journey/network_info'
+    );
     expect(networkData).toBeDefined();
 
-    const traceData = cli
-      .buffer()
-      .map(data => JSON.parse(data))
-      .find(({ type }) => type === 'step/metrics');
+    const traceData = data.find(({ type }) => type === 'step/metrics');
     expect(traceData).toBeDefined();
+
     expect(await cli.exitCode).toBe(0);
   }, 30000);
 
@@ -226,13 +233,7 @@ describe('CLI', () => {
       ])
       .run();
     await cli.waitFor('journey/end');
-    const data = cli.buffer().map(data => {
-      try {
-        return JSON.parse(data);
-      } catch (e) {
-        throw `Error ${e} could not parse data '${data}'`;
-      }
-    });
+    const data = safeParse(cli.buffer());
     const screenshotRef = data.find(
       ({ type }) => type === 'step/screenshot_ref'
     );
diff --git a/__tests__/formatter/__snapshots__/javascript.test.ts.snap b/__tests__/formatter/__snapshots__/javascript.test.ts.snap
index 4373f31..25fe925 100644
--- a/__tests__/formatter/__snapshots__/javascript.test.ts.snap
+++ b/__tests__/formatter/__snapshots__/javascript.test.ts.snap
@@ -1,36 +1,18 @@
 // Jest Snapshot v1, https://goo.gl/fbAQLP
 
-exports[`Synthetics JavaScript formatter inline journeys 1`] = `
-"step('Go to https://vigneshh.in/', async () => {
-  await page.goto('https://vigneshh.in/');
-  expect(await page.isVisible('text=Babel Minify')).toBeTruthy();
-  expect(await page.isEditable('text=Babel Minify')).toBeTruthy();
-  expect(await page.textContent('text=Babel Minify')).toMatch('Babel');
-});
-step('Click text=Babel Minify', async () => {
-  const [page1] = await Promise.all([
-    page.waitForEvent('popup'),
-    page.click('text=Babel Minify')
-  ]);
-  await Promise.all([
-    page1.waitForNavigation(/*{ url: 'https://github.com/babel/minify' }*/),
-    page1.click('a:has-text(\\"smoke\\")')
-  ]);
-  await page1.close();
-});"
-`;
-
-exports[`Synthetics JavaScript formatter suite journeys 1`] = `
+exports[`Synthetics JavaScript formatter accepts custom step organization 1`] = `
 "const { journey, step, expect } = require('@elastic/synthetics');
 
 journey('Recorded journey', async ({ page, context }) => {
-  step('Go to https://vigneshh.in/', async () => {
+  step('Open new page', async () => {
     await page.goto('https://vigneshh.in/');
+  });
+  step('Assert text=Babel Minify isVisible', async () => {
     expect(await page.isVisible('text=Babel Minify')).toBeTruthy();
     expect(await page.isEditable('text=Babel Minify')).toBeTruthy();
-    expect(await page.textContent('text=Babel Minify')).toMatch('Babel');
   });
-  step('Click text=Babel Minify', async () => {
+  step('Assert text=Babel Minify textContent', async () => {
+    expect(await page.textContent('text=Babel Minify')).toMatch('Babel');
     const [page1] = await Promise.all([
       page.waitForEvent('popup'),
       page.click('text=Babel Minify')
@@ -39,13 +21,17 @@ journey('Recorded journey', async ({ page, context }) => {
       page1.waitForNavigation(/*{ url: 'https://github.com/babel/minify' }*/),
       page1.click('a:has-text(\\"smoke\\")')
     ]);
+  });
+  step('Close page', async () => {
     await page1.close();
   });
 });"
 `;
 
-exports[`Synthetics JavaScript formatter use modified title if available 1`] = `
-"step('Visiting profile', async () => {
+exports[`Synthetics JavaScript formatter uses custom step names 1`] = `
+"step('test-name', async () => {
   await page.goto('https://vigneshh.in/');
+  expect(await page.isVisible('text=Babel Minify')).toBeTruthy();
+  expect(await page.isEditable('text=Babel Minify')).toBeTruthy();
 });"
 `;
diff --git a/__tests__/formatter/javascript.test.ts b/__tests__/formatter/javascript.test.ts
index 282e1b9..0b09872 100644
--- a/__tests__/formatter/javascript.test.ts
+++ b/__tests__/formatter/javascript.test.ts
@@ -24,162 +24,176 @@
  */
 import {
   SyntheticsGenerator,
-  ActionInContext,
+  Step,
+  Steps,
 } from '../../src/formatter/javascript';
 
-const recorderActions: Array<ActionInContext> = [
-  {
-    pageAlias: 'page',
-    isMainFrame: true,
-    frameUrl: 'about:blank',
-    committed: true,
-    action: {
-      name: 'openPage',
-      url: 'about:blank',
-      signals: [],
+const recorderStep: Step = {
+  actions: [
+    {
+      pageAlias: 'page',
+      isMainFrame: true,
+      frameUrl: 'about:blank',
+      committed: true,
+      action: {
+        name: 'openPage',
+        url: 'about:blank',
+        signals: [],
+      },
     },
-  },
-  {
-    pageAlias: 'page',
-    isMainFrame: true,
-    frameUrl: 'https://vigneshh.in/',
-    committed: true,
-    action: {
-      name: 'navigate',
-      url: 'https://vigneshh.in/',
-      signals: [],
+    {
+      pageAlias: 'page',
+      isMainFrame: true,
+      frameUrl: 'https://vigneshh.in/',
+      committed: true,
+      action: {
+        name: 'navigate',
+        url: 'https://vigneshh.in/',
+        signals: [],
+      },
     },
-  },
-  {
-    pageAlias: 'page',
-    isMainFrame: true,
-    frameUrl: 'https://vigneshh.in/',
-    action: {
-      name: 'assert',
-      isAssert: true,
-      command: 'isVisible',
-      selector: 'text=Babel Minify',
-      signals: [],
+    {
+      pageAlias: 'page',
+      isMainFrame: true,
+      frameUrl: 'https://vigneshh.in/',
+      action: {
+        name: 'assert',
+        isAssert: true,
+        command: 'isVisible',
+        selector: 'text=Babel Minify',
+        signals: [],
+      },
     },
-  },
-  {
-    pageAlias: 'page',
-    isMainFrame: true,
-    frameUrl: 'https://vigneshh.in/',
-    action: {
-      name: 'assert',
-      isAssert: true,
-      command: 'isEditable',
-      selector: 'text=Babel Minify',
-      signals: [],
+    {
+      pageAlias: 'page',
+      isMainFrame: true,
+      frameUrl: 'https://vigneshh.in/',
+      action: {
+        name: 'assert',
+        isAssert: true,
+        command: 'isEditable',
+        selector: 'text=Babel Minify',
+        signals: [],
+      },
     },
-  },
-  {
-    pageAlias: 'page',
-    isMainFrame: true,
-    frameUrl: 'https://vigneshh.in/',
-    action: {
-      name: 'assert',
-      isAssert: true,
-      command: 'textContent',
-      selector: 'text=Babel Minify',
-      value: 'Babel',
-      signals: [],
+    {
+      pageAlias: 'page',
+      isMainFrame: true,
+      frameUrl: 'https://vigneshh.in/',
+      action: {
+        name: 'assert',
+        isAssert: true,
+        command: 'textContent',
+        selector: 'text=Babel Minify',
+        value: 'Babel',
+        signals: [],
+      },
     },
-  },
-  {
-    pageAlias: 'page',
-    isMainFrame: true,
-    frameUrl: 'https://vigneshh.in/',
-    action: {
-      name: 'click',
-      selector: 'text=Babel Minify',
-      signals: [
-        {
-          name: 'popup',
-          popupAlias: 'page1',
-          isAsync: true,
-        },
-      ],
-      button: 'left',
-      modifiers: 0,
-      clickCount: 1,
+    {
+      pageAlias: 'page',
+      isMainFrame: true,
+      frameUrl: 'https://vigneshh.in/',
+      action: {
+        name: 'click',
+        selector: 'text=Babel Minify',
+        signals: [
+          {
+            name: 'popup',
+            popupAlias: 'page1',
+            isAsync: true,
+          },
+        ],
+        button: 'left',
+        modifiers: 0,
+        clickCount: 1,
+      },
+      committed: true,
     },
-    committed: true,
-  },
-  {
-    pageAlias: 'page1',
-    isMainFrame: true,
-    frameUrl: 'https://github.com/babel/minify',
-    action: {
-      name: 'click',
-      selector: 'a:has-text("smoke")',
-      signals: [
-        {
-          name: 'navigation',
-          url: 'https://github.com/babel/minify',
-        },
-        {
-          name: 'navigation',
-          url: 'https://github.com/babel/minify/tree/master/smoke',
-        },
-        {
-          name: 'navigation',
-          url: 'https://github.com/babel/minify/tree/master/smoke',
-          isAsync: true,
-        },
-        {
-          name: 'navigation',
-          url: 'https://github.com/babel/minify',
-          isAsync: true,
-        },
-      ],
-      button: 'left',
-      modifiers: 0,
-      clickCount: 1,
+    {
+      pageAlias: 'page1',
+      isMainFrame: true,
+      frameUrl: 'https://github.com/babel/minify',
+      action: {
+        name: 'click',
+        selector: 'a:has-text("smoke")',
+        signals: [
+          {
+            name: 'navigation',
+            url: 'https://github.com/babel/minify',
+          },
+          {
+            name: 'navigation',
+            url: 'https://github.com/babel/minify/tree/master/smoke',
+          },
+          {
+            name: 'navigation',
+            url: 'https://github.com/babel/minify/tree/master/smoke',
+            isAsync: true,
+          },
+          {
+            name: 'navigation',
+            url: 'https://github.com/babel/minify',
+            isAsync: true,
+          },
+        ],
+        button: 'left',
+        modifiers: 0,
+        clickCount: 1,
+      },
     },
-  },
-  {
-    pageAlias: 'page1',
-    isMainFrame: true,
-    frameUrl: 'https://github.com/babel/minify',
-    committed: true,
-    action: {
-      name: 'closePage',
-      signals: [],
+    {
+      pageAlias: 'page1',
+      isMainFrame: true,
+      frameUrl: 'https://github.com/babel/minify',
+      committed: true,
+      action: {
+        name: 'closePage',
+        signals: [],
+      },
     },
-  },
-  {
-    pageAlias: 'page',
-    isMainFrame: true,
-    frameUrl: 'https://vigneshh.in/',
-    committed: true,
-    action: {
-      name: 'closePage',
-      signals: [],
+    {
+      pageAlias: 'page',
+      isMainFrame: true,
+      frameUrl: 'https://vigneshh.in/',
+      committed: true,
+      action: {
+        name: 'closePage',
+        signals: [],
+      },
     },
-  },
-];
+  ],
+};
 
 describe('Synthetics JavaScript formatter', () => {
-  it('inline journeys', async () => {
-    const formatter = new SyntheticsGenerator(false);
-    expect(formatter.generateText(recorderActions)).toMatchSnapshot();
+  it('accepts custom step organization', () => {
+    const generator = new SyntheticsGenerator(true);
+    const testSteps: Steps = [
+      { actions: recorderStep.actions.slice(0, 2) },
+      { actions: recorderStep.actions.slice(2, 4) },
+      { actions: recorderStep.actions.slice(4, 7) },
+      { actions: recorderStep.actions.slice(7, 9) },
+    ];
+    expect(generator.generateFromSteps(testSteps)).toMatchSnapshot();
   });
 
-  it('suite journeys', async () => {
-    const formatter = new SyntheticsGenerator(true);
-    expect(formatter.generateText(recorderActions)).toMatchSnapshot();
+  it('uses custom step names', () => {
+    const generator = new SyntheticsGenerator(false);
+    const testSteps: Steps = [{ actions: recorderStep.actions.slice(0, 4) }];
+    expect(
+      generator.generateFromSteps(
+        testSteps.map((s: Step) => {
+          s.name = 'test-name';
+          return s;
+        })
+      )
+    ).toMatchSnapshot();
   });
 
-  it('use modified title if available', async () => {
-    const formatter = new SyntheticsGenerator(false);
-    const actions = [
-      {
-        ...recorderActions[1],
-        title: 'Visiting profile',
-      },
-    ];
-    expect(formatter.generateText(actions)).toMatchSnapshot();
+  it('throws error if processing empty step', () => {
+    const generator = new SyntheticsGenerator(false);
+    const testSteps: Steps = [{ actions: [] }];
+    expect(() => generator.generateFromSteps(testSteps)).toThrowError(
+      'Cannot process an empty step'
+    );
   });
 });
diff --git a/__tests__/index.test.ts b/__tests__/index.test.ts
index 8fdbed2..e025ec1 100644
--- a/__tests__/index.test.ts
+++ b/__tests__/index.test.ts
@@ -25,8 +25,6 @@
 
 import fs from 'fs';
 import { run, journey } from '../src/index';
-import { runner } from '../src/core';
-import { RunOptions } from '../src/core/runner';
 import { generateTempPath } from '../src/helpers';
 
 describe('Run', () => {
@@ -54,28 +52,4 @@ describe('Run', () => {
       {},
     ]);
   });
-
-  it('calls runner with proper options', async () => {
-    const runnerSpy = jest
-      .spyOn(runner, 'run')
-      .mockImplementation(() => Promise.resolve({}));
-
-    const options: RunOptions = {
-      environment: 'debug',
-      params: {
-        foo: 'bar',
-      },
-      screenshots: 'on',
-      filmstrips: false,
-      trace: false,
-      dryRun: true,
-      match: 'check*',
-      network: true,
-      pauseOnError: true,
-      quietExitCode: true,
-      reporter: 'json',
-    };
-    await run(options);
-    expect(runnerSpy.mock.calls[0][0]).toEqual(options);
-  });
 });
diff --git a/__tests__/options.test.ts b/__tests__/options.test.ts
new file mode 100644
index 0000000..3b4b64d
--- /dev/null
+++ b/__tests__/options.test.ts
@@ -0,0 +1,75 @@
+/**
+ * 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 { CliArgs } from '../src/common_types';
+import { normalizeOptions } from '../src/options';
+import { join } from 'path';
+
+describe('options', () => {
+  it('normalize', async () => {
+    const cliArgs: CliArgs = {
+      params: {
+        foo: 'bar',
+      },
+      headless: true,
+      sandbox: false,
+      screenshots: 'on',
+      dryRun: true,
+      match: 'check*',
+      pauseOnError: true,
+      config: join(__dirname, 'fixtures', 'synthetics.config.ts'),
+    };
+    expect(normalizeOptions({})).toMatchObject({
+      environment: 'test',
+      params: {},
+    });
+    expect(normalizeOptions(cliArgs)).toMatchObject({
+      dryRun: true,
+      environment: 'test',
+      match: 'check*',
+      params: {
+        foo: 'bar',
+        url: 'non-dev',
+      },
+      pauseOnError: true,
+      playwrightOptions: {
+        chromiumSandbox: false,
+        defaultBrowserType: 'chromium',
+        deviceScaleFactor: 4.5,
+        hasTouch: true,
+        headless: true,
+        ignoreHTTPSErrors: undefined,
+        isMobile: true,
+        userAgent:
+          'Mozilla/5.0 (Linux; Android 8.0.0; SM-G965U Build/R16NW) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/94.0.4595.0 Mobile Safari/537.36',
+        viewport: {
+          height: 658,
+          width: 320,
+        },
+      },
+      screenshots: 'on',
+    });
+  });
+});