Repository: elastic/synthetics (ts)

Issue:
[Proposal] allow configuration through `synthetics.config.js`
+ Suite params options is super useful for passing down params for every journey files, however it becomes difficult when there are multiple environments involved like `staging`, `ci` and `production` and the whole suite level params keep changing for each of those environments. The proposal address that by allowing a configuration file which lets users inject params depending on the environment. 

```js
module.exports = {
  // defaults to development
  params: {
    url: 'http://localhost:8080',
  },
};

// To change params based on environment, users can return
// a function

module.exports = env => {
  if (env === 'development') {
    return {
      params: {
        url: 'http://localhost:8080',
      },
    };
  }
  return {
    params: {
      url: 'http://elastic.co',
    },
  };
};
```

**Note: params is explicitly kept as key to allow extending this configuration in the future.**

This setup and validation of config would be done before invoking any journeys and we would pass down these parameters to the individual journeys. 

```js
beforeAll(({ params }) => {
  let server;
  if (params.env === "development") {
    server = await startServer();
  }
});

afterAll(({ params }) => {
  if (params.env === "development") {
    server.stop()
  }
})

journey('journey1', ({ page, params }) => {
  step('step1', async () => {
    await page.goto(params.url);
  });
});

```

Purposefully this model exists as we don't want to support any other way of loading configs, By that I mean we don't want to support `.json`, `.yml` or any other extensions. Let's stick to a single way of doing config loading. 

Pull request description:
feat: add synthetic config file support
+ fix #270 
+ PR allow users to supply `--config <synthetics.config.{js|ts}>` that lets users control the params passed to the journey callbacks and also for `before` and `after` callbacks. 
+ `params` object exposed to journeys are frozen meaning users will not be able to extend it. 
+ `params.environment` defaults to "development" and can be override by specifying `-e production` or through NODE_ENV="production" when running synthetics CLI. 

### Usage
```sh
npx @elastic/synthetics examples/todos --config examples/todos/synthetics.config.ts
```

```ts
// synthetics.config.ts
export default env => {
  if (env === 'development') {
    return {
      params: {
        url: 'http://localhost:8080',
      },
    };
  }
  return {
    params: {
      url: 'http://elastic.co',
    },
  };
};


journey('journey1', ({ page, params }) => {
  before(async ({ params }) => {
    if (params.environment === 'dev') {
      await startServer();
    }
  });
  after(({ params }) => {
    if (params.environment === 'dev') {
      stopServer();
    }
  });
  step('step1', async () => {
    await page.goto(params.url);
  });
});

```

### Config lookup algorithm

+ If `--config <filename>` option is provided, we look for the exact file and throw error in case the file does not exists.
+ If the option is not supplied, we look for presence of `synthetics.config.js` and `synthetics.config.ts` in the current suites directory all the way to the root. 
    - If present, we resolve to that file
    - If it does not exists, we resolve to empty object




Patch:
diff --git a/src/cli.ts b/src/cli.ts
index 39d79ac..2ac7f2c 100644
--- a/src/cli.ts
+++ b/src/cli.ts
@@ -38,6 +38,7 @@ import {
   totalist,
 } from './helpers';
 import { run } from './';
+import { readConfig } from './config';
 
 const options = program.opts() as CliArgs;
 const resolvedCwd = cwd();
