Repository: elastic/synthetics
Pull request #757 (2023-05-03 19:44:58)

[Synthetics] Migrate `SyntheticsGenerator` code back to Synthetics
Exports `SyntheticsGenerator` class from top-level core. Works around some export issues with playwright-core so the Synthetics Recorder can access this class.

Also imports tests and types from the recorder.


Patch:
diff --git a/src/formatter/javascript.ts b/src/formatter/javascript.ts
index abb7538..a2ac0c3 100644
--- a/src/formatter/javascript.ts
+++ b/src/formatter/javascript.ts
@@ -32,18 +32,26 @@ export type Step = {
   actions: ActionInContext[];
   name?: string;
 };
-
 export type Steps = Step[];
 
-export type ActionInContext = {
+// from playwright-core
+export type FrameDescription = {
   pageAlias: string;
+  isMainFrame?: boolean;
+  url: string;
+  name?: string;
+  selectorsChain?: string[];
+};
+
+export type ActionInContext = {
   frameName?: string;
-  frameUrl: string;
-  isMainFrame: boolean;
   action: Action;
   committed?: boolean;
   modified?: boolean;
   title?: string;
+  frame: FrameDescription;
+  isOpen?: boolean;
+  isSoftDeleted?: boolean;
 };
 
 export type Action = {
@@ -72,14 +80,138 @@ export type Signal = {
   dialogAlias?: string;
 };
 
+function toAssertCall(pageAlias: string, action: Action) {
+  const { command, selector, value } = action;
+  switch (command) {
+    case 'textContent':
+    case 'innerText':
+      return `expect(await ${pageAlias}.${command}(${quote(
+        selector ?? ''
+      )})).toMatch(${quote(value ?? '')});`;
+    case 'isVisible':
+    case 'isHidden':
+    case 'isChecked':
+    case 'isEditable':
+    case 'isEnabled':
+    case 'isDisabled':
+      return `expect(await ${pageAlias}.${command}(${quote(
+        selector ?? ''
+      )})).toBeTruthy();`;
+  }
+}
+
+function toSignalMap(action: Action) {
+  let waitForNavigation: Signal | undefined;
+  let assertNavigation: Signal | undefined;
+  let popup: Signal | undefined;
+  let download: Signal | undefined;
+  let dialog: Signal | undefined;
+  for (const signal of action.signals) {
+    if (signal.name === 'navigation' && signal.isAsync)
+      waitForNavigation = signal;
+    else if (signal.name === 'navigation' && !signal.isAsync)
+      assertNavigation = signal;
+    else if (signal.name === 'popup') popup = signal;
+    else if (signal.name === 'download') download = signal;
+    else if (signal.name === 'dialog') dialog = signal;
+  }
+  return {
+    waitForNavigation,
+    assertNavigation,
+    popup,
+    download,
+    dialog,
+  };
+}
+
+export function quote(text: string, char = "'"): string {
+  const stringified = JSON.stringify(text);
+  const escapedText = stringified
+    .substring(1, stringified.length - 1)
+    .replace(/\\"/g, '"');
+  if (char === "'") return char + escapedText.replace(/[']/g, "\\'") + char;
+  if (char === '"') return char + escapedText.replace(/["]/g, '\\"') + char;
+  if (char === '`') return char + escapedText.replace(/[`]/g, '`') + char;
+  throw new Error('Invalid escape char');
+}
+
+type Formattable = string | string[] | Record<string, unknown>;
+
+function isFormattable(value: unknown): value is Formattable {
+  return (
+    typeof value === 'string' ||
+    (Array.isArray(value) && value.every(v => typeof v === 'string')) ||
+    typeof value === 'object'
+  );
+}
+
+function formatObject(value: Formattable, indent = '  '): string {
+  if (typeof value === 'string') return quote(value);
+  if (Array.isArray(value))
+    return `[${value.map(o => formatObject(o)).join(', ')}]`;
+  if (typeof value === 'object') {
+    const keys = Object.keys(value);
+    if (!keys.length) return '{}';
+    const tokens: string[] = [];
+    for (const key of keys) {
+      const child = value[key];
+      if (child === undefined || !isFormattable(child)) continue;
+      tokens.push(`${key}: ${formatObject(child)}`);
+    }
+    return `{\n${indent}${tokens.join(`,\n${indent}`)}\n}`;
+  }
+  return String(value);
+}
+
+/**
+ * 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`;
+    case 'closePage':
+      return `Close page`;
+    case 'check':
+      return `Check ${action.selector}`;
+    case 'uncheck':
+      return `Uncheck ${action.selector}`;
+    case 'click': {
+      if (action.clickCount === 1) return `Click ${action.selector}`;
+      if (action.clickCount === 2) return `Double click ${action.selector}`;
+      if (action.clickCount === 3) return `Triple click ${action.selector}`;
+      return `${action.clickCount}× click`;
+    }
+    case 'fill':
+      return `Fill ${action.selector}`;
+    case 'setInputFiles':
+      if (action.files?.length === 0) return `Clear selected files`;
+      else return `Upload ${action.files?.join(', ')}`;
+    case 'navigate':
+      return `Go to ${action.url}`;
+    case 'press':
+      return (
+        `Press ${action.key}` + (action.modifiers ? ' with modifiers' : '')
+      );
+    case 'select':
+      return `Select ${action.options?.join(', ')}`;
+    case 'assert':
+      return `Assert ${action.selector} ${action.command}`;
+  }
+}
+
 export class SyntheticsGenerator extends JavaScriptLanguageGenerator {
-  private previousContext: ActionInContext;
+  private previousContext?: ActionInContext;
   private insideStep: boolean;
+  private varsToHoist: string[];
 
-  constructor(private isSuite: boolean) {
+  constructor(private isProject: boolean) {
     super(true);
     this.insideStep = false;
     this.previousContext = undefined;
+    this.varsToHoist = [];
   }
 
   /**
@@ -88,10 +220,12 @@ export class SyntheticsGenerator extends JavaScriptLanguageGenerator {
    * @returns the strings generated for the action.
    */
   generateAction(actionInContext: ActionInContext) {
-    const { action, pageAlias } = actionInContext;
+    const { action } = actionInContext;
+    const { pageAlias } = actionInContext.frame;
     if (action.name === 'openPage') {
       return '';
     }
+
     // Don't cleanup page object managed by Synthetics
     const isCleanUp = action.name === 'closePage' && pageAlias === 'page';
     if (isCleanUp) {
@@ -99,18 +233,29 @@ export class SyntheticsGenerator extends JavaScriptLanguageGenerator {
     }
 
     const stepIndent = this.insideStep ? 2 : 0;
-    const offset = this.isSuite ? 2 + stepIndent : 0 + stepIndent;
+    const offset = this.isProject ? 2 + stepIndent : 0 + stepIndent;
     const formatter = new JavaScriptFormatter(offset);
 
-    const subject = actionInContext.isMainFrame
-      ? pageAlias
-      : actionInContext.frameName
-      ? `${pageAlias}.frame(${formatObject({
-          name: actionInContext.frameName,
-        })})`
-      : `${pageAlias}.frame(${formatObject({
-          url: actionInContext.frameUrl,
-        })})`;
+    let subject: string;
+    if (actionInContext.frame.isMainFrame) {
+      subject = pageAlias;
+    } else if (
+      actionInContext.frame.selectorsChain &&
+      action.name !== 'navigate'
+    ) {
+      const locators = actionInContext.frame.selectorsChain.map(
+        selector => `.frameLocator(${quote(selector)})`
+      );
+      subject = `${pageAlias}${locators.join('')}`;
+    } else if (actionInContext.frame.name) {
+      subject = `${pageAlias}.frame(${formatObject({
+        name: actionInContext.frame.name,
+      })})`;
+    } else {
+      subject = `${pageAlias}.frame(${formatObject({
+        url: actionInContext.frame.url,
+      })})`;
+    }
 
     const signals = toSignalMap(action);
 
@@ -121,77 +266,53 @@ export class SyntheticsGenerator extends JavaScriptLanguageGenerator {
   });`);
     }
 
-    const emitPromiseAll =
-      signals.waitForNavigation || signals.popup || signals.download;
-    if (emitPromiseAll) {
-      // Generate either await Promise.all([]) or
-      // const [popup1] = await Promise.all([]).
-      let leftHandSide = '';
-      if (signals.popup)
-        leftHandSide = `const [${signals.popup.popupAlias}] = `;
-      else if (signals.download) leftHandSide = `const [download] = `;
-      formatter.add(`${leftHandSide}await Promise.all([`);
-    }
-
-    // Popup signals.
-    if (signals.popup) formatter.add(`${pageAlias}.waitForEvent('popup'),`);
-
-    // Navigation signal.
-    if (signals.waitForNavigation)
+    if (signals.popup)
       formatter.add(
-        `${pageAlias}.waitForNavigation(/*{ url: ${quote(
-          signals.waitForNavigation.url
-        )} }*/),`
+        `const ${signals.popup.popupAlias}Promise = ${pageAlias}.waitForEvent('popup');`
       );
-
-    // Download signals.
     if (signals.download)
-      formatter.add(`${pageAlias}.waitForEvent('download'),`);
+      formatter.add(
+        `const download${signals.download.downloadAlias}Promise = ${pageAlias}.waitForEvent('download');`
+      );
 
-    const prefix =
-      signals.popup || signals.waitForNavigation || signals.download
-        ? ''
-        : 'await ';
-    const actionCall = super._generateActionCall(action);
     // Add assertion from Synthetics.
     const isAssert = action.name === 'assert' && action.isAssert;
-
-    if (!isAssert) {
-      const suffix = signals.waitForNavigation || emitPromiseAll ? '' : ';';
-      formatter.add(`${prefix}${subject}.${actionCall}${suffix}`);
-
-      if (emitPromiseAll) {
-        formatter.add(`]);`);
-      } else if (signals.assertNavigation) {
-        formatter.add(
-          `  expect(${pageAlias}.url()).toBe(${quote(
-            signals.assertNavigation.url
-          )});`
-        );
-      }
-    } else if (action.command) {
+    if (isAssert && action.command) {
       formatter.add(toAssertCall(pageAlias, action));
+    } else {
+      const actionCall = super._generateActionCall(action);
+      formatter.add(`await ${subject}.${actionCall};`);
     }
 
+    if (signals.popup)
+      formatter.add(
+        `${signals.popup.popupAlias} = await ${signals.popup.popupAlias}Promise;`
+      );
+    if (signals.download)
+      formatter.add(
+        `download${signals.download.downloadAlias} = await download${signals.download.downloadAlias}Promise;`
+      );
+
     this.previousContext = actionInContext;
     return formatter.format();
   }
 
   isNewStep(actioninContext: ActionInContext) {
-    const { action, frameUrl } = actioninContext;
+    const { action, frame } = actioninContext;
     if (action.name === 'navigate') {
       return true;
     } else if (action.name === 'click') {
       return (
-        this.previousContext?.frameUrl === frameUrl && action.signals.length > 0
+        this.previousContext?.frame.url === frame.url &&
+        action.signals.length > 0
       );
     }
     return false;
   }
 
-  generateStepStart(name) {
+  generateStepStart(name: string) {
     this.insideStep = true;
-    const formatter = new JavaScriptFormatter(this.isSuite ? 2 : 0);
+    const formatter = new JavaScriptFormatter(this.getDefaultOffset());
     formatter.add(`step(${quote(name)}, async () => {`);
     return formatter.format();
   }
@@ -201,7 +322,7 @@ export class SyntheticsGenerator extends JavaScriptLanguageGenerator {
       return '';
     }
     this.insideStep = false;
-    const formatter = new JavaScriptFormatter(this.isSuite ? 2 : 0);
+    const formatter = new JavaScriptFormatter(this.getDefaultOffset());
     formatter.add(`});`);
     return formatter.format();
   }
@@ -228,20 +349,20 @@ export class SyntheticsGenerator extends JavaScriptLanguageGenerator {
    * @returns a list of the code strings outputted by the generator
    */
   generateFromSteps(steps: Steps): string {
-    const text = [];
-    if (this.isSuite) {
+    const text: string[] = [];
+    if (this.isProject) {
       text.push(this.generateHeader());
     }
+    this.varsToHoist = this.findVarsToHoist(steps);
+    text.push(this.generateHoistedVars());
     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)
-        )
-      );
+      const name =
+        step.name ??
+        step.actions[0].title ??
+        actionTitle(step.actions[0].action);
+      text.push(this.generateStepStart(name ?? ''));
 
       for (const action of step.actions) {
         const actionText = this.generateAction(action);
@@ -250,118 +371,44 @@ export class SyntheticsGenerator extends JavaScriptLanguageGenerator {
 
       text.push(this.generateStepEnd());
     }
-    if (this.isSuite) {
+    if (this.isProject) {
       text.push(this.generateFooter());
     }
+
     return text.filter(s => !!s).join('\n');
   }
-}
-
-// TODO: Replace once Playwright releases new version
-export function quote(text: string, char = "'") {
-  const stringified = JSON.stringify(text);
-  const escapedText = stringified
-    .substring(1, stringified.length - 1)
-    .replace(/\\"/g, '"');
-  if (char === "'") return char + escapedText.replace(/[']/g, "\\'") + char;
-  if (char === '"') return char + escapedText.replace(/["]/g, '\\"') + char;
-  if (char === '`') return char + escapedText.replace(/[`]/g, '`') + char;
-  throw new Error('Invalid escape char');
-}
 
-function toSignalMap(action) {
-  let waitForNavigation;
-  let assertNavigation;
-  let popup;
-  let download;
-  let dialog;
-  for (const signal of action.signals) {
-    if (signal.name === 'navigation' && signal.isAsync)
-      waitForNavigation = signal;
-    else if (signal.name === 'navigation' && !signal.isAsync)
-      assertNavigation = signal;
-    else if (signal.name === 'popup') popup = signal;
-    else if (signal.name === 'download') download = signal;
-    else if (signal.name === 'dialog') dialog = signal;
+  generateHoistedVars() {
+    const formatter = new JavaScriptFormatter(this.getDefaultOffset());
+    for (const varName of this.varsToHoist) {
+      formatter.add(`let ${varName};`);
+    }
+    return formatter.format();
   }
-  return {
-    waitForNavigation,
-    assertNavigation,
-    popup,
-    download,
-    dialog,
-  };
-}
 
-function toAssertCall(pageAlias, action) {
-  const { command, selector, value } = action;
-  switch (command) {
-    case 'textContent':
-    case 'innerText':
-      return `expect(await ${pageAlias}.${command}(${quote(
-        selector
-      )})).toMatch(${quote(value)});`;
-    case 'isVisible':
-    case 'isHidden':
-    case 'isChecked':
-    case 'isEditable':
-    case 'isEnabled':
-    case 'isDisabled':
-      return `expect(await ${pageAlias}.${command}(${quote(
-        selector
-      )})).toBeTruthy();`;
+  isVarHoisted(varName: string) {
+    return this.varsToHoist.indexOf(varName) >= 0;
   }
-}
 
-function formatObject(value, indent = '  ') {
-  if (typeof value === 'string') return quote(value);
-  if (Array.isArray(value))
-    return `[${value.map(o => formatObject(o)).join(', ')}]`;
-  if (typeof value === 'object') {
-    const keys = Object.keys(value);
-    if (!keys.length) return '{}';
-    const tokens = [];
-    for (const key of keys) tokens.push(`${key}: ${formatObject(value[key])}`);
-    return `{\n${indent}${tokens.join(`,\n${indent}`)}\n}`;
+  getDefaultOffset() {
+    return this.isProject ? 2 : 0;
   }
-  return String(value);
-}
 
-/**
- * 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`;
-    case 'closePage':
-      return `Close page`;
-    case 'check':
-      return `Check ${action.selector}`;
-    case 'uncheck':
-      return `Uncheck ${action.selector}`;
-    case 'click': {
-      if (action.clickCount === 1) return `Click ${action.selector}`;
-      if (action.clickCount === 2) return `Double click ${action.selector}`;
-      if (action.clickCount === 3) return `Triple click ${action.selector}`;
-      return `${action.clickCount}× click`;
+  /**
+   * We need to hoist any page or popup alias that appears in more than one step.
+   * @param steps the step IR to evaluate
+   * @returns an array that contains the names of all variables that need to be hoisted
+   */
+  findVarsToHoist(steps: Steps): string[] {
+    const aliasSet = new Set<string>();
+    for (const step of steps) {
+      for (const actionContext of step.actions) {
+        actionContext.action.signals
+          .filter(({ name, popupAlias }) => name === 'popup' && popupAlias)
+          .forEach(({ popupAlias }) => aliasSet.add(popupAlias as string));
+        aliasSet.add(actionContext.frame.pageAlias);
+      }
     }
-    case 'fill':
-      return `Fill ${action.selector}`;
-    case 'setInputFiles':
-      if (action.files.length === 0) return `Clear selected files`;
-      else return `Upload ${action.files.join(', ')}`;
-    case 'navigate':
-      return `Go to ${action.url}`;
-    case 'press':
-      return (
-        `Press ${action.key}` + (action.modifiers ? ' with modifiers' : '')
-      );
-    case 'select':
-      return `Select ${action.options.join(', ')}`;
-    case 'assert':
-      return `Assert ${action.selector} ${action.command}`;
+    return Array.from(aliasSet).filter(alias => alias !== 'page');
   }
 }
diff --git a/src/index.ts b/src/index.ts
index c6053f6..2fd5232 100644
--- a/src/index.ts
+++ b/src/index.ts
@@ -62,6 +62,7 @@ export type { default as Runner } from './core/runner';
 export type { Reporter, ReporterOptions } from './reporters';
 
 export type { SyntheticsConfig } from './common_types';
+
 export type {
   Action,
   ActionInContext,


Test patch:
diff --git a/__tests__/formatter/__snapshots__/javascript.test.ts.snap b/__tests__/formatter/__snapshots__/javascript.test.ts.snap
index 6e91a57..ac4e60a 100644
--- a/__tests__/formatter/__snapshots__/javascript.test.ts.snap
+++ b/__tests__/formatter/__snapshots__/javascript.test.ts.snap
@@ -4,6 +4,7 @@ exports[`Synthetics JavaScript formatter accepts custom step organization 1`] =
 "const { journey, step, expect } = require('@elastic/synthetics');
 
 journey('Recorded journey', async ({ page, context }) => {
+  let page1;
   step('Open new page', async () => {
     await page.goto('https://vigneshh.in/');
   });
@@ -13,14 +14,10 @@ journey('Recorded journey', async ({ page, context }) => {
   });
   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.locator('text=Babel Minify').click()
-    ]);
-    await Promise.all([
-      page1.waitForNavigation(/*{ url: 'https://github.com/babel/minify' }*/),
-      page1.locator('a:has-text(\\"smoke\\")').click()
-    ]);
+    const page1Promise = page.waitForEvent('popup');
+    await page.locator('text=Babel Minify').click();
+    page1 = await page1Promise;
+    await page1.locator('a:has-text(\\"smoke\\")').click();
   });
   step('Close page', async () => {
     await page1.close();
@@ -28,6 +25,76 @@ journey('Recorded journey', async ({ page, context }) => {
 });"
 `;
 
+exports[`Synthetics JavaScript formatter does not hoist when all accesses are in one step 1`] = `
+"let page1;
+let page2;
+step('Go to https://vigneshh.in/', async () => {
+  await page.goto('https://vigneshh.in/');
+  const page1Promise = page.waitForEvent('popup');
+  await page.getByRole('link', { name: 'Tailor' }).click();
+  page1 = await page1Promise;
+  await page1.getByRole('link', { name: 'Packages' }).click();
+  await page1.getByRole('link', { name: '@zalando Zalando SE' }).click();
+  const page2Promise = page.waitForEvent('popup');
+  await page.getByRole('link', { name: 'Babel Minify' }).click();
+  page2 = await page2Promise;
+  await page2.getByTitle('Topic: babel-minify').click();
+  await page2.close();
+  await page1.close();
+});"
+`;
+
+exports[`Synthetics JavaScript formatter hoist accounts for popup alias 1`] = `
+"let page1;
+let page2;
+step('Go to https://vigneshh.in/', async () => {
+  await page.goto('https://vigneshh.in/');
+  const page1Promise = page.waitForEvent('popup');
+  await page.locator('text=Tailor').click();
+  page1 = await page1Promise;
+  await page1.frame({
+    url: 'https://github.com/zalando/tailor'
+  }).locator('text=Packages 0').click();
+});
+step('Close page', async () => {
+  await page1.close();
+  const page2Promise = page.waitForEvent('popup');
+  await page.locator('text=Babel Minify').click();
+  page2 = await page2Promise;
+});
+step('Close page', async () => {
+  await page2.frame({
+    url: 'https://github.com/babel/minify'
+  }).close();
+});"
+`;
+
+exports[`Synthetics JavaScript formatter hoists page objects to prevent undefined references 1`] = `
+"let page1;
+let page2;
+step('Go to https://vigneshh.in/', async () => {
+  await page.goto('https://vigneshh.in/');
+  const page1Promise = page.waitForEvent('popup');
+  await page.locator('text=Tailor').click();
+  page1 = await page1Promise;
+  await page1.locator('text=Packages 0').click();
+});
+step('Close page', async () => {
+  await page1.close();
+});
+step('Click text=Babel Minify', async () => {
+  const page2Promise = page.waitForEvent('popup');
+  await page.locator('text=Babel Minify').click();
+  page2 = await page2Promise;
+  await page2.locator(':nth-match(a:has-text(\\"babel-minify\\"), 3)').click();
+});
+step('Close page', async () => {
+  await page2.frame({
+    url: 'https://github.com/topics/babel-minify'
+  }).close();
+});"
+`;
+
 exports[`Synthetics JavaScript formatter uses custom step names 1`] = `
 "step('test-name', async () => {
   await page.goto('https://vigneshh.in/');
diff --git a/__tests__/formatter/javascript.test.ts b/__tests__/formatter/javascript.test.ts
index 0b09872..e8ca5d1 100644
--- a/__tests__/formatter/javascript.test.ts
+++ b/__tests__/formatter/javascript.test.ts
@@ -31,9 +31,7 @@ import {
 const recorderStep: Step = {
   actions: [
     {
-      pageAlias: 'page',
-      isMainFrame: true,
-      frameUrl: 'about:blank',
+      frame: { pageAlias: 'page', isMainFrame: true, url: 'about:blank' },
       committed: true,
       action: {
         name: 'openPage',
@@ -42,9 +40,11 @@ const recorderStep: Step = {
       },
     },
     {
-      pageAlias: 'page',
-      isMainFrame: true,
-      frameUrl: 'https://vigneshh.in/',
+      frame: {
+        pageAlias: 'page',
+        isMainFrame: true,
+        url: 'https://vigneshh.in/',
+      },
       committed: true,
       action: {
         name: 'navigate',
@@ -53,9 +53,11 @@ const recorderStep: Step = {
       },
     },
     {
-      pageAlias: 'page',
-      isMainFrame: true,
-      frameUrl: 'https://vigneshh.in/',
+      frame: {
+        pageAlias: 'page',
+        isMainFrame: true,
+        url: 'https://vigneshh.in/',
+      },
       action: {
         name: 'assert',
         isAssert: true,
@@ -65,9 +67,11 @@ const recorderStep: Step = {
       },
     },
     {
-      pageAlias: 'page',
-      isMainFrame: true,
-      frameUrl: 'https://vigneshh.in/',
+      frame: {
+        pageAlias: 'page',
+        isMainFrame: true,
+        url: 'https://vigneshh.in/',
+      },
       action: {
         name: 'assert',
         isAssert: true,
@@ -77,9 +81,11 @@ const recorderStep: Step = {
       },
     },
     {
-      pageAlias: 'page',
-      isMainFrame: true,
-      frameUrl: 'https://vigneshh.in/',
+      frame: {
+        pageAlias: 'page',
+        isMainFrame: true,
+        url: 'https://vigneshh.in/',
+      },
       action: {
         name: 'assert',
         isAssert: true,
@@ -90,9 +96,11 @@ const recorderStep: Step = {
       },
     },
     {
-      pageAlias: 'page',
-      isMainFrame: true,
-      frameUrl: 'https://vigneshh.in/',
+      frame: {
+        pageAlias: 'page',
+        isMainFrame: true,
+        url: 'https://vigneshh.in/',
+      },
       action: {
         name: 'click',
         selector: 'text=Babel Minify',
@@ -110,9 +118,11 @@ const recorderStep: Step = {
       committed: true,
     },
     {
-      pageAlias: 'page1',
-      isMainFrame: true,
-      frameUrl: 'https://github.com/babel/minify',
+      frame: {
+        pageAlias: 'page1',
+        isMainFrame: true,
+        url: 'https://github.com/babel/minify',
+      },
       action: {
         name: 'click',
         selector: 'a:has-text("smoke")',
@@ -142,9 +152,11 @@ const recorderStep: Step = {
       },
     },
     {
-      pageAlias: 'page1',
-      isMainFrame: true,
-      frameUrl: 'https://github.com/babel/minify',
+      frame: {
+        pageAlias: 'page1',
+        isMainFrame: true,
+        url: 'https://github.com/babel/minify',
+      },
       committed: true,
       action: {
         name: 'closePage',
@@ -152,9 +164,11 @@ const recorderStep: Step = {
       },
     },
     {
-      pageAlias: 'page',
-      isMainFrame: true,
-      frameUrl: 'https://vigneshh.in/',
+      frame: {
+        pageAlias: 'page',
+        isMainFrame: true,
+        url: 'https://vigneshh.in/',
+      },
       committed: true,
       action: {
         name: 'closePage',
@@ -196,4 +210,729 @@ describe('Synthetics JavaScript formatter', () => {
       'Cannot process an empty step'
     );
   });
+
+  it('counts pages that cross steps', () => {
+    const generator = new SyntheticsGenerator(false);
+    const steps: Steps = [
+      {
+        actions: [
+          {
+            frame: {
+              pageAlias: 'page',
+              isMainFrame: true,
+              url: 'https://vigneshh.in/',
+            },
+            committed: true,
+            action: {
+              name: 'navigate',
+              url: 'https://vigneshh.in/',
+              signals: [],
+            },
+            title: 'Go to https://vigneshh.in/',
+          },
+          {
+            frame: {
+              pageAlias: 'page',
+              isMainFrame: true,
+              url: 'https://vigneshh.in/',
+            },
+            action: {
+              name: 'click',
+              selector: 'internal:role=link[name="Tailor"i]',
+              signals: [
+                {
+                  name: 'popup',
+                  popupAlias: 'page1',
+                },
+                {
+                  name: 'navigation',
+                  url: 'https://github.com/zalando/tailor',
+                },
+                {
+                  name: 'navigation',
+                  url: 'https://github.com/zalando/tailor',
+                },
+              ],
+              button: 'left',
+              modifiers: 0,
+              clickCount: 1,
+            },
+            committed: true,
+            title: 'Click internal:role=link[name="Tailor"i]',
+          },
+          {
+            frame: {
+              pageAlias: 'page1',
+              isMainFrame: true,
+              url: 'https://github.com/zalando/tailor',
+            },
+            action: {
+              name: 'click',
+              selector: 'internal:role=link[name="Packages"i]',
+              signals: [
+                {
+                  name: 'navigation',
+                  url: 'https://github.com/zalando/tailor',
+                },
+                {
+                  name: 'navigation',
+                  url: 'https://github.com/orgs/zalando/packages?repo_name=tailor',
+                },
+                {
+                  name: 'navigation',
+                  url: 'https://github.com/orgs/zalando/packages?repo_name=tailor',
+                },
+              ],
+              button: 'left',
+              modifiers: 0,
+              clickCount: 1,
+            },
+            committed: true,
+            title: 'Click internal:role=link[name="Packages"i]',
+          },
+        ],
+      },
+      {
+        actions: [
+          {
+            frame: {
+              pageAlias: 'page1',
+              isMainFrame: true,
+              url: 'https://github.com/orgs/zalando/packages?repo_name=tailor',
+            },
+            action: {
+              name: 'click',
+              selector: 'internal:role=link[name="Repositories"i]',
+              signals: [
+                {
+                  name: 'navigation',
+                  url: 'https://github.com/orgs/zalando/packages?repo_name=tailor',
+                },
+                {
+                  name: 'navigation',
+                  url: 'https://github.com/orgs/zalando/repositories',
+                },
+                {
+                  name: 'navigation',
+                  url: 'https://github.com/orgs/zalando/repositories',
+                },
+              ],
+              button: 'left',
+              modifiers: 0,
+              clickCount: 1,
+            },
+            committed: true,
+            title: 'Click internal:role=link[name="Repositories"i]',
+          },
+          {
+            frame: {
+              pageAlias: 'page1',
+              isMainFrame: true,
+              url: 'https://github.com/orgs/zalando/repositories',
+            },
+            action: {
+              name: 'click',
+              selector: 'internal:attr=[placeholder="Find a repository…"i]',
+              signals: [],
+              button: 'left',
+              modifiers: 0,
+              clickCount: 1,
+            },
+            committed: true,
+            title: 'Click internal:attr=[placeholder="Find a repository…"i]',
+          },
+          {
+            frame: {
+              pageAlias: 'page1',
+              isMainFrame: true,
+              url: 'https://github.com/orgs/zalando/repositories',
+            },
+            action: {
+              name: 'fill',
+              selector: 'internal:attr=[placeholder="Find a repository…"i]',
+              signals: [
+                {
+                  name: 'navigation',
+                  url: 'https://github.com/orgs/zalando/repositories?q=tailor&type=all&language=&sort=',
+                },
+              ],
+              text: 'tailor',
+            },
+            committed: true,
+            title: 'Fill internal:attr=[placeholder="Find a repository…"i]',
+          },
+          {
+            frame: {
+              pageAlias: 'page1',
+              isMainFrame: true,
+              url: 'https://github.com/orgs/zalando/repositories?q=tailor&type=all&language=&sort=',
+            },
+            action: {
+              name: 'click',
+              selector: 'internal:role=link[name="tailor"i]',
+              signals: [
+                {
+                  name: 'navigation',
+                  url: 'https://github.com/orgs/zalando/repositories?q=tailor&type=all&language=&sort=',
+                },
+                {
+                  name: 'navigation',
+                  url: 'https://github.com/zalando/tailor',
+                },
+                {
+                  name: 'navigation',
+                  url: 'https://github.com/zalando/tailor',
+                },
+              ],
+              button: 'left',
+              modifiers: 0,
+              clickCount: 1,
+            },
+            committed: true,
+            title: 'Click internal:role=link[name="tailor"i]',
+          },
+        ],
+      },
+      {
+        actions: [
+          {
+            frame: {
+              pageAlias: 'page',
+              isMainFrame: true,
+              url: 'https://vigneshh.in/',
+            },
+            action: {
+              name: 'click',
+              selector: 'internal:role=link[name="Babel Minify"i]',
+              signals: [
+                {
+                  name: 'popup',
+                  popupAlias: 'page2',
+                },
+                {
+                  name: 'navigation',
+                  url: 'https://github.com/babel/minify',
+                },
+                {
+                  name: 'navigation',
+                  url: 'https://github.com/babel/minify',
+                },
+              ],
+              button: 'left',
+              modifiers: 0,
+              clickCount: 1,
+            },
+            committed: true,
+            title: 'Click internal:role=link[name="Babel Minify"i]',
+          },
+          {
+            frame: {
+              pageAlias: 'page2',
+              isMainFrame: true,
+              url: 'https://github.com/babel/minify',
+            },
+            action: {
+              name: 'click',
+              selector: 'internal:attr=[title="Topic: babel-minify"i]',
+              signals: [
+                {
+                  name: 'navigation',
+                  url: 'https://github.com/babel/minify',
+                },
+                {
+                  name: 'navigation',
+                  url: 'https://github.com/babel/minify',
+                },
+                {
+                  name: 'navigation',
+                  url: 'https://github.com/topics/babel-minify',
+                },
+                {
+                  name: 'navigation',
+                  url: 'https://github.com/topics/babel-minify',
+                },
+                {
+                  name: 'navigation',
+                  url: 'https://github.com/topics/babel-minify',
+                },
+              ],
+              button: 'left',
+              modifiers: 0,
+              clickCount: 1,
+            },
+            committed: true,
+            title: 'Click internal:attr=[title="Topic: babel-minify"i]',
+          },
+        ],
+      },
+      {
+        actions: [
+          {
+            frame: {
+              pageAlias: 'page2',
+              isMainFrame: true,
+              url: 'https://github.com/topics/babel-minify',
+            },
+            committed: true,
+            action: {
+              name: 'closePage',
+              signals: [],
+            },
+            title: 'Close page',
+          },
+        ],
+      },
+      {
+        actions: [
+          {
+            frame: {
+              pageAlias: 'page1',
+              isMainFrame: true,
+              url: 'https://github.com/zalando/tailor',
+            },
+            committed: true,
+            action: {
+              name: 'closePage',
+              signals: [],
+            },
+            title: 'Close page',
+          },
+        ],
+      },
+    ];
+    expect(generator.findVarsToHoist(steps)).toEqual(['page1', 'page2']);
+  });
+
+  it('does not hoist when all accesses are in one step', () => {
+    expect(
+      new SyntheticsGenerator(false).generateFromSteps([
+        {
+          actions: [
+            {
+              frame: {
+                pageAlias: 'page',
+                isMainFrame: true,
+                url: 'https://vigneshh.in/',
+              },
+              committed: true,
+              action: {
+                name: 'navigate',
+                url: 'https://vigneshh.in/',
+                signals: [],
+              },
+              title: 'Go to https://vigneshh.in/',
+            },
+            {
+              frame: {
+                pageAlias: 'page',
+                isMainFrame: true,
+                url: 'https://vigneshh.in/',
+              },
+              action: {
+                name: 'click',
+                selector: 'internal:role=link[name="Tailor"i]',
+                signals: [
+                  {
+                    name: 'popup',
+                    popupAlias: 'page1',
+                  },
+                  {
+                    name: 'navigation',
+                    url: 'https://github.com/zalando/tailor',
+                  },
+                  {
+                    name: 'navigation',
+                    url: 'https://github.com/zalando/tailor',
+                  },
+                ],
+                button: 'left',
+                modifiers: 0,
+                clickCount: 1,
+              },
+              committed: true,
+              title: 'Click internal:role=link[name="Tailor"i]',
+            },
+            {
+              frame: {
+                pageAlias: 'page1',
+                isMainFrame: true,
+                url: 'https://github.com/zalando/tailor',
+              },
+              action: {
+                name: 'click',
+                selector: 'internal:role=link[name="Packages"i]',
+                signals: [
+                  {
+                    name: 'navigation',
+                    url: 'https://github.com/zalando/tailor',
+                  },
+                  {
+                    name: 'navigation',
+                    url: 'https://github.com/orgs/zalando/packages?repo_name=tailor',
+                  },
+                  {
+                    name: 'navigation',
+                    url: 'https://github.com/orgs/zalando/packages?repo_name=tailor',
+                  },
+                ],
+                button: 'left',
+                modifiers: 0,
+                clickCount: 1,
+              },
+              committed: true,
+              title: 'Click internal:role=link[name="Packages"i]',
+            },
+            {
+              frame: {
+                pageAlias: 'page1',
+                isMainFrame: true,
+                url: 'https://github.com/orgs/zalando/packages?repo_name=tailor',
+              },
+              action: {
+                name: 'click',
+                selector: 'internal:role=link[name="@zalando Zalando SE"i]',
+                signals: [
+                  {
+                    name: 'navigation',
+                    url: 'https://github.com/orgs/zalando/packages?repo_name=tailor',
+                  },
+                  {
+                    name: 'navigation',
+                    url: 'https://github.com/zalando',
+                  },
+                  {
+                    name: 'navigation',
+                    url: 'https://github.com/zalando',
+                  },
+                ],
+                button: 'left',
+                modifiers: 0,
+                clickCount: 1,
+              },
+              committed: true,
+              title: 'Click internal:role=link[name="@zalando Zalando SE"i]',
+            },
+            {
+              frame: {
+                pageAlias: 'page',
+                isMainFrame: true,
+                url: 'https://vigneshh.in/',
+              },
+              action: {
+                name: 'click',
+                selector: 'internal:role=link[name="Babel Minify"i]',
+                signals: [
+                  {
+                    name: 'popup',
+                    popupAlias: 'page2',
+                  },
+                  {
+                    name: 'navigation',
+                    url: 'https://github.com/babel/minify',
+                  },
+                  {
+                    name: 'navigation',
+                    url: 'https://github.com/babel/minify',
+                  },
+                ],
+                button: 'left',
+                modifiers: 0,
+                clickCount: 1,
+              },
+              committed: true,
+              title: 'Click internal:role=link[name="Babel Minify"i]',
+            },
+            {
+              frame: {
+                pageAlias: 'page2',
+                isMainFrame: true,
+                url: 'https://github.com/babel/minify',
+              },
+              action: {
+                name: 'click',
+                selector: 'internal:attr=[title="Topic: babel-minify"i]',
+                signals: [
+                  {
+                    name: 'navigation',
+                    url: 'https://github.com/babel/minify',
+                  },
+                  {
+                    name: 'navigation',
+                    url: 'https://github.com/babel/minify',
+                  },
+                  {
+                    name: 'navigation',
+                    url: 'https://github.com/topics/babel-minify',
+                  },
+                  {
+                    name: 'navigation',
+                    url: 'https://github.com/topics/babel-minify',
+                  },
+                  {
+                    name: 'navigation',
+                    url: 'https://github.com/topics/babel-minify',
+                  },
+                ],
+                button: 'left',
+                modifiers: 0,
+                clickCount: 1,
+              },
+              committed: true,
+              title: 'Click internal:attr=[title="Topic: babel-minify"i]',
+            },
+            {
+              frame: {
+                pageAlias: 'page2',
+                isMainFrame: true,
+                url: 'https://github.com/topics/babel-minify',
+              },
+              committed: true,
+              action: {
+                name: 'closePage',
+                signals: [],
+              },
+              title: 'Close page',
+            },
+            {
+              frame: {
+                pageAlias: 'page1',
+                isMainFrame: true,
+                url: 'https://github.com/zalando',
+              },
+              committed: true,
+              action: {
+                name: 'closePage',
+                signals: [],
+              },
+              title: 'Close page',
+            },
+          ],
+        },
+      ])
+    ).toMatchSnapshot();
+  });
+
+  it('hoist accounts for popup alias', () => {
+    expect(
+      new SyntheticsGenerator(false).generateFromSteps([
+        {
+          actions: [
+            {
+              frame: {
+                pageAlias: 'page',
+                isMainFrame: true,
+                url: 'https://vigneshh.in/',
+              },
+              action: {
+                name: 'navigate',
+                url: 'https://vigneshh.in/',
+                signals: [],
+              },
+            },
+            {
+              frame: {
+                pageAlias: 'page',
+                isMainFrame: true,
+                url: 'https://vigneshh.in/',
+              },
+              action: {
+                name: 'click',
+                selector: 'text=Tailor',
+                signals: [
+                  { name: 'popup', popupAlias: 'page1', isAsync: true },
+                ],
+                button: 'left',
+                modifiers: 0,
+                clickCount: 1,
+              },
+            },
+            {
+              frame: {
+                url: 'https://github.com/zalando/tailor',
+                pageAlias: 'page1',
+              },
+              action: {
+                name: 'click',
+                selector: 'text=Packages 0',
+                signals: [
+                  {
+                    name: 'navigation',
+                    url: 'https://github.com/orgs/zalando/packages?repo_name=tailor',
+                  },
+                ],
+                button: 'left',
+                modifiers: 0,
+                clickCount: 1,
+              },
+            },
+          ],
+        },
+        {
+          actions: [
+            {
+              frame: {
+                pageAlias: 'page1',
+                url: 'https://github.com/orgs/zalando/packages?repo_name=tailor',
+                isMainFrame: true,
+              },
+              action: { name: 'closePage', signals: [] },
+            },
+
+            {
+              frame: {
+                pageAlias: 'page',
+                isMainFrame: true,
+                url: 'https://vigneshh.in/',
+              },
+              action: {
+                name: 'click',
+                selector: 'text=Babel Minify',
+                signals: [
+                  { name: 'popup', popupAlias: 'page2', isAsync: true },
+                ],
+                button: 'left',
+                modifiers: 0,
+                clickCount: 1,
+              },
+            },
+          ],
+        },
+        {
+          actions: [
+            {
+              frame: {
+                url: 'https://github.com/babel/minify',
+                pageAlias: 'page2',
+              },
+              action: { name: 'closePage', signals: [] },
+            },
+          ],
+        },
+      ])
+    ).toMatchSnapshot();
+  });
+
+  it('hoists page objects to prevent undefined references', () => {
+    expect(
+      new SyntheticsGenerator(false).generateFromSteps([
+        {
+          actions: [
+            {
+              frame: {
+                pageAlias: 'page',
+                isMainFrame: true,
+                url: 'https://vigneshh.in/',
+              },
+              action: {
+                name: 'navigate',
+                url: 'https://vigneshh.in/',
+                signals: [],
+              },
+            },
+            {
+              frame: {
+                pageAlias: 'page',
+                isMainFrame: true,
+                url: 'https://vigneshh.in/',
+              },
+              action: {
+                name: 'click',
+                selector: 'text=Tailor',
+                signals: [
+                  { name: 'popup', popupAlias: 'page1', isAsync: true },
+                ],
+                button: 'left',
+                modifiers: 0,
+                clickCount: 1,
+              },
+            },
+            {
+              frame: {
+                pageAlias: 'page1',
+                isMainFrame: true,
+                url: 'https://github.com/zalando/tailor',
+              },
+              action: {
+                name: 'click',
+                selector: 'text=Packages 0',
+                signals: [
+                  {
+                    name: 'navigation',
+                    url: 'https://github.com/orgs/zalando/packages?repo_name=tailor',
+                  },
+                ],
+                button: 'left',
+                modifiers: 0,
+                clickCount: 1,
+              },
+            },
+          ],
+        },
+        {
+          actions: [
+            {
+              frame: {
+                pageAlias: 'page1',
+                isMainFrame: true,
+                url: 'https://github.com/orgs/zalando/packages?repo_name=tailor',
+              },
+              action: { name: 'closePage', signals: [] },
+            },
+          ],
+        },
+        {
+          actions: [
+            {
+              frame: {
+                pageAlias: 'page',
+                isMainFrame: true,
+                url: 'https://vigneshh.in/',
+              },
+              action: {
+                name: 'click',
+                selector: 'text=Babel Minify',
+                signals: [
+                  { name: 'popup', popupAlias: 'page2', isAsync: true },
+                ],
+                button: 'left',
+                modifiers: 0,
+                clickCount: 1,
+              },
+            },
+            {
+              frame: {
+                pageAlias: 'page2',
+                isMainFrame: true,
+                url: 'https://github.com/babel/minify',
+              },
+              action: {
+                name: 'click',
+                selector: ':nth-match(a:has-text("babel-minify"), 3)',
+                signals: [
+                  {
+                    name: 'navigation',
+                    url: 'https://github.com/topics/babel-minify',
+                  },
+                ],
+                button: 'left',
+                modifiers: 0,
+                clickCount: 1,
+              },
+            },
+          ],
+        },
+        {
+          actions: [
+            {
+              frame: {
+                url: 'https://github.com/topics/babel-minify',
+                pageAlias: 'page2',
+              },
+              action: { name: 'closePage', signals: [] },
+            },
+          ],
+        },
+      ])
+    ).toMatchSnapshot();
+  });
 });