瀏覽代碼

Support state nullification with a URL parameter. (#3687)

* Refactor recover command.

* RFC - A way to route multiple pattern matches for a URL and to short-circuit rule execution.

* User router to navigate.

* Simplify the tree handler command.

* Document Router#navigate()

* Use router to navigate away in the not found handler.

* Update query string parsing and constructing.

* Add recover on load command.

* Add Router#current() method and refactor navigation and routing.

* Instantiate new router.

* Clean up recovery logic.

* Simplify routing commands, no need to dispose.

* Add `when` promise to router navigate.

* Clean up.

* Simplify Router#navigate()

* Change `recover` parameter to `reset`.

* Removing reset parameter should not add to history.

* Clean up comments.

* Add initial router tests.

* Clean up.

* Change Router#current() to Router#current.

* Add hash to IRouter.ILocation

* Update tests.

* Minor test clean up.

* pop() is faster than shift()

* Remove one animation frame by removing a `.then()`.

* Make sure hash is preserved when resetting.

* Add command to reset application state. Insert command into the help menu.
Afshin Darian 7 年之前
父節點
當前提交
8321a59305

+ 37 - 25
packages/application-extension/src/index.ts

@@ -10,7 +10,7 @@ import {
 } from '@jupyterlab/apputils';
 
 import {
-  IStateDB, PageConfig, URLExt
+  IStateDB, PageConfig
 } from '@jupyterlab/coreutils';
 
 import {
@@ -51,6 +51,15 @@ namespace CommandIDs {
 }
 
 
+/**
+ * The routing regular expressions used by the application plugin.
+ */
+namespace Patterns {
+  export
+  const tree = /^\/tree\/(.+)/;
+}
+
+
 /**
  * The main extension.
  */
@@ -165,28 +174,33 @@ const router: JupyterLabPlugin<IRouter> = {
   id: '@jupyterlab/application-extension:router',
   activate: (app: JupyterLab) => {
     const { commands } = app;
-    const base = URLExt.join(
-      PageConfig.getBaseUrl(),
-      PageConfig.getOption('pageUrl')
-    );
+    const base = PageConfig.getOption('pageUrl');
     const router = new Router({ base, commands });
-    const pattern = /^\/tree\/(.*)/;
 
     commands.addCommand(CommandIDs.tree, {
-      execute: (args: IRouter.ICommandArgs) => {
-        return app.restored.then(() => {
-          const path = decodeURIComponent((args.path.match(pattern)[1]));
-
-          // Change the URL back to the base application URL.
-          window.history.replaceState({ }, '', base);
-
-          return commands.execute('filebrowser:navigate-main', { path });
-        });
+      execute: (args: IRouter.ILocation) => {
+        const path = decodeURIComponent((args.path.match(Patterns.tree)[1]));
+
+        // File browser navigation waits for the application to be restored.
+        // As a result, this command cannot return a promise because it would
+        // create a circular dependency on the restored promise that would
+        // cause the application to never restore.
+        const opened = commands.execute('filebrowser:navigate-main', { path });
+
+        // Change the URL back to the base application URL without adding the
+        // URL change to the browser history.
+        opened.then(() => { router.navigate('', { silent: true }); });
       }
     });
 
-    router.register({ command: CommandIDs.tree, pattern });
-    app.started.then(() => { router.route(window.location.href); });
+    router.register({ command: CommandIDs.tree, pattern: Patterns.tree });
+    app.started.then(() => {
+      // Route the very first request on load.
+      router.route();
+
+      // Route all pop state events.
+      window.addEventListener('popstate', () => { router.route(); });
+    });
 
     return router;
   },
@@ -200,20 +214,17 @@ const router: JupyterLabPlugin<IRouter> = {
  */
 const notfound: JupyterLabPlugin<void> = {
   id: '@jupyterlab/application-extension:notfound',
-  activate: (app: JupyterLab) => {
+  activate: (app: JupyterLab, router: IRouter) => {
     const bad = PageConfig.getOption('notFoundUrl');
+    const base = router.base;
 
     if (!bad) {
       return;
     }
 
-    const base = URLExt.join(
-      PageConfig.getBaseUrl(),
-      PageConfig.getOption('pageUrl')
-    );
-
-    // Change the URL back to the base application URL.
-    window.history.replaceState({ }, '', base);
+    // Change the URL back to the base application URL without adding the
+    // URL change to the browser history.
+    router.navigate('', { silent: true });
 
     showDialog({
       title: 'Path Not Found',
@@ -221,6 +232,7 @@ const notfound: JupyterLabPlugin<void> = {
       buttons: [Dialog.okButton()]
     });
   },
+  requires: [IRouter],
   autoStart: true
 };
 

+ 129 - 28
packages/application/src/router.ts

@@ -48,19 +48,39 @@ interface IRouter {
    */
   readonly commands: CommandRegistry;
 
+  /**
+   * The parsed current URL of the application.
+   */
+  readonly current: IRouter.ILocation;
+
   /**
    * A signal emitted when the router routes a route.
    */
-  readonly routed: ISignal<IRouter, IRouter.ICommandArgs>;
+  readonly routed: ISignal<IRouter, IRouter.ILocation>;
 
   /**
-   * Register to route a path pattern to a command.
+   * If a matching rule's command resolves with the `stop` token during routing,
+   * no further matches will execute.
+   */
+  readonly stop: Token<void>;
+
+  /**
+   * Navigate to a new path within the application.
+   *
+   * @param path - The new path or empty string if redirecting to root.
+   *
+   * @param options - The navigation options.
+   */
+  navigate(path: string, options?: IRouter.INavOptions): void;
+
+  /**
+   * Register a rule that maps a path pattern to a command.
    *
    * @param options - The route registration options.
    *
-   * @returns A disposable that removes the registered rul from the router.
+   * @returns A disposable that removes the registered rule from the router.
    */
-  register(options: IRouter.IRegisterArgs): IDisposable;
+  register(options: IRouter.IRegisterOptions): IDisposable;
 
   /**
    * Route a specific path to an action.
@@ -69,7 +89,7 @@ interface IRouter {
    *
    * #### Notes
    * If a pattern is matched, its command will be invoked with arguments that
-   * match the `IRouter.ICommandArgs` interface.
+   * match the `IRouter.ILocation` interface.
    */
   route(url: string): void;
 }
@@ -81,15 +101,28 @@ interface IRouter {
 export
 namespace IRouter {
   /**
-   * The arguments passed into a command execution when a path is routed.
+   * The parsed location currently being routed.
    */
   export
-  interface ICommandArgs extends ReadonlyJSONObject {
+  interface ILocation extends ReadonlyJSONObject {
+    /**
+     * The location hash.
+     */
+    hash: string;
+
     /**
      * The path that matched a routing pattern.
      */
     path: string;
 
+    /**
+     * The request being routed with the router `base` omitted.
+     *
+     * #### Notes
+     * This field includes the query string and hash, if they exist.
+     */
+    request: string;
+
     /**
      * The search element, including leading question mark (`'?'`), if any,
      * of the path.
@@ -97,11 +130,22 @@ namespace IRouter {
     search: string;
   }
 
+  /**
+   * The options passed into a navigation request.
+   */
+  export
+  interface INavOptions {
+    /**
+     * Whether the navigation should be added to the browser's history.
+     */
+    silent?: boolean;
+  }
+
   /**
    * The specification for registering a route with the router.
    */
   export
-  interface IRegisterArgs {
+  interface IRegisterOptions {
     /**
      * The command string that will be invoked upon matching.
      */
@@ -144,21 +188,63 @@ class Router implements IRouter {
    */
   readonly commands: CommandRegistry;
 
+  /**
+   * Returns the parsed current URL of the application.
+   */
+  get current(): IRouter.ILocation {
+    const { base } = this;
+    const parsed = URLExt.parse(window.location.href);
+    const { search, hash } = parsed;
+    const path = parsed.pathname.replace(base, '');
+    const request = path + search + hash;
+
+    return { hash, path, request, search };
+  }
+
   /**
    * A signal emitted when the router routes a route.
    */
-  get routed(): ISignal<this, IRouter.ICommandArgs> {
+  get routed(): ISignal<this, IRouter.ILocation> {
     return this._routed;
   }
 
+  /**
+   * If a matching rule's command resolves with the `stop` token during routing,
+   * no further matches will execute.
+   */
+  readonly stop = new Token<void>('@jupyterlab/application:Router#stop');
+
+  /**
+   * Navigate to a new path within the application.
+   *
+   * @param path - The new path or empty string if redirecting to root.
+   *
+   * @param options - The navigation options.
+   */
+  navigate(path: string, options: IRouter.INavOptions = { }): void {
+    const url = path ? URLExt.join(this.base, path) : this.base;
+    const { history } = window;
+    const { silent } = options;
+
+    if (silent) {
+      history.replaceState({ }, '', url);
+    } else {
+      history.pushState({ }, '', url);
+    }
+
+    // Because a `route()` call may still be in the stack after having received
+    // a `stop` token, wait for the next stack frame before calling `route()`.
+    requestAnimationFrame(() => { this.route(); });
+  }
+
   /**
    * Register to route a path pattern to a command.
    *
    * @param options - The route registration options.
    *
-   * @returns A disposable that removes the registered rul from the router.
+   * @returns A disposable that removes the registered rule from the router.
    */
-  register(options: IRouter.IRegisterArgs): IDisposable {
+  register(options: IRouter.IRegisterOptions): IDisposable {
     const { command, pattern } = options;
     const rank = 'rank' in options ? options.rank : 100;
     const rules = this._rules;
@@ -171,36 +257,51 @@ class Router implements IRouter {
   /**
    * Route a specific path to an action.
    *
-   * @param url - The URL string that will be routed.
-   *
    * #### Notes
    * If a pattern is matched, its command will be invoked with arguments that
-   * match the `IRouter.ICommandArgs` interface.
+   * match the `IRouter.ILocation` interface.
    */
-  route(url: string): void {
-    const parsed = URLExt.parse(url.replace(this.base, ''));
-    const args = { path: parsed.pathname, search: parsed.search };
+  route(): void {
+    const { commands, current, stop } = this;
+    const { request } = current;
+    const routed = this._routed;
+    const rules = this._rules;
     const matches: Private.Rule[] = [];
 
     // Collect all rules that match the URL.
-    this._rules.forEach((rule, pattern) => {
-      if (parsed.pathname.match(pattern)) {
+    rules.forEach((rule, pattern) => {
+      if (request.match(pattern)) {
         matches.push(rule);
       }
     });
 
-    // Order the matching rules by rank and execute them.
-    matches.sort((a, b) => a.rank - b.rank).forEach(rule => {
-      // Ignore the results of each executed promise.
-      this.commands.execute(rule.command, args).catch(reason => {
-        console.warn(`Routing ${url} using ${rule.command} failed:`, reason);
-      });
-    });
+    // Order the matching rules by rank and enqueue them.
+    const queue = matches.sort((a, b) => b.rank - a.rank);
 
-    this._routed.emit(args);
+    // Process each enqueued command sequentially and short-circuit if a promise
+    // resolves with the `stop` token.
+    (function next() {
+      if (!queue.length) {
+        routed.emit(current);
+        return;
+      }
+
+      const { command } = queue.pop();
+
+      commands.execute(command, current).then(result => {
+        if (result === stop) {
+          queue.length = 0;
+          console.log(`Routing ${request} was short-circuited by ${command}`);
+        }
+        next();
+      }).catch(reason => {
+        console.warn(`Routing ${request} to ${command} failed`, reason);
+        next();
+      });
+    })();
   }
 
-  private _routed = new Signal<this, IRouter.ICommandArgs>(this);
+  private _routed = new Signal<this, IRouter.ILocation>(this);
   private _rules = new Map<RegExp, Private.Rule>();
 }
 

+ 116 - 62
packages/apputils-extension/src/index.ts

@@ -8,11 +8,11 @@ import {
 } from '@jupyterlab/application';
 
 import {
-  Dialog, ICommandPalette, IThemeManager, ThemeManager, ISplashScreen
+  Dialog, ICommandPalette, ISplashScreen, IThemeManager, ThemeManager
 } from '@jupyterlab/apputils';
 
 import {
-  DataConnector, ISettingRegistry, IStateDB, SettingRegistry, StateDB
+  DataConnector, ISettingRegistry, IStateDB, SettingRegistry, StateDB, URLExt
 } from '@jupyterlab/coreutils';
 
 import {
@@ -28,7 +28,7 @@ import {
 } from '@phosphor/coreutils';
 
 import {
-  DisposableDelegate, DisposableSet, IDisposable
+  DisposableDelegate, IDisposable
 } from '@phosphor/disposable';
 
 import {
@@ -47,7 +47,7 @@ import '../style/index.css';
  * to allow for multiple quickly executed state changes to result in a single
  * workspace save operation.
  */
-const WORKSPACE_SAVE_DEBOUNCE_INTERVAL = 2000;
+const WORKSPACE_SAVE_DEBOUNCE_INTERVAL = 1500;
 
 /**
  * The interval in milliseconds before recover options appear during splash.
@@ -62,20 +62,35 @@ namespace CommandIDs {
   export
   const changeTheme = 'apputils:change-theme';
 
-  export
-  const clearState = 'apputils:clear-statedb';
-
   export
   const loadState = 'apputils:load-statedb';
 
   export
   const recoverState = 'apputils:recover-statedb';
 
+  export
+  const reset = 'apputils:reset';
+
+  export
+  const resetOnLoad = 'apputils:reset-on-load';
+
   export
   const saveState = 'apputils:save-statedb';
 }
 
 
+/**
+ * The routing regular expressions used by the apputils plugin.
+ */
+namespace Patterns {
+  export
+  const loadState = /^\/workspaces\/([^?]+)/;
+
+  export
+  const resetOnLoad = /(\?reset|\&reset)($|&)/;
+}
+
+
 /**
  * A data connector to access plugin settings.
  */
@@ -221,7 +236,7 @@ const splash: JupyterLabPlugin<ISplashScreen> = {
     return {
       show: () => {
         const { commands, restored } = app;
-        const recovery = () => { commands.execute(CommandIDs.recoverState); };
+        const recovery = () => { commands.execute(CommandIDs.reset); };
 
         return Private.showSplash(restored, recovery);
       }
@@ -239,10 +254,8 @@ const state: JupyterLabPlugin<IStateDB> = {
   provides: IStateDB,
   requires: [IRouter],
   activate: (app: JupyterLab, router: IRouter) => {
-    let command: string;
     let debouncer: number;
     let resolved = false;
-    let workspace = '';
 
     const { commands, info, serviceManager } = app;
     const { workspaces } = serviceManager;
@@ -251,39 +264,16 @@ const state: JupyterLabPlugin<IStateDB> = {
       namespace: info.namespace,
       transform: transform.promise
     });
-    const disposables = new DisposableSet();
-    const pattern = /^\/workspaces\/(.+)/;
-    const unload = () => {
-      disposables.dispose();
-      router.routed.disconnect(unload, state);
-
-      // If the request that was routed did not contain a workspace,
-      // leave the database intact.
-      if (!resolved) {
-        resolved = true;
-        transform.resolve({ type: 'cancel', contents: null });
-      }
-    };
-
-    command = CommandIDs.clearState;
-    commands.addCommand(command, {
-      label: 'Clear Application Restore State',
-      execute: () => state.clear()
-    });
 
-    command = CommandIDs.recoverState;
-    commands.addCommand(command, {
+    commands.addCommand(CommandIDs.recoverState, {
       execute: () => {
         const immediate = true;
         const silent = true;
 
-        // Clear the state silenty so that the state changed signal listener
-        // will not be triggered as it causes a save state, but the save state
-        // promise is lost and cannot be used to reload the application.
+        // Clear the state silently so that the state changed signal listener
+        // will not be triggered as it causes a save state.
         return state.clear(silent)
-          .then(() => commands.execute(CommandIDs.saveState, { immediate }))
-          .then(() => { document.location.reload(); })
-          .catch(() => { document.location.reload(); });
+          .then(() => commands.execute(CommandIDs.saveState, { immediate }));
       }
     });
 
@@ -291,11 +281,12 @@ const state: JupyterLabPlugin<IStateDB> = {
     // within the `WORKSPACE_SAVE_DEBOUNCE_INTERVAL` into a single promise.
     let conflated: PromiseDelegate<void> | null = null;
 
-    command = CommandIDs.saveState;
-    commands.addCommand(command, {
-      label: () => `Save Workspace (${workspace})`,
-      isEnabled: () => !!workspace,
+    commands.addCommand(CommandIDs.saveState, {
+      label: () => `Save Workspace (${Private.getWorkspace(router)})`,
+      isEnabled: () => !!Private.getWorkspace(router),
       execute: args => {
+        const workspace = Private.getWorkspace(router);
+
         if (!workspace) {
           return;
         }
@@ -330,32 +321,26 @@ const state: JupyterLabPlugin<IStateDB> = {
       }
     });
 
-    command = CommandIDs.loadState;
-    disposables.add(commands.addCommand(command, {
-      execute: (args: IRouter.ICommandArgs) => {
-        // Populate the workspace placeholder.
-        workspace = decodeURIComponent((args.path.match(pattern)[1]));
+    const listener = (sender: any, change: StateDB.Change) => {
+      commands.execute(CommandIDs.saveState);
+    };
 
-        // This command only runs once, when the page loads.
-        if (resolved) {
-          console.warn(`${command} was called after state resolution.`);
-          return;
-        }
+    commands.addCommand(CommandIDs.loadState, {
+      execute: (args: IRouter.ILocation) => {
+        const workspace = Private.getWorkspace(router);
 
-        // If there is no workspace, leave the state database intact.
+        // If there is no workspace, bail.
         if (!workspace) {
-          resolved = true;
-          transform.resolve({ type: 'cancel', contents: null });
           return;
         }
 
         // Any time the local state database changes, save the workspace.
-        state.changed.connect((sender: any, change: StateDB.Change) => {
-          commands.execute(CommandIDs.saveState);
-        });
+        state.changed.connect(listener, state);
 
         // Fetch the workspace and overwrite the state database.
         return workspaces.fetch(workspace).then(session => {
+          // If this command is called after a reset, the state database will
+          // already be resolved.
           if (!resolved) {
             resolved = true;
             transform.resolve({ type: 'overwrite', contents: session.data });
@@ -373,12 +358,71 @@ const state: JupyterLabPlugin<IStateDB> = {
           return commands.execute(CommandIDs.saveState);
         });
       }
-    }));
-    disposables.add(router.register({ command, pattern }));
+    });
+    router.register({
+      command: CommandIDs.loadState,
+      pattern: Patterns.loadState
+    });
 
-    // After the first route in the application lifecycle has been routed,
-    // stop listening to routing events.
-    router.routed.connect(unload, state);
+    commands.addCommand(CommandIDs.reset, {
+      label: 'Reset Application State',
+      execute: () => {
+        commands.execute(CommandIDs.recoverState)
+          .then(() => { document.location.reload(); })
+          .catch(() => { document.location.reload(); });
+      }
+    });
+
+    commands.addCommand(CommandIDs.resetOnLoad, {
+      execute: (args: IRouter.ILocation) => {
+        const { hash, path, search } = args;
+        const query = URLExt.queryStringToObject(search || '');
+        const reset = 'reset' in query;
+
+        if (!reset) {
+          return;
+        }
+
+        // If the state database has already been resolved, resetting is
+        // impossible without reloading.
+        if (resolved) {
+          return document.location.reload();
+        }
+
+        // Empty the state database.
+        resolved = true;
+        transform.resolve({ type: 'clear', contents: null });
+
+        // Maintain the query string parameters but remove `reset`.
+        delete query['reset'];
+
+        const url = path + URLExt.objectToQueryString(query) + hash;
+        const cleared = commands.execute(CommandIDs.recoverState)
+          .then(() => router.stop); // Stop routing before new route navigation.
+
+        // After the state has been reset, navigate to the URL.
+        cleared.then(() => { router.navigate(url, { silent: true }); });
+
+        return cleared;
+      }
+    });
+    router.register({
+      command: CommandIDs.resetOnLoad,
+      pattern: Patterns.resetOnLoad,
+      rank: 10 // Set reset rank at a higher priority than the default 100.
+    });
+
+    const fallthrough = () => {
+      // If the state database is still unresolved after the first URL has been
+      // routed, leave it intact.
+      if (!resolved) {
+        resolved = true;
+        transform.resolve({ type: 'cancel', contents: null });
+      }
+      router.routed.disconnect(fallthrough, state);
+    };
+
+    router.routed.connect(fallthrough, state);
 
     return state;
   }
@@ -398,6 +442,16 @@ export default plugins;
  * The namespace for module private data.
  */
 namespace Private {
+  /**
+   * Returns the workspace name from the URL, if it exists.
+   */
+  export
+  function getWorkspace(router: IRouter): string {
+    const match = router.current.path.match(Patterns.loadState);
+
+    return match && decodeURIComponent(match[1]) || '';
+  }
+
   /**
    * Create a splash element.
    */

+ 26 - 4
packages/coreutils/src/url.ts

@@ -83,13 +83,35 @@ namespace URLExt {
    * @returns an encoded url query.
    *
    * #### Notes
-   * From [stackoverflow](http://stackoverflow.com/a/30707423).
+   * Modified version of [stackoverflow](http://stackoverflow.com/a/30707423).
    */
   export
   function objectToQueryString(value: JSONObject): string {
-    return '?' + Object.keys(value).map(key =>
-      encodeURIComponent(key) + '=' + encodeURIComponent(String(value[key]))
-    ).join('&');
+    const keys = Object.keys(value);
+
+    if (!keys.length) {
+      return '';
+    }
+
+    return '?' + keys.map(key => {
+      const content = encodeURIComponent(String(value[key]));
+
+      return key + (content ? '=' + content : '');
+    }).join('&');
+  }
+
+  /**
+   * Return a parsed object that represents the values in a query string.
+   */
+  export
+  function queryStringToObject(value: string): JSONObject {
+    return value.replace(/^\?/, '').split('&').reduce((acc, val) => {
+      const [key, value] = val.split('=');
+
+      acc[key] = decodeURIComponent(value || '');
+
+      return acc;
+    }, { } as { [key: string]: string });
   }
 
   /**

+ 2 - 1
packages/help-extension/src/index.ts

@@ -201,6 +201,7 @@ function activate(app: JupyterLab, mainMenu: IMainMenu, palette: ICommandPalette
   const resourcesGroup = RESOURCES
     .map(args => ({ args, command: CommandIDs.open }));
   helpMenu.addGroup(resourcesGroup, 10);
+  helpMenu.addGroup([{ command: 'apputils:reset' }], 20);
 
   // Generate a cache of the kernel help links.
   const kernelInfoCache = new Map<string, KernelMessage.IInfoReply>();
@@ -388,7 +389,7 @@ function activate(app: JupyterLab, mainMenu: IMainMenu, palette: ICommandPalette
   RESOURCES.forEach(args => {
     palette.addItem({ args, command: CommandIDs.open, category });
   });
-  palette.addItem({ command: 'apputils:clear-statedb', category });
+  palette.addItem({ command: 'apputils:reset', category });
   palette.addItem({ command: CommandIDs.launchClassic, category });
 
 }

+ 171 - 0
tests/test-application/src/router.spec.ts

@@ -0,0 +1,171 @@
+// Copyright (c) Jupyter Development Team.
+// Distributed under the terms of the Modified BSD License.
+
+import expect = require('expect.js');
+
+import {
+  Router
+} from '@jupyterlab/application';
+
+import {
+  CommandRegistry
+} from '@phosphor/commands';
+
+import {
+  Token
+} from '@phosphor/coreutils';
+
+
+const base = '/';
+
+
+describe('apputils', () => {
+
+  describe('Router', () => {
+
+    let commands: CommandRegistry;
+    let router: Router;
+
+    beforeEach(() => {
+      commands = new CommandRegistry();
+      router = new Router({ base, commands });
+    });
+
+    describe('#constructor()', () => {
+
+      it('should construct a new router', () => {
+        expect(router).to.be.a(Router);
+      });
+
+    });
+
+    describe('#base', () => {
+
+      it('should be the base URL of the application', () => {
+        expect(router.base).to.be(base);
+      });
+
+    });
+
+    describe('#commands', () => {
+
+      it('should be the command registry used by the router', () => {
+        expect(router.commands).to.be(commands);
+      });
+
+    });
+
+    describe('#current', () => {
+
+      it('should return the current window location as an object', () => {
+        // The karma test window location is a file called `context.html`
+        // without any query string parameters or hash.
+        const path = 'context.html';
+        const request = path;
+        const search = '';
+        const hash = '';
+
+        expect(router.current).to.eql({ hash, path, request, search });
+      });
+
+    });
+
+    describe('#routed', () => {
+
+      it('should emit a signal when a path is routed', done => {
+        let routed = false;
+
+        commands.addCommand('a', { execute: () => { routed = true; } });
+        router.register({ command: 'a', pattern: /.*/, rank: 10 });
+
+        router.routed.connect(() => {
+          expect(routed).to.be(true);
+          done();
+        });
+        router.route();
+      });
+
+    });
+
+    describe('#stop', () => {
+
+      it('should be a unique token', () => {
+        expect(router.stop).to.be.a(Token);
+      });
+
+      it('should stop routing if returned by a routed command', done => {
+        const wanted = ['a', 'b'];
+        let recorded: string[] = [];
+
+        commands.addCommand('a', { execute: () => { recorded.push('a'); } });
+        commands.addCommand('b', { execute: () => { recorded.push('b'); } });
+        commands.addCommand('c', { execute: () => router.stop });
+        commands.addCommand('d', { execute: () => { recorded.push('d'); } });
+
+        router.register({ command: 'a', pattern: /.*/, rank: 10 });
+        router.register({ command: 'b', pattern: /.*/, rank: 20 });
+        router.register({ command: 'c', pattern: /.*/, rank: 30 });
+        router.register({ command: 'd', pattern: /.*/, rank: 40 });
+
+        router.routed.connect(() => {
+          expect(recorded).to.eql(wanted);
+          done();
+        });
+        router.route();
+      });
+
+    });
+
+    describe('#navigate()', () => {
+
+      it('cannot be tested since changing location is a security risk', () => {
+        // Router#navigate() changes window.location.href but karma tests
+        // disallow changing the window location.
+      });
+
+    });
+
+    describe('#register()', () => {
+
+      it('should register a command with a route pattern', done => {
+        const wanted = ['a'];
+        let recorded: string[] = [];
+
+        commands.addCommand('a', { execute: () => { recorded.push('a'); } });
+        router.register({ command: 'a', pattern: /.*/ });
+
+        router.routed.connect(() => {
+          expect(recorded).to.eql(wanted);
+          done();
+        });
+        router.route();
+      });
+
+    });
+
+    describe('#route()', () => {
+
+      it('should route the location to a command', done => {
+        const wanted = ['a'];
+        let recorded: string[] = [];
+
+        commands.addCommand('a', { execute: () => { recorded.push('a'); } });
+        router.register({ command: 'a', pattern: /#a/, rank: 10 });
+        expect(recorded).to.be.empty();
+
+        // Change the hash because changing location is a security error.
+        window.location.hash = 'a';
+
+        router.routed.connect(() => {
+          expect(recorded).to.eql(wanted);
+          window.location.hash = '';
+          done();
+        });
+        router.route();
+      });
+
+    });
+
+  });
+
+});