@@ -101,7 +102,7 @@ async function prepareSuites(inputs: string[]) {
      * Validate for package.json file before running
      * the suites
      */
-    await findPkgJsonByTraversing(absPath, resolvedCwd);
+    findPkgJsonByTraversing(absPath, resolvedCwd);
     if (await isDirectory(absPath)) {
       await totalist(absPath, (rel, abs) => {
         if (pattern.test(rel) && !ignored.test(rel)) {
@@ -157,14 +158,23 @@ async function prepareSuites(inputs: string[]) {
     requireSuites(suites);
   }
 
+  /**
+   * Use the NODE_ENV variable to control the environment if its not explicity
+   * passed from either CLI or through the API
+   */
+  const environment = options.environment || process.env['NODE_ENV'];
+  /**
+   * Validate and handle configs
+   */
+  const config = readConfig(environment, options.config);
+  const params = config.params || JSON.parse(options.suiteParams);
   /**
    * use JSON reporter if json flag is enabled
    */
   const reporter = options.json ? 'json' : options.reporter;
 
   const results = await run({
-    params: JSON.parse(options.suiteParams),
-    environment: options.environment,
+    params: Object.freeze({ ...params, environment }),
     reporter,
     headless: options.headless,
     screenshots: options.screenshots,
diff --git a/src/common_types.ts b/src/common_types.ts
index 85f0af3..b6f7c69 100644
--- a/src/common_types.ts
+++ b/src/common_types.ts
@@ -76,6 +76,7 @@ export type NetworkInfo = {
 } & DefaultPluginOutput;
 
 export type CliArgs = {
+  config?: string;
   environment?: string;
   outfd?: number;
   headless?: boolean;
diff --git a/src/config.ts b/src/config.ts
new file mode 100644
index 0000000..e05c829
--- /dev/null
+++ b/src/config.ts
@@ -0,0 +1,102 @@
+/**
+ * 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 { isAbsolute, resolve, dirname } from 'path';
+import { isFile } from './helpers';
+
+interface Config {
+  params?: Record<string, unknown>;
+}
+
+export function readConfig(env: string, config?: string): Config {
+  let options = {};
+  const cwd = process.cwd();
+  /**
+   * If config is passed via `--config` flag, try to resolve it relative to the
+   * current working directory
+   */
+  if (typeof config === 'string') {
+    const configPath = resolveConfigPath(config, cwd);
+    options = readAndParseConfig(configPath);
+  } else {
+    /**
+     * resolve to `synthetics.config.js` and `synthetics.config.ts`
+     * recursively till root
+     */
+    const configPath = findSyntheticsConfig(cwd, cwd);
+    configPath && (options = readAndParseConfig(configPath));
+  }
+  if (typeof options === 'function') {
+    options = options(env);
+  }
+  return options;
+}
+
+function resolveConfigPath(configPath: string, cwd: string) {
+  const absolutePath = isAbsolute(configPath)
+    ? configPath
+    : resolve(cwd, configPath);
+
+  if (isFile(absolutePath)) {
+    return absolutePath;
+  }
+  throw new Error('Synthetics config file does not exist: ' + absolutePath);
+}
+
+function interopRequireDefault(obj: any) {
+  return obj && obj.__esModule ? obj : { default: obj };
+}
+
+function readAndParseConfig(configPath) {
+  try {
+    /* eslint-disable @typescript-eslint/no-var-requires */
+    const requiredModule = require(configPath);
+    return interopRequireDefault(requiredModule).default;
+  } catch (e) {
+    throw new Error('Unable to read synthetics config: ' + configPath);
+  }
+}
+
+function getConfigFile(ext: string) {
+  return 'synthetics.config' + ext;
+}
+
+function findSyntheticsConfig(resolvePath, cwd) {
+  const configPath = ['.js', '.ts']
+    .map(ext => resolve(resolvePath, getConfigFile(ext)))
+    .find(isFile);
+  if (configPath) {
+    return configPath;
+  }
+  const parentDirectory = dirname(resolvePath);
+  /**
+   * We are in the system root, so return empty path and fallback
+   * to empty suite params
+   */
+  if (resolvePath === parentDirectory) {
+    return '';
+  }
+  return findSyntheticsConfig(parentDirectory, cwd);
+}
diff --git a/src/core/runner.ts b/src/core/runner.ts
index 66d0368..36ce74f 100644
--- a/src/core/runner.ts
+++ b/src/core/runner.ts
@@ -49,12 +49,13 @@ export type RunOptions = Omit<
   | 'require'
   | 'suiteParams'
   | 'reporter'
+  | 'environment'
 > & {
   params?: RunParamaters;
   reporter?: CliArgs['reporter'] | Reporter;
 };
 
-type RunParamaters = Record<string, unknown>;
+type RunParamaters = { environment: string } & Record<string, unknown>;
 
 type BaseContext = {
   params?: RunParamaters;
diff --git a/src/helpers.ts b/src/helpers.ts
index daceee9..74b7fe6 100644
--- a/src/helpers.ts
+++ b/src/helpers.ts
@@ -26,7 +26,7 @@
 import { red, green, yellow, cyan } from 'kleur/colors';
 import os from 'os';
 import { resolve, join, dirname } from 'path';
-import fs from 'fs';
+import fs, { statSync } from 'fs';
 import { promisify } from 'util';
 import { performance } from 'perf_hooks';
 
@@ -109,8 +109,8 @@ export async function isDirectory(path) {
   return (await statAsync(path)).isDirectory();
 }
 
-export async function isFile(filePath) {
-  return fs.existsSync(filePath) && (await statAsync(filePath)).isFile();
+export function isFile(filePath) {
+  return fs.existsSync(filePath) && statSync(filePath).isFile();
 }
 
 /**
@@ -118,9 +118,9 @@ export async function isFile(filePath) {
  * package.json file to check if the user is invoking our script
  * from an NPM project.
  */
-export async function findPkgJsonByTraversing(resolvePath, cwd) {
+export function findPkgJsonByTraversing(resolvePath, cwd) {
   const packageJSON = resolve(resolvePath, 'package.json');
-  if (await isFile(packageJSON)) {
+  if (isFile(packageJSON)) {
     return packageJSON;
   }
   const parentDirectory = dirname(resolvePath);
diff --git a/src/index.ts b/src/index.ts
index 1f4776a..4e51c31 100644
--- a/src/index.ts
+++ b/src/index.ts
@@ -35,11 +35,6 @@ export async function run(options: RunOptions) {
   sourceMapSupport.install({
     environment: 'node',
   });
-  /**
-   * Use the NODE_ENV variable to control the environment if its not explicity
-   * passed from either CLI or through the API
-   */
-  options.environment = options.environment || process.env['NODE_ENV'];
   /**
    * set up logger with appropriate file descriptor
    * to capture all the DEBUG logs when run through heartbeat
diff --git a/src/parse_args.ts b/src/parse_args.ts
index b03cfa0..ef56b42 100644
--- a/src/parse_args.ts
+++ b/src/parse_args.ts
@@ -35,7 +35,11 @@ const { name, version } = require('../package.json');
 program
   .name(`npx ${name}`)
   .usage('[options] [dir] [files] file')
-  .option('-s, --suite-params <jsonstring>', 'Variables', '{}')
+  .option(
+    '-c, --config <path>',
+    'configuration path (default: synthetics.config.js)'
+  )
+  .option('-s, --suite-params <jsonstring>', 'suite variables', '{}')
   .option('-e, --environment <envname>', 'e.g. production', 'development')
   .option('-j, --json', 'output newline delimited JSON')
   .option(


Test patch:
diff --git a/__tests__/cli.test.ts b/__tests__/cli.test.ts
index 03a26c5..77e6deb 100644
--- a/__tests__/cli.test.ts
+++ b/__tests__/cli.test.ts
@@ -51,6 +51,23 @@ describe('CLI', () => {
     });
     expect(await cli.exitCode).toBe(0);
   });
+
+  it('pass config to journey params', async () => {
+    const cli = new CLIMock([
+      join(FIXTURES_DIR, 'fake.journey.ts'),
+      '--json',
+      '--config',
+      join(FIXTURES_DIR, 'synthetics.config.ts'),
+      '-e',
+      'testing',
+    ]);
+    await cli.waitFor('fake journey');
+    const output = cli.output();
+    expect(JSON.parse(output).payload).toMatchObject({
+      params: { url: 'non-dev', environment: 'testing' },
+    });
+    expect(await cli.exitCode).toBe(0);
+  });
 });
 
 class CLIMock {
diff --git a/__tests__/config.test.ts b/__tests__/config.test.ts
new file mode 100644
index 0000000..679112f
--- /dev/null
+++ b/__tests__/config.test.ts
@@ -0,0 +1,60 @@
+/**
+ * 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 { join } from 'path';
+import { generateTempPath } from '../src/helpers';
+import { readConfig } from '../src/config';
+
+const FIXTURES_DIR = join(__dirname, 'fixtures');
+
+describe('Config', () => {
+  it('read config based on environment', async () => {
+    const configPath = join(FIXTURES_DIR, 'synthetics.config.ts');
+    expect(readConfig('development', configPath)).toEqual({
+      params: {
+        url: 'dev',
+      },
+    });
+    expect(readConfig('testing', configPath)).toEqual({
+      params: {
+        url: 'non-dev',
+      },
+    });
+  });
+
+  it('throw error when config does not exist', async () => {
+    const tempPath = generateTempPath();
+    function getConfig() {
+      readConfig('development', tempPath);
+    }
+    expect(getConfig).toThrowError(
+      new Error('Synthetics config file does not exist: ' + tempPath)
+    );
+  });
+
+  it('recursively look for configs and exit', async () => {
+    expect(readConfig('development')).toEqual({});
+  });
+});
diff --git a/__tests__/fixtures/synthetics.config.ts b/__tests__/fixtures/synthetics.config.ts
new file mode 100644
index 0000000..e49acea
--- /dev/null
+++ b/__tests__/fixtures/synthetics.config.ts
@@ -0,0 +1,39 @@
+/**
+ * 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.
+ *
+ */
+
+module.exports = env => {
+  if (env === 'development') {
+    return {
+      params: {
+        url: 'dev',
+      },
+    };
+  }
+  return {
+    params: {
+      url: 'non-dev',
+    },
+  };
+};
diff --git a/__tests__/index.test.ts b/__tests__/index.test.ts
index 0fdbcc6..f58359a 100644
--- a/__tests__/index.test.ts
+++ b/__tests__/index.test.ts
@@ -61,8 +61,9 @@ describe('Run', () => {
       .mockImplementation(() => Promise.resolve({}));
 
     const runParams: RunOptions = {
-      params: {},
-      environment: 'debug',
+      params: {
+        environment: 'debug',
+      },
       headless: false,
       screenshots: true,
       filmstrips: false,
diff --git a/__tests__/plugins/network.test.ts b/__tests__/plugins/network.test.ts
index 386dd2c..2ff5033 100644
--- a/__tests__/plugins/network.test.ts
+++ b/__tests__/plugins/network.test.ts
@@ -128,6 +128,7 @@ describe('network', () => {
     });
 
     await driver.page.goto(server.PREFIX + '/index');
+    await driver.page.waitForLoadState();
     await Gatherer.dispose(driver);
     const netinfo = await network.stop();
     expect(netinfo.length).toBe(2);
diff --git a/__tests__/reporters/__snapshots__/json.test.ts.snap b/__tests__/reporters/__snapshots__/json.test.ts.snap
index abc2692..7a7a773 100644
--- a/__tests__/reporters/__snapshots__/json.test.ts.snap
+++ b/__tests__/reporters/__snapshots__/json.test.ts.snap
@@ -278,7 +278,7 @@ Object {
 
 exports[`json reporter writes each step as NDJSON to the FD 1`] = `
 "{\\"type\\":\\"journey/register\\",\\"@timestamp\\":1600300800000000,\\"journey\\":{\\"name\\":\\"j1\\",\\"id\\":\\"j1\\"},\\"root_fields\\":{\\"os\\":{\\"platform\\":\\"darwin\\"},\\"package\\":{\\"name\\":\\"@elastic/synthetics\\",\\"version\\":\\"0.0.1\\"}},\\"package_version\\":\\"0.0.1\\"}
-{\\"type\\":\\"journey/start\\",\\"@timestamp\\":1600300800000000,\\"journey\\":{\\"name\\":\\"j1\\",\\"id\\":\\"j1\\"},\\"root_fields\\":{\\"os\\":{\\"platform\\":\\"darwin\\"},\\"package\\":{\\"name\\":\\"@elastic/synthetics\\",\\"version\\":\\"0.0.1\\"}},\\"payload\\":{\\"params\\":{},\\"source\\":\\"() => { }\\"},\\"package_version\\":\\"0.0.1\\"}
+{\\"type\\":\\"journey/start\\",\\"@timestamp\\":1600300800000000,\\"journey\\":{\\"name\\":\\"j1\\",\\"id\\":\\"j1\\"},\\"root_fields\\":{\\"os\\":{\\"platform\\":\\"darwin\\"},\\"package\\":{\\"name\\":\\"@elastic/synthetics\\",\\"version\\":\\"0.0.1\\"}},\\"payload\\":{\\"params\\":{\\"environment\\":\\"testing\\"},\\"source\\":\\"() => { }\\"},\\"package_version\\":\\"0.0.1\\"}
 {\\"type\\":\\"step/screenshot\\",\\"@timestamp\\":1600300800000000,\\"journey\\":{\\"name\\":\\"j1\\",\\"id\\":\\"j1\\"},\\"step\\":{\\"name\\":\\"s1\\",\\"index\\":1},\\"root_fields\\":{\\"os\\":{\\"platform\\":\\"darwin\\"},\\"package\\":{\\"name\\":\\"@elastic/synthetics\\",\\"version\\":\\"0.0.1\\"}},\\"blob\\":\\"dummy\\",\\"blob_mime\\":\\"image/jpeg\\",\\"package_version\\":\\"0.0.1\\"}
 {\\"type\\":\\"step/end\\",\\"@timestamp\\":1600300800000000,\\"journey\\":{\\"name\\":\\"j1\\",\\"id\\":\\"j1\\"},\\"step\\":{\\"name\\":\\"s1\\",\\"index\\":1,\\"status\\":\\"succeeded\\"},\\"root_fields\\":{\\"os\\":{\\"platform\\":\\"darwin\\"},\\"package\\":{\\"name\\":\\"@elastic/synthetics\\",\\"version\\":\\"0.0.1\\"}},\\"payload\\":{\\"source\\":\\"() => { }\\",\\"start\\":0,\\"end\\":10,\\"url\\":\\"dummy\\",\\"status\\":\\"succeeded\\"},\\"url\\":\\"dummy\\",\\"package_version\\":\\"0.0.1\\"}
 {\\"type\\":\\"journey/network_info\\",\\"@timestamp\\":1600300800000000,\\"journey\\":{\\"name\\":\\"j1\\",\\"id\\":\\"j1\\"},\\"root_fields\\":{\\"user_agent\\":{},\\"http\\":{\\"request\\":{\\"body\\":{\\"bytes\\":0,\\"content\\":\\"\\"}}},\\"os\\":{\\"platform\\":\\"darwin\\"},\\"package\\":{\\"name\\":\\"@elastic/synthetics\\",\\"version\\":\\"0.0.1\\"}},\\"payload\\":{\\"browser\\":{},\\"is_navigation_request\\":true},\\"package_version\\":\\"0.0.1\\"}
diff --git a/__tests__/reporters/base.test.ts b/__tests__/reporters/base.test.ts
index c9ca764..90a4140 100644
--- a/__tests__/reporters/base.test.ts
+++ b/__tests__/reporters/base.test.ts
@@ -48,7 +48,7 @@ describe('base reporter', () => {
     const j1 = journey('j1', () => {});
     runner.emit('journey:start', {
       journey: j1,
-      params: {},
+      params: { environment: 'testing' },
       timestamp,
     });
     const error = {
diff --git a/__tests__/reporters/json.test.ts b/__tests__/reporters/json.test.ts
index cf632e3..4ffb9a5 100644
--- a/__tests__/reporters/json.test.ts
+++ b/__tests__/reporters/json.test.ts
@@ -101,7 +101,7 @@ describe('json reporter', () => {
     });
     runner.emit('journey:start', {
       journey: j1,
-      params: {},
+      params: { environment: 'testing' },
       timestamp,
     });
     runner.emit('step:end', {
diff --git a/__tests__/reporters/junit.test.ts b/__tests__/reporters/junit.test.ts
index bca18bf..f1e6201 100644
--- a/__tests__/reporters/junit.test.ts
+++ b/__tests__/reporters/junit.test.ts
@@ -55,7 +55,7 @@ describe('junit reporter', () => {
   it('writes the output to fd', async () => {
     runner.emit('journey:start', {
       journey: j1,
-      params: {},
+      params: { environment: 'testing' },
       timestamp,
     });
     const error = new Error('Boom');
@@ -100,7 +100,9 @@ describe('junit reporter', () => {
     process.env.SYNTHETICS_JUNIT_FILE = filepath;
     runner.emit('journey:start', {
       journey: j1,
-      params: {},
+      params: {
+        environment: 'testing',
+      },
       timestamp,
     });
     runner.emit('step:end', {