Repository: elastic/synthetics (ts)

Issue:
playwrightOptions does not honor ignoreHTTPSErrors 
**Version:** 1.0.0-beta.26

**Describe the bug:**

`playwrightOptions` allows for HB config and Synthetics Service to specify `ignoreHTTPSErrors` flag, but it is not taken into account when running tests.

**Steps to reproduce:**
1. Run a HTTPS endpoint with untrusted SSL certs:
````
docker run -p 8080:8080 -p 8443:8443 --rm mendhak/http-https-echo:23
````

2. Configure a synthetics project with the following:

`synthetics.config.js`
````js
const config = {
  playwrightOptions: {
    ignoreHTTPSErrors: true,
  },
};

module.exports = config;

````
`test.journey.ts`
````js
const { journey, step, expect } = require('@elastic/synthetics');

journey(
  { name: 'Test', tags: ['browse'] },
  ({ page, params }) => {

    step('go to my untrusted HTTPS endpoint', async () => {
	await page.goto('https://localhost:8443/');
	await page.locator('pre').click();
    });
  }
);


````
3. Execute the test with `npm run test`:

````bash
$ npm run test

> test
> npx elastic-synthetics .


Journey: Test
   ✖  Step: 'go to my untrusted HTTPS endpoint' failed (100 ms)
   ---
      stack: |-
        page.goto: net::ERR_CERT_AUTHORITY_INVALID at https://localhost:8443/
        =========================== logs ===========================
        navigating to "https://localhost:8443/", waiting until "load"
        ============================================================
            at Step.callback (/home/jumpeax/repos/synthetics-demo/e-commerce/demo.journey.js:8:13)
            at Runner.runStep (/home/jumpeax/repos/synthetics-demo/e-commerce/node_modules/@elastic/synthetics/src/core/runner.ts:213:18)
            at Runner.runSteps (/home/jumpeax/repos/synthetics-demo/e-commerce/node_modules/@elastic/synthetics/src/core/runner.ts:263:27)
            at Runner.runJourney (/home/jumpeax/repos/synthetics-demo/e-commerce/node_modules/@elastic/synthetics/src/core/runner.ts:353:38)
            at async Runner.run (/home/jumpeax/repos/synthetics-demo/e-commerce/node_modules/@elastic/synthetics/src/core/runner.ts:441:11)
            at async Command.<anonymous> (/home/jumpeax/repos/synthetics-demo/e-commerce/node_modules/@elastic/synthetics/src/cli.ts:130:23)
   ---


 1 failed (1368 ms)
````

**Expected behavior:**

Playwright ignores SSL verification errors by using `ignoreHTTPSErrors` flag. It works correctly if synthetic's `--ignore-https-errors` flag is used:

````
$ npm run test

> test
> npx elastic-synthetics --ignore-https-errors .


Journey: Test
   ✓  Step: 'go to my untrusted HTTPS endpoint' succeeded (188 ms)

 1 passed (1472 ms)
````


Pull request description:
fix: allow overriding flags with playwright options
+ fix #516 
+ Honor playwright options via the CLI and config and prefer the individual options over the other two. 

