e2e: update snapshot test config

The e2e tests will prefer a locally installed npm binary and
use a prebuilt headless chrome docker image. This will speed up
test runs for devs with npm installed but still leave the option
to opt out of installing node directly on the host machine.

Jest now uses a custom test environment that initializes global
variables, starts chrome, and adds authorization headers to
requests.

Change-Id: Ie43952ada6fd3e4df0fd522c84691186ef0d7f80
Reviewed-on: https://go-review.googlesource.com/c/pkgsite/+/310314
Trust: Jamal Carvalho <jamal@golang.org>
Reviewed-by: Julie Qiu <julie@golang.org>
diff --git a/all.bash b/all.bash
index 86b9027..98d79f7 100755
--- a/all.bash
+++ b/all.bash
@@ -179,20 +179,16 @@
   runcmd go run ./devtools/cmd/static
 }
 
-npm() {
+run_npm() {
+  npmcmd="./devtools/docker_nodejs.sh npm"
+  if [[ -x "$(command -v npm)" ]]; then
+    npmcmd="npm"
+  fi
   # Run npm install if node_modules directory does not exist.
   if [ ! -d "node_modules" ]; then
-    ./devtools/docker_compose.sh nodejs npm install --quiet
+    runcmd $npmcmd install --quiet
   fi
-  ./devtools/docker_compose.sh nodejs npm $@
-}
-
-run_e2e() {
-  # Run npm install if node_modules directory does not exist.
-  if [ ! -d "node_modules" ]; then
-    ./devtools/docker_compose.sh nodejs npm install --quiet
-  fi
-  ./devtools/docker_compose.sh --build ci npm run test:jest:e2e -- $@
+  runcmd $npmcmd $@
 }
 
 prettier_file_globs='content/static/**/*.{js,css} **/*.md'
@@ -207,9 +203,6 @@
   fi
   if [[ -x "$(command -v prettier)" ]]; then
     runcmd prettier --write $files
-  elif [[ -x "$(command -v docker-compose)" && "$(docker images -q pkgsite_nodejs)" ]]; then
-    runcmd docker-compose -f devtools/config/docker-compose.yaml run --entrypoint=npx \
-    nodejs prettier --write $files
   else
     err "prettier must be installed: see https://prettier.io/docs/en/install.html"
   fi
@@ -239,7 +232,8 @@
   (empty)        - run all standard checks and tests
   ci             - run checks and tests suitable for continuous integration
   cl             - run checks and tests on the current CL, suitable for a commit or pre-push hook
-  e2e            - run e2e tests locally.
+  e2e            - run e2e tests locally
+  e2e_update     - update e2e snapshots
   lint           - run all standard linters below:
   headers        - (lint) check source files for the license disclaimer
   migrations     - (lint) check migration sequence numbers
@@ -335,8 +329,10 @@
     unparam) check_unparam ;;
     script_hashes) check_script_hashes ;;
     build_static) run_build_static ;;
-    npm) npm ${@:2} ;;
-    e2e) run_e2e ${@:2} ;;
+    npm) run_npm ${@:2} ;;
+    e2e) run_npm run e2e;;
+    e2e_update) run_npm run e2e -- -u;;
+
     *)
       usage
       exit 1
diff --git a/devtools/docker_nodejs.sh b/devtools/docker_nodejs.sh
new file mode 100755
index 0000000..0964463
--- /dev/null
+++ b/devtools/docker_nodejs.sh
@@ -0,0 +1,11 @@
+#!/usr/bin/env bash
+
+# Copyright 2020 The Go Authors. All rights reserved.
+# Use of this source code is governed by a BSD-style
+# license that can be found in the LICENSE file.
+
+set -e
+
+# Script for running a nodejs docker image.
+
+docker run -it -v `pwd`:/pkgsite -w /pkgsite  node:14.15.1 $@
diff --git a/e2e/global-setup.ts b/e2e/global-setup.ts
index 189b6bb..b6d2945 100644
--- a/e2e/global-setup.ts
+++ b/e2e/global-setup.ts
@@ -1,19 +1,45 @@
-import fs from 'fs';
-import os from 'os';
-import path from 'path';
-import mkdirp from 'mkdirp';
-import puppeteer, { Browser } from 'puppeteer';
+/**
+ * @license
+ * Copyright 2021 The Go Authors. All rights reserved.
+ * Use of this source code is governed by a BSD-style
+ * license that can be found in the LICENSE file.
+ */
 
