Repository: elastic/synthetics (ts)

Issue:
Add slowMo flag for playwright
Playwright supports a [`slowMo`](https://github.com/microsoft/playwright/blob/master/docs/api.md) flag, which slows down every operation it performs. This is useful for debugging with `--no-headless` as it lets you actually see what's going on.
Add `ignoreHTTPSError` option on journeys and CLI
Users sometimes need to test sites that have known bad certs, we should probably  set this on a per-journey and CLI options level, both probably have their uses. See https://github.com/microsoft/playwright/issues/2814 for more info on implementation, it's a browser level option.
Support extra HTTP headers for pages
We should allow people to set custom headers, which must be done when initializing the page (via playwright's `extraHTTPHeaders` option on the page constructor). I propose we add this ability to `before` hooks, letting them either invoke a special method to set this, or set a special variable. This issue is a stub and could use more detail.
Emulate device type (UA String and Viewport)
As a user of Elastic Synthetic Monitoring
I want to be able to emulate a different device types
So that I can see how well my site performs from when loading the site that is optimised for that device (e.g. a mobile site)

ACs:
* The ability to configure the UA String used by a specific monitor (the same UA String will be used throughout all steps of the user journey)
* The ability to configure the viewport (width and height) properties used by a specific monitor
  * The UI reporting will be able to represent these different viewports, both for the thumbnail and full size screenshots
* The ability to configure the orientation of the screen (if this is possible in Playwright)
* It is possible to determine the device type/emulation used when looking at a test result

We need to consider where these properties are set, for example as parameters to the `journey` object? When integrated with Fleet with a curated UI for configuring monitors, perhaps this could be extended to have a list of common device types to choose from, which sets both the UA string and viewport accordingly (whilst manual configuration of the monitor still allows for these parameters to be set independently).

Whilst most monitors will have the same viewport throughout the whole user journey (and as such, is convenient to set as a parameter for the `journey` object), there may be a future requirement to be able to set this and the orientation on a step by step level (e.g. where a user journey may need to flip between portrait and horizontal for some kind of behaviour). In these cases, it may also be useful to have these defined as `step` parameters (which may be a future enhancement, not necessarily required for this initial version). This seems to be possible in the Playwright [implementation](https://playwright.dev/docs/api/class-page/#pagesetviewportsizeviewportsize).

As in the ACs, outside of any Fleet/Integration curated UI to configure these, there is a @elastic/observability-design  requirement to consider where this is shown in the results. Although the UA String can be determined from the request headers, that’s quite deep in the report hierarchy to discover. The viewport/orientation isn’t shown anywhere right now. It should be possible to see what emulation was set against each monitor (bearing in mind it could change over time).
Add support for browser & context options via config
There are lot of ways we can configure the Playwright Browser and the context that is used to creating page objects in Playwright. 

1. Browser launch options - `chromium.launch(options)` -  https://playwright.dev/docs/api/class-browsertype?_highlight=launch#browsertypelaunchoptions
    - This would let us control the browser with `timeouts, headless, sandbox, slowMo` and also `browserName` in the future - elastic/synthetics#169 
 
2. Browser Context options - `browser.newContext(options)` - https://playwright.dev/docs/api/class-browser#browsernewcontextoptions
     - These options allow for `ignoreHttpsError, Throttling, Offline emulation, device emulation etc` - elastic/synthetics#171 elastic/synthetics#209 

To make it easier for users, We can introduce new flags in the `synthetics.config.ts` instead of adding CLI option for each and every config out there in the playwright. 

This Proposal is as allow configuring `browser` and `context` options to the synthetic config in addition to the existing `params` support. 

```ts
{
import {devices} from "playwright-chromium"

export default env => {
  return {
    params: {
      url: 'non-dev',
    },
    browser: {
      ...// browser options
    },
    context: {
      ...devices['Pixel 5'] // uses chromium
    }
  };
};
}
```
We could also merge them under `playwrightOptions` instead of separating them out and provide a validation that allowss only a restrictive list of options to keep things cleaner as well. 

```ts
import { devices } from 'playwright-chromium';

export default env => {
  return {
    params: {
      url: 'non-dev',
    },
    playwrightOptions: {
      ignoreHttpsError: true,
      slowMo: true,
      timeout: 1000,
      ...devices['Pixel 5'],
      screenshots: true, // validate and throw error as we capture it ourselves
    },
  };
};
```

@andrewvc @paulb-elastic Thoughts? 

Pull request description:
feat: add browser and context options support
+ fixes #171 
+ fixes #169 
+ fixes #299 
+ fixes #198 
+ fixes #209 
+ Exposes all playwright `BrowserLaunch` options and `ContextOptions` through `playwrightOptions` using the `synthetics.config.ts` file. 
+ CLI params that exist today like `--headless` and `--sandbox` takes precedence over the playwrightOptions when launch in non headless mode and such. 
+ There is no validation right now as its mostly an escape hatch and will be added in the future to avoid `recordVideo` and `screenshot` capture as synthetics provides them out of the box. 

### Usage
**synthetics.config.ts**
```ts
import type { SyntheticsConfig } from '@elastic/synthetics';

const config: SyntheticsConfig = {
  playwrightOptions: {
    ignoreHTTPSErrors: true,
    extraHTTPHeaders: {
      "foo": "bar"
    },
    ...devices['Galaxy S9+'],
  },
}
export default config;
```

Patch:
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 3e1401f..a8dbd72 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -2,6 +2,28 @@
 
 All notable changes to this project will be documented in this file.
 
+## v1.0.0-beta.4 (2021-06-28)
+
+### Breaking Changes
+
+- Remove -e from CLI args and use `NODE_ENV` for controlling the
+  environment [#318](https://github.com/elastic/synthetics/pull/318)
+
+### Features
+
+- Add new screenshots options - `on|off|only-on-failure` to manage capturing
+  the step screenshots [#311](https://github.com/elastic/synthetics/pull/311)
+- Enable screenshot deduplication feature by default when the agent is invoked
+  via Heartbeat [#322](https://github.com/elastic/synthetics/pull/322)
+
+### Bug fixes
+
+- Avoid creating duplicate screenshot JSON docs for each journey
+  [#320](https://github.com/elastic/synthetics/pull/320)
+- Associate `beforeAll` hook errors across all journeys
+  [#316](https://github.com/elastic/synthetics/pull/316)
+- Provide journey isolation via context [#314](https://github.com/elastic/synthetics/pull/314)
+
 ## v1.0.0-beta.3 (2021-06-15)
 
 ### Features
diff --git a/examples/todos/advanced.journey.ts b/examples/todos/advanced.journey.ts
index 1abc12a..8f43bf9 100644
--- a/examples/todos/advanced.journey.ts
+++ b/examples/todos/advanced.journey.ts
@@ -7,10 +7,10 @@ import {
   destroyTaskStep,
 } from './helpers';
 
-journey('addition and completion of single task', ({ page }) => {
+journey('addition and completion of single task', ({ page, params }) => {
   const testText = "Don't put salt in your eyes";
 
-  loadAppStep(page);
+  loadAppStep(page, params.url);
   addTaskStep(page, testText);
   assertTaskListSizeStep(page, 1);
   checkForTaskStep(page, testText);
@@ -18,10 +18,10 @@ journey('addition and completion of single task', ({ page }) => {
   assertTaskListSizeStep(page, 0);
 });
 
-journey('adding and removing multiple tasks', ({ page }) => {
+journey('adding and removing multiple tasks', ({ page, params }) => {
   const testTasks = ['Task 1', 'Task 2', 'Task 3'];
 
-  loadAppStep(page);
+  loadAppStep(page, params.url);
   testTasks.forEach(t => {
     addTaskStep(page, t);
   });
diff --git a/examples/todos/basic.journey.ts b/examples/todos/basic.journey.ts
index 71f0571..8c28b01 100644
--- a/examples/todos/basic.journey.ts
+++ b/examples/todos/basic.journey.ts
@@ -1,10 +1,9 @@
 import { journey, step, expect } from '@elastic/synthetics';
 import { join } from 'path';
 
-journey('check if title is present', ({ page }) => {
+journey('check if title is present', ({ page, params }) => {
   step('launch app', async () => {
-    const path = 'file://' + join(__dirname, 'app', 'index.html');
-    await page.goto(path);
+    await page.goto(params.url);
   });
 
   step('assert title', async () => {
@@ -13,10 +12,9 @@ journey('check if title is present', ({ page }) => {
   });
 });
 
-journey('check if input placeholder is correct', ({ page }) => {
+journey('check if input placeholder is correct', ({ page, params }) => {
   step('launch app', async () => {
-    const path = 'file://' + join(__dirname, 'app', 'index.html');
-    await page.goto(path);
+    await page.goto(params.url);
   });
 
   step('assert placeholder value', async () => {
diff --git a/examples/todos/helpers.ts b/examples/todos/helpers.ts
index 4a0a542..722ee9f 100644
--- a/examples/todos/helpers.ts
+++ b/examples/todos/helpers.ts
@@ -1,10 +1,9 @@
 import { step, Page, expect } from '@elastic/synthetics';
 import { join } from 'path';
 
-export const loadAppStep = (page: Page) => {
+export const loadAppStep = (page: Page, url: string) => {
   step('launch app', async () => {
-    const path = 'file://' + join(__dirname, 'app', 'index.html');
-    await page.goto(path);
+    await page.goto(url);
   });
 };
 
diff --git a/examples/todos/hooks.journey.ts b/examples/todos/hooks.journey.ts
new file mode 100644
index 0000000..6a4ba11
--- /dev/null
+++ b/examples/todos/hooks.journey.ts
@@ -0,0 +1,17 @@
+import { beforeAll, afterAll } from '@elastic/synthetics';
+import { once } from 'events';
+import { createServer, Server } from 'http';
+import { join } from 'path';
+import { Server as StaticServer } from 'node-static';
+
+let srv: Server;
+
+beforeAll(async () => {
+  const file = new StaticServer(join(__dirname, 'app'));
+  srv = createServer(file.serve.bind(file)).listen(8080);
+  await once(srv, 'listening');
+});
+
+afterAll(async () => {
+  srv && (await srv.close());
+});
diff --git a/examples/todos/package.json b/examples/todos/package.json
index 64576e8..ee07d08 100644
--- a/examples/todos/package.json
+++ b/examples/todos/package.json
@@ -5,6 +5,9 @@
   "scripts": {},
   "license": "MIT",
   "dependencies": {
-    "@elastic/synthetics": "^1.0.0-beta"
+    "@elastic/synthetics": "^1.0.0-beta.3"
+  },
+  "devDependencies": {
+    "node-static": "^0.7.11"
   }
 }
diff --git a/examples/todos/synthetics.config.ts b/examples/todos/synthetics.config.ts
new file mode 100644
index 0000000..147375f
--- /dev/null
+++ b/examples/todos/synthetics.config.ts
@@ -0,0 +1,28 @@
+// note that `env` is the `env` argument passed to the synthetics program
+// the default being `development`
+export default env => {
+  const cfg = {
+    params: {
+      url: 'http://localhost:8080',
+    },
+    playwrightOptions: {
+      // Note, this is mostly equivalent to `...devices["iPhone 6"]` with import { devices } from 'playwright-chromium';
+      // Just expanded for illustration
+      userAgent:
+        'Mozilla/5.0 (iPhone; CPU iPhone OS 11_0 like Mac OS X) AppleWebKit/604.1.38 (KHTML, like Gecko) Version/11.0 Mobile/15A372 Safari/604.1',
+      viewport: {
+        width: 375,
+        height: 667,
+      },
+      deviceScaleFactor: 2,
+      isMobile: true,
+      hasTouch: true,
+    },
+  };
+
+  if (env === 'production') {
+    cfg.params.url = 'https://elastic.github.io/synthetics-demo/';
+  }
+
+  return cfg;
+};
diff --git a/package-lock.json b/package-lock.json
index e1b7299..cef28c5 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -1,6 +1,6 @@
 {
   "name": "@elastic/synthetics",
-  "version": "1.0.0-beta.3",
+  "version": "1.0.0-beta.4",
   "lockfileVersion": 1,
   "requires": true,
   "dependencies": {
diff --git a/package.json b/package.json
index 8bd0c54..239639e 100644
--- a/package.json
+++ b/package.json
@@ -1,11 +1,11 @@
 {
   "name": "@elastic/synthetics",
-  "version": "1.0.0-beta.3",
+  "version": "1.0.0-beta.4",
   "description": "Elastic synthetic monitoring agent",
   "main": "dist/index.js",
   "types": "dist/index.d.ts",
   "scripts": {
-    "clean": "rm -rf dist",
+    "clean": "rimraf dist",
     "prepublish": "npm run clean && npm run build",
     "build": "tsc",
     "watch": "tsc -w",
@@ -64,6 +64,7 @@
     "jest": "^26.6.3",
     "lint-staged": "^10.5.3",
     "prettier": "^2.2.1",
-    "ts-jest": "^26.4.4"
+    "ts-jest": "^26.4.4",
+    "rimraf": "^3.0.2"
   }
 }
diff --git a/src/cli.ts b/src/cli.ts
index 9062017..d479a2e 100644
--- a/src/cli.ts
+++ b/src/cli.ts
@@ -166,6 +166,10 @@ async function prepareSuites(inputs: string[]) {
    */
   const config = readConfig(environment, options.config);
   const params = merge(config.params, JSON.parse(options.suiteParams));
+  const playwrightOptions = merge(config.playwrightOptions, {
+    headless: options.headless,
+    chromiumSandbox: options.sandbox,
+  });
   /**
    * use JSON reporter if json flag is enabled
    */
@@ -175,6 +179,7 @@ async function prepareSuites(inputs: string[]) {
     params: Object.freeze(params),
     environment,
     reporter,
+    playwrightOptions,
     ...options,
   });
 
diff --git a/src/common_types.ts b/src/common_types.ts
index 531f991..3514fc2 100644
--- a/src/common_types.ts
+++ b/src/common_types.ts
@@ -23,12 +23,13 @@
  *
  */
 
+import { BrowserContextOptions, LaunchOptions } from 'playwright-chromium';
 import { Protocol } from 'playwright-chromium/types/protocol';
 import { Step } from './dsl';
 import { reporters } from './reporters';
 
 export type VoidCallback = () => void;
-export type Params = Record<string, unknown>;
+export type Params = Record<string, any>;
 export type HooksArgs = {
   env: string;
   params: Params;
@@ -142,3 +143,9 @@ export type CliArgs = {
   suiteParams?: string;
   richEvents?: boolean;
 };
+
+export type PlaywrightOptions = LaunchOptions & BrowserContextOptions;
+export type SyntheticsConfig = {
+  params?: Params;
+  playwrightOptions?: PlaywrightOptions;
+};
diff --git a/src/config.ts b/src/config.ts
index e05c829..467962f 100644
--- a/src/config.ts
+++ b/src/config.ts
@@ -24,13 +24,10 @@
  */
 
 import { isAbsolute, resolve, dirname } from 'path';
+import { SyntheticsConfig } from './common_types';
 import { isFile } from './helpers';
 
-interface Config {
-  params?: Record<string, unknown>;
-}
-
-export function readConfig(env: string, config?: string): Config {
+export function readConfig(env: string, config?: string): SyntheticsConfig {
   let options = {};
   const cwd = process.cwd();
   /**
diff --git a/src/core/gatherer.ts b/src/core/gatherer.ts
index 437b43b..62a1aea 100644
--- a/src/core/gatherer.ts
+++ b/src/core/gatherer.ts
@@ -49,19 +49,16 @@ export class Gatherer {
   static browser: ChromiumBrowser;
 
   static async setupDriver(options: RunOptions): Promise<Driver> {
+    const { wsEndpoint, playwrightOptions } = options;
     if (Gatherer.browser == null) {
-      const { wsEndpoint, headless, sandbox = false } = options;
       if (wsEndpoint) {
         log(`Gatherer: connecting to WS endpoint: ${wsEndpoint}`);
         Gatherer.browser = await chromium.connect({ wsEndpoint });
       } else {
-        Gatherer.browser = await chromium.launch({
-          headless,
-          chromiumSandbox: sandbox,
-        });
+        Gatherer.browser = await chromium.launch(playwrightOptions);
       }
     }
-    const context = await Gatherer.browser.newContext();
+    const context = await Gatherer.browser.newContext(playwrightOptions);
     const page = await context.newPage();
     const client = await context.newCDPSession(page);
     return { browser: Gatherer.browser, context, page, client };
diff --git a/src/core/runner.ts b/src/core/runner.ts
index 81d0c3b..f5e244b 100644
--- a/src/core/runner.ts
+++ b/src/core/runner.ts
@@ -25,7 +25,7 @@
 
 import { once, EventEmitter } from 'events';
 import { join } from 'path';
-import { mkdirSync, rmdirSync, writeFileSync } from 'fs';
+import { mkdir, rm, writeFile } from 'fs/promises';
 import { Journey } from '../dsl/journey';
 import { Step } from '../dsl/step';
 import { reporters, Reporter } from '../reporters';
@@ -33,8 +33,8 @@ import {
   CACHE_PATH,
   monotonicTimeInSeconds,
   getTimestamp,
-  now,
   runParallel,
+  generateUniqueId,
 } from '../helpers';
 import {
   StatusValue,
@@ -43,6 +43,7 @@ import {
   PluginOutput,
   CliArgs,
   HooksArgs,
+  PlaywrightOptions,
 } from '../common_types';
 import { PluginManager } from '../plugins';
 import { PerformanceManager, Metrics } from '../plugins';
@@ -60,9 +61,12 @@ export type RunOptions = Omit<
   | 'reporter'
   | 'richEvents'
   | 'capability'
+  | 'sandbox'
+  | 'headless'
 > & {
   environment?: string;
   params?: Params;
+  playwrightOptions?: PlaywrightOptions;
   reporter?: CliArgs['reporter'] | Reporter;
 };
 
@@ -131,13 +135,18 @@ export default class Runner extends EventEmitter {
   currentJourney?: Journey = null;
   journeys: Journey[] = [];
   hooks: SuiteHooks = { beforeAll: [], afterAll: [] };
-  screenshotPath = join(CACHE_PATH, 'screenshots');
   hookError: Error | undefined;
+  static screenshotPath = join(CACHE_PATH, 'screenshots');
 
   static async createContext(options: RunOptions): Promise<JourneyContext> {
     const start = monotonicTimeInSeconds();
     const driver = await Gatherer.setupDriver(options);
     const pluginManager = await Gatherer.beginRecording(driver, options);
+    /**
+     * For each journey we create the screenshots folder for
+     * caching all screenshots and clear them at end of each journey
+     */
+    await mkdir(this.screenshotPath, { recursive: true });
     return {
       start,
       params: options.params,
@@ -160,9 +169,9 @@ export default class Runner extends EventEmitter {
      * each journey without impacting the step timing information
      */
     if (buffer) {
-      const fileName = now().toString() + '.json';
-      writeFileSync(
-        join(this.screenshotPath, fileName),
+      const fileName = `${generateUniqueId()}.json`;
+      await writeFile(
+        join(Runner.screenshotPath, fileName),
         JSON.stringify({
           step,
           data: buffer.toString('base64'),
@@ -312,6 +321,8 @@ export default class Runner extends EventEmitter {
     if (options.reporter === 'json') {
       await once(this, 'journey:end:reported');
     }
+    // clear screenshots cache after each journey
+    await rm(Runner.screenshotPath, { recursive: true, force: true });
   }
 
   /**
@@ -376,7 +387,7 @@ export default class Runner extends EventEmitter {
     return result;
   }
 
-  init(options: RunOptions) {
+  async init(options: RunOptions) {
     const { reporter, outfd } = options;
     /**
      * Set up the corresponding reporter and fallback
@@ -390,7 +401,7 @@ export default class Runner extends EventEmitter {
     /**
      * Set up the directory for caching screenshots
      */
-    mkdirSync(this.screenshotPath, { recursive: true });
+    await mkdir(CACHE_PATH, { recursive: true });
   }
 
   async run(options: RunOptions) {
@@ -428,16 +439,16 @@ export default class Runner extends EventEmitter {
       env: options.environment,
       params: options.params,
     });
-    this.reset();
+    await this.reset();
     return result;
   }
 
-  reset() {
+  async reset() {
     /**
      * Clear all cache data stored for post processing by
      * the current synthetic agent run
      */
-    rmdirSync(CACHE_PATH, { recursive: true });
+    await rm(CACHE_PATH, { recursive: true, force: true });
     this.currentJourney = null;
     this.journeys = [];
     this.active = false;
diff --git a/src/helpers.ts b/src/helpers.ts
index 35deba6..a0f7318 100644
--- a/src/helpers.ts
+++ b/src/helpers.ts
@@ -48,8 +48,12 @@ export const symbols = {
   failed: red('✖'),
 };
 
+export function generateUniqueId() {
+  return `${Date.now() + Math.floor(Math.random() * 1e13)}`;
+}
+
 export function generateTempPath() {
-  return join(os.tmpdir(), `synthetics-${process.hrtime().toString()}`);
+  return join(os.tmpdir(), `synthetics-${generateUniqueId()}`);
 }
 
 /**
diff --git a/src/index.ts b/src/index.ts
index 89dd512..3a9af83 100644
--- a/src/index.ts
+++ b/src/index.ts
@@ -70,3 +70,5 @@ export type {
  */
 export type { default as Runner } from './core/runner';
 export type { Reporter, ReporterOptions } from './reporters';
+
+export type { SyntheticsConfig } from './common_types';
diff --git a/src/parse_args.ts b/src/parse_args.ts
index d5d3208..48044a9 100644
--- a/src/parse_args.ts
+++ b/src/parse_args.ts
@@ -38,7 +38,6 @@ program
     'configuration path (default: synthetics.config.js)'
   )
   .option('-s, --suite-params <jsonstring>', 'suite variables', '{}')
-  .option('-j, --json', 'output newline delimited JSON')
   .addOption(
     new Option('--reporter <value>', `output repoter format`).choices(
       Object.keys(reporters)
@@ -52,7 +51,7 @@ program
   .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')
+  .option('--sandbox', 'enable chromium sandboxing', false)
   .option('--rich-events', 'Mimics a heartbeat run')
   .option(
     '--capability <features...>',
@@ -63,7 +62,6 @@ program
       .choices(['on', 'off', 'only-on-failure'])
       .default('on')
   )
-  .option('--network', 'capture network information for all journeys')
   .option(
     '--dry-run',
     "don't actually execute anything, report only registered journeys"
@@ -101,14 +99,21 @@ const options = command.opts() as CliArgs;
  */
 if (options.richEvents) {
   options.reporter = options.reporter ?? 'json';
-  options.screenshots = 'on';
+  options.ssblocks = true;
   options.network = true;
 }
 
 if (options.capability) {
-  const supportedCapabilities = ['trace', 'filmstrips', 'metrics', 'ssblocks'];
+  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


Test patch:
diff --git a/__tests__/cli.test.ts b/__tests__/cli.test.ts
index 40eeba3..0f21fbc 100644
--- a/__tests__/cli.test.ts
+++ b/__tests__/cli.test.ts
@@ -42,7 +42,11 @@ describe('CLI', () => {
   });
 
   it('produce json output via --json flag', async () => {
-    const cli = new CLIMock([join(FIXTURES_DIR, 'fake.journey.ts'), '--json']);
+    const cli = new CLIMock([
+      join(FIXTURES_DIR, 'fake.journey.ts'),
+      '--reporter',
+      'json',
+    ]);
     await cli.waitFor('fake journey');
     const output = cli.output();
     expect(JSON.parse(output).journey).toEqual({
@@ -52,17 +56,41 @@ describe('CLI', () => {
     expect(await cli.exitCode).toBe(0);
   });
 
-  it('enables rich events on `--rich-events` flag', async () => {
+  it('mimick heartbeat with `--rich-events` flag', async () => {
     const cli = new CLIMock([
       join(FIXTURES_DIR, 'fake.journey.ts'),
       '--rich-events',
     ]);
-    await cli.waitFor('fake journey');
-    const output = cli.output();
-    expect(JSON.parse(output).journey).toEqual({
-      id: 'fake journey',
-      name: 'fake journey',
+    await cli.waitFor('journey/end');
+    const screenshotRef = cli
+      .buffer()
+      .map(data => JSON.parse(data))
+      .find(({ type }) => type === 'step/screenshot_ref');
+
+    expect(screenshotRef).toMatchObject({
+      journey: {
+        id: 'fake journey',
+        name: 'fake journey',
+      },
+      root_fields: expect.any(Object),
     });
+
+    expect(await cli.exitCode).toBe(0);
+  });
+
+  it('override screenshots with `--rich-events` flag', async () => {
+    const cli = new CLIMock([
+      join(FIXTURES_DIR, 'fake.journey.ts'),
+      '--rich-events',
+      '--screenshots',
+      'off',
+    ]);
+    await cli.waitFor('journey/end');
+    const screenshots = cli
+      .buffer()
+      .map(data => JSON.parse(data))
+      .find(({ type }) => type === 'step/screenshot_ref');
+    expect(screenshots).not.toBeDefined();
     expect(await cli.exitCode).toBe(0);
   });
 
@@ -72,7 +100,8 @@ describe('CLI', () => {
     const output = async () => {
       const cli = new CLIMock([
         join(FIXTURES_DIR, 'fake.journey.ts'),
-        '--json',
+        '--reporter',
+        'json',
         '--config',
         join(FIXTURES_DIR, 'synthetics.config.ts'),
       ]);
@@ -91,10 +120,26 @@ describe('CLI', () => {
     process.env['NODE_ENV'] = original;
   });
 
+  it('pass playwright options to runner', async () => {
+    const cli = new CLIMock([
+      join(FIXTURES_DIR, 'pwoptions.journey.ts'),
+      '--json',
+      '--config',
+      join(FIXTURES_DIR, 'synthetics.config.ts'),
+    ]);
+    await cli.waitFor('step/end');
+    const output = cli.output();
+    expect(await cli.exitCode).toBe(0);
+    expect(JSON.parse(output).step).toMatchObject({
+      status: 'succeeded',
+    });
+  });
+
   it('suite params wins over config params', async () => {
     const cli = new CLIMock([
       join(FIXTURES_DIR, 'fake.journey.ts'),
-      '--json',
+      '--reporter',
+      'json',
       '--config',
       join(FIXTURES_DIR, 'synthetics.config.ts'),
       '-s',
@@ -111,7 +156,8 @@ describe('CLI', () => {
   it('throw error on modifying params', async () => {
     const cli = new CLIMock([
       join(FIXTURES_DIR, 'params-error.journey.ts'),
-      '-j',
+      '--reporter',
+      'json',
     ]);
     expect(await cli.exitCode).toBe(1);
     const output = cli.output();
@@ -124,7 +170,8 @@ describe('CLI', () => {
   it('support capability flag', async () => {
     const cli = new CLIMock([
       join(FIXTURES_DIR, 'example.journey.ts'),
-      '-j',
+      '--reporter',
+      'json',
       '--capability',
       'metrics',
     ]);
@@ -137,7 +184,8 @@ describe('CLI', () => {
   it('show warn for unknown capability flag', async () => {
     const cli = new CLIMock([
       join(FIXTURES_DIR, 'fake.journey.ts'),
-      '-j',
+      '--reporter',
+      'json',
       '--capability',
       'unknown',
     ]);
@@ -159,13 +207,13 @@ describe('CLI', () => {
 
 class CLIMock {
   private process: ChildProcess;
-  private data: string;
+  private data = '';
+  private chunks: Array<string> = [];
   private waitForText: string;
   private waitForPromise: () => void;
   exitCode: Promise<number>;
 
   constructor(args: string[]) {
-    this.data = '';
     this.process = spawn(
       'node',
       [join(__dirname, '..', 'dist', 'cli.js'), ...args],
@@ -176,6 +224,7 @@ class CLIMock {
     );
     const dataListener = data => {
       this.data = data.toString();
+      this.chunks.push(...this.data.split('\n').filter(Boolean));
       if (this.waitForPromise && this.data.includes(this.waitForText)) {
         this.process.stdout.off('data', dataListener);
         this.waitForPromise();
@@ -197,4 +246,8 @@ class CLIMock {
   output() {
     return this.data;
   }
+
+  buffer() {
+    return this.chunks;
+  }
 }
diff --git a/__tests__/config.test.ts b/__tests__/config.test.ts
index 679112f..8999db8 100644
--- a/__tests__/config.test.ts
+++ b/__tests__/config.test.ts
@@ -24,6 +24,7 @@
  */
 
 import { join } from 'path';
+import { devices } from 'playwright-chromium';
 import { generateTempPath } from '../src/helpers';
 import { readConfig } from '../src/config';
 
@@ -36,8 +37,11 @@ describe('Config', () => {
       params: {
         url: 'dev',
       },
+      playwrightOptions: {
+        ...devices['Galaxy S9+'],
+      },
     });
-    expect(readConfig('testing', configPath)).toEqual({
+    expect(readConfig('testing', configPath)).toMatchObject({
       params: {
         url: 'non-dev',
       },
diff --git a/__tests__/core/index.test.ts b/__tests__/core/index.test.ts
index 4afb423..4c4e751 100644
--- a/__tests__/core/index.test.ts
+++ b/__tests__/core/index.test.ts
@@ -33,7 +33,7 @@ import {
   afterAll,
 } from '../../src/core/index';
 
-beforeEach(() => runner.reset());
+beforeEach(async () => await runner.reset());
 
 const noop = () => {};
 const name = 'journey';
diff --git a/__tests__/core/runner.test.ts b/__tests__/core/runner.test.ts
index d4ba7d9..e37cb18 100644
--- a/__tests__/core/runner.test.ts
+++ b/__tests__/core/runner.test.ts
@@ -515,4 +515,49 @@ describe('runner', () => {
       fd: expect.any(Number),
     });
   });
+
+  const readAndCloseStreamJson = () => {
+    const fd = fs.openSync(dest, 'r');
+    const buffer = fs.readFileSync(fd, 'utf-8');
+    const out = [];
+    buffer.split('\n').forEach(l => {
+      try {
+        out.push(JSON.parse(l));
+      } catch (e) {
+        return; // ignore empty lines
+      }
+    });
+    return out;
+  };
+
+  it('run api - verify screenshots', async () => {
+    const j1 = new Journey({ name: 'j1' }, noop);
+    const s1 = j1.addStep('j1s1', noop);
+    const j2 = new Journey({ name: 'j2' }, noop);
+    const s2 = j2.addStep('j2s2', noop);
+    runner.addJourney(j1);
+    runner.addJourney(j2);
+
+    await runner.run({
+      wsEndpoint,
+      reporter: 'json',
+      screenshots: 'on',
+      outfd: fs.openSync(dest, 'w'),
+    });
+
+    const screenshotJson = readAndCloseStreamJson().filter(
+      ({ type }) => type === 'step/screenshot'
+    );
+    expect(screenshotJson.length).toEqual(2);
+    expect(screenshotJson).toMatchObject([
+      {
+        journey: { name: j1.name },
+        step: { name: s1.name, index: 1 },
+      },
+      {
+        journey: { name: j2.name },
+        step: { name: s2.name, index: 1 },
+      },
+    ]);
+  });
 });
diff --git a/__tests__/fixtures/pwoptions.journey.ts b/__tests__/fixtures/pwoptions.journey.ts
new file mode 100644
index 0000000..9f49d88
--- /dev/null
+++ b/__tests__/fixtures/pwoptions.journey.ts
@@ -0,0 +1,36 @@
+/**
+ * MIT License
+ *
+ * Copyright (c) 2020-present, Elastic NV
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to deal
+ * in the Software without restriction, including without limitation the rights
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons to whom the Software is
+ * furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in
+ * all copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+ * THE SOFTWARE.
+ *
+ */
+
+import { journey, step, expect } from '../../dist';
+
+journey('launch with viewport', ({ page }) => {
+  step('Ensure viewport width', () => {
+    expect(page.viewportSize()).toEqual({
+      height: 658,
+      width: 320,
+    });
+    expect(page.touchscreen).toBeDefined();
+  });
+});
diff --git a/__tests__/fixtures/synthetics.config.ts b/__tests__/fixtures/synthetics.config.ts
index e49acea..1c3d944 100644
--- a/__tests__/fixtures/synthetics.config.ts
+++ b/__tests__/fixtures/synthetics.config.ts
@@ -23,17 +23,20 @@
  *
  */
 
+import { devices } from 'playwright-chromium';
+import type { SyntheticsConfig } from '../../src';
+
 module.exports = env => {
-  if (env === 'development') {
-    return {
-      params: {
-        url: 'dev',
-      },
-    };
-  }
-  return {
+  const config: SyntheticsConfig = {
     params: {
-      url: 'non-dev',
+      url: 'dev',
+    },
+    playwrightOptions: {
+      ...devices['Galaxy S9+'],
     },
   };
+  if (env !== 'development') {
+    config.params.url = 'non-dev';
+  }
+  return config;
 };
diff --git a/__tests__/index.test.ts b/__tests__/index.test.ts
index f5682d0..a953655 100644
--- a/__tests__/index.test.ts
+++ b/__tests__/index.test.ts
@@ -65,7 +65,6 @@ describe('Run', () => {
       params: {
         foo: 'bar',
       },
-      headless: false,
       screenshots: 'on',
       filmstrips: false,
       trace: false,
@@ -74,7 +73,6 @@ describe('Run', () => {
       network: true,
       pauseOnError: true,
       reporter: 'json',
-      sandbox: true,
     };
     await run(runParams);
     expect(runnerSpy.mock.calls[0][0]).toEqual(runParams);