Patch:
diff --git a/src/cli.ts b/src/cli.ts
index 3bc6252..5b275ad 100644
--- a/src/cli.ts
+++ b/src/cli.ts
@@ -65,7 +65,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', false)
+  .option('--sandbox', 'enable chromium sandboxing')
   .option('--rich-events', 'Mimics a heartbeat run')
   .option(
     '--capability <features...>',
diff --git a/src/core/gatherer.ts b/src/core/gatherer.ts
index 6d9639a..2c615cc 100644
--- a/src/core/gatherer.ts
+++ b/src/core/gatherer.ts
@@ -65,12 +65,12 @@ export class Gatherer {
   }
 
   static async getUserAgent(userAgent?: string) {
-    const syntheticsIdentifier = ' Elastic/Synthetics';
     if (!userAgent) {
       const session = await Gatherer.browser.newBrowserCDPSession();
       ({ userAgent } = await session.send('Browser.getVersion'));
+      return userAgent + ' Elastic/Synthetics';
     }
-    return userAgent + syntheticsIdentifier;
+    return userAgent;
   }
 
   static setNetworkConditions(
diff --git a/src/options.ts b/src/options.ts
index 6491606..676bf45 100644
--- a/src/options.ts
+++ b/src/options.ts
@@ -99,15 +99,21 @@ export function normalizeOptions(cliArgs: CliArgs): RunOptions {
    */
   options.params = Object.freeze(merge(config.params, cliArgs.params || {}));
 
-  options.playwrightOptions = merge.all([
-    config.playwrightOptions || {},
-    cliArgs.playwrightOptions || {},
-    {
-      headless: cliArgs.headless,
-      chromiumSandbox: cliArgs.sandbox,
-      ignoreHTTPSErrors: cliArgs.ignoreHttpsErrors,
-    },
-  ]);
+  /**
+   * Merge playwright options from CLI and Synthetics config
+   * and prefer individual options over other option
+   */
+  const playwrightOpts = merge(
+    config.playwrightOptions,
+    cliArgs.playwrightOptions || {}
+  );
+  options.playwrightOptions = {
+    ...playwrightOpts,
+    headless: cliArgs.headless,
+    chromiumSandbox: cliArgs.sandbox ?? playwrightOpts?.chromiumSandbox,
+    ignoreHTTPSErrors:
+      cliArgs.ignoreHttpsErrors ?? playwrightOpts?.ignoreHTTPSErrors,
+  };
 
   /**
    * Get the default monitor config from synthetics.config.ts file


Test patch:
diff --git a/__tests__/cli.test.ts b/__tests__/cli.test.ts
index b7a4872..a08081a 100644
--- a/__tests__/cli.test.ts
+++ b/__tests__/cli.test.ts
@@ -250,7 +250,7 @@ describe('CLI', () => {
   it('pass dynamic config to journey params', async () => {
     // jest by default sets NODE_ENV to `test`
     const original = process.env['NODE_ENV'];
-    const output = async () => {
+    const getJourneyStart = async () => {
       const cli = new CLIMock()
         .args([
           join(FIXTURES_DIR, 'fake.journey.ts'),
@@ -265,11 +265,14 @@ describe('CLI', () => {
       return cli.output();
     };
 
-    expect(JSON.parse(await output()).payload).toMatchObject({
+    let [output] = safeParse([await getJourneyStart()]);
+    expect(output.payload).toMatchObject({
       params: { url: 'non-dev' },
     });
+
     process.env['NODE_ENV'] = 'development';
-    expect(JSON.parse(await output()).payload).toMatchObject({
+    [output] = safeParse([await getJourneyStart()]);
+    expect(output.payload).toMatchObject({
       params: { url: 'dev' },
     });
     process.env['NODE_ENV'] = original;
@@ -368,8 +371,6 @@ describe('CLI', () => {
         JSON.stringify({ url: tlsServer.TEST_PAGE }),
         '--reporter',
         'json',
-        '--screenshots',
-        'off',
       ];
     });
 
@@ -378,17 +379,35 @@ describe('CLI', () => {
     it('fails by default', async () => {
       const cli = new CLIMock().args(cliArgs).run();
       expect(await cli.exitCode).toBe(1);
-      expect(JSON.parse(cli.output()).journey).toEqual(
+      const [output] = safeParse([cli.output()]);
+      expect(output.journey).toEqual(
         expect.objectContaining({ status: 'failed' })
       );
     });
 
-    it('succeeds succeeds with --ignore-https-errors', async () => {
+    it('succeeds with --ignore-https-errors', async () => {
       const cli = new CLIMock()
         .args(cliArgs.concat('--ignore-https-errors'))
         .run();
       expect(await cli.exitCode).toBe(0);
-      expect(JSON.parse(cli.output()).journey).toEqual(
+      const [output] = safeParse([cli.output()]);
+      expect(output.journey).toEqual(
+        expect.objectContaining({ status: 'succeeded' })
+      );
+    });
+
+    it('succeeds with --playwright-options', async () => {
+      const cli = new CLIMock()
+        .args(
+          cliArgs.concat(
+            '--playwright-options',
+            JSON.stringify({ ignoreHTTPSErrors: true })
+          )
+        )
+        .run();
+      expect(await cli.exitCode).toBe(0);
+      const [output] = safeParse([cli.output()]);
+      expect(output.journey).toEqual(
         expect.objectContaining({ status: 'succeeded' })
       );
     });
diff --git a/__tests__/core/gatherer.test.ts b/__tests__/core/gatherer.test.ts
index bd3f6ec..c9c7512 100644
--- a/__tests__/core/gatherer.test.ts
+++ b/__tests__/core/gatherer.test.ts
@@ -117,7 +117,7 @@ describe('Gatherer', () => {
   });
 
   describe('Elastic UA identifier', () => {
-    it('works on a single page', async () => {
+    it('present when UA is not overriden', async () => {
       const driver = await Gatherer.setupDriver({ wsEndpoint });
       expect(await driver.page.evaluate(() => navigator.userAgent)).toContain(
         ' Elastic/Synthetics'
@@ -126,29 +126,26 @@ describe('Gatherer', () => {
       await Gatherer.stop();
     });
 
-    it('works with device emulation', async () => {
+    it('not present when UA is modified - emulated', async () => {
       const driver = await Gatherer.setupDriver({
         wsEndpoint,
         playwrightOptions: { ...devices['iPhone 12 Pro Max'] },
       });
       const userAgent = await driver.page.evaluate(() => navigator.userAgent);
-      expect(userAgent).toContain('Elastic/Synthetics');
+      expect(userAgent).not.toContain('Elastic/Synthetics');
       expect(userAgent).toContain('iPhone');
       await Gatherer.dispose(driver);
       await Gatherer.stop();
     });
 
-    it('works with popup window', async () => {
+    it('present on popup windows', async () => {
       const driver = await Gatherer.setupDriver({
         wsEndpoint,
-        playwrightOptions: { ...devices['Galaxy S9+'] },
       });
       const { page, context } = driver;
       await page.goto(server.TEST_PAGE);
       context.on('request', request => {
-        expect(request.headers()['user-agent']).toContain(
-          ' Elastic/Synthetics'
-        );
+        expect(request.headers()['user-agent']).toContain('Elastic/Synthetics');
       });
       await page.setContent(
         '<a target=_blank rel=noopener href="/popup.html">popup</a>'
@@ -159,7 +156,7 @@ describe('Gatherer', () => {
       ]);
       await page1.waitForLoadState();
       expect(await page1.evaluate(() => navigator.userAgent)).toContain(
-        ' Elastic/Synthetics'
+        'Elastic/Synthetics'
       );
       expect(await page1.textContent('body')).toEqual('Not found');
       await Gatherer.dispose(driver);