-declare const global: NodeJS.Global & typeof globalThis & { browser: Browser };
+import { spawn, ChildProcessWithoutNullStreams } from 'child_process';
+import wait from 'wait-port';
 
-const DIR = path.join(os.tmpdir(), 'jest_puppeteer_global_setup');
+declare const global: NodeJS.Global &
+  typeof globalThis & { chromium: ChildProcessWithoutNullStreams };
+
+/**
+ * port is the port the chrome instance will listen for connections on.
+ * puppeteer will connect to ws://localhost:<port>, while the test debugger
+ * is available at http://localhost:<port>. This must match the value in
+ * ./test-environment.js.
+ */
+const port = Number(process.env.PORT) || 3000;
+
+/**
+ * setup starts a docker-ized instance of chrome, waits for the websocket port
+ * that puppeteer will use to control chrome with to be listening for connections,
+ * and sleeps momentarily to make sure everything is ready to go.
+ */
 export default async function setup(): Promise<void> {
-  global.browser = await puppeteer.launch({
-    args: ['--no-sandbox', '--disable-dev-shm-usage'],
+  global.chromium = spawn('docker', ['run', '-p', `${port}:${port}`, 'browserless/chrome'], {
+    stdio: 'ignore',
   });
 
-  // Writing the websocket endpoint to a file so that tests
-  // can use it to connect to a global browser instance.
-  mkdirp.sync(DIR);
-  fs.writeFileSync(path.join(DIR, 'wsEndpoint'), global.browser.wsEndpoint());
+  global.chromium.on('error', e => {
+    console.error(e);
+    process.exit(1);
+  });
+
+  await wait({ port, output: 'dots' });
+  await sleep(1000);
+}
+
+function sleep(ms: number) {
+  return new Promise(resolve => {
+    setTimeout(resolve, ms);
+  });
 }
diff --git a/e2e/global-teardown.ts b/e2e/global-teardown.ts
index 313470b..e294dbe 100644
--- a/e2e/global-teardown.ts
+++ b/e2e/global-teardown.ts
@@ -1,13 +1,18 @@
-import os from 'os';
-import path from 'path';
-import rimraf from 'rimraf';
-import { Browser } from 'puppeteer';
+/**
+ * @license
+ * Copyright 2021 The Go Authors. All rights reserved.
+ * Use of this source code is governed by a BSD-style
+ * license that can be found in the LICENSE file.
+ */
 
-declare const global: NodeJS.Global & typeof globalThis & { browser: Browser };
+import { ChildProcessWithoutNullStreams } from 'child_process';
 
-const DIR = path.join(os.tmpdir(), 'jest_puppeteer_global_setup');
+declare const global: NodeJS.Global &
+  typeof globalThis & { chromium: ChildProcessWithoutNullStreams };
+
+/**
+ * teardown kills the chromium instance when the test run is complete.
+ */
 export default async function teardown(): Promise<void> {
-  await global.browser.close();
-  // Clean-up the websocket endpoint file.
-  rimraf.sync(DIR);
+  global.chromium.kill();
 }
diff --git a/e2e/global-types.ts b/e2e/global-types.ts
index d054468..02af55a 100644
--- a/e2e/global-types.ts
+++ b/e2e/global-types.ts
@@ -1,5 +1,29 @@
-import { Browser } from 'puppeteer';
+/**
+ * @license
+ * Copyright 2021 The Go Authors. All rights reserved.
+ * Use of this source code is governed by a BSD-style
+ * license that can be found in the LICENSE file.
+ */
 
+import { Browser, Page } from 'puppeteer';
+
+/**
+ * global declares global variables available in e2e test files.
+ */
 declare global {
+  /**
+   * A controller for the instance of Chrome used in for the tests.
+   */
   const browser: Browser;
+
+  /**
+   * The baseURL for pkgsite pages (e.g., https://staging-pkg.go.dev).
+   */
+  const baseURL: string;
+
+  /**
+   * newPage resolves to a new Page object. The Page object provides methods to
+   * interact with a single Chrome tab.
+   */
+  const newPage: () => Promise<Page>;
 }
diff --git a/e2e/setup.ts b/e2e/setup.ts
index 67b1e32..b4bd61c 100644
--- a/e2e/setup.ts
+++ b/e2e/setup.ts
@@ -1,26 +1,11 @@
-import fs from 'fs';
-import os from 'os';
-import path from 'path';
+/**
+ * @license
+ * Copyright 2021 The Go Authors. All rights reserved.
+ * Use of this source code is governed by a BSD-style
+ * license that can be found in the LICENSE file.
+ */
+
 import { toMatchImageSnapshot } from 'jest-image-snapshot';
-import puppeteer, { Browser } from 'puppeteer';
 
-declare const global: NodeJS.Global & typeof globalThis & { browser: Browser };
-
+// Extends jest to compare image snapshots.
 expect.extend({ toMatchImageSnapshot });
-
-beforeAll(async () => {
-  const DIR = path.join(os.tmpdir(), 'jest_puppeteer_global_setup');
-  const wsEndpoint = fs.readFileSync(path.join(DIR, 'wsEndpoint'), 'utf8');
-  if (!wsEndpoint) {
-    throw new Error('wsEndpoint not found');
-  }
-
-  global.browser = await puppeteer.connect({
-    browserWSEndpoint: wsEndpoint,
-    defaultViewport: { height: 800, width: 1280 },
-  });
-});
-
-afterAll(async () => {
-  global.browser.disconnect();
-});
diff --git a/e2e/test-environment.js b/e2e/test-environment.js
new file mode 100644
index 0000000..2488acc
--- /dev/null
+++ b/e2e/test-environment.js
@@ -0,0 +1,59 @@
+/**
+ * @license
+ * Copyright 2021 The Go Authors. All rights reserved.
+ * Use of this source code is governed by a BSD-style
+ * license that can be found in the LICENSE file.
+ */
+
+const puppeteer = require('puppeteer');
+const NodeEnvironment = require('jest-environment-node');
+
+const {
+  AUTHORIZATION = null,
+  BASE_URL = 'http://host.docker.internal:8080',
+  // PORT default value should match ./global-setup.ts.
+  PORT = 3000,
+} = process.env;
+
+/**
+ * PuppeteerEnvironment is a custom jest test environment. It extends the node
+ * test environment to initialize global variables, connect puppeteer on
+ * the host machine to the chromium instance running in docker, and add
+ * authorization to requests when the AUTHORIZATION env var is set.
+ */
+class PuppeteerEnvironment extends NodeEnvironment {
+  constructor(config) {
+    super(config);
+    this.global.baseURL = BASE_URL;
+    this.global.newPage = async () => {
+      const page = await this.global.browser.newPage();
+      if (AUTHORIZATION) {
+        await page.setRequestInterception(true);
+        page.on('request', r => {
+          const url = new URL(r.url());
+          let headers = r.headers();
+          if (url.origin.endsWith('pkg.go.dev')) {
+            headers = { ...r.headers(), Authorization: `Bearer ${AUTHORIZATION}` };
+          }
+          r.continue({ headers });
+        });
+      }
+      return page;
+    };
+  }
+
+  async setup() {
+    await super.setup();
+    this.global.browser = await puppeteer.connect({
+      browserWSEndpoint: `ws://localhost:${PORT}`,
+      defaultViewport: { height: 800, width: 1280 },
+    });
+  }
+
+  async teardown() {
+    await super.teardown();
+    await this.global.browser.disconnect();
+  }
+}
+
+module.exports = PuppeteerEnvironment;
diff --git a/jest.config.js b/jest.config.js
new file mode 100644
index 0000000..754ed08
--- /dev/null
+++ b/jest.config.js
@@ -0,0 +1,33 @@
+/**
+ * @license
+ * Copyright 2021 The Go Authors. All rights reserved.
+ * Use of this source code is governed by a BSD-style
+ * license that can be found in the LICENSE file.
+ */
+
+let config = {
+  preset: 'ts-jest',
+  globals: {
+    'ts-jest': {
+      isolatedModules: true,
+    },
+  },
+  moduleFileExtensions: ['ts', 'js'],
+  testRunner: 'jest-circus/runner',
+};
+
+// eslint-disable-next-line no-undef
+const e2e = process.argv.some(arg => arg.includes('e2e'));
+if (e2e) {
+  config = {
+    ...config,
+    setupFilesAfterEnv: ['<rootDir>/e2e/setup.ts'],
+    globalSetup: '<rootDir>/e2e/global-setup.ts',
+    globalTeardown: '<rootDir>/e2e/global-teardown.ts',
+    testEnvironment: '<rootDir>/e2e/test-environment.js',
+    testTimeout: 60000,
+  };
+}
+
+// eslint-disable-next-line no-undef
+module.exports = config;
diff --git a/package-lock.json b/package-lock.json
index bd10259..68b6641 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -783,9 +783,9 @@
       "integrity": "sha512-//oorEZjL6sbPcKUaCdIGlIUeH26mgzimjBB77G6XRgnDl/L5wOnpyBGRe/Mmf5CVW3PwEBE1NjiMZ/ssFh4wA=="
     },
     "@types/pixelmatch": {
-      "version": "5.2.2",
-      "resolved": "https://registry.npmjs.org/@types/pixelmatch/-/pixelmatch-5.2.2.tgz",
-      "integrity": "sha512-ndpfW/H8+SAiI3wt+f8DlHGgB7OeBdgFgBJ6v/1l3SpJ0MCn9wtXFb4mUccMujN5S4DMmAh7MVy1O3WcXrHUKw==",
+      "version": "5.2.3",
+      "resolved": "https://registry.npmjs.org/@types/pixelmatch/-/pixelmatch-5.2.3.tgz",
+      "integrity": "sha512-p+nAQVYK/DUx7+s1Xyu9dqAg0gobf7VmJ+iDA4lljg1o4XRgQHr7R2h1NwFt3gdNOZiftxWB11+0TuZqXYf19w==",
       "requires": {
         "@types/node": "*"
       }
@@ -1235,9 +1235,9 @@
       }
     },
     "bl": {
-      "version": "4.0.3",
-      "resolved": "https://registry.npmjs.org/bl/-/bl-4.0.3.tgz",
-      "integrity": "sha512-fs4G6/Hu4/EE+F75J8DuN/0IpQqNjAdC7aEQv7Qt8MHGUH7Ckv2MwTEEeN9QehD0pfIDkMI1bkHYkKy7xHyKIg==",
+      "version": "4.1.0",
+      "resolved": "https://registry.npmjs.org/bl/-/bl-4.1.0.tgz",
+      "integrity": "sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w==",
       "requires": {
         "buffer": "^5.5.0",
         "inherits": "^2.0.4",
@@ -1604,6 +1604,11 @@
         "delayed-stream": "~1.0.0"
       }
     },
