Repository: elastic/synthetics
Pull request #457 (2022-02-11 00:02:54)

feat: Make JavaScript generator auto step insertion optional
Updates the JS code generator to allow for a flag that will enable calling code to generate a single step with all the passed actions contained in it.

Also added a code to ignore the check for being in a current step.

Introduced a new function that will generate code directly from a set of steps.

Moved step types from the recorder to here to avoid circular references.

~Code is WIP.~

apmmachine: 
## :grey_exclamation: Build Aborted
  
  > Either there was a build timeout or someone aborted the build.
  


<!-- 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-457/detail/PR-457/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-457/detail/PR-457/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-457/detail/PR-457/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-457/detail/PR-457/1//artifacts)   [![preview](https://img.shields.io/badge/elastic-observability-blue)](https://ci-stats.elastic.co/app/apm/services/apm-ci/transactions/view?rangeFrom=2022-02-10T23:53:09.016Z&rangeTo=2022-02-11T00:13:09.016Z&transactionName=apm-agent-rum/elastic-synthetics/PR-{number}&transactionType=job&latencyAggregationType=avg&traceId=56e7856b0bc4a46a9277acac80322d33&transactionId=95e251dd9479c987)

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

#### Build stats




* Start Time: 2022-02-11T00:03:09.016+0000


* Duration: 29 min 1 sec




#### Test stats :test_tube:

| Test         | Results                         |
| ------------ | :-----------------------------: |
| Failed       | 0  |
| Passed       | 146  |
| Skipped      | 2 |
| Total        | 148   |


</p>
</details>

<!-- TEST RESULTS IF ANY-->


<!-- STEPS ERRORS IF ANY -->












## :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-->
justinkambic: /test
justinkambic: /test
justinkambic: After some offline conferring we decided it's the best course of action to allow the Recorder to simply specify declarative objects that contain the data needed for Synthetics to generate the step code. This means we're stripping all step insertion logic out of the `generateAction` function, and leaving that up to the `generateFromSteps` function added in this PR.
justinkambic: @vigneshshanmugam I think we are good for a re-review now.

