Repository: mapbox/batfish
Pull request #171 (2017-09-07 20:36:26)

Refactor and test start
In order to test the `start` function well, I split it up into a lot of smaller functions, tested individually. So this PR includes a lot of refactoring and a lot of new tests.

@jfurrow for review, could you please try a few examples to double-check that they're not broken? No need to real all of these code changes unless you want to. I tested several examples myself and didn't find any regressions.

davidtheclark: @jfurrow I might pile on some more refactoring and tests in this PR, so there's only one PR to review at the high level (checking examples). So please hold off until further notice.
davidtheclark: Ok. That's enough tests, I think, to lock a lot of the current functionality in place and give us quite a bit of confidence.  

@jfurrow ready for "review" — double-checking that examples still work. 

Patch:
diff --git a/bin/batfish.js b/bin/batfish.js
index 47891ee..ea2384f 100755
--- a/bin/batfish.js
+++ b/bin/batfish.js
@@ -133,7 +133,9 @@ if (cli.flags.clear === false) {
 
 const executeCommand = commands[command];
 const emitter = executeCommand(config, path.dirname(configPath));
-emitter.on(constants.EVENT_NOTIFICATION, batfishLog.log);
+emitter.on(constants.EVENT_NOTIFICATION, message => {
+  batfishLog.log(message);
+});
 emitter.on(constants.EVENT_ERROR, error => {
   const niceMessage = getLoggableErrorMessage(error);
   if (niceMessage) {
diff --git a/docs/configuration.md b/docs/configuration.md
index 81807a7..1763dd3 100644
--- a/docs/configuration.md
+++ b/docs/configuration.md
@@ -206,6 +206,18 @@ Additional plugins to pass to Babel during both Webpack builds (client bundling
 **You should `require()` your plugins instead of referencing them as strings.**
 Otherwise, Babel might end up looking in the wrong place for the npm package.
 
+Plugins you provide are concatenated to the following default plugins:
+
+- Always
+  - [babel-plugin-syntax-dynamic-import](https://babeljs.io/docs/plugins/syntax-dynamic-import/)
+  - [babel-plugin-transform-class-properties](https://babeljs.io/docs/plugins/transform-class-properties/)
+  - [@mapbox/babel-plugin-transform-jsxtreme-markdown](https://github.com/mapbox/jsxtreme-markdown/tree/master/packages/babel-plugin-transform-jsxtreme-markdown): See [`jsxtremeMarkdownOptions`].
+- Development only
+  - [babel-plugin-transform-react-jsx-source](https://github.com/babel/babel/tree/master/packages/babel-plugin-transform-react-jsx-source)
+  - [babel-plugin-transform-react-jsx-self](https://github.com/babel/babel/tree/master/packages/babel-plugin-transform-react-jsx-self)
+- Production only
+  - [babel-plugin-transform-react-remove-prop-types](https://github.com/oliviertassinari/babel-plugin-transform-react-remove-prop-types)
+
 ### babelPresets
 
 Type: `Array`.
diff --git a/flow-typed/defs.js b/flow-typed/defs.js
index f35ff60..6b2b528 100644
--- a/flow-typed/defs.js
+++ b/flow-typed/defs.js
@@ -36,7 +36,7 @@ declare type BatfishConfiguration = {
     rehypePlugins?: Array<Function>
   },
   includePromisePolyfill: boolean,
-  inlineJs?: Array<{ filename: string, uglify?: boolean }>,
+  inlineJs?: Array<InlineJsEntry>,
   production?: boolean,
   developmentDevtool: string | false,
   productionDevtool: string | false,
@@ -48,38 +48,40 @@ declare type BatfishConfiguration = {
   verbose: boolean
 };
 
+declare type InlineJsEntry = { filename: string, uglify?: boolean };
+
 declare type BatfishPageData = {
-  +filePath: string,
-  +path: string,
-  +frontMatter: { [string]: JsonValue }
+  filePath: string,
+  path: string,
+  frontMatter: { [string]: JsonValue }
 };
 
 declare type BatfishSiteData = {
-  +pages: Array<BatfishPageData>
+  pages: Array<BatfishPageData>
 };
 
 declare type BatfishPageModule = {
-  +component: React.Component<*, *>,
-  +props: {
-    +frontMatter: { [string]: JsonValue }
+  component: React.Component<*, *>,
+  props: {
+    frontMatter: { [string]: JsonValue }
   }
 };
 
 declare type BatfishRouteData = {
-  +path: string,
-  +getPage: () => Promise<BatfishPageModule>,
-  +internalRouting?: boolean,
-  +is404?: true
+  path: string,
+  getPage: () => Promise<BatfishPageModule>,
+  internalRouting?: boolean,
+  is404?: true
 };
 
 declare type BatfishContext = {
-  +selectedConfig: {
-    +siteBasePath: string,
-    +siteOrigin: string,
-    +hijackLinks: boolean
+  selectedConfig: {
+    siteBasePath: string,
+    siteOrigin: string,
+    hijackLinks: boolean
   },
-  +routes: Array<BatfishRouteData>,
-  +notFoundRoute: BatfishRouteData
+  routes: Array<BatfishRouteData>,
+  notFoundRoute: BatfishRouteData
 };
 
 declare type BatfishLocation = {
@@ -88,6 +90,12 @@ declare type BatfishLocation = {
   hash?: string
 };
 
+declare type BatfishServer = {
+  start: () => void,
+  reload: (filename?: string) => void,
+  browserSyncInstance: browserSync$server
+};
+
 declare module 'batfish-internal/context' {
   declare export var batfishContext: BatfishContext;
 }
diff --git a/flow-typed/modules.js b/flow-typed/modules.js
index c5012b2..97beda5 100644
--- a/flow-typed/modules.js
+++ b/flow-typed/modules.js
@@ -125,15 +125,16 @@ declare module 'time-stamp' {
   declare module.exports: string => string;
 }
 
-declare module 'browser-sync' {
-  declare class browserSync$server {
-    emitter: events$EventEmitter,
-    init(config?: Object, callback?: Function): void,
-    reload(filename?: string | Array<string>): void
-  }
+declare class browserSync$server {
+  emitter: events$EventEmitter,
+  init(config?: Object, callback?: Function): void,
+  reload(filename?: string | Array<string>): void
+}
 
+declare module 'browser-sync' {
   declare module.exports: {
-    create(): browserSync$server
+    create(name?: string): browserSync$server,
+    get(name: string): browserSync$server
   };
 }
 
diff --git a/src/node/build-html.js b/src/node/build-html.js
new file mode 100644
index 0000000..0291ebf
--- /dev/null
+++ b/src/node/build-html.js
@@ -0,0 +1,85 @@
+// @flow
+'use strict';
+
+const _ = require('lodash');
+const path = require('path');
+const fs = require('fs');
+const pTry = require('p-try');
+const joinUrlParts = require('./join-url-parts');
+const constants = require('./constants');
+const errorTypes = require('./error-types');
+const wrapError = require('./wrap-error');
+const UglifyJs = require('uglify-js');
+const getWebpackAssetAbsolutePath = require('./get-webpack-asset-absolute-path');
+
+// We need to define this type because Flow can't understand the non-literal
+// require that pulls in static-render-pages.js below.
+declare type StaticRenderPagesFunction = (
+  BatfishConfiguration,
+  {
+    vendor: { js: string },
+    app: { js: string }
+  },
+  string,
+  cssUrl?: string
+) => Promise<void>;
+
+function buildHtml(
+  batfishConfig: BatfishConfiguration,
+  cssFilename?: string
+): Promise<void> {
+  return pTry(() => {
+    const assetsDirectory = path.join(
+      batfishConfig.outputDirectory,
+      constants.PUBLIC_PATH_ASSETS
+    );
+
+    // This file reading is synced to make scoping easier, and with so few
+    // files doesn't matter for performance.
+    const rawAssets = fs.readFileSync(
+      path.join(assetsDirectory, 'assets.json'),
+      'utf8'
+    );
+    const assets: {
+      manifest: { js: string },
+      app: { js: string },
+      vendor: { js: string }
+    } = Object.freeze(JSON.parse(rawAssets));
+    const manifestJs = fs.readFileSync(
+      getWebpackAssetAbsolutePath(batfishConfig, assets.manifest.js),
+      'utf8'
+    );
+
+    const uglified = UglifyJs.minify(manifestJs);
+    if (uglified.error) throw uglified.error;
+    const uglifiedManifestJs: string = uglified.code;
+
+    let cssUrl;
+    if (!_.isEmpty(batfishConfig.stylesheets) && cssFilename) {
+      cssUrl = joinUrlParts(
+        batfishConfig.siteBasePath,
+        constants.PUBLIC_PATH_ASSETS,
+        path.basename(cssFilename)
+      );
+    }
+
+    try {
+      const staticRenderPages: StaticRenderPagesFunction = require(path.join(
+        assetsDirectory,
+        'static-render-pages.js'
+      )).default;
+      return staticRenderPages(
+        batfishConfig,
+        assets,
+        uglifiedManifestJs,
+        cssUrl
+      ).catch(error => {
+        throw wrapError(error, errorTypes.WebpackNodeExecutionError);
+      });
+    } catch (requireError) {
+      throw wrapError(requireError, errorTypes.WebpackNodeParseError);
+    }
+  });
+}
+
+module.exports = buildHtml;
diff --git a/src/node/build.js b/src/node/build.js
index 5f1e036..8512ee8 100644
--- a/src/node/build.js
+++ b/src/node/build.js
@@ -2,62 +2,20 @@
 'use strict';
 
 const _ = require('lodash');
-const webpack = require('webpack');
 const path = require('path');
-const pify = require('pify');
-const fs = require('fs');
-const del = require('del');
-const cpy = require('cpy');
-const pTry = require('p-try');
 const EventEmitter = require('events');
-const UglifyJs = require('uglify-js');
 const createWebpackConfigClient = require('./create-webpack-config-client');
 const createWebpackConfigStatic = require('./create-webpack-config-static');
 const validateConfig = require('./validate-config');
 const inlineCss = require('./inline-css');
 const generateSitemap = require('./generate-sitemap');
 const compileStylesheets = require('./compile-stylesheets');
-const joinUrlParts = require('./join-url-parts');
 const constants = require('./constants');
-const errorTypes = require('./error-types');
-const wrapError = require('./wrap-error');
-const createWebpackStatsError = require('./create-webpack-stats-error');
-
-// We need to define this type because Flow can't understand the non-literal
-// require that pulls in static-render-pages.js below.
-declare type StaticRenderPagesFunction = (
-  BatfishConfiguration,
-  {
-    +vendor: { +js: string },
-    +app: { +js: string }
-  },
-  string,
-  cssUrl?: string
-) => Promise<void>;
-
-function webpackCompile(
-  webpackConfig: webpack$Configuration
-): Promise<webpack$Stats> {
-  return new Promise((resolve, reject) => {
-    let compiler;
-    try {
-      compiler = webpack(webpackConfig);
-    } catch (initializationError) {
-      return reject(
-        wrapError(initializationError, errorTypes.WebpackFatalError)
-      );
-    }
-    compiler.run((fatalError, stats) => {
-      if (fatalError) {
-        return reject(wrapError(fatalError, errorTypes.WebpackFatalError));
-      }
-      if (stats.hasErrors()) {
-        return reject(createWebpackStatsError(stats));
-      }
-      resolve(stats);
-    });
-  });
-}
+const maybeClearOutputDirectory = require('./maybe-clear-output-directory');
+const copyNonPageFiles = require('./copy-non-page-files');
+const writeWebpackStats = require('./write-webpack-stats');
+const buildHtml = require('./build-html');
+const webpackCompilePromise = require('./webpack-compile-promise');
 
 function build(rawConfig?: Object, projectDirectory?: string): EventEmitter {
   // Default production to true when building.
@@ -93,110 +51,24 @@ function build(rawConfig?: Object, projectDirectory?: string): EventEmitter {
     outputDirectory: assetsDirectory
   });
 
-  // Get the absolute path to an asset referenced by a relative path in
-  // assets.json.
-  const getAssetFilePath = (assetPath: string): string => {
-    let pathWithoutBase = assetPath;
-    if (batfishConfig.siteBasePath) {
-      pathWithoutBase = assetPath.replace(
-        new RegExp(`^${batfishConfig.siteBasePath}`),
-        ''
-      );
-    }
-    return path.join(outputDirectory, pathWithoutBase);
-  };
-
-  const copyNonPages = (): Promise<void> => {
-    // Don't copy .js, .md, and .css files, which are already incorporated into
-    // the build in other ways.
-    let copyGlob = ['**/*.!(js|md|css)'];
-    // Copy unprocessed page files directly.
-    if (batfishConfig.unprocessedPageFiles !== undefined) {
-      copyGlob = copyGlob.concat(batfishConfig.unprocessedPageFiles);
-    }
-    return cpy(copyGlob, batfishConfig.outputDirectory, {
-      cwd: batfishConfig.pagesDirectory,
-      parents: true
-    });
-  };
-
   const buildClient = (): Promise<void> => {
     return createWebpackConfigClient(tailoredBatfishConfig)
-      .then(webpackCompile)
+      .then(webpackCompilePromise)
       .then(stats => {
-        // For bundle debugging, write stats.json.
-        return pify(fs.writeFile)(
-          path.join(outputDirectory, constants.STATS_BASENAME),
-          JSON.stringify(stats.toJson())
-        );
+        return writeWebpackStats(outputDirectory, stats);
       });
   };
 
   const buildStatic = (): Promise<webpack$Stats> => {
     return createWebpackConfigStatic(tailoredBatfishConfig).then(
-      webpackCompile
+      webpackCompilePromise
     );
   };
 
   // The compiled CSS filename will differ depending on whether this is a
-  // production build or not.
+  // production build or not. So it needs to be a variable.
   let cssFilename;
-  const renderHtml = (): Promise<void> => {
-    return pTry(() => {
-      // This file reading is synced to make scoping easier, and with so few
-      // files doesn't matter for performance.
-      const rawAssets = fs.readFileSync(
-        path.join(assetsDirectory, 'assets.json'),
-        'utf8'
-      );
-      const assets: {
-        manifest: { js: string },
-        app: { js: string },
-        vendor: { js: string }
-      } = Object.freeze(JSON.parse(rawAssets));
-      const manifestJs = fs.readFileSync(
-        getAssetFilePath(assets.manifest.js),
-        'utf8'
-      );
-
-      const uglified = UglifyJs.minify(manifestJs);
-      if (uglified.error) throw uglified.error;
-      const uglifiedManifestJs: string = uglified.code;
-
-      let cssUrl;
-      if (!stylesheetsIsEmpty && cssFilename) {
-        cssUrl = joinUrlParts(
-          batfishConfig.siteBasePath,
-          constants.PUBLIC_PATH_ASSETS,
-          path.basename(cssFilename)
-        );
-      }
-
-      try {
-        const staticRenderPages: StaticRenderPagesFunction = require(path.join(
-          assetsDirectory,
-          'static-render-pages.js'
-        )).default;
-        return staticRenderPages(
-          batfishConfig,
-          assets,
-          uglifiedManifestJs,
-          cssUrl
-        ).catch(error => {
-          throw wrapError(error, errorTypes.WebpackNodeExecutionError);
-        });
-      } catch (requireError) {
-        throw wrapError(requireError, errorTypes.WebpackNodeParseError);
-      }
-    });
-  };
-
-  Promise.resolve()
-    .then(() => {
-      if (batfishConfig.clearOutputDirectory) {
-        return del(outputDirectory, { force: true });
-      }
-    })
+  maybeClearOutputDirectory(batfishConfig)
     .then(() => {
       if (stylesheetsIsEmpty) return;
       emitNotification('Compiling CSS.');
@@ -218,15 +90,17 @@ function build(rawConfig?: Object, projectDirectory?: string): EventEmitter {
     })
     .then(() => {
       emitNotification('Copying unprocessed files.');
-      return Promise.all([copyNonPages()]);
+      return copyNonPageFiles(batfishConfig);
     })
     .then(() => {
       emitNotification('Building HTML.');
-      return renderHtml();
+      return buildHtml(batfishConfig, cssFilename);
     })
     .then(() => {
       if (!batfishConfig.production) return;
       return new Promise((resolve, reject) => {
+        // This line within the callback function scope to please Flow about
+        // the cssFilename variable.
         if (stylesheetsIsEmpty || !cssFilename) return resolve();
         const inlineCssEmitter = inlineCss(outputDirectory, cssFilename, {
           verbose: batfishConfig.verbose
diff --git a/src/node/compile-stylesheets.js b/src/node/compile-stylesheets.js
index ec15001..8920279 100644
--- a/src/node/compile-stylesheets.js
+++ b/src/node/compile-stylesheets.js
@@ -21,9 +21,9 @@ const errorTypes = require('./error-types');
 const getPostcssPlugins = require('./get-postcss-plugins');
 
 type StylesheetData = {|
-  +locator: string,
-  +css: string,
-  +map?: string
+  locator: string,
+  css: string,
+  map?: string
 |};
 
 const urlCache: Map<string, StylesheetData> = new Map();
@@ -65,7 +65,7 @@ function compileStylesheets(
             `Stylesheet at ${chalk.yellow(url)} could not be downloaded.`
           );
         })
-        .then((response: { +body: string }) => {
+        .then((response: { body: string }) => {
           return (
             postcss()
               // Make all the URLs in the stylesheet absolute so the new local
@@ -75,7 +75,7 @@ function compileStylesheets(
               .catch(rethrowPostcssError)
           );
         })
-        .then((result: { +css: string }) => {
+        .then((result: { css: string }) => {
           const contentsItem = {
             locator: url,
             css: result.css,
diff --git a/src/node/copy-non-page-files.js b/src/node/copy-non-page-files.js
new file mode 100644
index 0000000..9be6fd3
--- /dev/null
+++ b/src/node/copy-non-page-files.js
@@ -0,0 +1,20 @@
+// @flow
+'use strict';
+
+const cpy = require('cpy');
+
+function copyNonPageFiles(batfishConfig: BatfishConfiguration): Promise<void> {
+  // Don't copy .js, .md, and .css files, which are already incorporated into
+  // the build in other ways.
+  let copyGlob = ['**/*.!(js|md|css)'];
+  // Copy unprocessed page files directly.
+  if (batfishConfig.unprocessedPageFiles !== undefined) {
+    copyGlob = copyGlob.concat(batfishConfig.unprocessedPageFiles);
+  }
+  return cpy(copyGlob, batfishConfig.outputDirectory, {
+    cwd: batfishConfig.pagesDirectory,
+    parents: true
+  });
+}
+
+module.exports = copyNonPageFiles;
diff --git a/src/node/create-server.js b/src/node/create-server.js
new file mode 100644
index 0000000..850e734
--- /dev/null
+++ b/src/node/create-server.js
@@ -0,0 +1,39 @@
+// @flow
+'use strict';
+
+const browserSync = require('browser-sync');
+const getPort = require('get-port');
+
+function createServer(options: {
+  browserSyncOptions: Object,
+  onError: Error => any
+}): BatfishServer {
+  const browserSyncInstance = browserSync.create();
+  browserSyncInstance.emitter.on('error', options.onError);
+
+  const start = () => {
+    getPort(options.browserSyncOptions.port)
+      .then(availablePort => {
+        browserSyncInstance.init(
+          Object.assign({}, options.browserSyncOptions, {
+            port: availablePort
+          })
+        );
+      })
+      .catch(options.onError);
+  };
+
+  const reload = (filename?: string) => {
+    browserSyncInstance.reload(filename);
+  };
+
+  const server = {
+    start,
+    reload,
+    browserSyncInstance
+  };
+
+  return server;
+}
+
+module.exports = createServer;
diff --git a/src/node/create-webpack-config-base.js b/src/node/create-webpack-config-base.js
index d960117..f4b2b56 100644
--- a/src/node/create-webpack-config-base.js
+++ b/src/node/create-webpack-config-base.js
@@ -158,7 +158,7 @@ function createWebpackConfigBase(
           constants.PUBLIC_PATH_ASSETS,
           ''
         ),
-        pathinfo: batfishConfig.verbose,
+        pathinfo: !batfishConfig.production,
         filename: '[name].js'
       },
       performance: {
@@ -173,7 +173,7 @@ function createWebpackConfigBase(
           )
         },
         // Loader names need to be strings, and to allow them to be looked
-        // up within batfish's module dependencies, not just the project's,
+        // up within Batfish's module dependencies, not just the project's,
         // we need this.
         modules: [path.join(__dirname, '../../node_modules'), 'node_modules']
       },
@@ -192,7 +192,8 @@ function createWebpackConfigBase(
             ? '"development"'
             : '"production"'
         }),
-        // Determine hashes with file content, not random strings. This allows for long-term caching.
+        // Determine hashes with file content, not random strings. This allows
+        // for long-term caching.
         new WebpackChunkHash()
       ],
       // Designate sourcemap type.
diff --git a/src/node/get-webpack-asset-absolute-path.js b/src/node/get-webpack-asset-absolute-path.js
new file mode 100644
index 0000000..da5d504
--- /dev/null
+++ b/src/node/get-webpack-asset-absolute-path.js
@@ -0,0 +1,22 @@
+// @flow
+'use strict';
+
+const path = require('path');
+
+// Get the absolute path to an asset referenced by a relative path in
+// assets.json, which Webpack generates.
+function getWebpackAssetAbsolutePath(
+  batfishConfig: BatfishConfiguration,
+  assetPath: string
+): string {
+  let pathWithoutBase = assetPath;
+  if (batfishConfig.siteBasePath) {
+    pathWithoutBase = assetPath.replace(
+      new RegExp(`^${batfishConfig.siteBasePath}`),
+      ''
+    );
+  }
+  return path.join(batfishConfig.outputDirectory, pathWithoutBase);
+}
+
+module.exports = getWebpackAssetAbsolutePath;
diff --git a/src/node/inline-css-worker.js b/src/node/inline-css-worker.js
index ff5c99a..c504377 100644
--- a/src/node/inline-css-worker.js
+++ b/src/node/inline-css-worker.js
@@ -27,7 +27,7 @@ function inlineCssWorker(
         .use(postcssCsso())
         .process(css, { from: cssPath, to: cssPath })
         .catch(rethrowPostcssError)
-        .then((result: { +css: string }) => {
+        .then((result: { css: string }) => {
           return html.replace(
             constants.INLINE_CSS_MARKER,
             `<style>${result.css}</style>`
diff --git a/src/node/maybe-clear-output-directory.js b/src/node/maybe-clear-output-directory.js
new file mode 100644
index 0000000..d6d65cc
--- /dev/null
+++ b/src/node/maybe-clear-output-directory.js
@@ -0,0 +1,16 @@
+// @flow
+'use strict';
+
+const del = require('del');
+const pTry = require('p-try');
+
+function maybeClearOutputDirectory(
+  batfishConfig: BatfishConfiguration
+): Promise<void | Array<string>> {
+  return pTry(() => {
+    if (!batfishConfig.clearOutputDirectory) return;
+    return del(batfishConfig.outputDirectory, { force: true });
+  });
+}
+
+module.exports = maybeClearOutputDirectory;
diff --git a/src/node/render-inline-js-scripts.js b/src/node/render-inline-js-scripts.js
new file mode 100644
index 0000000..ca3fdee
--- /dev/null
+++ b/src/node/render-inline-js-scripts.js
@@ -0,0 +1,25 @@
+// @flow
+'use strict';
+
+const fs = require('fs');
+const UglifyJs = require('uglify-js');
+
+function renderInlineJsScripts(inlineJsEntries?: Array<InlineJsEntry>): string {
+  if (!inlineJsEntries) {
+    return '';
+  }
+
+  return inlineJsEntries
+    .map(jsData => {
+      let code = fs.readFileSync(jsData.filename, 'utf8');
+      if (jsData.uglify !== false) {
+        const uglified = UglifyJs.minify(code);
+        if (uglified.error) throw uglified.error;
+        code = uglified.code;
+      }
+      return `<script>${code}</script>`;
+    })
+    .join('\n');
+}
+
+module.exports = renderInlineJsScripts;
diff --git a/src/node/serve-static.js b/src/node/serve-static.js
index 200a3b5..7d51806 100644
--- a/src/node/serve-static.js
+++ b/src/node/serve-static.js
@@ -1,12 +1,12 @@
 // @flow
 'use strict';
 
-const getPort = require('get-port');
-const browserSync = require('browser-sync');
 const EventEmitter = require('events');
 const validateConfig = require('./validate-config');
 const serverInitMessage = require('./server-init-message');
 const constants = require('./constants');
+const createServer = require('./create-server');
+const staticServerMiddlewares = require('./static-server-middlewares');
 
 function serveStatic(
   rawConfig?: Object,
@@ -32,34 +32,27 @@ function serveStatic(
     return emitter;
   }
 
-  const server = browserSync.create();
-  server.emitter.on('error', emitError);
-
-  const realUrlMiddleware = (req: { url: string }, res, next: Function) => {
-    if (req.url.startsWith(batfishConfig.siteBasePath)) {
-      req.url = req.url.replace(batfishConfig.siteBasePath, '') || '/';
+  const middleWares = staticServerMiddlewares.init(batfishConfig);
+  const server = createServer({
+    onError: emitError,
+    browserSyncOptions: {
+      port: batfishConfig.port,
+      server: {
+        baseDir: batfishConfig.outputDirectory,
+        middleware: [middleWares.stripSiteBasePath]
+      },
+      notify: false,
+      open: false,
+      logLevel: 'silent',
+      offline: true
     }
-    next();
-  };
-
-  getPort(batfishConfig.port)
-    .then(availablePort => {
-      server.emitter.on('init', instance => {
-        emitNotification(serverInitMessage(instance, batfishConfig));
-      });
-      server.init({
-        port: availablePort,
-        server: {
-          baseDir: batfishConfig.outputDirectory,
-          middleware: [realUrlMiddleware]
-        },
-        notify: false,
-        open: false,
-        logLevel: 'silent',
-        offline: true
-      });
-    })
-    .catch(emitError);
+  });
+  server.browserSyncInstance.emitter.on('init', () => {
+    emitNotification(
+      serverInitMessage(server.browserSyncInstance, batfishConfig)
+    );
+  });
+  server.start();
 
   return emitter;
 }
diff --git a/src/node/server-init-message.js b/src/node/server-init-message.js
index 92d7766..ecbe825 100644
--- a/src/node/server-init-message.js
+++ b/src/node/server-init-message.js
@@ -4,7 +4,7 @@
 const chalk = require('chalk');
 const path = require('path');
 
-// Log some things when a BrowserSync starts.
+// Log some things when BrowserSync starts.
 // Using this to replace BrowserSync's regular logging to ensure the
 // siteBasePath is included and users know where to go.g
 function serverInitMessage(
@@ -14,9 +14,9 @@ function serverInitMessage(
   const relativeOutputDirectory =
     path.relative(
       process.cwd(),
-      serverInstance.options.get('server').get('baseDir')
+      serverInstance.instance.options.get('server').get('baseDir')
     ) + '/';
-  const urls = serverInstance.options.get('urls');
+  const urls = serverInstance.instance.options.get('urls');
   const localUrl =
     urls.get('local') + batfishConfig.siteBasePath.replace(/([^/])$/, '$1/');
   const externalUrl =
diff --git a/src/node/start.js b/src/node/start.js
index 90d58d4..5f62e87 100644
--- a/src/node/start.js
+++ b/src/node/start.js
@@ -1,38 +1,25 @@
 // @flow
 'use strict';
 
-const webpack = require('webpack');
 const chalk = require('chalk');
-const del = require('del');
-const fs = require('fs');
-const path = require('path');
-const pify = require('pify');
-const _ = require('lodash');
-const chokidar = require('chokidar');
 const EventEmitter = require('events');
-const getPort = require('get-port');
-const browserSync = require('browser-sync');
 const historyApiFallback = require('connect-history-api-fallback');
-const HtmlWebpackPlugin = require('html-webpack-plugin');
-const webpackMerge = require('webpack-merge');
-const createWebpackConfigClient = require('./create-webpack-config-client');
 const constants = require('./constants');
 const validateConfig = require('./validate-config');
-const serverInitMessage = require('./server-init-message');
 const compileStylesheets = require('./compile-stylesheets');
 const joinUrlParts = require('./join-url-parts');
-const errorTypes = require('./error-types');
-const wrapError = require('./wrap-error');
-const createWebpackStatsError = require('./create-webpack-stats-error');
-const writeContextModule = require('./write-context-module');
+const watchCss = require('./watch-css');
+const watchWebpack = require('./watch-webpack');
+const createServer = require('./create-server');
+const maybeClearOutputDirectory = require('./maybe-clear-output-directory');
 
 function start(rawConfig?: Object, projectDirectory?: string): EventEmitter {
   rawConfig = rawConfig || {};
   const emitter = new EventEmitter();
-  const emitError = error => {
+  const emitError = (error: Error) => {
     emitter.emit(constants.EVENT_ERROR, error);
   };
-  const emitNotification = message => {
+  const emitNotification = (message: string) => {
     emitter.emit(constants.EVENT_NOTIFICATION, message);
   };
 
@@ -47,15 +34,6 @@ function start(rawConfig?: Object, projectDirectory?: string): EventEmitter {
     return emitter;
   }
 
-  const stylesheetsIsEmpty = _.isEmpty(batfishConfig.stylesheets);
-  const htmlWebpackPluginOptions = {
-    template: path.join(__dirname, '../webpack/html-webpack-template.ejs'),
-    cssBasename: stylesheetsIsEmpty ? '' : constants.BATFISH_CSS_BASENAME
-  };
-  const statsFilename = path.join(
-    batfishConfig.outputDirectory,
-    constants.STATS_BASENAME
-  );
   const serveAssetsDir = joinUrlParts(
     batfishConfig.siteBasePath,
     constants.PUBLIC_PATH_ASSETS
@@ -63,150 +41,45 @@ function start(rawConfig?: Object, projectDirectory?: string): EventEmitter {
   // This allows us to serve static files within the pages directory.
   const servePagesDir = batfishConfig.siteBasePath;
 
-  const server = browserSync.create();
-  server.emitter.on('error', emitError);
-
-  const startCssWatcher = () => {
-    if (stylesheetsIsEmpty) return;
-    const cssWatcher: EventEmitter = chokidar.watch(batfishConfig.stylesheets);
-    cssWatcher.on('change', () => {
-      compileStylesheets(batfishConfig)
-        .then(compiledFilename => {
-          emitNotification('CSS finished compiling.');
-          server.reload(compiledFilename);
-        })
-        .catch(emitError);
-    });
-    cssWatcher.on('error', emitError);
-  };
-
-  const getServerInstance = new Promise(resolve => {
-    server.emitter.on('init', instance => {
-      resolve(instance);
-    });
-  });
-
-  const startServer = () => {
-    getPort(batfishConfig.port)
-      .then(availablePort => {
-        server.init({
-          port: availablePort,
-          server: {
-            baseDir: batfishConfig.outputDirectory,
-            routes: {
-              [serveAssetsDir]: batfishConfig.outputDirectory,
-              [servePagesDir]: batfishConfig.pagesDirectory
-            },
-            middleware: [historyApiFallback()]
-          },
-          notify: false,
-          open: false,
-          logLevel: 'silent',
-          reloadDebounce: 500,
-          offline: true,
-          injectChanges: true
-        });
-      })
-      .catch(emitError);
-  };
-
-  const startWebpackWatcher = (config: webpack$Configuration) => {
-    let compiler;
-    try {
-      compiler = webpack(config);
-    } catch (compilerInitializationError) {
-      emitError(
-        wrapError(compilerInitializationError, errorTypes.WebpackFatalError)
-      );
-      return;
-    }
-
-    const onCompilation = (fatalError, stats) => {
-      // Don't do anything if the compilation is just repetition.
-      // There's often a series of many compilations with the same output.
-      if (stats.hash === lastHash) return;
-      lastHash = stats.hash;
-
-      if (!hasCompiled) {
-        hasCompiled = true;
-        emitNotification(chalk.green.bold('Go!'));
-        getServerInstance
-          .then(serverInstance => {
-            emitNotification(serverInitMessage(serverInstance, batfishConfig));
-          })
-          .catch(emitError);
-      }
-
-      if (fatalError) {
-        emitError(wrapError(fatalError, errorTypes.WebpackFatalError));
-        return;
-      }
-
-      if (stats.hasErrors()) {
-        emitError(createWebpackStatsError(stats));
-      }
-
-      const statsString = JSON.stringify(stats.toJson());
-      pify(fs.writeFile)(statsFilename, statsString).catch(emitError);
-      if (batfishConfig.verbose) {
-        emitNotification(
-          stats.toString({
-            chunks: false,
-            colors: true
-          })
-        );
-      }
-      emitNotification('Webpack finished compiling.');
-      server.reload();
-    };
-
-    // Watch pages separately, so we can rewrite the context module, which
-    // will capture changes to front matter, page additions and deletions.
-    const pageGlob = path.join(batfishConfig.pagesDirectory, './**/*.{js,md}');
-    const pageWatcher: EventEmitter = chokidar.watch(pageGlob);
-    const rebuildPages = () => {
-      writeContextModule(batfishConfig)
-        .then(() => {
-          compiler.run(onCompilation);
-        })
-        .catch(emitError);
-    };
-    pageWatcher.on('change', rebuildPages);
-    pageWatcher.on('unlink', rebuildPages);
-    pageWatcher.on('error', emitError);
-
-    let lastHash;
-    let hasCompiled = false;
-
-    compiler.watch(
-      {
-        ignored: [/node_modules/, pageGlob]
+  const server = createServer({
+    onError: emitError,
+    browserSyncOptions: {
+      port: batfishConfig.port,
+      server: {
+        baseDir: batfishConfig.outputDirectory,
+        routes: {
+          [serveAssetsDir]: batfishConfig.outputDirectory,
+          [servePagesDir]: batfishConfig.pagesDirectory
+        },
+        middleware: [historyApiFallback()]
       },
-      onCompilation
-    );
-  };
-
-  createWebpackConfigClient(batfishConfig, { devServer: true })
-    .then(clientConfig => {
-      // Create an HTML file to load the assets in the browser.
-      const config = webpackMerge(clientConfig, {
-        plugins: [new HtmlWebpackPlugin(htmlWebpackPluginOptions)]
-      });
+      notify: false,
+      open: false,
+      logLevel: 'silent',
+      reloadDebounce: 500,
+      offline: true,
+      injectChanges: true
+    }
+  });
 
+  maybeClearOutputDirectory(batfishConfig)
+    .then(() => {
       emitNotification('Starting the development server.');
       emitNotification(chalk.yellow.bold('Wait ...'));
-      return Promise.resolve()
-        .then(() => {
-          if (batfishConfig.clearOutputDirectory) {
-            return del(batfishConfig.outputDirectory, { force: true });
-          }
-        })
-        .then(() => compileStylesheets(batfishConfig).catch(emitError))
-        .then(() => {
-          startServer();
-          startCssWatcher();
-          startWebpackWatcher(config);
-        });
+    })
+    .then(() => compileStylesheets(batfishConfig).catch(emitError))
+    .then(() => {
+      server.start();
+      watchCss(batfishConfig, {
+        onError: emitError,
+        afterCompilation: compiledFilename => {
+          emitNotification('CSS finished compiling.');
+          server.reload(compiledFilename);
+        }
+      });
+      const webpackWatcher = watchWebpack(batfishConfig, server);
+      webpackWatcher.on(constants.EVENT_NOTIFICATION, emitNotification);
+      webpackWatcher.on(constants.EVENT_ERROR, emitError);
     })
     .catch(emitError);
 
diff --git a/src/node/static-server-middlewares.js b/src/node/static-server-middlewares.js
new file mode 100644
index 0000000..3d0dc22
--- /dev/null
+++ b/src/node/static-server-middlewares.js
@@ -0,0 +1,23 @@
+// @flow
+'use strict';
+
+function init(batfishConfig: BatfishConfiguration) {
+  function stripSiteBasePath(
+    req: { url: string },
+    res: Object,
+    next: Function
+  ) {
+    if (req.url.startsWith(batfishConfig.siteBasePath)) {
+      req.url = req.url.replace(batfishConfig.siteBasePath, '') || '/';
+    }
+    next();
+  }
+
+  return {
+    stripSiteBasePath
+  };
+}
+
+module.exports = {
+  init
+};
diff --git a/src/node/watch-context.js b/src/node/watch-context.js
new file mode 100644
index 0000000..9b27c30
--- /dev/null
+++ b/src/node/watch-context.js
@@ -0,0 +1,30 @@
+// @flow
+'use strict';
+
+const path = require('path');
+const chokidar = require('chokidar');
+const writeContextModule = require('./write-context-module');
+
+// Rebuild the context module when there are changes that would require that.
+function watchContext(
+  batfishConfig: BatfishConfiguration,
+  options: {
+    onError: Error => any,
+    afterCompilation: () => any
+  }
+) {
+  const pageGlob = path.join(batfishConfig.pagesDirectory, './**/*.{js,md}');
+  const pageWatcher = chokidar.watch(pageGlob);
+  const rebuildPages = () => {
+    writeContextModule(batfishConfig)
+      .then(() => {
+        options.afterCompilation();
+      })
+      .catch(options.onError);
+  };
+  pageWatcher.on('change', rebuildPages);
+  pageWatcher.on('unlink', rebuildPages);
+  pageWatcher.on('error', options.onError);
+}
+
+module.exports = watchContext;
diff --git a/src/node/watch-css.js b/src/node/watch-css.js
new file mode 100644
index 0000000..c3ea412
--- /dev/null
+++ b/src/node/watch-css.js
@@ -0,0 +1,30 @@
+// @flow
+'use strict';
+
+const _ = require('lodash');
+const chokidar = require('chokidar');
+const compileStylesheets = require('./compile-stylesheets');
+
+function watchCss(
+  batfishConfig: BatfishConfiguration,
+  options: {
+    onError: (error: Error) => any,
+    afterCompilation?: (string | void) => any
+  }
+) {
+  if (_.isEmpty(batfishConfig.stylesheets)) return;
+
+  const cssWatcher = chokidar.watch(batfishConfig.stylesheets);
+  cssWatcher.on('change', () => {
+    compileStylesheets(batfishConfig)
+      .then(compiledFilename => {
+        if (options.afterCompilation) {
+          options.afterCompilation(compiledFilename);
+        }
+      })
+      .catch(options.onError);
+  });
+  cssWatcher.on('error', options.onError);
+}
+
+module.exports = watchCss;
diff --git a/src/node/watch-webpack.js b/src/node/watch-webpack.js
new file mode 100644
index 0000000..b312ed8
--- /dev/null
+++ b/src/node/watch-webpack.js
@@ -0,0 +1,124 @@
+// @flow
+'use strict';
+
+const _ = require('lodash');
+const webpack = require('webpack');
+const path = require('path');
+const chalk = require('chalk');
+const EventEmitter = require('events');
+const webpackMerge = require('webpack-merge');
+const HtmlWebpackPlugin = require('html-webpack-plugin');
+const errorTypes = require('./error-types');
+const wrapError = require('./wrap-error');
+const constants = require('./constants');
+const createWebpackConfigClient = require('./create-webpack-config-client');
+const createWebpackStatsError = require('./create-webpack-stats-error');
+const watchContext = require('./watch-context');
+const serverInitMessage = require('./server-init-message');
+const writeWebpackStats = require('./write-webpack-stats');
+
+// Returns an EventEmitter that fires notification and error events.
+function watchWebpack(
+  batfishConfig: BatfishConfiguration,
+  server: BatfishServer
+): EventEmitter {
+  const emitter = new EventEmitter();
+  const emitError = (error: Error) => {
+    emitter.emit(constants.EVENT_ERROR, error);
+  };
+  const emitNotification = (message: string) => {
+    emitter.emit(constants.EVENT_NOTIFICATION, message);
+  };
+
+  const htmlWebpackPluginOptions = {
+    template: path.join(__dirname, '../webpack/html-webpack-template.ejs'),
+    cssBasename: _.isEmpty(batfishConfig.stylesheets)
+      ? ''
+      : constants.BATFISH_CSS_BASENAME
+  };
+  let lastHash;
+  let hasCompiled = false;
+
+  createWebpackConfigClient(batfishConfig, {
+    devServer: true
+  })
+    .then(clientConfig => {
+      // Create an HTML file to load the assets in the browser.
+      const config = webpackMerge(clientConfig, {
+        plugins: [new HtmlWebpackPlugin(htmlWebpackPluginOptions)]
+      });
+
+      let compiler;
+      try {
+        compiler = webpack(config);
+      } catch (compilerInitializationError) {
+        emitError(
+          wrapError(compilerInitializationError, errorTypes.WebpackFatalError)
+        );
+        return;
+      }
+
+      const onCompilation = (fatalError, stats) => {
+        // Don't do anything if the compilation is just repetition.
+        // There's often a series of many compilations with the same output.
+        if (stats.hash === lastHash) return;
+        lastHash = stats.hash;
+
+        if (!hasCompiled) {
+          hasCompiled = true;
+          emitNotification(chalk.green.bold('Go!'));
+          emitNotification(
+            serverInitMessage(server.browserSyncInstance, batfishConfig)
+          );
+        }
+
+        if (fatalError) {
+          emitError(wrapError(fatalError, errorTypes.WebpackFatalError));
+          return;
+        }
+
+        if (stats.hasErrors()) {
+          emitError(createWebpackStatsError(stats));
+        }
+
+        writeWebpackStats(batfishConfig.outputDirectory, stats).catch(
+          emitError
+        );
+        if (batfishConfig.verbose) {
+          emitNotification(
+            stats.toString({
+              chunks: false,
+              colors: true
+            })
+          );
+        }
+        emitNotification('Webpack finished compiling.');
+        server.reload();
+      };
+
+      compiler.watch(
+        {
+          ignored: [
+            /node_modules/,
+            // Ignore page files because they are watched by watchContext.
+            path.join(batfishConfig.pagesDirectory, './**/*.{js,md}')
+          ]
+        },
+        onCompilation
+      );
+
+      // Watch pages separately, so we can rewrite the context module, which
+      // will capture changes to front matter, page additions and deletions.
+      watchContext(batfishConfig, {
+        onError: emitError,
+        afterCompilation: () => {
+          compiler.run(onCompilation);
+        }
+      });
+    })
+    .catch(emitError);
+
+  return emitter;
+}
+
+module.exports = watchWebpack;
diff --git a/src/node/webpack-compile-promise.js b/src/node/webpack-compile-promise.js
new file mode 100644
index 0000000..a233e49
--- /dev/null
+++ b/src/node/webpack-compile-promise.js
@@ -0,0 +1,33 @@
+// @flow
+'use strict';
+
+const webpack = require('webpack');
+const createWebpackStatsError = require('./create-webpack-stats-error');
+const wrapError = require('./wrap-error');
+const errorTypes = require('./error-types');
+
+function webpackCompilePromise(
+  webpackConfig: webpack$Configuration
+): Promise<webpack$Stats> {
+  return new Promise((resolve, reject) => {
+    let compiler;
+    try {
+      compiler = webpack(webpackConfig);
+    } catch (initializationError) {
+      return reject(
+        wrapError(initializationError, errorTypes.WebpackFatalError)
+      );
+    }
+    compiler.run((fatalError, stats) => {
+      if (fatalError) {
+        return reject(wrapError(fatalError, errorTypes.WebpackFatalError));
+      }
+      if (stats.hasErrors()) {
+        return reject(createWebpackStatsError(stats));
+      }
+      resolve(stats);
+    });
+  });
+}
+
+module.exports = webpackCompilePromise;
diff --git a/src/node/write-webpack-stats.js b/src/node/write-webpack-stats.js
new file mode 100644
index 0000000..8bf4511
--- /dev/null
+++ b/src/node/write-webpack-stats.js
@@ -0,0 +1,19 @@
+// @flow
+'use strict';
+
+const fs = require('fs');
+const pify = require('pify');
+const path = require('path');
+const constants = require('./constants');
+
+function writeWebpackStats(
+  outputDirectory: string,
+  stats: webpack$Stats
+): Promise<void> {
+  return pify(fs.writeFile)(
+    path.join(outputDirectory, constants.STATS_BASENAME),
+    JSON.stringify(stats.toJson())
+  );
+}
+
+module.exports = writeWebpackStats;
diff --git a/src/webpack/batfish-app.js b/src/webpack/batfish-app.js
index bff2229..1f863cc 100644
--- a/src/webpack/batfish-app.js
+++ b/src/webpack/batfish-app.js
@@ -5,7 +5,7 @@ import { Router } from './router';
 import { findMatchingRoute } from './find-matching-route';
 import ApplicationWrapper from 'batfish-internal/application-wrapper';
 
-// The initialization of any Batfish.
+// The initialization of any Batfish app.
 // Get the current page and render it, wrapped in the user's ApplicationWrapper
 // component.
 
diff --git a/src/webpack/change-page.js b/src/webpack/change-page.js
new file mode 100644
index 0000000..e3b047a
--- /dev/null
+++ b/src/webpack/change-page.js
@@ -0,0 +1,57 @@
+// @flow
+import type { RouterState } from './router';
+
+import scrollRestorer from '@mapbox/scroll-restorer';
+import { findMatchingRoute } from './find-matching-route';
+import { scrollToFragment } from './scroll-to-fragment';
+import {
+  _invokeRouteChangeStartCallbacks,
+  _invokeRouteChangeEndCallbacks
+} from '@mapbox/batfish/modules/route-change-listeners';
+import { getCurrentLocation } from './get-current-location';
+import { getWindow } from './get-window';
+
+export function changePage(
+  nextLocation: BatfishLocation,
+  setRouterState: (state: RouterState, callback: () => void) => void,
+  options: { pushState?: boolean, scrollToTop?: boolean } = {},
+  onFinish?: () => mixed
+): Promise<void> {
+  const win = getWindow();
+  const matchingRoute = findMatchingRoute(nextLocation.pathname);
+  const nextUrl = [
+    nextLocation.pathname,
+    nextLocation.hash,
+    nextLocation.search
+  ].join('');
+  // Call the change-start callbacks immediately, not after the page chunk
+  // has already been fetched.
+  const startChange = _invokeRouteChangeStartCallbacks(nextLocation.pathname);
+  return matchingRoute
+    .getPage()
+    .then(pageModule => {
+      return startChange.then(() => pageModule);
+    })
+    .then(pageModule => {
+      if (options.pushState) {
+        win.history.pushState({}, null, nextUrl);
+      }
+      const nextState = {
+        path: matchingRoute.path,
+        PageComponent: pageModule.component,
+        pageProps: pageModule.props,
+        location: getCurrentLocation()
+      };
+      setRouterState(nextState, () => {
+        if (options.scrollToTop) {
+          win.scrollTo(0, 0);
+        } else if (scrollRestorer.getSavedScroll()) {
+          scrollRestorer.restoreScroll();
+        } else {
+          scrollToFragment();
+        }
+        if (onFinish) onFinish();
+        _invokeRouteChangeEndCallbacks(nextLocation.pathname);
+      });
+    });
+}
diff --git a/src/webpack/find-matching-route.js b/src/webpack/find-matching-route.js
index 3ad6f4f..100c075 100644
--- a/src/webpack/find-matching-route.js
+++ b/src/webpack/find-matching-route.js
@@ -1,8 +1,8 @@
 // @flow
 import { batfishContext } from 'batfish-internal/context';
 
-// Returns an array of regular expressions that are used to check whether a URL path
-// matches one of the routes.
+// Returns an array of regular expressions that are used to check whether a URL
+// path matches one of the routes.
 function createPathRegExps(
   routes: Array<BatfishRouteData>
 ): { [string]: RegExp } {
diff --git a/src/webpack/get-current-location.js b/src/webpack/get-current-location.js
new file mode 100644
index 0000000..1e3c933
--- /dev/null
+++ b/src/webpack/get-current-location.js
@@ -0,0 +1,13 @@
+// @flow
+import { getWindow } from './get-window';
+
+export function getCurrentLocation(): BatfishLocation {
+  const win = getWindow();
+  let pathname = win.location.pathname;
+  if (!/\/$/.test(pathname)) pathname += '/';
+  return {
+    pathname,
+    hash: win.location.hash,
+    search: win.location.search
+  };
+}
diff --git a/src/webpack/get-window.js b/src/webpack/get-window.js
new file mode 100644
index 0000000..ffd66d9
--- /dev/null
+++ b/src/webpack/get-window.js
@@ -0,0 +1,10 @@
+// @flow
+
+export function getWindow() {
+  if (typeof window === 'undefined') {
+    throw new Error(
+      'Do not call getWindow in code that will run during the static build.'
+    );
+  }
+  return window;
+}
diff --git a/src/webpack/public/prefix-url.js b/src/webpack/public/prefix-url.js
index bf342f9..84bd76b 100644
--- a/src/webpack/public/prefix-url.js
+++ b/src/webpack/public/prefix-url.js
@@ -1,6 +1,5 @@
 // @flow
-let siteBasePath = '';
-let siteOrigin;
+import { batfishContext } from 'batfish-internal/context';
 
 // Crude heuristic but probably ok.
 function isAbsoluteUrl(url: string): boolean {
@@ -11,18 +10,21 @@ function prefixUrl(url: string): string {
   if (isAbsoluteUrl(url)) {
     return url;
   }
-  if (siteBasePath && url.indexOf(siteBasePath) === 0) {
+  if (
+    batfishContext.selectedConfig.siteBasePath &&
+    url.indexOf(batfishContext.selectedConfig.siteBasePath) === 0
+  ) {
     return url;
   }
   if (!/^\//.test(url)) url = '/' + url;
-  return siteBasePath + url;
+  return batfishContext.selectedConfig.siteBasePath + url;
 }
 
 function prefixUrlAbsolute(url: string): string {
   if (isAbsoluteUrl(url)) {
     return url;
   }
-  if (!siteOrigin) {
+  if (!batfishContext.selectedConfig.siteOrigin) {
     if (process.env.NODE_ENV !== 'production') {
       throw new Error(
         'The siteOrigin property is not specified in your Batfish configuration. Unable to prefix with absolute path.'
@@ -31,16 +33,15 @@ function prefixUrlAbsolute(url: string): string {
     return url;
   }
   if (!/^\//.test(url)) url = '/' + url;
-  return siteOrigin + siteBasePath + url;
+  return (
+    batfishContext.selectedConfig.siteOrigin +
+    batfishContext.selectedConfig.siteBasePath +
+    url
+  );
 }
 
 function isUrlPrefixed(url: string): boolean {
-  return url.indexOf(siteBasePath) === 0;
+  return url.indexOf(batfishContext.selectedConfig.siteBasePath) === 0;
 }
 
-prefixUrl._configure = (a?: string, b?: string) => {
-  siteBasePath = a || '';
-  siteOrigin = b;
-};
-
 export { prefixUrl, prefixUrlAbsolute, isUrlPrefixed };
diff --git a/src/webpack/render-html-page.js b/src/webpack/render-html-page.js
new file mode 100644
index 0000000..4891618
--- /dev/null
+++ b/src/webpack/render-html-page.js
@@ -0,0 +1,57 @@
+// @flow
+import React from 'react';
+import ReactDOMServer from 'react-dom/server';
+import Helmet from 'react-helmet';
+import ApplicationWrapper from 'batfish-internal/application-wrapper';
+import { StaticHtmlPage } from './static-html-page';
+import { Router } from './router';
+import constants from '../node/constants';
+
+export function renderHtmlPage(
+  route: BatfishRouteData,
+  inlineJsScripts: string,
+  loadCssScript: string,
+  appendToBody: Array<string>
+): Promise<string> {
+  return route.getPage().then(pageModule => {
+    // We render the page content separately from the StaticHtmlPage, because
+    // the page content is what will be re-rendered when the bundled JS loads
+    // so it must match exactly what batfish-app.js renders (or you get React
+    // checksum errors). The rest of StaticHtmlPage will never be re-rendered
+    // by React.
+    const pageContent = ReactDOMServer.renderToString(
+      <ApplicationWrapper>
+        <Router
+          startingPath={route.path}
+          startingComponent={pageModule.component}
+          startingProps={pageModule.props}
+        />
+      </ApplicationWrapper>
+    );
+
+    const helmetHead = Helmet.rewind();
+    const reactDocument = (
+      <StaticHtmlPage
+        rawAppHtml={pageContent}
+        htmlAttributes={helmetHead.htmlAttributes.toComponent()}
+        bodyAttributes={helmetHead.bodyAttributes.toComponent()}
+        appendToHead={[
+          helmetHead.title.toString(),
+          helmetHead.base.toString(),
+          helmetHead.meta.toString(),
+          helmetHead.link.toString(),
+          inlineJsScripts,
+          helmetHead.script.toString(),
+          constants.INLINE_CSS_MARKER,
+          loadCssScript,
+          // This comes after the inlined and dynamically loaded CSS
+          // so it will override regular stylesheets
+          helmetHead.style.toString()
+        ]}
+        appendToBody={appendToBody}
+      />
+    );
+    const html = ReactDOMServer.renderToStaticMarkup(reactDocument);
+    return `<!doctype html>${html}`;
+  });
+}
diff --git a/src/webpack/router.js b/src/webpack/router.js
index 3f008a8..c03701d 100644
--- a/src/webpack/router.js
+++ b/src/webpack/router.js
@@ -7,28 +7,11 @@ import linkToLocation from '@mapbox/link-to-location';
 import querySelectorContainsNode from '@mapbox/query-selector-contains-node';
 import { batfishContext } from 'batfish-internal/context';
 import { routeTo } from '@mapbox/batfish/modules/route-to';
-import { prefixUrl } from '@mapbox/batfish/modules/prefix-url';
 import { findMatchingRoute } from './find-matching-route';
 import { scrollToFragment } from './scroll-to-fragment';
-import {
-  _invokeRouteChangeStartCallbacks,
-  _invokeRouteChangeEndCallbacks
-} from '@mapbox/batfish/modules/route-change-listeners';
-
-prefixUrl._configure(
-  batfishContext.selectedConfig.siteBasePath,
-  batfishContext.selectedConfig.siteOrigin
-);
-
-function getContextLocation(): BatfishLocation {
-  let tidyPath = window.location.pathname;
-  if (!/\/$/.test(tidyPath)) tidyPath += '/';
-  return {
-    pathname: tidyPath,
-    hash: window.location.hash,
-    search: window.location.search
-  };
-}
+import { getWindow } from './get-window';
+import { changePage } from './change-page';
+import { getCurrentLocation } from './get-current-location';
 
 type Props = {
   startingPath: string,
@@ -36,22 +19,23 @@ type Props = {
   startingProps: Object
 };
 
-type State = {
+export type RouterState = {
   path: string,
   PageComponent: React$ComponentType<*>,
   pageProps: Object,
   location: BatfishLocation
 };
 
-class Router extends React.PureComponent<Props, State> {
+class Router extends React.PureComponent<Props, RouterState> {
   constructor(props: Props) {
     super(props);
     const location: BatfishLocation = {
       pathname: this.props.startingPath
     };
     if (typeof window !== 'undefined') {
-      location.search = window.location.search;
-      location.hash = window.location.hash;
+      const win = getWindow();
+      location.search = win.location.search;
+      location.hash = win.location.hash;
     }
     this.state = {
       path: this.props.startingPath,
@@ -77,13 +61,17 @@ class Router extends React.PureComponent<Props, State> {
     }
 
     routeTo._setRouteToHandler(this.routeTo);
-    window.addEventListener('popstate', event => {
+    const win = getWindow();
+    win.addEventListener('popstate', event => {
       event.preventDefault();
-      this.changePage({
-        pathname: window.location.pathname,
-        search: window.location.search,
-        hash: window.location.hash
-      });
+      changePage(
+        {
+          pathname: win.location.pathname,
+          search: win.location.search,
+          hash: win.location.hash
+        },
+        this.setState.bind(this)
+      );
     });
 
     if (batfishContext.selectedConfig.hijackLinks) {
@@ -97,7 +85,7 @@ class Router extends React.PureComponent<Props, State> {
     }
 
     this.setState({
-      location: getContextLocation()
+      location: getCurrentLocation()
     });
   }
 
@@ -105,66 +93,19 @@ class Router extends React.PureComponent<Props, State> {
   // If it matches a route, go there dynamically and scroll to the top of the viewport.
   // If it doesn't match a route, go there non-dynamically.
   routeTo = (input: string | HTMLAnchorElement) => {
+    const win = getWindow();
     const targetLocation: BatfishLocation = linkToLocation(input);
     if (findMatchingRoute(targetLocation.pathname).is404) {
-      return window.location.assign(input);
+      return win.location.assign(input);
     }
-    this.changePage(targetLocation, {
+    changePage(targetLocation, this.setState.bind(this), {
       pushState: true,
       scrollToTop:
-        window.location.pathname !== targetLocation.pathname ||
+        win.location.pathname !== targetLocation.pathname ||
         !targetLocation.hash
     });
   };
 
-  // To change the page, we need to
-  // - Get the matching page module, which is an async Webpack bundle.
-  // - Use pushState to change the URL and add a new history entry.
-  // - Change the state of this component to render the new page.
-  // - Adjust scroll position on the new page.
-  changePage = (
-    nextLocation: BatfishLocation,
-    options: { pushState?: boolean, scrollToTop?: boolean } = {},
-    callback?: () => mixed
-  ) => {
-    const matchingRoute = findMatchingRoute(nextLocation.pathname);
-    const nextUrl = [
-      nextLocation.pathname,
-      nextLocation.hash,
-      nextLocation.search
-    ].join('');
-    // Call the change-start callbacks immediately, not after the page chunk
-    // has already been fetched.
-    const startChange = _invokeRouteChangeStartCallbacks(nextLocation.pathname);
-    return matchingRoute
-      .getPage()
-      .then(pageModule => {
-        return startChange.then(() => pageModule);
-      })
-      .then(pageModule => {
-        if (options.pushState) {
-          window.history.pushState({}, null, nextUrl);
-        }
-        const nextState = {
-          path: matchingRoute.path,
-          PageComponent: pageModule.component,
-          pageProps: pageModule.props,
-          location: getContextLocation()
-        };
-        this.setState(nextState, () => {
-          if (options.scrollToTop) {
-            window.scrollTo(0, 0);
-          } else if (scrollRestorer.getSavedScroll()) {
-            scrollRestorer.restoreScroll();
-          } else {
-            scrollToFragment();
-          }
-          if (callback) callback();
-          _invokeRouteChangeEndCallbacks(nextLocation.pathname);
-        });
-      });
-  };
-
   render() {
     const { PageComponent } = this.state;
     if (!PageComponent) return null;
diff --git a/src/webpack/static-render-pages.js b/src/webpack/static-render-pages.js
index 9588328..8a80bc2 100644
--- a/src/webpack/static-render-pages.js
+++ b/src/webpack/static-render-pages.js
@@ -1,23 +1,18 @@
 // @flow
+
 // **WARNING**
 // This file gets compiled by Webpack before it is executed.
 // So when it is executed, the __dirname will be {outputDirectory}/assets/.
 // For any other fs activity, this file location must be taken into acconut.
 
 import 'source-map-support/register';
-import React from 'react';
-import ReactDOMServer from 'react-dom/server';
-import Helmet from 'react-helmet';
 import fs from 'fs';
 import pify from 'pify';
 import mkdirp from 'mkdirp';
 import path from 'path';
-import UglifyJs from 'uglify-js';
 import { batfishContext } from 'batfish-internal/context';
-import ApplicationWrapper from 'batfish-internal/application-wrapper';
-import { StaticHtmlPage } from './static-html-page';
-import { Router } from './router';
-import constants from '../node/constants';
+import renderInlineJsScripts from '../node/render-inline-js-scripts';
+import { renderHtmlPage } from './render-html-page';
 
 // Statically render pages as HTML.
 //
@@ -25,89 +20,36 @@ import constants from '../node/constants';
 function staticRenderPages(
   batfishConfig: BatfishConfiguration,
   assets: {
-    +vendor: { +js: string },
-    +app: { +js: string }
+    vendor: { js: string },
+    app: { js: string }
   },
   manifestJs: string,
   cssUrl?: string
 ): Promise<Array<void>> {
-  const renderPage = (route: BatfishRouteData): Promise<string> => {
-    let inlineJs;
-    if (batfishConfig.production && batfishConfig.inlineJs) {
-      inlineJs = batfishConfig.inlineJs
-        .map(jsData => {
-          let code = fs.readFileSync(jsData.filename, 'utf8');
-          if (jsData.uglify !== false) {
-            const uglified = UglifyJs.minify(code);
-            if (uglified.error) throw uglified.error;
-            code = uglified.code;
-          }
-          return `<script>${code}</script>`;
-        })
-        .join('\n');
-    }
+  const inlineJsScripts = renderInlineJsScripts(batfishConfig.inlineJs);
 
-    return route.getPage().then(pageModule => {
-      // We render the page content separately from the StaticHtmlPage, because the page
-      // content is what will be re-rendered when the bundled JS loads so it must
-      // match exactly what batfish-app.js renders (or you get React checksum errors).
-      // The rest of StaticHtmlPage will never be re-rendered by React.
-      let pageContent;
-      try {
-        pageContent = ReactDOMServer.renderToString(
-          <ApplicationWrapper>
-            <Router
-              startingPath={route.path}
-              startingComponent={pageModule.component}
-              startingProps={pageModule.props}
-            />
-          </ApplicationWrapper>
-        );
-      } catch (renderError) {
-        throw renderError;
-      }
+  // Load the full stylesheet lazily, after DOMContentLoaded. The page will
+  // still render quickly because it will have its own CSS injected inline.
+  let loadCssScript = '';
+  if (cssUrl) {
+    const loadCssJs = `document.addEventListener('DOMContentLoaded',function(){var s=document.createElement('link');s.rel='stylesheet';s.href='${cssUrl}';document.head.insertBefore(s, document.getElementById('loadCss'));});`;
+    loadCssScript = `<script id="loadCss">${loadCssJs}</script>`;
+  }
 
-      // Load the full stylesheet lazily, after DOMContentLoaded. The page will
-      // still render quickly because it will have its own CSS injected inline.
-      let loadCssScript = '';
-      if (cssUrl) {
-        const loadCssJs = `document.addEventListener('DOMContentLoaded',function(){var s=document.createElement('link');s.rel='stylesheet';s.href='${cssUrl}';document.head.insertBefore(s, document.getElementById('loadCss')); });`;
-        loadCssScript = `<script id="loadCss">${loadCssJs}</script>`;
-      }
-      const head = Helmet.rewind();
-      const reactDocument = (
-        <StaticHtmlPage
-          rawAppHtml={pageContent}
-          htmlAttributes={head.htmlAttributes.toComponent()}
-          bodyAttributes={head.bodyAttributes.toComponent()}
-          appendToHead={[
-            head.title.toString(),
-            head.base.toString(),
-            head.meta.toString(),
-            head.link.toString(),
-            inlineJs,
-            head.script.toString(),
-            constants.INLINE_CSS_MARKER,
-            loadCssScript,
-            // This comes after the inlined and dynamically loaded CSS
-            // so it will override regular stylesheets
-            head.style.toString()
-          ]}
-          appendToBody={[
-            // The Webpack manifest is inlined because it is very small.
-            `<script>${manifestJs.replace(/\/\/#.*?map$/, '')}</script>`,
-            `<script src="${assets.vendor.js}"></script>`,
-            `<script src="${assets.app.js}"></script>`
-          ]}
-        />
-      );
-      const html = ReactDOMServer.renderToStaticMarkup(reactDocument);
-      return `<!doctype html>${html}`;
-    });
-  };
+  const appendToBody = [
+    // The Webpack manifest is inlined because it is very small.
+    `<script>${manifestJs.replace(/\/\/#.*?map$/, '')}</script>`,
+    `<script src="${assets.vendor.js}"></script>`,
+    `<script src="${assets.app.js}"></script>`
+  ];
 
   const writePage = (route: BatfishRouteData): Promise<void> => {
-    return renderPage(route).then(html => {
+    return renderHtmlPage(
+      route,
+      inlineJsScripts,
+      loadCssScript,
+      appendToBody
+    ).then(html => {
       // Write every page as an index.html file in the directory corresponding
       // to its route's path. Except the 404 page.
       if (route.is404) {


Test patch:
diff --git a/test/__snapshots__/build.test.js.snap b/test/__snapshots__/build.test.js.snap
new file mode 100644
index 0000000..51a0299
--- /dev/null
+++ b/test/__snapshots__/build.test.js.snap
@@ -0,0 +1,26 @@
+// Jest Snapshot v1, https://goo.gl/fbAQLP
+
+exports[`build order of notifications, *with* CSS inlining and sitemap 1`] = `
+Array [
+  "Compiling CSS.",
+  "Starting the Webpack bundling.",
+  "Creating the client bundle.",
+  "Creating the static-page-rendering Node bundle.",
+  "Copying unprocessed files.",
+  "Building HTML.",
+  "CSS inlining message 1",
+  "CSS inlining message 2",
+  "Building the sitemap.",
+]
+`;
+
+exports[`build order of notifications, without CSS inlining or sitemap 1`] = `
+Array [
+  "Starting the Webpack bundling.",
+  "Creating the client bundle.",
+  "Creating the static-page-rendering Node bundle.",
+  "Copying unprocessed files.",
+  "Building HTML.",
+  "siteOrigin is not specified; unable to generate sitemap.",
+]
+`;
diff --git a/test/__snapshots__/create-webpack-config-base.test.js.snap b/test/__snapshots__/create-webpack-config-base.test.js.snap
index 1a211c9..77dda7f 100644
--- a/test/__snapshots__/create-webpack-config-base.test.js.snap
+++ b/test/__snapshots__/create-webpack-config-base.test.js.snap
@@ -147,7 +147,7 @@ Object {
   "output": Object {
     "filename": "[name].js",
     "path": "<PROJECT_ROOT>/_batfish_site",
-    "pathinfo": false,
+    "pathinfo": true,
     "publicPath": "/assets/",
   },
   "performance": Object {
diff --git a/test/__snapshots__/render-html.test.js.snap b/test/__snapshots__/render-html.test.js.snap
new file mode 100644
index 0000000..2453c5f
--- /dev/null
+++ b/test/__snapshots__/render-html.test.js.snap
@@ -0,0 +1,25 @@
+// Jest Snapshot v1, https://goo.gl/fbAQLP
+
+exports[`renderHtmlPage gives us what we want 1`] = `
+"<!doctype html><div id=\\"static-html-page\\" data-props=\\"{
+  \\"rawAppHtml\\": \\"<div id=\\\\\\"application-wrapper\\\\\\" data-reactroot=\\\\\\"\\\\\\" data-reactid=\\\\\\"1\\\\\\" data-react-checksum=\\\\\\"-1663992387\\\\\\"><!-- react-text: 2 -->{}<!-- /react-text --><div id=\\\\\\"router\\\\\\" data-props=\\\\\\"{\\\\n  &quot;startingPath&quot;: &quot;/mock/route/path/&quot;,\\\\n  &quot;startingComponent&quot;: &quot;mockPageModule.component&quot;,\\\\n  &quot;startingProps&quot;: &quot;mockPageModule.props&quot;\\\\n}\\\\\\" data-reactid=\\\\\\"3\\\\\\"></div></div>\\",
+  \\"htmlAttributes\\": \\"htmlAttributes.mockToComponent\\",
+  \\"bodyAttributes\\": \\"bodyAttributes.mockToComponent\\",
+  \\"appendToHead\\": [
+    \\"title.mockToString\\",
+    \\"base.mockToString\\",
+    \\"meta.mockToString\\",
+    \\"link.mockToString\\",
+    \\"inline-js-scripts\\",
+    \\"script.mockToString\\",
+    \\"<!-- INLINE CSS HERE, BATFISH -->\\",
+    \\"load-css-script\\",
+    \\"style.mockToString\\"
+  ],
+  \\"appendToBody\\": [
+    \\"append\\",
+    \\"to\\",
+    \\"body\\"
+  ]
+}\\"></div>"
+`;
diff --git a/test/__snapshots__/router.test.js.snap b/test/__snapshots__/router.test.js.snap
index 950e55b..2a6a7ba 100644
--- a/test/__snapshots__/router.test.js.snap
+++ b/test/__snapshots__/router.test.js.snap
@@ -1,18 +1,18 @@
 // Jest Snapshot v1, https://goo.gl/fbAQLP
 
-exports[`Router instance.changePage invokes change-start and change-end callbacks 1`] = `
+exports[`Router renders the initial page with a window passes current location details 1`] = `
 <StartingComponent
   location={
     Object {
-      "hash": "",
+      "hash": "#grr",
       "pathname": "/magic/",
-      "search": "",
+      "search": "?foo=bar",
     }
   }
 />
 `;
 
-exports[`Router renders the initial page 1`] = `
+exports[`Router renders the initial page without a window 1`] = `
 <StartingComponent
   location={
     Object {
diff --git a/test/__snapshots__/start.test.js.snap b/test/__snapshots__/start.test.js.snap
new file mode 100644
index 0000000..5648516
--- /dev/null
+++ b/test/__snapshots__/start.test.js.snap
@@ -0,0 +1,12 @@
+// Jest Snapshot v1, https://goo.gl/fbAQLP
+
+exports[`start order of notifications 1`] = `
+Array [
+  "Starting the development server.",
+  "Wait ...",
+  "CSS finished compiling.",
+  "Webpack notification 1",
+  "CSS finished compiling.",
+  "Webpack notification 2",
+]
+`;
diff --git a/test/build-html.test.js b/test/build-html.test.js
new file mode 100644
index 0000000..729f229
--- /dev/null
+++ b/test/build-html.test.js
@@ -0,0 +1,176 @@
+'use strict';
+
+const fs = require('fs');
+const path = require('path');
+const UglifyJs = require('uglify-js');
+const buildHtml = require('../src/node/build-html');
+const errorTypes = require('../src/node/error-types');
+
+const MOCK_ASSETS_DIRECTORY = '/mock/output/assets';
+const MOCK_MANIFEST_JS_PATH = 'mock-asset-manifest-js-path';
+const MOCK_MANIFEST_JS = 'mock-asset-manifest-js-content';
+
+jest.mock('uglify-js', () => {
+  return {
+    minify: jest.fn(() => ({
+      code: 'minified-js',
+      error: null
+    }))
+  };
+});
+
+jest.mock('../src/node/get-webpack-asset-absolute-path', () => {
+  return jest.fn(() => 'mock-asset-manifest-js-path');
+});
+
+jest.mock(
+  '/mock/output/assets/static-render-pages.js',
+  () => {
+    return {
+      default: jest.fn(() => Promise.resolve())
+    };
+  },
+  { virtual: true }
+);
+
+describe('buildHtml', () => {
+  let batfishConfig;
+
+  beforeEach(() => {
+    batfishConfig = {
+      outputDirectory: '/mock/output',
+      siteBasePath: '/base',
+      stylesheets: ['one.css', 'two.css']
+    };
+    jest.spyOn(fs, 'readFileSync').mockImplementation(filename => {
+      if (filename === path.join(MOCK_ASSETS_DIRECTORY, 'assets.json')) {
+        return JSON.stringify({
+          manifest: { js: 'mock-manifest.js' },
+          app: { js: 'mock-app.js' },
+          vendor: { js: 'mock-app.js' }
+        });
+      } else if (filename === MOCK_MANIFEST_JS_PATH) {
+        return MOCK_MANIFEST_JS;
+      }
+    });
+  });
+
+  afterEach(() => {
+    fs.readFileSync.mockRestore();
+  });
+
+  test('reads assets.json', () => {
+    return buildHtml(batfishConfig, 'mock-stylesheet.css').then(() => {
+      expect(fs.readFileSync).toHaveBeenCalledWith(
+        path.join(MOCK_ASSETS_DIRECTORY, 'assets.json'),
+        'utf8'
+      );
+    });
+  });
+
+  test('reads manifest', () => {
+    return buildHtml(batfishConfig, 'mock-stylesheet.css').then(() => {
+      expect(fs.readFileSync).toHaveBeenCalledWith(
+        MOCK_MANIFEST_JS_PATH,
+        'utf8'
+      );
+    });
+  });
+
+  test('uglifies manifest', () => {
+    return buildHtml(batfishConfig, 'mock-stylesheet.css').then(() => {
+      expect(UglifyJs.minify).toHaveBeenCalledWith(MOCK_MANIFEST_JS);
+    });
+  });
+
+  test('runs the compiled static-render-pages.js', () => {
+    // eslint-disable-next-line node/no-missing-require
+    const mockStaticRenderPages = require('/mock/output/assets/static-render-pages.js')
+      .default;
+    return buildHtml(batfishConfig, 'mock-stylesheet.css').then(() => {
+      expect(mockStaticRenderPages).toHaveBeenCalledTimes(1);
+      expect(mockStaticRenderPages).toHaveBeenCalledWith(
+        batfishConfig,
+        {
+          manifest: { js: 'mock-manifest.js' },
+          app: { js: 'mock-app.js' },
+          vendor: { js: 'mock-app.js' }
+        },
+        'minified-js',
+        '/base/assets/mock-stylesheet.css'
+      );
+    });
+  });
+
+  test('handles errors when reading assets.json', () => {
+    const expectedError = new Error();
+    fs.readFileSync.mockImplementation(filename => {
+      if (filename === path.join(MOCK_ASSETS_DIRECTORY, 'assets.json')) {
+        throw expectedError;
+      } else {
+        return '';
+      }
+    });
+    expect(buildHtml(batfishConfig)).rejects.toBe(expectedError);
+  });
+
+  test('handles errors when reading man', () => {
+    const expectedError = new Error();
+    fs.readFileSync.mockImplementation(filename => {
+      if (filename === path.join(MOCK_ASSETS_DIRECTORY, 'assets.json')) {
+        return JSON.stringify({ manifest: { js: 'mock-manifest.js' } });
+      } else if (filename === MOCK_MANIFEST_JS_PATH) {
+        throw expectedError;
+      }
+    });
+    expect(buildHtml(batfishConfig)).rejects.toBe(expectedError);
+  });
+
+  test('if there are no stylesheets, no cssUrl argument is passed to staticRenderPages', () => {
+    // eslint-disable-next-line node/no-missing-require
+    const mockStaticRenderPages = require('/mock/output/assets/static-render-pages.js')
+      .default;
+    batfishConfig.stylesheets = [];
+    return buildHtml(batfishConfig).then(() => {
+      expect(mockStaticRenderPages.mock.calls[0][3]).toBeUndefined();
+    });
+  });
+
+  test('catches errors parsing compiled static-render-pages.js', () => {
+    const expectedError = new Error();
+    // eslint-disable-next-line node/no-missing-require
+    const mockStaticRenderPages = require('/mock/output/assets/static-render-pages.js')
+      .default;
+    mockStaticRenderPages.mockImplementation(() => {
+      throw expectedError;
+    });
+    return buildHtml(batfishConfig).then(
+      () => {
+        throw new Error('should have errored');
+      },
+      error => {
+        expect(error).toBeInstanceOf(errorTypes.WebpackNodeParseError);
+        expect(error.originalError).toBe(expectedError);
+      }
+    );
+  });
+
+  test('catches errors executing compiled static-render-pages.js', () => {
+    const expectedError = new Error();
+    // eslint-disable-next-line node/no-missing-require
+    const mockStaticRenderPages = require('/mock/output/assets/static-render-pages.js')
+      .default;
+    mockStaticRenderPages.mockImplementation(() =>
+      Promise.reject(expectedError)
+    );
+    return buildHtml(batfishConfig).then(
+      () => {
+        throw new Error('should have errored');
+      },
+      error => {
+        expect(error).toBeInstanceOf(errorTypes.WebpackNodeExecutionError);
+        expect(error.originalError).toBe(expectedError);
+      }
+    );
+  });
+});
diff --git a/test/build.test.js b/test/build.test.js
new file mode 100644
index 0000000..c264a08
--- /dev/null
+++ b/test/build.test.js
@@ -0,0 +1,512 @@
+'use strict';
+
+const stripAnsi = require('strip-ansi');
+const build = require('../src/node/build');
+const constants = require('../src/node/constants');
+const inlineCss = require('../src/node/inline-css');
+const generateSitemap = require('../src/node/generate-sitemap');
+const compileStylesheets = require('../src/node/compile-stylesheets');
+const createWebpackConfigClient = require('../src/node/create-webpack-config-client');
+const createWebpackConfigStatic = require('../src/node/create-webpack-config-static');
+const maybeClearOutputDirectory = require('../src/node/maybe-clear-output-directory');
+const copyNonPageFiles = require('../src/node/copy-non-page-files');
+const writeWebpackStats = require('../src/node/write-webpack-stats');
+const buildHtml = require('../src/node/build-html');
+const webpackCompilePromise = require('../src/node/webpack-compile-promise');
+const validateConfig = require('../src/node/validate-config');
+
+jest.mock('../src/node/inline-css', () => {
+  const mockEmitter = {
+    on: jest.fn()
+  };
+  const fn = jest.fn(() => mockEmitter);
+  fn.mockEmitter = mockEmitter;
+  return fn;
+});
+
+jest.mock('../src/node/generate-sitemap', () => {
+  return jest.fn(() => Promise.resolve());
+});
+
+jest.mock('../src/node/compile-stylesheets', () => {
+  return jest.fn(() => Promise.resolve('mock-stylesheet.css'));
+});
+
+jest.mock('../src/node/create-webpack-config-client', () => {
+  return jest.fn(() => Promise.resolve({ mockConfigClient: true }));
+});
+
+jest.mock('../src/node/create-webpack-config-static', () => {
+  return jest.fn(() => Promise.resolve({ mockConfigStatic: true }));
+});
+
+jest.mock('../src/node/maybe-clear-output-directory', () => {
+  return jest.fn(() => Promise.resolve());
+});
+
+jest.mock('../src/node/copy-non-page-files', () => {
+  return jest.fn(() => Promise.resolve());
+});
+
+jest.mock('../src/node/write-webpack-stats', () => {
+  return jest.fn(() => Promise.resolve());
+});
+
+jest.mock('../src/node/build-html', () => {
+  return jest.fn(() => Promise.resolve());
+});
+
+jest.mock('../src/node/webpack-compile-promise', () => {
+  return jest.fn(() => Promise.resolve());
+});
+
+jest.mock('../src/node/validate-config', () => {
+  const fn = jest.fn(() => fn.mockValidatedConfig);
+  fn.mockValidatedConfig = {};
+  return fn;
+});
+
+describe('build', () => {
+  beforeEach(() => {
+    validateConfig.mockValidatedConfig = {
+      outputDirectory: '/mock/output',
+      production: true,
+      siteOrigin: 'https://www.mapbox.com',
+      verbose: false
+    };
+  });
+
+  test('validates the config after setting production: true', () => {
+    const rawConfig = {
+      foo: 'bar'
+    };
+    const emitter = build(rawConfig, 'project-directory');
+    emitter.on(constants.EVENT_ERROR, logEmitterError);
+    expect(validateConfig).toHaveBeenCalledWith(
+      {
+        foo: 'bar',
+        production: true
+      },
+      'project-directory'
+    );
+  });
+
+  test('does not set production: true if production is not undefined', () => {
+    const rawConfig = {
+      foo: 'bar',
+      production: false
+    };
+    const emitter = build(rawConfig, 'project-directory');
+    emitter.on(constants.EVENT_ERROR, logEmitterError);
+    expect(validateConfig).toHaveBeenCalledWith(
+      {
+        foo: 'bar',
+        production: false
+      },
+      'project-directory'
+    );
+  });
+
+  test('catches errors while validating config', done => {
+    const expectedError = new Error();
+    validateConfig.mockImplementationOnce(() => {
+      throw expectedError;
+    });
+    const emitter = build({ badProperty: true });
+    emitter.on(constants.EVENT_ERROR, error => {
+      expect(error).toBe(expectedError);
+      done();
+    });
+  });
+
+  test('maybe clears the output directory', () => {
+    const emitter = build();
+    emitter.on(constants.EVENT_ERROR, logEmitterError);
+    expect(maybeClearOutputDirectory).toHaveBeenCalledWith(
+      validateConfig.mockValidatedConfig
+    );
+  });
+
+  test('compiles stylesheets if there are some', done => {
+    validateConfig.mockValidatedConfig.stylesheets = ['one.css', 'two.css'];
+    const emitter = build();
+    emitter.on(constants.EVENT_ERROR, logEmitterError);
+    process.nextTick(() => {
+      expect(compileStylesheets).toHaveBeenCalledTimes(1);
+      expect(compileStylesheets).toHaveBeenCalledWith(
+        validateConfig.mockValidatedConfig,
+        '/mock/output/assets'
+      );
+      done();
+    });
+  });
+
+  test('does not compile stylesheets if there are none', done => {
+    validateConfig.mockValidatedConfig.stylesheets = [];
+    const emitter = build();
+    emitter.on(constants.EVENT_ERROR, logEmitterError);
+    process.nextTick(() => {
+      expect(compileStylesheets).not.toHaveBeenCalled();
+      done();
+    });
+  });
+
+  test('catches errors when compiling stylesheets', done => {
+    const expectedError = new Error();
+    compileStylesheets.mockReturnValueOnce(Promise.reject(expectedError));
+    validateConfig.mockValidatedConfig.stylesheets = ['one.css', 'two.css'];
+    const emitter = build();
+    emitter.on(constants.EVENT_ERROR, error => {
+      expect(error).toBe(expectedError);
+      done();
+    });
+  });
+
+  test('creates client webpack config', done => {
+    const emitter = build();
+    emitter.on(constants.EVENT_ERROR, logEmitterError);
+    process.nextTick(() => {
+      expect(createWebpackConfigClient).toHaveBeenCalledTimes(1);
+      expect(createWebpackConfigClient).toHaveBeenCalledWith({
+        outputDirectory: '/mock/output/assets',
+        production: true,
+        siteOrigin: 'https://www.mapbox.com',
+        verbose: false
+      });
+      done();
+    });
+  });
+
+  test('catches errors when creating client webpack config', done => {
+    const expectedError = new Error();
+    createWebpackConfigClient.mockReturnValueOnce(
+      Promise.reject(expectedError)
+    );
+    const emitter = build();
+    emitter.on(constants.EVENT_ERROR, error => {
+      expect(error).toBe(expectedError);
+      done();
+    });
+  });
+
+  test('runs client webpack process', done => {
+    const webpackConfig = {};
+    createWebpackConfigClient.mockReturnValueOnce(
+      Promise.resolve(webpackConfig)
+    );
+    const emitter = build();
+    emitter.on(constants.EVENT_ERROR, logEmitterError);
+    process.nextTick(() => {
+      expect(webpackCompilePromise).toHaveBeenCalledWith(webpackConfig);
+      done();
+    });
+  });
+
+  test('catches errors when running client webpack process', done => {
+    const expectedError = new Error();
+    webpackCompilePromise.mockReturnValueOnce(Promise.reject(expectedError));
+    const emitter = build();
+    emitter.on(constants.EVENT_ERROR, error => {
+      expect(error).toBe(expectedError);
+      done();
+    });
+  });
+
+  test('writes stats from client webpack process', done => {
+    const stats = {};
+    webpackCompilePromise.mockReturnValueOnce(Promise.resolve(stats));
+    const emitter = build();
+    emitter.on(constants.EVENT_ERROR, logEmitterError);
+    process.nextTick(() => {
+      expect(writeWebpackStats).toHaveBeenCalledWith('/mock/output', stats);
+      done();
+    });
+  });
+
+  test('catches errors when writing stats', done => {
+    const expectedError = new Error();
+    writeWebpackStats.mockReturnValueOnce(Promise.reject(expectedError));
+    const emitter = build();
+    emitter.on(constants.EVENT_ERROR, error => {
+      expect(error).toBe(expectedError);
+      done();
+    });
+  });
+
+  test('creates static webpack config', done => {
+    const emitter = build();
+    emitter.on(constants.EVENT_ERROR, logEmitterError);
+    process.nextTick(() => {
+      expect(createWebpackConfigStatic).toHaveBeenCalledTimes(1);
+      expect(createWebpackConfigStatic).toHaveBeenCalledWith({
+        outputDirectory: '/mock/output/assets',
+        production: true,
+        siteOrigin: 'https://www.mapbox.com',
+        verbose: false
+      });
+      done();
+    });
+  });
+
+  test('catches errors when creating static webpack config', done => {
+    const expectedError = new Error();
+    createWebpackConfigStatic.mockReturnValueOnce(
+      Promise.reject(expectedError)
+    );
+    const emitter = build();
+    emitter.on(constants.EVENT_ERROR, error => {
+      expect(error).toBe(expectedError);
+      done();
+    });
+  });
+
+  test('runs static webpack process', done => {
+    const webpackConfig = {};
+    createWebpackConfigStatic.mockReturnValueOnce(
+      Promise.resolve(webpackConfig)
+    );
+    const emitter = build();
+    emitter.on(constants.EVENT_ERROR, logEmitterError);
+    process.nextTick(() => {
+      expect(webpackCompilePromise).toHaveBeenCalledWith(webpackConfig);
+      done();
+    });
+  });
+
+  test('catches errors when running static webpack process', done => {
+    const expectedError = new Error();
+    webpackCompilePromise.mockReturnValueOnce(Promise.reject(expectedError));
+    const emitter = build();
+    emitter.on(constants.EVENT_ERROR, error => {
+      expect(error).toBe(expectedError);
+      done();
+    });
+  });
+
+  test('copies non-page files', done => {
+    const emitter = build();
+    emitter.on(constants.EVENT_ERROR, logEmitterError);
+    process.nextTick(() => {
+      expect(copyNonPageFiles).toHaveBeenCalledTimes(1);
+      expect(copyNonPageFiles).toHaveBeenCalledWith(
+        validateConfig.mockValidatedConfig
+      );
+      done();
+    });
+  });
+
+  test('catches errors when copying non-page files', done => {
+    const expectedError = new Error();
+    copyNonPageFiles.mockReturnValueOnce(Promise.reject(expectedError));
+    const emitter = build();
+    emitter.on(constants.EVENT_ERROR, error => {
+      expect(error).toBe(expectedError);
+      done();
+    });
+  });
+
+  test('renders HTML', done => {
+    const emitter = build();
+    emitter.on(constants.EVENT_ERROR, logEmitterError);
+    process.nextTick(() => {
+      expect(buildHtml).toHaveBeenCalledTimes(1);
+      expect(buildHtml).toHaveBeenCalledWith(
+        validateConfig.mockValidatedConfig,
+        undefined
+      );
+      done();
+    });
+  });
+
+  test('renders HTML with CSS', done => {
+    validateConfig.mockValidatedConfig.stylesheets = ['one.css', 'two.css'];
+    const emitter = build();
+    emitter.on(constants.EVENT_ERROR, logEmitterError);
+    process.nextTick(() => {
+      expect(buildHtml).toHaveBeenCalledTimes(1);
+      expect(buildHtml).toHaveBeenCalledWith(
+        validateConfig.mockValidatedConfig,
+        'mock-stylesheet.css'
+      );
+      done();
+    });
+  });
+
+  test('catches errors when rendering HTML', done => {
+    const expectedError = new Error();
+    buildHtml.mockReturnValueOnce(Promise.reject(expectedError));
+    const emitter = build();
+    emitter.on(constants.EVENT_ERROR, error => {
+      expect(error).toBe(expectedError);
+      done();
+    });
+  });
+
+  test('if there are no stylesheets, skip CSS inlining', done => {
+    const emitter = build();
+    emitter.on(constants.EVENT_ERROR, logEmitterError);
+    process.nextTick(() => {
+      expect(inlineCss).not.toHaveBeenCalled();
+      done();
+    });
+  });
+
+  test('if there are stylesheets but production option is not true, skip CSS inlining', done => {
+    validateConfig.mockValidatedConfig.stylesheets = ['one.css', 'two.css'];
+    validateConfig.mockValidatedConfig.production = false;
+    const emitter = build();
+    emitter.on(constants.EVENT_ERROR, logEmitterError);
+    process.nextTick(() => {
+      expect(inlineCss).not.toHaveBeenCalled();
+      done();
+    });
+  });
+
+  test('if there are stylesheets and production is true, inline CSS', done => {
+    validateConfig.mockValidatedConfig.stylesheets = ['one.css', 'two.css'];
+    const emitter = build();
+    emitter.on(constants.EVENT_ERROR, logEmitterError);
+    process.nextTick(() => {
+      expect(
+        inlineCss
+      ).toHaveBeenCalledWith('/mock/output', 'mock-stylesheet.css', {
+        verbose: false
+      });
+      done();
+    });
+  });
+
+  test('catches errors while inlining CSS', done => {
+    const expectedError = new Error();
+    validateConfig.mockValidatedConfig.stylesheets = ['one.css', 'two.css'];
+    const emitter = build();
+    emitter.on(constants.EVENT_ERROR, error => {
+      expect(error).toBe(expectedError);
+      done();
+    });
+    process.nextTick(() => {
+      const errorHandler = inlineCss.mockEmitter.on.mock.calls.find(
+        call => call[0] === constants.EVENT_ERROR
+      )[1];
+      errorHandler(expectedError);
+    });
+  });
+
+  test('handles notifications while inlining CSS', done => {
+    validateConfig.mockValidatedConfig.stylesheets = ['one.css', 'two.css'];
+    const emitter = build();
+    emitter.on(constants.EVENT_ERROR, logEmitterError);
+    process.nextTick(() => {
+      const notificationHandler = inlineCss.mockEmitter.on.mock.calls.find(
+        call => call[0] === constants.EVENT_NOTIFICATION
+      )[1];
+      emitter.on(constants.EVENT_NOTIFICATION, message => {
+        expect(message).toBe('xyz');
+        done();
+      });
+      notificationHandler('xyz');
+    });
+  });
+
+  test('does not finish until the inlining CSS fires its done event', done => {
+    validateConfig.mockValidatedConfig.stylesheets = ['one.css', 'two.css'];
+    const emitter = build();
+    emitter.on(constants.EVENT_ERROR, logEmitterError);
+    let buildDone = false;
+    emitter.on(constants.EVENT_DONE, () => {
+      buildDone = true;
+      done();
+    });
+    process.nextTick(() => {
+      const doneHandler = inlineCss.mockEmitter.on.mock.calls.find(
+        call => call[0] === constants.EVENT_DONE
+      )[1];
+      expect(buildDone).toBe(false);
+      doneHandler();
+    });
+  });
+
+  test('if no CSS is inlined, build can complete', done => {
+    validateConfig.mockValidatedConfig.stylesheets = [];
+    const emitter = build();
+    emitter.on(constants.EVENT_ERROR, logEmitterError);
+    emitter.on(constants.EVENT_DONE, () => {
+      done();
+    });
+  });
+
+  test('if config includes siteOrigin, creates a sitemap', done => {
+    const emitter = build();
+    emitter.on(constants.EVENT_ERROR, logEmitterError);
+    process.nextTick(() => {
+      expect(generateSitemap).toHaveBeenCalledWith(
+        validateConfig.mockValidatedConfig
+      );
+      done();
+    });
+  });
+
+  test('handles errors creating a sitemap', done => {
+    const expectedError = new Error();
+    generateSitemap.mockReturnValueOnce(Promise.reject(expectedError));
+    const emitter = build();
+    emitter.on(constants.EVENT_ERROR, error => {
+      expect(error).toBe(expectedError);
+      done();
+    });
+  });
+
+  test('if config does not include siteOrigin, does not create a sitemap and emits a notification', done => {
+    validateConfig.mockValidatedConfig.siteOrigin = undefined;
+    const emitter = build();
+    emitter.on(constants.EVENT_ERROR, logEmitterError);
+    emitter.on(constants.EVENT_NOTIFICATION, message => {
+      if (/unable to generate sitemap/.test(message)) {
+        done();
+      }
+    });
+  });
+
+  test('order of notifications, without CSS inlining or sitemap', done => {
+    delete validateConfig.mockValidatedConfig.siteOrigin;
+    const notifications = [];
+    const emitter = build();
+    emitter.on(constants.EVENT_ERROR, logEmitterError);
+    emitter.on(constants.EVENT_NOTIFICATION, message => {
+      notifications.push(stripAnsi(message));
+    });
+    emitter.on(constants.EVENT_DONE, () => {
+      expect(notifications).toMatchSnapshot();
+      done();
+    });
+  });
+
+  test('order of notifications, *with* CSS inlining and sitemap', done => {
+    validateConfig.mockValidatedConfig.stylesheets = ['one.css', 'two.css'];
+    const notifications = [];
+    const emitter = build();
+    emitter.on(constants.EVENT_ERROR, logEmitterError);
+    emitter.on(constants.EVENT_NOTIFICATION, message => {
+      notifications.push(stripAnsi(message));
+    });
+    emitter.on(constants.EVENT_DONE, () => {
+      expect(notifications).toMatchSnapshot();
+      done();
+    });
+    process.nextTick(() => {
+      const cssInliningNotificationHandler = inlineCss.mockEmitter.on.mock.calls.find(
+        call => call[0] === constants.EVENT_NOTIFICATION
+      )[1];
+      cssInliningNotificationHandler('CSS inlining message 1');
+      cssInliningNotificationHandler('CSS inlining message 2');
+      const cssInliningDoneHandler = inlineCss.mockEmitter.on.mock.calls.find(
+        call => call[0] === constants.EVENT_DONE
+      )[1];
+      cssInliningDoneHandler();
+    });
+  });
+});
+
+function logEmitterError(error) {
+  console.error(error.stack); // eslint-disable-line no-console
+}
diff --git a/test/change-page.test.js b/test/change-page.test.js
new file mode 100644
index 0000000..e81ea8b
--- /dev/null
+++ b/test/change-page.test.js
@@ -0,0 +1,238 @@
+'use strict';
+
+const scrollRestorer = require('@mapbox/scroll-restorer');
+const changePage = require('../src/webpack/change-page').changePage;
+const findMatchingRouteModule = require('../src/webpack/find-matching-route');
+const scrollToFragment = require('../src/webpack/scroll-to-fragment')
+  .scrollToFragment;
+/* eslint-disable node/no-missing-require */
+const _invokeRouteChangeStartCallbacks = require('@mapbox/batfish/modules/route-change-listeners')
+  ._invokeRouteChangeStartCallbacks;
+const _invokeRouteChangeEndCallbacks = require('@mapbox/batfish/modules/route-change-listeners')
+  ._invokeRouteChangeEndCallbacks;
+/* eslint-enable node/no-missing-require */
+const getWindowModule = require('../src/webpack/get-window');
+
+jest.mock('../src/webpack/scroll-to-fragment', () => {
+  return {
+    scrollToFragment: jest.fn()
+  };
+});
+
+jest.mock('../src/webpack/get-current-location', () => {
+  return {
+    getCurrentLocation: jest.fn(() => ({
+      pathname: '/current/location/',
+      hash: '',
+      search: ''
+    }))
+  };
+});
+
+jest.mock('../src/webpack/find-matching-route', () => {
+  const mock = {};
+  mock.mockMatchinRoutePageModule = {
+    component: {},
+    props: {}
+  };
+  mock.mockMatchingRoute = {
+    path: '/bar/baz/',
+    getPage: jest.fn(() => Promise.resolve(mock.mockMatchinRoutePageModule))
+  };
+  mock.findMatchingRoute = jest.fn(() => mock.mockMatchingRoute);
+  return mock;
+});
+
+jest.mock(
+  '@mapbox/batfish/modules/route-change-listeners',
+  () => {
+    return {
+      _invokeRouteChangeStartCallbacks: jest.fn(() => Promise.resolve()),
+      _invokeRouteChangeEndCallbacks: jest.fn(() => Promise.resolve())
+    };
+  },
+  { virtual: true }
+);
+
+jest.mock('@mapbox/scroll-restorer', () => {
+  return {
+    start: jest.fn(),
+    restoreScroll: jest.fn(),
+    getSavedScroll: jest.fn(() => null)
+  };
+});
+
+describe('changePage', () => {
+  let mockWindow;
+  let setRouterState;
+  let nextLocation;
+
+  beforeEach(() => {
+    setRouterState = jest.fn();
+    nextLocation = {
+      pathname: '/bar/baz/',
+      hash: '',
+      search: ''
+    };
+    mockWindow = {
+      location: {
+        pathname: '/foo/',
+        hash: '',
+        search: ''
+      },
+      history: {
+        pushState: jest.fn()
+      },
+      scrollTo: jest.fn()
+    };
+    jest.spyOn(getWindowModule, 'getWindow').mockReturnValue(mockWindow);
+  });
+
+  afterEach(() => {
+    getWindowModule.getWindow.mockRestore();
+  });
+
+  test('finds the matching route', () => {
+    return changePage(nextLocation, setRouterState).then(() => {
+      expect(findMatchingRouteModule.findMatchingRoute).toHaveBeenCalledTimes(
+        1
+      );
+      expect(findMatchingRouteModule.findMatchingRoute).toHaveBeenCalledWith(
+        '/bar/baz/'
+      );
+    });
+  });
+
+  test('invokes change-start callbacks right away', () => {
+    const result = changePage(nextLocation, setRouterState);
+    expect(_invokeRouteChangeStartCallbacks).toHaveBeenCalledTimes(1);
+    expect(_invokeRouteChangeStartCallbacks).toHaveBeenCalledWith('/bar/baz/');
+    expect(_invokeRouteChangeEndCallbacks).toHaveBeenCalledTimes(0);
+    return result;
+  });
+
+  test('allows change-start callbacks to delay resolution', done => {
+    jest.useFakeTimers();
+    const wait = new Promise(resolve => {
+      setTimeout(resolve, 500);
+    });
+    _invokeRouteChangeStartCallbacks.mockReturnValueOnce(wait);
+    changePage(nextLocation, setRouterState);
+    jest.runTimersToTime(400);
+    expect(setRouterState).not.toHaveBeenCalled();
+    jest.runAllTimers();
+    process.nextTick(() => {
+      expect(setRouterState).toHaveBeenCalled();
+      jest.useRealTimers();
+      done();
+    });
+  });
+
+  test('does not invoke pushState if not instructed to', () => {
+    return changePage(nextLocation, setRouterState).then(() => {
+      expect(mockWindow.history.pushState).not.toHaveBeenCalled();
+    });
+  });
+
+  test('invokes pushState if instructed to', () => {
+    return changePage(nextLocation, setRouterState, {
+      pushState: true
+    }).then(() => {
+      expect(mockWindow.history.pushState).toHaveBeenCalledWith(
+        {},
+        null,
+        '/bar/baz/'
+      );
+    });
+  });
+
+  test('invokes setRouterState', () => {
+    return changePage(nextLocation, setRouterState).then(() => {
+      expect(setRouterState).toHaveBeenCalledWith(
+        {
+          path: '/bar/baz/',
+          PageComponent:
+            findMatchingRouteModule.mockMatchinRoutePageModule.component,
+          pageProps: findMatchingRouteModule.mockMatchinRoutePageModule.props,
+          location: {
+            pathname: '/current/location/',
+            hash: '',
+            search: ''
+          }
+        },
+        expect.any(Function)
+      );
+    });
+  });
+
+  test('setRouterState callback does not scroll to top unless instructed to', () => {
+    return changePage(nextLocation, setRouterState).then(() => {
+      const callback = setRouterState.mock.calls[0][1];
+      callback();
+      expect(mockWindow.scrollTo).not.toHaveBeenCalled();
+      expect(scrollRestorer.restoreScroll).not.toHaveBeenCalled();
+    });
+  });
+
+  test('setRouterState callback does scroll to top if instructed to', () => {
+    return changePage(nextLocation, setRouterState, {
+      scrollToTop: true
+    }).then(() => {
+      expect(mockWindow.scrollTo).not.toHaveBeenCalled();
+      const callback = setRouterState.mock.calls[0][1];
+      callback();
+      expect(mockWindow.scrollTo).toHaveBeenCalledWith(0, 0);
+      expect(scrollRestorer.restoreScroll).not.toHaveBeenCalled();
+    });
+  });
+
+  test('setRouterState callback restores saved scroll', () => {
+    scrollRestorer.getSavedScroll.mockReturnValueOnce({});
+    return changePage(nextLocation, setRouterState).then(() => {
+      expect(scrollRestorer.restoreScroll).not.toHaveBeenCalled();
+      const callback = setRouterState.mock.calls[0][1];
+      callback();
+      expect(mockWindow.scrollTo).not.toHaveBeenCalled();
+      expect(scrollRestorer.restoreScroll).toHaveBeenCalled();
+    });
+  });
+
+  test('setRouterState callback does not restore scroll if none is saved', () => {
+    return changePage(nextLocation, setRouterState).then(() => {
+      const callback = setRouterState.mock.calls[0][1];
+      callback();
+      expect(mockWindow.scrollTo).not.toHaveBeenCalled();
+      expect(scrollRestorer.restoreScroll).not.toHaveBeenCalled();
+    });
+  });
+
+  test('setRouterState callback tries scrolling to fragment if no other scrolling happens', () => {
+    return changePage(nextLocation, setRouterState).then(() => {
+      expect(scrollToFragment).not.toHaveBeenCalled();
+      const callback = setRouterState.mock.calls[0][1];
+      callback();
+      expect(mockWindow.scrollTo).not.toHaveBeenCalled();
+      expect(scrollRestorer.restoreScroll).not.toHaveBeenCalled();
+      expect(scrollToFragment).toHaveBeenCalled();
+    });
+  });
+
+  test('setRouterState callback invokes change-end callbacks', () => {
+    return changePage(nextLocation, setRouterState).then(() => {
+      expect(_invokeRouteChangeEndCallbacks).not.toHaveBeenCalled();
+      const callback = setRouterState.mock.calls[0][1];
+      callback();
+      expect(_invokeRouteChangeEndCallbacks).toHaveBeenCalledWith('/bar/baz/');
+    });
+  });
+
+  test('setRouterState callback invokes onFinish callback', () => {
+    const onFinish = jest.fn();
+    return changePage(nextLocation, setRouterState, {}, onFinish).then(() => {
+      expect(onFinish).not.toHaveBeenCalled();
+      const callback = setRouterState.mock.calls[0][1];
+      callback();
+      expect(onFinish).toHaveBeenCalledWith();
+    });
+  });
+});
diff --git a/test/copy-non-page-files.test.js b/test/copy-non-page-files.test.js
new file mode 100644
index 0000000..7c0edd7
--- /dev/null
+++ b/test/copy-non-page-files.test.js
@@ -0,0 +1,45 @@
+'use strict';
+
+const cpy = require('cpy');
+const copyNonPageFiles = require('../src/node/copy-non-page-files');
+
+jest.mock('cpy', () => {
+  return jest.fn(() => Promise.resolve());
+});
+
+describe('copyNonPageFiles', () => {
+  test('copies files that are not .js, .md, or .css', () => {
+    copyNonPageFiles({
+      outputDirectory: 'mockOutputDirectory',
+      pagesDirectory: 'mockPagesDirectory'
+    }).then(() => {
+      expect(cpy).toHaveBeenCalledTimes(1);
+      expect(cpy).toHaveBeenCalledWith(
+        ['**/*.!(js|md|css)'],
+        'mockOutputDirectory',
+        {
+          cwd: 'mockPagesDirectory',
+          parents: true
+        }
+      );
+    });
+  });
+
+  test('copies files designated by unprocessedPageFiles option', () => {
+    copyNonPageFiles({
+      outputDirectory: 'mockOutputDirectory',
+      pagesDirectory: 'mockPagesDirectory',
+      unprocessedPageFiles: ['**/home.js', '**/horse.md']
+    }).then(() => {
+      expect(cpy).toHaveBeenCalledTimes(1);
+      expect(cpy).toHaveBeenCalledWith(
+        ['**/*.!(js|md|css)', '**/home.js', '**/horse.md'],
+        'mockOutputDirectory',
+        {
+          cwd: 'mockPagesDirectory',
+          parents: true
+        }
+      );
+    });
+  });
+});
diff --git a/test/create-server.test.js b/test/create-server.test.js
new file mode 100644
index 0000000..c6ec620
--- /dev/null
+++ b/test/create-server.test.js
@@ -0,0 +1,74 @@
+'use strict';
+
+const browserSync = require('browser-sync');
+const getPort = require('get-port');
+const createServer = require('../src/node/create-server');
+
+jest.mock('browser-sync', () => {
+  const instance = {
+    emitter: {
+      on: jest.fn()
+    },
+    reload: jest.fn(),
+    init: jest.fn()
+  };
+  return {
+    create: jest.fn(() => instance),
+    get: () => instance
+  };
+});
+
+jest.mock('get-port', () => {
+  return jest.fn(() => Promise.resolve(1234));
+});
+
+describe('createServer', () => {
+  test('initializes', () => {
+    const browserSyncOptions = {};
+    const onError = jest.fn();
+    const server = createServer({ browserSyncOptions, onError });
+    expect(browserSync.create).toHaveBeenCalledTimes(1);
+    expect(browserSync.get().emitter.on).toHaveBeenCalledTimes(1);
+    expect(browserSync.get().emitter.on).toHaveBeenCalledWith('error', onError);
+    expect(server.browserSyncInstance).toBe(browserSync.get());
+  });
+
+  test('start', () => {
+    const browserSyncOptions = { foo: 'bar', baz: 7, port: 9876 };
+    const onError = jest.fn();
+    const server = createServer({ browserSyncOptions, onError });
+    server.start();
+    expect(getPort).toHaveBeenCalledTimes(1);
+    expect(getPort).toHaveBeenCalledWith(9876);
+    return Promise.resolve().then(() => {
+      expect(browserSync.get().init).toHaveBeenCalledTimes(1);
+      expect(browserSync.get().init).toHaveBeenCalledWith({
+        foo: 'bar',
+        baz: 7,
+        port: 1234
+      });
+    });
+  });
+
+  test('start catches getPort errors', () => {
+    const error = new Error();
+    getPort.mockReturnValue(Promise.reject(error));
+    const browserSyncOptions = { foo: 'bar', baz: 7, port: 9876 };
+    const onError = jest.fn();
+    const server = createServer({ browserSyncOptions, onError });
+    server.start();
+    return new Promise(resolve => setTimeout(resolve, 100)).then(() => {
+      expect(onError).toHaveBeenCalledTimes(1);
+      expect(onError).toHaveBeenCalledWith(error);
+    });
+  });
+
+  test('reload works', () => {
+    const browserSyncOptions = {};
+    const onError = jest.fn();
+    const server = createServer({ browserSyncOptions, onError });
+    server.reload('tumbleweed.txt');
+    expect(browserSync.get().reload).toHaveBeenCalledTimes(1);
+    expect(browserSync.get().reload).toHaveBeenCalledWith('tumbleweed.txt');
+  });
+});
diff --git a/test/get-current-location.test.js b/test/get-current-location.test.js
new file mode 100644
index 0000000..f5e1e0a
--- /dev/null
+++ b/test/get-current-location.test.js
@@ -0,0 +1,54 @@
+'use strict';
+
+const getCurrentLocation = require('../src/webpack/get-current-location')
+  .getCurrentLocation;
+const getWindowModule = require('../src/webpack/get-window');
+
+describe('getCurrentLocation', () => {
+  let mockWindow;
+  beforeEach(() => {
+    mockWindow = {
+      location: {
+        pathname: '/foo/',
+        hash: '',
+        search: ''
+      }
+    };
+    jest.spyOn(getWindowModule, 'getWindow').mockReturnValue(mockWindow);
+  });
+
+  afterEach(() => {
+    getWindowModule.getWindow.mockRestore();
+  });
+
+  test('returns the pathname', () => {
+    expect(getCurrentLocation()).toEqual({
+      pathname: '/foo/',
+      hash: '',
+      search: ''
+    });
+  });
+
+  test('returns all the things', () => {
+    mockWindow.location = {
+      pathname: '/bar/',
+      hash: '#pig',
+      search: '?horse=apocalypse',
+      extra: 8
+    };
+    expect(getCurrentLocation()).toEqual({
+      pathname: '/bar/',
+      hash: '#pig',
+      search: '?horse=apocalypse'
+    });
+  });
+
+  test('adds a trailing slash to the pathname', () => {
+    mockWindow.location.pathname = '/foo/bar/baz';
+    expect(getCurrentLocation()).toEqual({
+      pathname: '/foo/bar/baz/',
+      hash: '',
+      search: ''
+    });
+  });
+});
diff --git a/test/get-webpack-asset-absolute-path.test.js b/test/get-webpack-asset-absolute-path.test.js
new file mode 100644
index 0000000..71e4368
--- /dev/null
+++ b/test/get-webpack-asset-absolute-path.test.js
@@ -0,0 +1,23 @@
+'use strict';
+
+const getWebpackAssetAbsolutePath = require('../src/node/get-webpack-asset-absolute-path');
+
+describe('getWebpackAssetAbsolutePath', () => {
+  test('strips siteBasePath from start and joins path to outputDirectory', () => {
+    expect(
+      getWebpackAssetAbsolutePath(
+        { siteBasePath: '/foo', outputDirectory: '/output/directory' },
+        '/foo/assets/foo/baz.js'
+      )
+    ).toBe('/output/directory/assets/foo/baz.js');
+  });
+
+  test('works if there is no siteBasePath', () => {
+    expect(
+      getWebpackAssetAbsolutePath(
+        { outputDirectory: '/output/directory' },
+        '/foo/assets/foo/baz.js'
+      )
+    ).toBe('/output/directory/foo/assets/foo/baz.js');
+  });
+});
diff --git a/test/maybe-clear-output-directory.test.js b/test/maybe-clear-output-directory.test.js
new file mode 100644
index 0000000..770e6df
--- /dev/null
+++ b/test/maybe-clear-output-directory.test.js
@@ -0,0 +1,30 @@
+'use strict';
+
+const del = require('del');
+const maybeClearOutputDirectory = require('../src/node/maybe-clear-output-directory');
+
+jest.mock('del', () => {
+  return jest.fn(() => Promise.resolve());
+});
+
+describe('maybeClearOutputDirectory', () => {
+  test('clears the output directory', () => {
+    return maybeClearOutputDirectory({
+      clearOutputDirectory: true,
+      outputDirectory: 'mock/output/directory/'
+    }).then(() => {
+      expect(del).toHaveBeenCalledWith('mock/output/directory/', {
+        force: true
+      });
+    });
+  });
+
+  test('does not clear the output directory, but still returns a Promise', () => {
+    return maybeClearOutputDirectory({
+      clearOutputDirectory: false,
+      outputDirectory: 'mock/output/directory/'
+    }).then(() => {
+      expect(del).not.toHaveBeenCalled();
+    });
+  });
+});
diff --git a/test/prefix-url.test.js b/test/prefix-url.test.js
index 6880615..2f72956 100644
--- a/test/prefix-url.test.js
+++ b/test/prefix-url.test.js
@@ -1,16 +1,29 @@
 'use strict';
 
+// eslint-disable-next-line node/no-missing-require
+const batfishContextModule = require('batfish-internal/context');
 const prefixUrl = require('../src/webpack/public/prefix-url').prefixUrl;
 const prefixUrlAbsolute = require('../src/webpack/public/prefix-url')
   .prefixUrlAbsolute;
 
+jest.mock(
+  'batfish-internal/context',
+  () => {
+    return {
+      batfishContext: {}
+    };
+  },
+  { virtual: true }
+);
+
 describe('prefixUrl', () => {
   beforeEach(() => {
-    prefixUrl._configure();
-  });
-
-  afterEach(() => {
-    prefixUrl._configure();
+    batfishContextModule.batfishContext = {
+      selectedConfig: {
+        siteBasePath: '',
+        siteOrigin: ''
+      }
+    };
   });
 
   test('prefixUrl without starting slash', () => {
@@ -28,11 +41,12 @@ describe('prefixUrl', () => {
 
 describe('createPrefixUrl generated module with siteBasePath and siteOrigin', () => {
   beforeEach(() => {
-    prefixUrl._configure('/foo/bar', 'https://www.test.com');
-  });
-
-  afterEach(() => {
-    prefixUrl._configure();
+    batfishContextModule.batfishContext = {
+      selectedConfig: {
+        siteBasePath: '/foo/bar',
+        siteOrigin: 'https://www.test.com'
+      }
+    };
   });
 
   test('prefixUrl without starting slash', () => {
diff --git a/test/render-html.test.js b/test/render-html.test.js
new file mode 100644
index 0000000..2b5425c
--- /dev/null
+++ b/test/render-html.test.js
@@ -0,0 +1,146 @@
+'use strict';
+
+const _ = require('lodash');
+const ReactDOMServer = require('react-dom/server');
+const renderHtmlPage = require('../src/webpack/render-html-page')
+  .renderHtmlPage;
+
+jest.mock('react-helmet', () => {
+  const mockHelmetHead = {
+    htmlAttributes: {
+      toComponent: jest.fn(() => 'htmlAttributes.mockToComponent')
+    },
+    bodyAttributes: {
+      toComponent: jest.fn(() => 'bodyAttributes.mockToComponent')
+    },
+    title: { toString: jest.fn(() => 'title.mockToString') },
+    base: { toString: jest.fn(() => 'base.mockToString') },
+    meta: { toString: jest.fn(() => 'meta.mockToString') },
+    link: { toString: jest.fn(() => 'link.mockToString') },
+    script: { toString: jest.fn(() => 'script.mockToString') },
+    style: { toString: jest.fn(() => 'style.mockToString') }
+  };
+
+  return {
+    rewind: jest.fn(() => mockHelmetHead)
+  };
+});
+
+jest.mock(
+  'batfish-internal/application-wrapper',
+  () => {
+    const _ = require('lodash');
+    const React = require('react');
+    return function ApplicationWrapper(props) {
+      return React.createElement(
+        'div',
+        { id: 'application-wrapper' },
+        JSON.stringify(_.omit(props, ['children']), null, 2),
+        props.children
+      );
+    };
+  },
+  { virtual: true }
+);
+
+jest.mock('../src/webpack/router', () => {
+  const _ = require('lodash');
+  const React = require('react');
+  return {
+    Router: function Router(props) {
+      return React.createElement(
+        'div',
+        {
+          id: 'router',
+          'data-props': JSON.stringify(_.omit(props, ['children']), null, 2)
+        },
+        props.children
+      );
+    }
+  };
+});
+
+jest.mock('../src/webpack/static-html-page', () => {
+  const _ = require('lodash');
+  const React = require('react');
+  return {
+    StaticHtmlPage: function StaticHtmlPage(props) {
+      return React.createElement(
+        'div',
+        {
+          id: 'static-html-page',
+          'data-props': JSON.stringify(_.omit(props, ['children']), null, 2)
+        },
+        props.children
+      );
+    }
+  };
+});
+
+describe('renderHtmlPage', () => {
+  let mockRoute;
+  let mockPageModule;
+
+  beforeEach(() => {
+    mockPageModule = {
+      component: 'mockPageModule.component',
+      props: 'mockPageModule.props'
+    };
+    mockRoute = {
+      path: '/mock/route/path/',
+      getPage: jest.fn(() => Promise.resolve(mockPageModule))
+    };
+  });
+
+  test('gives us what we want', () => {
+    return renderHtmlPage(mockRoute, 'inline-js-scripts', 'load-css-script', [
+      'append',
+      'to',
+      'body'
+    ]).then(html => {
+      expect(_.unescape(html)).toMatchSnapshot();
+    });
+  });
+
+  test('registers errors from React.renderToString errors', () => {
+    const expectedError = new Error();
+    jest.spyOn(ReactDOMServer, 'renderToString').mockImplementation(() => {
+      throw expectedError;
+    });
+    return renderHtmlPage(mockRoute, 'inline-js-scripts', 'load-css-script', [
+      'append',
+      'to',
+      'body'
+    ]).then(
+      () => {
+        throw new Error('should have errored');
+      },
+      error => {
+        expect(error).toBe(expectedError);
+        ReactDOMServer.renderToString.mockRestore();
+      }
+    );
+  });
+
+  test('registers errors from React.renderToStaticMarkup errors', () => {
+    const expectedError = new Error();
+    jest
+      .spyOn(ReactDOMServer, 'renderToStaticMarkup')
+      .mockImplementation(() => {
+        throw expectedError;
+      });
+    return renderHtmlPage(mockRoute, 'inline-js-scripts', 'load-css-script', [
+      'append',
+      'to',
+      'body'
+    ]).then(
+      () => {
+        throw new Error('should have errored');
+      },
+      error => {
+        expect(error).toBe(expectedError);
+        ReactDOMServer.renderToStaticMarkup.mockRestore();
+      }
+    );
+  });
+});
diff --git a/test/route-change-listeners.test.js b/test/route-change-listeners.test.js
index 866c144..72d3a3d 100644
--- a/test/route-change-listeners.test.js
+++ b/test/route-change-listeners.test.js
@@ -2,6 +2,23 @@
 
 const RouteChangeListeners = require('../src/webpack/public/route-change-listeners');
 
+// This mock indirectly supports route-change-listeners by
+// supporting prefix-url.
+jest.mock(
+  'batfish-internal/context',
+  () => {
+    return {
+      batfishContext: {
+        selectedConfig: {
+          siteBasePath: '',
+          siteOrigin: ''
+        }
+      }
+    };
+  },
+  { virtual: true }
+);
+
 describe('start change listeners', () => {
   test('no listeners', () => {
     expect(() =>
diff --git a/test/router.test.js b/test/router.test.js
index c10e1f4..41234a1 100644
--- a/test/router.test.js
+++ b/test/router.test.js
@@ -2,7 +2,6 @@
  * @jest-environment jsdom
  */
 /* eslint-disable node/no-missing-require */
-/* globals window */
 'use strict';
 
 const React = require('react');
@@ -11,22 +10,14 @@ const mount = require('enzyme').mount;
 const toJson = require('enzyme-to-json').default;
 const Router = require('../src/webpack/router').Router;
 const routeTo = require('@mapbox/batfish/modules/route-to').routeTo;
-const _invokeRouteChangeStartCallbacks = require('@mapbox/batfish/modules/route-change-listeners')
-  ._invokeRouteChangeStartCallbacks;
-const _invokeRouteChangeEndCallbacks = require('@mapbox/batfish/modules/route-change-listeners')
-  ._invokeRouteChangeEndCallbacks;
 const scrollRestorer = require('@mapbox/scroll-restorer');
 const linkHijacker = require('@mapbox/link-hijacker');
 const linkToLocation = require('@mapbox/link-to-location');
+const getWindowModule = require('../src/webpack/get-window');
+const changePage = require('../src/webpack/change-page').changePage;
 const findMatchingRoute = require('../src/webpack/find-matching-route')
   .findMatchingRoute;
 
-jest.mock('../src/webpack/find-matching-route', () => {
-  return {
-    findMatchingRoute: jest.fn()
-  };
-});
-
 jest.mock(
   '@mapbox/batfish/modules/route-to',
   () => {
@@ -47,17 +38,6 @@ jest.mock(
   { virtual: true }
 );
 
-jest.mock(
-  '@mapbox/batfish/modules/route-change-listeners',
-  () => {
-    return {
-      _invokeRouteChangeStartCallbacks: jest.fn(() => Promise.resolve()),
-      _invokeRouteChangeEndCallbacks: jest.fn(() => Promise.resolve())
-    };
-  },
-  { virtual: true }
-);
-
 jest.mock(
   'batfish-internal/context',
   () => {
@@ -92,32 +72,57 @@ jest.mock('@mapbox/link-to-location', () => {
   return jest.fn();
 });
 
-describe('Router', () => {
-  function StartingComponent() {
-    return <div>magic</div>;
-  }
-  const pageProps = { isMagic: true };
+jest.mock('../src/webpack/change-page', () => {
+  return {
+    changePage: jest.fn()
+  };
+});
 
+jest.mock('../src/webpack/find-matching-route', () => {
+  return {
+    findMatchingRoute: jest.fn()
+  };
+});
+
+describe('Router', () => {
+  let StartingComponent;
+  let pageProps;
+  let mockWindow;
   beforeEach(() => {
-    Object.defineProperty(window.location, 'pathname', {
-      writable: true,
-      value: '/magic/'
-    });
-    Object.defineProperty(window.location, 'search', {
-      writable: true,
-      value: ''
-    });
-    Object.defineProperty(window.location, 'hash', {
-      writable: true,
-      value: ''
-    });
-    Object.defineProperty(window.location, 'assign', {
-      writable: true,
-      value: jest.fn()
-    });
+    StartingComponent = function StartingComponent() {
+      return <div>magic</div>;
+    };
+    pageProps = { isMagic: true };
+    mockWindow = {
+      location: {
+        pathname: '/magic/',
+        hash: '',
+        search: '',
+        assign: jest.fn()
+      },
+      addEventListener: jest.fn()
+    };
+    jest.spyOn(getWindowModule, 'getWindow').mockReturnValue(mockWindow);
+  });
+
+  afterEach(() => {
+    getWindowModule.getWindow.mockRestore();
+  });
+
+  test('renders the initial page without a window', () => {
+    const wrapper = shallow(
+      React.createElement(Router, {
+        startingPath: '/magic/',
+        startingComponent: StartingComponent,
+        pageProps: pageProps
+      })
+    );
+    expect(toJson(wrapper)).toMatchSnapshot();
   });
 
-  test('renders the initial page', () => {
+  test('renders the initial page with a window passes current location details', () => {
+    mockWindow.location.search = '?foo=bar';
+    mockWindow.location.hash = '#grr';
     const wrapper = shallow(
       React.createElement(Router, {
         startingPath: '/magic/',
@@ -169,49 +174,39 @@ describe('Router', () => {
       wrapper.instance().routeTo
     );
   });
-
+  //
   test('on mount, adds a popstate listener that changes pages', () => {
-    Object.defineProperty(window, 'addEventListener', {
-      writable: true,
-      value: jest.fn()
-    });
-    const wrapper = mount(
+    mount(
       React.createElement(Router, {
         startingPath: '/magic/',
         startingComponent: StartingComponent,
         pageProps: pageProps
       })
     );
-    expect(window.addEventListener).toHaveBeenCalledTimes(1);
-    expect(window.addEventListener.mock.calls[0][0]).toBe('popstate');
+    expect(mockWindow.addEventListener).toHaveBeenCalledTimes(1);
+    expect(mockWindow.addEventListener.mock.calls[0][0]).toBe('popstate');
 
-    Object.defineProperty(window.location, 'pathname', {
-      writable: true,
-      value: '/magic/'
-    });
-    Object.defineProperty(window.location, 'search', {
-      writable: true,
-      value: '?foo=bar'
-    });
-    Object.defineProperty(window.location, 'hash', {
-      writable: true,
-      value: '#baz'
-    });
-    const instance = wrapper.instance();
-    jest.spyOn(instance, 'changePage').mockImplementation(() => {});
+    mockWindow.location = {
+      pathname: '/magic/',
+      search: '?foo=bar',
+      hash: '#baz'
+    };
     const mockEvent = {
       preventDefault: jest.fn()
     };
-    expect(mockEvent.preventDefault).toHaveBeenCalledTimes(0);
-    expect(instance.changePage).toHaveBeenCalledTimes(0);
-    window.addEventListener.mock.calls[0][1](mockEvent);
+    expect(mockEvent.preventDefault).not.toHaveBeenCalled();
+    expect(changePage).not.toHaveBeenCalled();
+    mockWindow.addEventListener.mock.calls[0][1](mockEvent);
     expect(mockEvent.preventDefault).toHaveBeenCalledTimes(1);
-    expect(instance.changePage).toHaveBeenCalledTimes(1);
-    expect(instance.changePage).toHaveBeenCalledWith({
-      hash: '#baz',
-      pathname: '/magic/',
-      search: '?foo=bar'
-    });
+    expect(changePage).toHaveBeenCalledTimes(1);
+    expect(changePage).toHaveBeenCalledWith(
+      {
+        hash: '#baz',
+        pathname: '/magic/',
+        search: '?foo=bar'
+      },
+      expect.any(Function)
+    );
   });
 
   test('on mount, resets the location state to capture hash and search', () => {
@@ -228,18 +223,11 @@ describe('Router', () => {
       hash: ''
     });
 
-    Object.defineProperty(window.location, 'pathname', {
-      writable: true,
-      value: '/magic/'
-    });
-    Object.defineProperty(window.location, 'search', {
-      writable: true,
-      value: '?foo=bar'
-    });
-    Object.defineProperty(window.location, 'hash', {
-      writable: true,
-      value: '#baz'
-    });
+    mockWindow.location = {
+      pathname: '/magic/',
+      search: '?foo=bar',
+      hash: '#baz'
+    };
     wrapper.instance().componentDidMount();
     wrapper.update();
     expect(wrapper.find('StartingComponent').props().location).toEqual({
@@ -260,11 +248,11 @@ describe('Router', () => {
     linkToLocation.mockReturnValue({ pathname: '/horse/' });
     findMatchingRoute.mockReturnValue({});
     const instance = wrapper.instance();
-    instance.changePage = jest.fn();
     instance.routeTo('foo');
-    expect(instance.changePage).toHaveBeenCalledTimes(1);
-    expect(instance.changePage).toHaveBeenCalledWith(
+    expect(changePage).toHaveBeenCalledTimes(1);
+    expect(changePage).toHaveBeenCalledWith(
       { pathname: '/horse/' },
+      expect.any(Function),
       {
         pushState: true,
         scrollToTop: true
@@ -283,11 +271,11 @@ describe('Router', () => {
     linkToLocation.mockReturnValue({ pathname: '/magic/', hash: '#dog' });
     findMatchingRoute.mockReturnValue({});
     const instance = wrapper.instance();
-    instance.changePage = jest.fn();
     instance.routeTo('foo');
-    expect(instance.changePage).toHaveBeenCalledTimes(1);
-    expect(instance.changePage).toHaveBeenCalledWith(
+    expect(changePage).toHaveBeenCalledTimes(1);
+    expect(changePage).toHaveBeenCalledWith(
       { pathname: '/magic/', hash: '#dog' },
+      expect.any(Function),
       {
         pushState: true,
         scrollToTop: false
@@ -306,61 +294,9 @@ describe('Router', () => {
     linkToLocation.mockReturnValue({ pathname: '/not/known/page' });
     findMatchingRoute.mockReturnValue({ is404: true });
     const instance = wrapper.instance();
-    instance.changePage = jest.fn();
     instance.routeTo('/not/known/page');
-    expect(instance.changePage).toHaveBeenCalledTimes(0);
-    expect(window.location.assign).toHaveBeenCalledTimes(1);
-    expect(window.location.assign).toHaveBeenCalledWith('/not/known/page');
+    expect(changePage).not.toHaveBeenCalled();
+    expect(mockWindow.location.assign).toHaveBeenCalledTimes(1);
+    expect(mockWindow.location.assign).toHaveBeenCalledWith('/not/known/page');
   });
-
-  test.only(
-    'instance.changePage invokes change-start and change-end callbacks',
-    () => {
-      const wrapper = shallow(
-        React.createElement(Router, {
-          startingPath: '/magic/',
-          startingComponent: StartingComponent,
-          pageProps: pageProps
-        })
-      );
-      const instance = wrapper.instance();
-      const mockPageModule = {
-        component: function Mockery() {
-          return <div>mockery</div>;
-        },
-        props: {}
-      };
-      const mockRoute = {
-        getPage: () => Promise.resolve(mockPageModule),
-        path: '/mock/route/'
-      };
-      const mockNextLocation = {
-        pathname: '/mock/route/',
-        hash: '#foo'
-      };
-      findMatchingRoute.mockImplementation(pathname => {
-        if (pathname === mockNextLocation.pathname) {
-          return mockRoute;
-        } else {
-          throw new Error(`Oops. Unknown pathname ${pathname}`);
-        }
-      });
-      const changer = instance.changePage(mockNextLocation);
-      expect(findMatchingRoute).toHaveBeenCalledTimes(1);
-      expect(findMatchingRoute).toHaveBeenCalledWith(mockNextLocation.pathname);
-      expect(_invokeRouteChangeStartCallbacks).toHaveBeenCalledTimes(1);
-      expect(_invokeRouteChangeStartCallbacks).toHaveBeenCalledWith(
-        mockNextLocation.pathname
-      );
-      expect(_invokeRouteChangeEndCallbacks).toHaveBeenCalledTimes(0);
-      wrapper.update();
-      return changer.then(() => {
-        expect(toJson(wrapper)).toMatchSnapshot();
-        expect(_invokeRouteChangeEndCallbacks).toHaveBeenCalledTimes(1);
-        expect(_invokeRouteChangeEndCallbacks).toHaveBeenCalledWith(
-          mockNextLocation.pathname
-        );
-      });
-    }
-  );
 });
diff --git a/test/serve-static.test.js b/test/serve-static.test.js
new file mode 100644
index 0000000..62bae90
--- /dev/null
+++ b/test/serve-static.test.js
@@ -0,0 +1,135 @@
+'use strict';
+
+const serveStatic = require('../src/node/serve-static');
+const staticServerMiddlewares = require('../src/node/static-server-middlewares');
+const createServer = require('../src/node/create-server');
+const constants = require('../src/node/constants');
+const validateConfig = require('../src/node/validate-config');
+
+jest.mock('../src/node/static-server-middlewares', () => {
+  return {
+    init: jest.fn(() => ({ stripSiteBasePath: 'mockStripSiteBasePath' }))
+  };
+});
+
+jest.mock('../src/node/create-server', () => {
+  const mockServer = {
+    browserSyncInstance: {
+      emitter: {
+        on: jest.fn()
+      }
+    },
+    start: jest.fn()
+  };
+  const fn = jest.fn(() => mockServer);
+  fn.mockServer = mockServer;
+  return fn;
+});
+
+jest.mock('../src/node/server-init-message', () => {
+  return jest.fn(() => 'server initialization message');
+});
+
+jest.mock('../src/node/validate-config', () => {
+  const fn = jest.fn(() => fn.mockValidatedConfig);
+  fn.mockValidatedConfig = {};
+  return fn;
+});
+
+describe('serveStatic', () => {
+  beforeEach(() => {
+    validateConfig.mockValidatedConfig = {
+      port: 6666,
+      outputDirectory: '/mock/output',
+      pagesDirectory: '/mock/pages'
+    };
+  });
+
+  test('validates the config', () => {
+    const rawConfig = {
+      foo: 'bar'
+    };
+    const emitter = serveStatic(rawConfig, 'project-directory');
+    emitter.on(constants.EVENT_ERROR, logEmitterError);
+    expect(validateConfig).toHaveBeenCalledWith(
+      { foo: 'bar' },
+      'project-directory'
+    );
+  });
+
+  test('catches errors while validating config', done => {
+    const expectedError = new Error();
+    validateConfig.mockImplementationOnce(() => {
+      throw expectedError;
+    });
+    const emitter = serveStatic({ badProperty: true });
+    emitter.on(constants.EVENT_ERROR, error => {
+      expect(error).toBe(expectedError);
+      done();
+    });
+  });
+
+  test('initializes middlewares with config', () => {
+    const emitter = serveStatic({ port: 6666 });
+    emitter.on(constants.EVENT_ERROR, logEmitterError);
+    expect(staticServerMiddlewares.init).toHaveBeenCalledWith(
+      expect.objectContaining({ port: 6666 })
+    );
+  });
+
+  test('creates a server', () => {
+    const emitter = serveStatic();
+    emitter.on(constants.EVENT_ERROR, logEmitterError);
+    expect(createServer).toHaveBeenCalledTimes(1);
+    const createServerOptions = createServer.mock.calls[0][0];
+    expect(createServerOptions.onError).toBeInstanceOf(Function);
+    expect(createServerOptions.browserSyncOptions).toHaveProperty('port', 6666);
+    expect(createServerOptions.browserSyncOptions).toHaveProperty('server');
+    expect(createServerOptions.browserSyncOptions.server).toHaveProperty(
+      'baseDir',
+      '/mock/output'
+    );
+    expect(
+      createServerOptions.browserSyncOptions.server
+    ).toHaveProperty('middleware', ['mockStripSiteBasePath']);
+  });
+
+  test('catches errors when creating the server', done => {
+    const expectedError = new Error();
+    const emitter = serveStatic();
+    emitter.on(constants.EVENT_ERROR, error => {
+      expect(error).toBe(expectedError);
+      done();
+    });
+    createServer.mock.calls[0][0].onError(expectedError);
+  });
+
+  test('starts the server', () => {
+    const emitter = serveStatic();
+    emitter.on(constants.EVENT_ERROR, logEmitterError);
+    expect(createServer.mockServer.start).toHaveBeenCalledTimes(1);
+  });
+
+  test('when the server initializes sends a message', done => {
+    const emitter = serveStatic();
+    emitter.on(constants.EVENT_ERROR, logEmitterError);
+    expect(
+      createServer.mockServer.browserSyncInstance.emitter.on
+    ).toHaveBeenCalledTimes(1);
+    expect(
+      createServer.mockServer.browserSyncInstance.emitter.on.mock.calls[0][0]
+    ).toBe('init');
+    process.nextTick(() => {
+      emitter.on(constants.EVENT_NOTIFICATION, message => {
+        expect(message).toBe('server initialization message');
+        done();
+      });
+      // Manually invoke the init handler.
+      createServer.mockServer.browserSyncInstance.emitter.on.mock.calls[0][1]();
+    });
+  });
+});
+
+function logEmitterError(error) {
+  console.error(error.stack); // eslint-disable-line no-console
+}
diff --git a/test/start.test.js b/test/start.test.js
new file mode 100644
index 0000000..ce4c383
--- /dev/null
+++ b/test/start.test.js
@@ -0,0 +1,274 @@
+'use strict';
+
+const stripAnsi = require('strip-ansi');
+const start = require('../src/node/start');
+const compileStylesheets = require('../src/node/compile-stylesheets');
+const watchCss = require('../src/node/watch-css');
+const watchWebpack = require('../src/node/watch-webpack');
+const createServer = require('../src/node/create-server');
+const maybeClearOutputDirectory = require('../src/node/maybe-clear-output-directory');
+const constants = require('../src/node/constants');
+const validateConfig = require('../src/node/validate-config');
+
+jest.mock('../src/node/create-webpack-config-client', () => {
+  return jest.fn(() => Promise.resolve({ mockConfigClient: true }));
+});
+
+jest.mock('../src/node/compile-stylesheets', () => {
+  return jest.fn(() => Promise.resolve('mock-stylesheet.css'));
+});
+
+jest.mock('../src/node/watch-css', () => {
+  return jest.fn();
+});
+
+jest.mock('../src/node/watch-webpack', () => {
+  const mockWebpackEmitter = {
+    on: jest.fn()
+  };
+  const fn = jest.fn(() => mockWebpackEmitter);
+  fn.mockWebpackEmitter = mockWebpackEmitter;
+  return fn;
+});
+
+jest.mock('../src/node/create-server', () => {
+  const mockServer = {
+    start: jest.fn(),
+    reload: jest.fn()
+  };
+  const fn = jest.fn(() => mockServer);
+  fn.mockServer = mockServer;
+  return fn;
+});
+
+jest.mock('../src/node/maybe-clear-output-directory', () => {
+  return jest.fn(() => Promise.resolve());
+});
+
+jest.mock('../src/node/validate-config', () => {
+  const fn = jest.fn(() => fn.mockValidatedConfig);
+  fn.mockValidatedConfig = {};
+  return fn;
+});
+
+describe('start', () => {
+  beforeEach(() => {
+    validateConfig.mockValidatedConfig = {
+      port: 6666,
+      outputDirectory: '/mock/output',
+      pagesDirectory: '/mock/pages'
+    };
+  });
+
+  test('validates the config', () => {
+    const rawConfig = {
+      foo: 'bar'
+    };
+    const emitter = start(rawConfig, 'project-directory');
+    emitter.on(constants.EVENT_ERROR, logEmitterError);
+    expect(validateConfig).toHaveBeenCalledWith(
+      { foo: 'bar' },
+      'project-directory'
+    );
+  });
+
+  test('catches errors while validating config', done => {
+    const expectedError = new Error();
+    validateConfig.mockImplementationOnce(() => {
+      throw expectedError;
+    });
+    const emitter = start({ badProperty: true });
+    emitter.on(constants.EVENT_ERROR, error => {
+      expect(error).toBe(expectedError);
+      done();
+    });
+  });
+
+  test('creates a server', () => {
+    validateConfig.mockValidatedConfig.siteBasePath = '/walk/';
+    const emitter = start();
+    emitter.on(constants.EVENT_ERROR, logEmitterError);
+    expect(createServer).toHaveBeenCalledTimes(1);
+    const createServerOptions = createServer.mock.calls[0][0];
+    expect(createServerOptions.onError).toBeInstanceOf(Function);
+    expect(createServerOptions.browserSyncOptions).toHaveProperty('port', 6666);
+    expect(createServerOptions.browserSyncOptions).toHaveProperty('server');
+    expect(createServerOptions.browserSyncOptions.server).toHaveProperty(
+      'baseDir',
+      '/mock/output'
+    );
+    expect(createServerOptions.browserSyncOptions.server).toHaveProperty(
+      'routes'
+    );
+    expect(createServerOptions.browserSyncOptions.server.routes).toHaveProperty(
+      '/walk/assets',
+      '/mock/output'
+    );
+    expect(createServerOptions.browserSyncOptions.server.routes).toHaveProperty(
+      '/walk/',
+      '/mock/pages'
+    );
+  });
+
+  test('handles errors when creating a server', done => {
+    const expectedError = new Error();
+    const emitter = start();
+    emitter.on(constants.EVENT_ERROR, error => {
+      expect(error).toBe(expectedError);
+      done();
+    });
+    const onError = createServer.mock.calls[0][0].onError;
+    onError(expectedError);
+  });
+
+  test('maybe clears the output directory', () => {
+    const emitter = start();
+    emitter.on(constants.EVENT_ERROR, logEmitterError);
+    expect(maybeClearOutputDirectory).toHaveBeenCalledTimes(1);
+    expect(maybeClearOutputDirectory.mock.calls[0][0]).toHaveProperty(
+      'outputDirectory',
+      '/mock/output'
+    );
+  });
+
+  test('compiles stylesheets', done => {
+    const emitter = start();
+    emitter.on(constants.EVENT_ERROR, logEmitterError);
+    process.nextTick(() => {
+      expect(compileStylesheets).toHaveBeenCalledTimes(1);
+      expect(compileStylesheets.mock.calls[0][0]).toHaveProperty(
+        'outputDirectory',
+        '/mock/output'
+      );
+      done();
+    });
+  });
+
+  test('handles errors in stylesheet compilation', done => {
+    const expectedError = new Error();
+    compileStylesheets.mockReturnValueOnce(Promise.reject(expectedError));
+    const emitter = start();
+    emitter.on(constants.EVENT_ERROR, error => {
+      expect(error).toBe(expectedError);
+      done();
+    });
+  });
+
+  test('starts the server it creates', done => {
+    const emitter = start();
+    emitter.on(constants.EVENT_ERROR, logEmitterError);
+    process.nextTick(() => {
+      expect(createServer.mockServer.start).toHaveBeenCalledTimes(1);
+      done();
+    });
+  });
+
+  test('starts the css watcher', done => {
+    const emitter = start();
+    emitter.on(constants.EVENT_ERROR, logEmitterError);
+    process.nextTick(() => {
+      expect(watchCss).toHaveBeenCalledTimes(1);
+      expect(watchCss.mock.calls[0][0]).toHaveProperty(
+        'outputDirectory',
+        '/mock/output'
+      );
+      expect(watchCss.mock.calls[0][1].onError).toBeInstanceOf(Function);
+      expect(watchCss.mock.calls[0][1].afterCompilation).toBeInstanceOf(
+        Function
+      );
+      done();
+    });
+  });
+
+  test('catches errors from the css watcher', done => {
+    const expectedError = new Error();
+    const emitter = start();
+    emitter.on(constants.EVENT_ERROR, error => {
+      expect(error).toBe(expectedError);
+    });
+    process.nextTick(() => {
+      watchCss.mock.calls[0][1].onError(expectedError);
+      done();
+    });
+  });
+
+  test('reloads the server after CSS compilation', done => {
+    const emitter = start();
+    emitter.on(constants.EVENT_ERROR, logEmitterError);
+    process.nextTick(() => {
+      watchCss.mock.calls[0][1].afterCompilation('a-file.txt');
+      expect(createServer.mockServer.reload).toHaveBeenCalledWith('a-file.txt');
+      done();
+    });
+  });
+
+  test('starts the webpack watcher', done => {
+    const emitter = start();
+    emitter.on(constants.EVENT_ERROR, logEmitterError);
+    process.nextTick(() => {
+      expect(watchWebpack).toHaveBeenCalledTimes(1);
+      expect(watchWebpack.mock.calls[0][0]).toHaveProperty(
+        'outputDirectory',
+        '/mock/output'
+      );
+      expect(watchWebpack.mock.calls[0][1]).toBe(createServer.mockServer);
+      done();
+    });
+  });
+
+  test('catches webpack watcher errors', done => {
+    const expectedError = new Error();
+    const emitter = start();
+    emitter.on(constants.EVENT_ERROR, error => {
+      expect(error).toBe(expectedError);
+      done();
+    });
+    process.nextTick(() => {
+      watchWebpack.mockWebpackEmitter.on.mock.calls.find(
+        call => call[0] === constants.EVENT_ERROR
+      )[1](expectedError);
+    });
+  });
+
+  test('catches webpack watcher notifications', done => {
+    const emitter = start();
+    emitter.on(constants.EVENT_ERROR, logEmitterError);
+    emitter.on(constants.EVENT_NOTIFICATION, message => {
+      if (message === 'foo') {
+        done();
+      }
+    });
+    process.nextTick(() => {
+      watchWebpack.mockWebpackEmitter.on.mock.calls.find(
+        call => call[0] === constants.EVENT_NOTIFICATION
+      )[1]('foo');
+    });
+  });
+
+  test('order of notifications', done => {
+    const notifications = [];
+    const emitter = start();
+    emitter.on(constants.EVENT_ERROR, logEmitterError);
+    emitter.on(constants.EVENT_NOTIFICATION, message => {
+      notifications.push(stripAnsi(message));
+    });
+    process.nextTick(() => {
+      const cssAfterCompilation = watchCss.mock.calls[0][1].afterCompilation;
+      const webpackWatcherNotificationHandler = watchWebpack.mockWebpackEmitter.on.mock.calls.find(
+        call => call[0] === constants.EVENT_NOTIFICATION
+      )[1];
+      cssAfterCompilation('a-file.txt');
+      webpackWatcherNotificationHandler('Webpack notification 1');
+      cssAfterCompilation('b-file.txt');
+      webpackWatcherNotificationHandler('Webpack notification 2');
+      process.nextTick(() => {
+        expect(notifications).toMatchSnapshot();
+        done();
+      });
+    });
+  });
+});
+
+function logEmitterError(error) {
+  console.error(error.stack); // eslint-disable-line no-console
+}
diff --git a/test/static-server-middlewares.test.js b/test/static-server-middlewares.test.js
new file mode 100644
index 0000000..7ec1752
--- /dev/null
+++ b/test/static-server-middlewares.test.js
@@ -0,0 +1,37 @@
+'use strict';
+
+const staticServerMiddlewares = require('../src/node/static-server-middlewares');
+
+describe('staticServerMiddlewares', () => {
+  describe('stripSiteBasePath', () => {
+    test('strips siteBasePath from URL', () => {
+      const middlewares = staticServerMiddlewares.init({
+        siteBasePath: '/foo'
+      });
+      const req = { url: '/foo/bar/baz' };
+      middlewares.stripSiteBasePath(req, {}, () => {
+        expect(req).toEqual({ url: '/bar/baz' });
+      });
+    });
+
+    test('replaces URL that is siteBasePath with /', () => {
+      const middlewares = staticServerMiddlewares.init({
+        siteBasePath: '/foo'
+      });
+      const req = { url: '/foo' };
+      middlewares.stripSiteBasePath(req, {}, () => {
+        expect(req).toEqual({ url: '/' });
+      });
+    });
+
+    test('ignores URLs that do not start with siteBasePath', () => {
+      const middlewares = staticServerMiddlewares.init({
+        siteBasePath: '/foo'
+      });
+      const req = { url: '/bar/foo/baz' };
+      middlewares.stripSiteBasePath(req, {}, () => {
+        expect(req).toEqual({ url: '/bar/foo/baz' });
+      });
+    });
+  });
+});
diff --git a/test/watch-context.test.js b/test/watch-context.test.js
new file mode 100644
index 0000000..627ac65
--- /dev/null
+++ b/test/watch-context.test.js
@@ -0,0 +1,86 @@
+'use strict';
+
+const chokidar = require('chokidar');
+const writeContextModule = require('../src/node/write-context-module');
+const watchContext = require('../src/node/watch-context');
+
+jest.mock('chokidar', () => {
+  const watcher = {
+    on: jest.fn()
+  };
+  return {
+    watch: jest.fn(() => watcher),
+    // Just for testing.
+    watcher
+  };
+});
+
+jest.mock('../src/node/write-context-module', () => {
+  return jest.fn(() => Promise.resolve('mock/context.js'));
+});
+
+describe('watchContext', () => {
+  let onError;
+  let afterCompilation;
+
+  beforeEach(() => {
+    onError = jest.fn();
+    afterCompilation = jest.fn();
+  });
+
+  test('creates a watcher', () => {
+    watchContext(
+      { pagesDirectory: 'src/pages/' },
+      { onError, afterCompilation }
+    );
+    expect(chokidar.watch).toHaveBeenCalledWith('src/pages/**/*.{js,md}');
+  });
+
+  test('handles errors', () => {
+    watchContext(
+      { pagesDirectory: 'src/pages/' },
+      { onError, afterCompilation }
+    );
+    expect(chokidar.watcher.on).toHaveBeenCalledWith('error', onError);
+  });
+
+  test('rebuilds the context module on change events', () => {
+    const batfishConfig = { pagesDirectory: 'src/pages/' };
+    watchContext(batfishConfig, { onError, afterCompilation });
+    expect(chokidar.watcher.on.mock.calls[0][0]).toBe('change');
+    // Manually trigger a change event.
+    chokidar.watcher.on.mock.calls[0][1]();
+    expect(writeContextModule).toHaveBeenCalledWith(batfishConfig);
+    return Promise.resolve().then(() => {
+      expect(afterCompilation).toHaveBeenCalled();
+    });
+  });
+
+  test('rebuilds the context module on unlink events', () => {
+    const batfishConfig = { pagesDirectory: 'src/pages/' };
+    watchContext(batfishConfig, { onError, afterCompilation });
+    expect(chokidar.watcher.on.mock.calls[1][0]).toBe('unlink');
+    // Manually trigger a change event.
+    chokidar.watcher.on.mock.calls[1][1]();
+    expect(writeContextModule).toHaveBeenCalledWith(batfishConfig);
+    return Promise.resolve().then(() => {
+      expect(afterCompilation).toHaveBeenCalled();
+    });
+  });
+
+  test('handles errors writing the context module', () => {
+    const batfishConfig = { pagesDirectory: 'src/pages/' };
+    watchContext(batfishConfig, { onError, afterCompilation });
+    expect(chokidar.watcher.on.mock.calls[0][0]).toBe('change');
+    // Set up the error.
+    const error = new Error();
+    writeContextModule.mockReturnValue(Promise.reject(error));
+    // Manually trigger a change event.
+    chokidar.watcher.on.mock.calls[0][1]();
+    expect(writeContextModule).toHaveBeenCalledWith(batfishConfig);
+    return new Promise(resolve => setTimeout(resolve, 100)).then(() => {
+      expect(afterCompilation).not.toHaveBeenCalled();
+      expect(onError).toHaveBeenCalledWith(error);
+    });
+  });
+});
diff --git a/test/watch-css.test.js b/test/watch-css.test.js
new file mode 100644
index 0000000..7fe4322
--- /dev/null
+++ b/test/watch-css.test.js
@@ -0,0 +1,78 @@
+'use strict';
+
+const chokidar = require('chokidar');
+const compileStylesheets = require('../src/node/compile-stylesheets');
+const watchCss = require('../src/node/watch-css');
+
+jest.mock('chokidar', () => {
+  const watcher = {
+    on: jest.fn()
+  };
+  return {
+    watch: jest.fn(() => watcher),
+    // Just for testing.
+    watcher
+  };
+});
+
+jest.mock('../src/node/compile-stylesheets', () => {
+  return jest.fn(() => Promise.resolve('mock/compiled.css'));
+});
+
+describe('watchCss', () => {
+  let onError;
+  let afterCompilation;
+
+  beforeEach(() => {
+    onError = jest.fn();
+    afterCompilation = jest.fn();
+  });
+
+  test('does nothing if there are no stylesheets', () => {
+    watchCss({ stylesheets: [] }, { onError, afterCompilation });
+    expect(chokidar.watch).not.toHaveBeenCalled();
+  });
+
+  test('creates a watcher', () => {
+    watchCss(
+      { stylesheets: ['foo.css', 'bar.css'] },
+      { onError, afterCompilation }
+    );
+    expect(chokidar.watch).toHaveBeenCalledWith(['foo.css', 'bar.css']);
+  });
+
+  test('handles errors', () => {
+    watchCss(
+      { stylesheets: ['foo.css', 'bar.css'] },
+      { onError, afterCompilation }
+    );
+    expect(chokidar.watcher.on).toHaveBeenCalledWith('error', onError);
+  });
+
+  test('handles change events', () => {
+    const batfishConfig = { stylesheets: ['foo.css', 'bar.css'] };
+    watchCss(batfishConfig, { onError, afterCompilation });
+    expect(chokidar.watcher.on.mock.calls[0][0]).toBe('change');
+    // Manually trigger a change event.
+    chokidar.watcher.on.mock.calls[0][1]();
+    expect(compileStylesheets).toHaveBeenCalledWith(batfishConfig);
+    return Promise.resolve(() => {
+      expect(afterCompilation).toHaveBeenCalledWith('mock/compiled.css');
+    });
+  });
+
+  test('handles CSS compilation errors', () => {
+    const batfishConfig = { stylesheets: ['foo.css', 'bar.css'] };
+    watchCss(batfishConfig, { onError, afterCompilation });
+    expect(chokidar.watcher.on.mock.calls[0][0]).toBe('change');
+    // Set up the error
+    const error = new Error();
+    compileStylesheets.mockReturnValue(Promise.reject(error));
+    // Manually trigger a change event.
+    chokidar.watcher.on.mock.calls[0][1]();
+    return Promise.resolve(() => {
+      expect(afterCompilation).not.toHaveBeenCalled();
+      expect(onError).toHaveBeenCalledWith(error);
+    });
+  });
+});
diff --git a/test/watch-webpack.test.js b/test/watch-webpack.test.js
new file mode 100644
index 0000000..cdf2042
--- /dev/null
+++ b/test/watch-webpack.test.js
@@ -0,0 +1,233 @@
+'use strict';
+
+const webpack = require('webpack');
+const stripAnsi = require('strip-ansi');
+const watchWebpack = require('../src/node/watch-webpack');
+const createWebpackConfigClient = require('../src/node/create-webpack-config-client');
+const errorTypes = require('../src/node/error-types');
+const watchContext = require('../src/node/watch-context');
+const writeWebpackStats = require('../src/node/write-webpack-stats');
+
+jest.mock('webpack', () => {
+  const compiler = {
+    watch: jest.fn()
+  };
+  const mockWebpack = jest.fn(() => compiler);
+  mockWebpack.compiler = compiler;
+  return mockWebpack;
+});
+
+jest.mock('../src/node/create-webpack-config-client', () => {
+  return jest.fn(() => Promise.resolve({ mockClientClient: true }));
+});
+
+jest.mock('../src/node/watch-context', () => {
+  return jest.fn();
+});
+
+jest.mock('../src/node/server-init-message', () => {
+  return jest.fn(() => 'server initialization message');
+});
+
+jest.mock('../src/node/write-webpack-stats', () => {
+  return jest.fn(() => Promise.resolve());
+});
+
+function createMockStats(hash) {
+  return {
+    hash,
+    toJson: jest.fn(() => 'stats-to-json'),
+    hasErrors: jest.fn(() => false)
+  };
+}
+
+describe('watchWebpack', () => {
+  let batfishConfig;
+  let server;
+
+  beforeEach(() => {
+    batfishConfig = {
+      outputDirectory: '/mock/output/directory/',
+      pagesDirectory: '/mock/pages/directory/',
+      stylesheets: ['one.css', 'two.css'],
+      verbose: false
+    };
+    server = {
+      browserSyncInstance: {},
+      reload: jest.fn()
+    };
+  });
+
+  test('creates a webpack client config', () => {
+    watchWebpack(batfishConfig, server);
+    expect(createWebpackConfigClient).toHaveBeenCalledWith(batfishConfig, {
+      devServer: true
+    });
+  });
+
+  test('handles webpack configuration errors', done => {
+    const webpackError = new Error();
+    webpack.mockImplementationOnce(() => {
+      throw webpackError;
+    });
+    const watcher = watchWebpack(batfishConfig, server);
+    expect.hasAssertions();
+    watcher.on('error', error => {
+      expect(error).toBeInstanceOf(errorTypes.WebpackFatalError);
+      expect(error.originalError).toBe(webpackError);
+      done();
+    });
+  });
+
+  test("starts webpack's watch", done => {
+    watchWebpack(batfishConfig, server);
+    process.nextTick(() => {
+      expect(webpack.compiler.watch).toHaveBeenCalledTimes(1);
+      expect(webpack.compiler.watch.mock.calls[0][0]).toEqual({
+        ignored: [/node_modules/, '/mock/pages/directory/**/*.{js,md}']
+      });
+      expect(webpack.compiler.watch.mock.calls[0][1]).toBeInstanceOf(Function);
+      done();
+    });
+  });
+
+  test('starts the context watcher', done => {
+    watchWebpack(batfishConfig, server);
+    process.nextTick(() => {
+      expect(watchContext).toHaveBeenCalledTimes(1);
+      expect(watchContext.mock.calls[0][0]).toBe(batfishConfig);
+      expect(watchContext.mock.calls[0][1]).toHaveProperty('onError');
+      expect(watchContext.mock.calls[0][1]).toHaveProperty('afterCompilation');
+      done();
+    });
+  });
+
+  test('catches errors from context watcher', done => {
+    const expectedError = new Error();
+    const watcher = watchWebpack(batfishConfig, server);
+    watcher.on('error', error => {
+      expect(error).toBe(expectedError);
+      done();
+    });
+    process.nextTick(() => {
+      watchContext.mock.calls[0][1].onError(expectedError);
+    });
+  });
+
+  test('on first compilation sends some special notifications', done => {
+    const watcher = watchWebpack(batfishConfig, server);
+    expect.hasAssertions();
+    let notificationCount = 0;
+    watcher.on('notification', message => {
+      notificationCount += 1;
+      if (notificationCount === 1) {
+        expect(stripAnsi(message)).toBe('Go!');
+      } else if (notificationCount === 2) {
+        expect(message).toBe('server initialization message');
+      } else {
+        expect(message).not.toMatch(/(Go|server initialization)/);
+      }
+    });
+    process.nextTick(() => {
+      // Invoke the watcher callback twice.
+      webpack.compiler.watch.mock.calls[0][1](null, createMockStats('a'));
+      webpack.compiler.watch.mock.calls[0][1](null, createMockStats('b'));
+      done();
+    });
+  });
+
+  test('handles fatal errors', done => {
+    const expectedError = new Error();
+    const watcher = watchWebpack(batfishConfig, server);
+    expect.hasAssertions();
+    watcher.on('error', error => {
+      expect(error).toBeInstanceOf(errorTypes.WebpackFatalError);
+      expect(error.originalError).toBe(expectedError);
+      done();
+    });
+    process.nextTick(() => {
+      // Invoke the watcher callback with an error.
+      webpack.compiler.watch.mock.calls[0][1](
+        expectedError,
+        createMockStats('a')
+      );
+    });
+  });
+
+  test('handles errors in stats', done => {
+    const erroneousStats = createMockStats('a');
+    erroneousStats.hasErrors.mockReturnValue(true);
+    const watcher = watchWebpack(batfishConfig, server);
+    expect.hasAssertions();
+    watcher.on('error', error => {
+      expect(error).toBeInstanceOf(errorTypes.WebpackCompilationError);
+      expect(error.stats).toBe(erroneousStats);
+      done();
+    });
+    process.nextTick(() => {
+      // Invoke the watcher callback with an error.
+      webpack.compiler.watch.mock.calls[0][1](null, erroneousStats);
+    });
+  });
+
+  test('on every new compilation, writes stats', done => {
+    watchWebpack(batfishConfig, server);
+    process.nextTick(() => {
+      const firstStats = createMockStats('a');
+      webpack.compiler.watch.mock.calls[0][1](null, firstStats);
+      expect(writeWebpackStats).toHaveBeenCalledTimes(1);
+      expect(writeWebpackStats).toHaveBeenCalledWith(
+        batfishConfig.outputDirectory,
+        firstStats
+      );
+      const secondStats = createMockStats('b');
+      webpack.compiler.watch.mock.calls[0][1](null, secondStats);
+      expect(writeWebpackStats).toHaveBeenCalledTimes(2);
+      expect(writeWebpackStats).toHaveBeenCalledWith(
+        batfishConfig.outputDirectory,
+        secondStats
+      );
+      done();
+    });
+  });
+
+  test('handles errors writing stats', done => {
+    const expectedError = new Error();
+    writeWebpackStats.mockReturnValueOnce(Promise.reject(expectedError));
+    const watcher = watchWebpack(batfishConfig, server);
+    watcher.on('error', error => {
+      expect(error).toBe(expectedError);
+      done();
+    });
+    return Promise.resolve().then(() => {
+      webpack.compiler.watch.mock.calls[0][1](null, createMockStats('a'));
+      expect(writeWebpackStats).toHaveBeenCalledTimes(1);
+    });
+  });
+
+  test('on every new compilation, reloads', done => {
+    expect.hasAssertions();
+    watchWebpack(batfishConfig, server);
+    process.nextTick(() => {
+      webpack.compiler.watch.mock.calls[0][1](null, createMockStats('a'));
+      expect(server.reload).toHaveBeenCalledTimes(1);
+      webpack.compiler.watch.mock.calls[0][1](null, createMockStats('b'));
+      expect(server.reload).toHaveBeenCalledTimes(2);
+      done();
+    });
+  });
+
+  test('new compilations without new hashes do nothing', done => {
+    expect.hasAssertions();
+    watchWebpack(batfishConfig, server);
+    process.nextTick(() => {
+      webpack.compiler.watch.mock.calls[0][1](null, createMockStats('a'));
+      expect(server.reload).toHaveBeenCalledTimes(1);
+      webpack.compiler.watch.mock.calls[0][1](null, createMockStats('a'));
+      expect(server.reload).toHaveBeenCalledTimes(1);
+      webpack.compiler.watch.mock.calls[0][1](null, createMockStats('a'));
+      expect(server.reload).toHaveBeenCalledTimes(1);
+      done();
+    });
+  });
+});
diff --git a/test/webpack-compile-promise.test.js b/test/webpack-compile-promise.test.js
new file mode 100644
index 0000000..7e7502e
--- /dev/null
+++ b/test/webpack-compile-promise.test.js
@@ -0,0 +1,94 @@
+'use strict';
+
+const webpack = require('webpack');
+const webpackCompilePromise = require('../src/node/webpack-compile-promise');
+const errorTypes = require('../src/node/error-types');
+
+jest.mock('webpack', () => {
+  const compiler = {
+    run: jest.fn()
+  };
+  const mockWebpack = jest.fn(() => compiler);
+  mockWebpack.compiler = compiler;
+  return mockWebpack;
+});
+
+describe('webpackCompilePromise', () => {
+  test('creates a Wepback compiler', () => {
+    const webpackConfig = {};
+    webpackCompilePromise(webpackConfig);
+    expect(webpack).toHaveBeenCalledTimes(1);
+    expect(webpack).toHaveBeenCalledWith(webpackConfig);
+  });
+
+  test('catches errors while creating the Wepback compiler', () => {
+    const expectedError = new Error();
+    webpack.mockImplementationOnce(() => {
+      throw expectedError;
+    });
+    return webpackCompilePromise({}).then(
+      () => {
+        throw new Error('should have errored');
+      },
+      error => {
+        expect(error).toBeInstanceOf(errorTypes.WebpackFatalError);
+        expect(error.originalError).toBe(expectedError);
+      }
+    );
+  });
+
+  test('runs the compiler', () => {
+    webpackCompilePromise({});
+    expect(webpack.compiler.run).toHaveBeenCalledTimes(1);
+    expect(webpack.compiler.run.mock.calls[0][0]).toBeInstanceOf(Function);
+  });
+
+  test('receives stats after a successful run', () => {
+    const result = webpackCompilePromise({});
+    expect(webpack.compiler.run).toHaveBeenCalledTimes(1);
+    const stats = {
+      hasErrors: jest.fn(() => false)
+    };
+    webpack.compiler.run.mock.calls[0][0](null, stats);
+    return result.then(resolution => {
+      expect(resolution).toBe(stats);
+    });
+  });
+
+  test('catches errors from run', () => {
+    const expectedError = new Error();
+    const result = webpackCompilePromise({});
+    expect(webpack.compiler.run).toHaveBeenCalledTimes(1);
+    const stats = {
+      hasErrors: jest.fn(() => false)
+    };
+    webpack.compiler.run.mock.calls[0][0](expectedError, stats);
+    return result.then(
+      () => {
+        throw new Error('should have errored');
+      },
+      error => {
+        expect(error).toBeInstanceOf(errorTypes.WebpackFatalError);
+        expect(error.originalError).toBe(expectedError);
+      }
+    );
+  });
+
+  test('catches errors in stats', () => {
+    const result = webpackCompilePromise({});
+    expect(webpack.compiler.run).toHaveBeenCalledTimes(1);
+    const stats = {
+      hasErrors: jest.fn(() => true)
+    };
+    webpack.compiler.run.mock.calls[0][0](null, stats);
+    return result.then(
+      () => {
+        throw new Error('should have errored');
+      },
+      error => {
+        expect(error).toBeInstanceOf(errorTypes.WebpackCompilationError);
+        expect(error.stats).toBe(stats);
+      }
+    );
+  });
+});