+    "commander": {
+      "version": "3.0.2",
+      "resolved": "https://registry.npmjs.org/commander/-/commander-3.0.2.tgz",
+      "integrity": "sha512-Gar0ASD4BDyKC4hl4DwHqDrmvjoxWKZigVnAbn5H1owvm4CxCPdb0HQDehwNYMJpla5+M2tPmPARzhtYuwpHow=="
+    },
     "component-emitter": {
       "version": "1.3.0",
       "resolved": "https://registry.npmjs.org/component-emitter/-/component-emitter-1.3.0.tgz",
@@ -6839,9 +6844,9 @@
       }
     },
     "tar-stream": {
-      "version": "2.1.4",
-      "resolved": "https://registry.npmjs.org/tar-stream/-/tar-stream-2.1.4.tgz",
-      "integrity": "sha512-o3pS2zlG4gxr67GmFYBLlq+dM8gyRGUOvsrHclSkvtVtQbjV0s/+ZE8OpICbaj8clrX3tjeHngYGP7rweaBnuw==",
+      "version": "2.2.0",
+      "resolved": "https://registry.npmjs.org/tar-stream/-/tar-stream-2.2.0.tgz",
+      "integrity": "sha512-ujeqbceABgwMZxEJnk2HDY2DlnUZ+9oEcb1KzTVfYHio0UE6dG71n60d8D2I4qNvleWrrXpmjpt7vZeF1LnMZQ==",
       "requires": {
         "bl": "^4.0.3",
         "end-of-stream": "^1.4.1",
@@ -7268,6 +7273,28 @@
         "xml-name-validator": "^3.0.0"
       }
     },