Patch:
diff --git a/package-lock.json b/package-lock.json
index f52c66f..360cd6b 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -1800,7 +1800,8 @@
     "buffer-from": {
       "version": "1.1.2",
       "resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz",
-      "integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ=="
+      "integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==",
+      "dev": true
     },
     "callsites": {
       "version": "3.1.0",
@@ -5873,12 +5874,14 @@
     "source-map": {
       "version": "0.6.1",
       "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz",
-      "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g=="
+      "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==",
+      "dev": true
     },
     "source-map-support": {
       "version": "0.5.21",
       "resolved": "https://registry.npmjs.org/source-map-support/-/source-map-support-0.5.21.tgz",
       "integrity": "sha512-uBHU3L3czsIyYXKX88fdrGovxdSCoTGDRZ6SYXtSRxLZUzHg5P/66Ht6uoUlHu9EZod+inXhKo3qQgwXUT/y1w==",
+      "dev": true,
       "requires": {
         "buffer-from": "^1.0.0",
         "source-map": "^0.6.0"
@@ -6151,9 +6154,9 @@
       }
     },
     "ts-node": {
-      "version": "10.5.0",
-      "resolved": "https://registry.npmjs.org/ts-node/-/ts-node-10.5.0.tgz",
-      "integrity": "sha512-6kEJKwVxAJ35W4akuiysfKwKmjkbYxwQMTBaAxo9KKAx/Yd26mPUyhGz3ji+EsJoAgrLqVsYHNuuYwQe22lbtw==",
+      "version": "10.7.0",
+      "resolved": "https://registry.npmjs.org/ts-node/-/ts-node-10.7.0.tgz",
+      "integrity": "sha512-TbIGS4xgJoX2i3do417KSaep1uRAW/Lu+WAL2doDHC0D6ummjirVOXU5/7aiZotbQ5p1Zp9tP7U6cYhA0O7M8A==",
       "requires": {
         "@cspotcode/source-map-support": "0.7.0",
         "@tsconfig/node10": "^1.0.7",
diff --git a/package.json b/package.json
index ab24fbd..a6f85d2 100644
--- a/package.json
+++ b/package.json
@@ -38,6 +38,7 @@
   "author": "",
   "license": "MIT",
   "dependencies": {
+    "@cspotcode/source-map-support": "^0.7.0",
     "commander": "^9.0.0",
     "deepmerge": "^4.2.2",
     "expect": "^27.0.2",
@@ -48,8 +49,7 @@
     "sharp": "^0.30.1",
     "snakecase-keys": "^3.2.1",
     "sonic-boom": "^2.6.0",
-    "source-map-support": "^0.5.21",
-    "ts-node": "^10.5.0",
+    "ts-node": "^10.7.0",
     "typescript": "^4.5.5"
   },
   "devDependencies": {
diff --git a/src/cli.ts b/src/cli.ts
index 078ddda..d4ba124 100644
--- a/src/cli.ts
+++ b/src/cli.ts
@@ -141,7 +141,7 @@ async function prepareSuites(inputs: string[]) {
     compilerOptions: {
       esModuleInterop: true,
       allowJs: true,
-      target: 'es2018',
+      target: 'es2020',
     },
   });
 
diff --git a/src/common_types.ts b/src/common_types.ts
index 227139e..956f73a 100644
--- a/src/common_types.ts
+++ b/src/common_types.ts
@@ -36,13 +36,13 @@ import { Step } from './dsl';
 import { reporters } from './reporters';
 
 export type VoidCallback = () => void;
-export type Params = Record<string, any>;
-export type NetworkConditions = {
-  offline: boolean;
-  downloadThroughput: number;
-  uploadThroughput: number;
-  latency: number;
+export type Location = {
+  file: string;
+  line: number;
+  column: number;
 };
+
+export type Params = Record<string, any>;
 export type HooksArgs = {
   env: string;
   params: Params;
@@ -51,6 +51,13 @@ export type HooksCallback = (args: HooksArgs) => void;
 export type StatusValue = 'succeeded' | 'failed' | 'skipped';
 export type Reporters = keyof typeof reporters;
 
+export type NetworkConditions = {
+  offline: boolean;
+  downloadThroughput: number;
+  uploadThroughput: number;
+  latency: number;
+};
+
 export type Driver = {
   browser: ChromiumBrowser;
   context: ChromiumBrowserContext;
diff --git a/src/core/index.ts b/src/core/index.ts
index 2a5be46..ce89b4f 100644
--- a/src/core/index.ts
+++ b/src/core/index.ts
@@ -25,7 +25,8 @@
 
 import { Journey, JourneyCallback, JourneyOptions } from '../dsl';
 import Runner from './runner';
-import { VoidCallback, HooksCallback } from '../common_types';
+import { VoidCallback, HooksCallback, Location } from '../common_types';
+import { wrapFnWithLocation } from '../helpers';
 import { log } from './logger';
 
 /**
@@ -39,23 +40,28 @@ if (!global[SYNTHETICS_RUNNER]) {
 
 export const runner: Runner = global[SYNTHETICS_RUNNER];
 
-export const journey = (
-  options: JourneyOptions | string,
-  callback: JourneyCallback
-) => {
-  log(`register journey: ${JSON.stringify(options)}`);
-  if (typeof options === 'string') {
-    options = { name: options, id: options };
+export const journey = wrapFnWithLocation(
+  (
+    location: Location,
+    options: JourneyOptions | string,
+    callback: JourneyCallback
+  ) => {
+    log(`register journey: ${JSON.stringify(options)}`);
+    if (typeof options === 'string') {
+      options = { name: options, id: options };
+    }
+    const j = new Journey(options, callback, location);
+    runner.addJourney(j);
+    return j;
   }
-  const j = new Journey(options, callback);
-  runner.addJourney(j);
-  return j;
-};
+);
 
-export const step = (name: string, callback: VoidCallback) => {
-  log(`register step: ${name}`);
-  return runner.currentJourney?.addStep(name, callback);
-};
+export const step = wrapFnWithLocation(
+  (location: Location, name: string, callback: VoidCallback) => {
+    log(`register step: ${name}`);
+    return runner.currentJourney?.addStep(name, callback, location);
+  }
+);
 
 export const beforeAll = (callback: HooksCallback) => {
   runner.addHook('beforeAll', callback);
diff --git a/src/dsl/journey.ts b/src/dsl/journey.ts
index 2bdc55a..22aa144 100644
--- a/src/dsl/journey.ts
+++ b/src/dsl/journey.ts
@@ -26,7 +26,7 @@
 import { Browser, Page, BrowserContext, CDPSession } from 'playwright-chromium';
 import micromatch, { isMatch } from 'micromatch';
 import { Step } from './step';
-import { VoidCallback, HooksCallback, Params } from '../common_types';
+import { VoidCallback, HooksCallback, Params, Location } from '../common_types';
 
 export type JourneyOptions = {
   name: string;
@@ -49,18 +49,24 @@ export class Journey {
   id?: string;
   tags?: string[];
   callback: JourneyCallback;
+  location?: Location;
   steps: Step[] = [];
   hooks: Hooks = { before: [], after: [] };
 
-  constructor(options: JourneyOptions, callback: JourneyCallback) {
+  constructor(
+    options: JourneyOptions,
+    callback: JourneyCallback,
+    location?: Location
+  ) {
     this.name = options.name;
     this.id = options.id || options.name;
     this.tags = options.tags;
     this.callback = callback;
+    this.location = location;
   }
 
-  addStep(name: string, callback: VoidCallback) {
-    const step = new Step(name, this.steps.length + 1, callback);
+  addStep(name: string, callback: VoidCallback, location?: Location) {
+    const step = new Step(name, this.steps.length + 1, callback, location);
     this.steps.push(step);
     return step;
   }
diff --git a/src/dsl/step.ts b/src/dsl/step.ts
index 8ed81a6..44148ee 100644
--- a/src/dsl/step.ts
+++ b/src/dsl/step.ts
@@ -23,16 +23,23 @@
  *
  */
 
-import { VoidCallback } from '../common_types';
+import { Location, VoidCallback } from '../common_types';
 
 export class Step {
   name: string;
   index: number;
   callback: VoidCallback;
+  location?: Location;
 
-  constructor(name: string, index: number, callback: VoidCallback) {
+  constructor(
+    name: string,
+    index: number,
+    callback: VoidCallback,
+    location: Location
+  ) {
     this.name = name;
     this.index = index;
     this.callback = callback;
+    this.location = location;
   }
 }
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/helpers.ts b/src/helpers.ts
index e74e71f..76d2a57 100644
--- a/src/helpers.ts
+++ b/src/helpers.ts
@@ -29,7 +29,13 @@ import { resolve, join, dirname } from 'path';
 import fs from 'fs';
 import { promisify } from 'util';
 import { performance } from 'perf_hooks';
-import { HooksArgs, HooksCallback, NetworkConditions } from './common_types';
+import sourceMapSupport from '@cspotcode/source-map-support';
+import {
+  HooksArgs,
+  HooksCallback,
+  NetworkConditions,
+  Location,
+} from './common_types';
 
 const lstatAsync = promisify(fs.lstat);
 const readdirAsync = promisify(fs.readdir);
@@ -315,3 +321,36 @@ export function parseNetworkConditions(args: string): NetworkConditions {
 
   return networkConditions;
 }
+
+// default stack trace limit
+const dstackTraceLimit = 10;
+
+// Uses the V8 Stacktrace API to get the function location
+// information - https://v8.dev/docs/stack-trace-api#customizing-stack-traces
+export function wrapFnWithLocation<A extends unknown[], R>(
+  func: (location: Location, ...args: A) => R
+): (...args: A) => R {
+  return (...args) => {
+    const _prepareStackTrace = Error.prepareStackTrace;
+    Error.prepareStackTrace = (_, stackFrames) => {
+      // Deafult CallSite would not map to the original transpiled source
+      // from ts-node properly, So we wrap it with the library that knows
+      // how to retrive those source-map for the transpiled code
+      const frame: NodeJS.CallSite = sourceMapSupport.wrapCallSite(
+        stackFrames[1]
+      );
+      return {
+        file: frame.getFileName(),
+        line: frame.getLineNumber(),
+        column: frame.getColumnNumber(),
+      };
+    };
+    Error.stackTraceLimit = 2;
+    const obj: { stack: Location } = {} as any;
+    Error.captureStackTrace(obj);
+    const location = obj.stack;
+    Error.stackTraceLimit = dstackTraceLimit;
+    Error.prepareStackTrace = _prepareStackTrace;
+    return func(location, ...args);
+  };
+}
diff --git a/src/index.ts b/src/index.ts
index 4419eff..288e054 100644
--- a/src/index.ts
+++ b/src/index.ts
@@ -25,16 +25,8 @@
 
 import { runner } from './core';
 import { RunOptions } from './core/runner';
-import sourceMapSupport from 'source-map-support';
 
 export async function run(options: RunOptions) {
-  /**
-   * Install source map support
-   */
-  sourceMapSupport.install({
-    environment: 'node',
-  });
-
   try {
     return await runner.run(options);
   } catch (e) {
@@ -66,4 +58,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/tsconfig.json b/tsconfig.json
index ce0d1ec..1605b9a 100644
--- a/tsconfig.json
+++ b/tsconfig.json
@@ -8,7 +8,7 @@
     "noImplicitOverride": true,
     "sourceMap": true,
     "outDir": "dist",
-    "target": "es2018",
+    "target": "es2020",
     "module": "commonjs"
   },
   "include": ["src/**/*"]


Test patch:
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__/helper.test.ts b/__tests__/helper.test.ts
index 83ccbe0..b77fd74 100644
--- a/__tests__/helper.test.ts
+++ b/__tests__/helper.test.ts
@@ -33,6 +33,7 @@ import {
   rewriteErrorStack,
   findPWLogsIndexes,
   microSecsToSeconds,
+  wrapFnWithLocation,
 } from '../src/helpers';
 
 it('indent message with seperator', () => {
@@ -133,3 +134,12 @@ it('does not rewrite non playwright errors', () => {
   const newNormalStack = rewriteErrorStack(normalStack, indexes);
   expect(normalStack).toStrictEqual(newNormalStack);
 });
+
+it('location info on execution', () => {
+  const checkLoc = wrapFnWithLocation(location => {
+    return location;
+  });
+  // line no and column no will not match as we are using
+  // ts-jest preset to transpile code.
+  expect(checkLoc().file).toBe(__filename);
+});