+    "wait-port": {
+      "version": "0.2.9",
+      "resolved": "https://registry.npmjs.org/wait-port/-/wait-port-0.2.9.tgz",
+      "integrity": "sha512-hQ/cVKsNqGZ/UbZB/oakOGFqic00YAMM5/PEj3Bt4vKarv2jWIWzDbqlwT94qMs/exAQAsvMOq99sZblV92zxQ==",
+      "requires": {
+        "chalk": "^2.4.2",
+        "commander": "^3.0.2",
+        "debug": "^4.1.1"
+      },
+      "dependencies": {
+        "chalk": {
+          "version": "2.4.2",
+          "resolved": "https://registry.npmjs.org/chalk/-/chalk-2.4.2.tgz",
+          "integrity": "sha512-Mti+f9lpJNcwF4tWV8/OrTTtF1gZi+f8FqlyAdouralcFWFQWF2+NgCHShjkCb+IFBLq9buZwE1xckQU4peSuQ==",
+          "requires": {
+            "ansi-styles": "^3.2.1",
+            "escape-string-regexp": "^1.0.5",
+            "supports-color": "^5.3.0"
+          }
+        }
+      }
+    },
     "walker": {
       "version": "1.0.7",
       "resolved": "https://registry.npmjs.org/walker/-/walker-1.0.7.tgz",
diff --git a/package.json b/package.json
index 97bde73..02d9540 100644
--- a/package.json
+++ b/package.json
@@ -12,9 +12,8 @@
     "lint:ts": "eslint . --ext .ts",
     "test": "run-s --continue-on-error test:*",
     "test:typecheck": "tsc --noEmit",
-    "test:jest": "jest --config devtools/config/jest.config.js content",
-    "test:jest:e2e": "jest --config devtools/config/jest.config.js e2e",
-    "test-ci": "run-s --continue-on-error lint:* test:**"
+    "test:unit": "jest content",
+    "e2e": "jest e2e"
   },
   "dependencies": {
     "@types/jest": "26.0.16",
@@ -41,6 +40,7 @@
     "stylelint-order": "4.1.0",
     "stylelint-prettier": "1.1.2",
     "ts-jest": "26.4.4",
-    "typescript": "4.0.3"
+    "typescript": "4.0.3",
+    "wait-port": "0.2.9"
   }
 }