Browse Source

Add a testutils package

Steven Silvester 6 years ago
parent
commit
1dd243e630

+ 2 - 1
package.json

@@ -85,6 +85,7 @@
     "buildutils/template",
     "buildutils/test-template",
     "tests",
-    "tests/test-*"
+    "tests/test-*",
+    "testutils"
   ]
 }

+ 0 - 64
packages/services/test/src/utils.ts

@@ -706,70 +706,6 @@ export class TerminalTester extends SocketTester {
   private _onMessage: (msg: TerminalSession.IMessage) => void = null;
 }
 
-/**
- * Test a single emission from a signal.
- *
- * @param signal - The signal we are listening to.
- * @param find - An optional function to determine which emission to test,
- * defaulting to the first emission.
- * @param test - An optional function which contains the tests for the emission.
- * @param value - An optional value that the promise resolves to if it is
- * successful.
- *
- * @returns a promise that rejects if the function throws an error (e.g., if an
- * expect test doesn't pass), and resolves otherwise.
- *
- * #### Notes
- * The first emission for which the find function returns true will be tested in
- * the test function. If the find function is not given, the first signal
- * emission will be tested.
- *
- * You can test to see if any signal comes which matches a criteria by just
- * giving a find function. You can test the very first signal by just giving a
- * test function. And you can test the first signal matching the find criteria
- * by giving both.
- *
- * The reason this function is asynchronous is so that the thing causing the
- * signal emission (such as a websocket message) can be asynchronous.
- */
-export async function testEmission<T, U, V>(
-  signal: ISignal<T, U>,
-  options: {
-    find?: (a: T, b: U) => boolean;
-    test?: (a: T, b: U) => void;
-    value?: V;
-  }
-): Promise<V> {
-  const done = new PromiseDelegate<V>();
-  const object = {};
-  signal.connect((sender: T, args: U) => {
-    if (!options.find || options.find(sender, args)) {
-      try {
-        Signal.disconnectReceiver(object);
-        if (options.test) {
-          options.test(sender, args);
-        }
-      } catch (e) {
-        done.reject(e);
-      }
-      done.resolve(options.value || undefined);
-    }
-  }, object);
-  return done.promise;
-}
-
-/**
- * Test to see if a promise is fulfilled.
- *
- * @returns true if the promise is fulfilled (either resolved or rejected), and
- * false if the promise is still pending.
- */
-export async function isFulfilled<T>(p: PromiseLike<T>): Promise<boolean> {
-  let x = Object.create(null);
-  let result = await Promise.race([p, x]).catch(() => false);
-  return result !== x;
-}
-
 /**
  * Make a new type with the given keys declared as optional.
  *

+ 1 - 0
tests/test-apputils/package.json

@@ -22,6 +22,7 @@
     "@phosphor/commands": "^1.5.0",
     "@phosphor/coreutils": "^1.3.0",
     "@phosphor/messaging": "^1.2.2",
+    "@phosphor/signaling": "^1.2.2",
     "@phosphor/virtualdom": "^1.1.2",
     "@phosphor/widgets": "^1.6.0",
     "chai": "~4.1.2",

+ 19 - 14
tests/test-apputils/src/instancetracker.spec.ts

@@ -5,6 +5,10 @@ import { expect } from 'chai';
 
 import { InstanceTracker } from '@jupyterlab/apputils';
 
+import { PromiseDelegate } from '@phosphor/coreutils';
+
+import { ISignal, Signal } from '@phosphor/signaling';
+
 import { each } from '@phosphor/algorithm';
 
 import { Panel, Widget } from '@phosphor/widgets';
@@ -40,35 +44,34 @@ describe('@jupyterlab/apputils', () => {
     });
 
     describe('#currentChanged', () => {
-      it('should emit when the current widget has been updated', () => {
+      it('should emit when the current widget has been updated', async () => {
         const tracker = new InstanceTracker<Widget>({ namespace });
         const widget = new Widget();
         widget.node.tabIndex = -1;
-        let called = false;
-        tracker.currentChanged.connect(() => {
-          called = true;
-        });
+        let promise = testEmission(tracker.currentChanged, {});
         Widget.attach(widget, document.body);
         widget.node.focus();
         simulate(widget.node, 'focus');
         tracker.add(widget);
-        expect(called).to.equal(true);
+        await promise;
         Widget.detach(widget);
       });
     });
 
     describe('#widgetAdded', () => {
-      it('should emit when a widget has been added', done => {
+      it('should emit when a widget has been added', async () => {
         const tracker = new InstanceTracker<Widget>({ namespace });
         const widget = new Widget();
-        tracker.widgetAdded.connect((sender, added) => {
-          expect(added).to.equal(widget);
-          done();
+        let promise = testEmission(tracker.widgetAdded, {
+          test: (sender, added) => {
+            expect(added).to.equal(widget);
+          }
         });
         tracker.add(widget);
+        await promise;
       });
 
-      it('should not emit when a widget has been injected', done => {
+      it('should not emit when a widget has been injected', async () => {
         const tracker = new InstanceTracker<Widget>({ namespace });
         const one = new Widget();
         const two = new Widget();
@@ -77,9 +80,10 @@ describe('@jupyterlab/apputils', () => {
         tracker.widgetAdded.connect(() => {
           total++;
         });
-        tracker.currentChanged.connect(() => {
-          expect(total).to.equal(1);
-          done();
+        let promise = testEmission(tracker.currentChanged, {
+          find: () => {
+            return total === 1;
+          }
         });
         tracker.add(one);
         tracker.inject(two);
@@ -87,6 +91,7 @@ describe('@jupyterlab/apputils', () => {
         two.node.focus();
         simulate(two.node, 'focus');
         Widget.detach(two);
+        await promise;
       });
     });
 

+ 46 - 0
testutils/package.json

@@ -0,0 +1,46 @@
+{
+  "name": "@jupyterlab/testutils",
+  "version": "0.1.0",
+  "description": "JupyterLab - Test Utilities",
+  "homepage": "https://github.com/jupyterlab/jupyterlab",
+  "bugs": {
+    "url": "https://github.com/jupyterlab/jupyterlab/issues"
+  },
+  "license": "BSD-3-Clause",
+  "author": "Project Jupyter",
+  "files": [
+    "lib/*.d.ts",
+    "lib/*.js.map",
+    "lib/*.js"
+  ],
+  "main": "lib/index.js",
+  "types": "lib/index.d.ts",
+  "directories": {
+    "lib": "lib/"
+  },
+  "repository": {
+    "type": "git",
+    "url": "https://github.com/jupyterlab/jupyterlab.git"
+  },
+  "scripts": {
+    "build": "tsc",
+    "clean": "rimraf lib",
+    "prepublishOnly": "npm run build",
+    "watch": "tsc -w --listEmittedFiles"
+  },
+  "dependencies": {
+    "@jupyterlab/apputils": "^0.17.2",
+    "@jupyterlab/coreutils": "^2.0.2",
+    "@jupyterlab/docregistry": "^0.17.2",
+    "@jupyterlab/notebook": "^0.17.2",
+    "@jupyterlab/rendermime": "^0.17.3",
+    "@jupyterlab/services": "^3.0.3",
+    "@phosphor/coreutils": "^1.3.0",
+    "@phosphor/signaling": "^1.2.2",
+    "json-to-html": "~0.1.2",
+    "simulate-event": "~1.4.0"
+  },
+  "devDependencies": {
+    "typescript": "~2.9.2"
+  }
+}

+ 329 - 0
testutils/src/index.ts

@@ -0,0 +1,329 @@
+// Copyright (c) Jupyter Development Team.
+// Distributed under the terms of the Modified BSD License.
+
+// tslint:disable-next-line
+/// <reference path="./json-to-html.d.ts"/>
+
+import json2html = require('json-to-html');
+
+import { simulate } from 'simulate-event';
+
+import { ServiceManager } from '@jupyterlab/services';
+
+import { ClientSession } from '@jupyterlab/apputils';
+
+import { nbformat } from '@jupyterlab/coreutils';
+
+import { PromiseDelegate, UUID } from '@phosphor/coreutils';
+
+import { ISignal, Signal } from '@phosphor/signaling';
+import {
+  TextModelFactory,
+  DocumentRegistry,
+  Context
+} from '@jupyterlab/docregistry';
+
+import { INotebookModel, NotebookModelFactory } from '@jupyterlab/notebook';
+
+import {
+  IRenderMime,
+  RenderMimeRegistry,
+  RenderedHTML,
+  standardRendererFactories
+} from '@jupyterlab/rendermime';
+
+/**
+ * Test a single emission from a signal.
+ *
+ * @param signal - The signal we are listening to.
+ * @param find - An optional function to determine which emission to test,
+ * defaulting to the first emission.
+ * @param test - An optional function which contains the tests for the emission.
+ * @param value - An optional value that the promise resolves to if it is
+ * successful.
+ *
+ * @returns a promise that rejects if the function throws an error (e.g., if an
+ * expect test doesn't pass), and resolves otherwise.
+ *
+ * #### Notes
+ * The first emission for which the find function returns true will be tested in
+ * the test function. If the find function is not given, the first signal
+ * emission will be tested.
+ *
+ * You can test to see if any signal comes which matches a criteria by just
+ * giving a find function. You can test the very first signal by just giving a
+ * test function. And you can test the first signal matching the find criteria
+ * by giving both.
+ *
+ * The reason this function is asynchronous is so that the thing causing the
+ * signal emission (such as a websocket message) can be asynchronous.
+ */
+export function testEmission<T, U, V>(
+  signal: ISignal<T, U>,
+  options: {
+    find?: (a: T, b: U) => boolean;
+    test?: (a: T, b: U) => void;
+    value?: V;
+  }
+): Promise<V> {
+  const done = new PromiseDelegate<V>();
+  const object = {};
+  signal.connect((sender: T, args: U) => {
+    if (!options.find || options.find(sender, args)) {
+      try {
+        Signal.disconnectReceiver(object);
+        if (options.test) {
+          options.test(sender, args);
+        }
+      } catch (e) {
+        done.reject(e);
+      }
+      done.resolve(options.value || undefined);
+    }
+  }, object);
+  return done.promise;
+}
+
+/**
+ * Test to see if a promise is fulfilled.
+ *
+ * @returns true if the promise is fulfilled (either resolved or rejected), and
+ * false if the promise is still pending.
+ */
+export async function isFulfilled<T>(p: PromiseLike<T>): Promise<boolean> {
+  let x = Object.create(null);
+  let result = await Promise.race([p, x]).catch(() => false);
+  return result !== x;
+}
+
+/**
+ * Convert a requestAnimationFrame into a Promise.
+ */
+export function framePromise(): Promise<void> {
+  const done = new PromiseDelegate<void>();
+  requestAnimationFrame(() => {
+    done.resolve(void 0);
+  });
+  return done.promise;
+}
+
+/**
+ * Return a promise that resolves in the given milliseconds with the given value.
+ */
+export function sleep<T>(milliseconds: number = 0, value?: T): Promise<T> {
+  return new Promise((resolve, reject) => {
+    setTimeout(() => {
+      resolve(value);
+    }, milliseconds);
+  });
+}
+
+/**
+ * Get a copy of the default rendermime instance.
+ */
+export function defaultRenderMime(): RenderMimeRegistry {
+  return Private.rendermime.clone();
+}
+
+/**
+ * Create a client session object.
+ */
+export async function createClientSession(
+  options: Partial<ClientSession.IOptions> = {}
+): Promise<ClientSession> {
+  const manager = options.manager || Private.manager.sessions;
+
+  await manager.ready;
+  return new ClientSession({
+    manager,
+    path: options.path || UUID.uuid4(),
+    name: options.name,
+    type: options.type,
+    kernelPreference: options.kernelPreference || {
+      shouldStart: true,
+      canStart: true,
+      name: manager.specs.default
+    }
+  });
+}
+
+/**
+ * Create a context for a file.
+ */
+export function createFileContext(
+  path?: string,
+  manager?: ServiceManager.IManager
+): Context<DocumentRegistry.IModel> {
+  const factory = Private.textFactory;
+
+  manager = manager || Private.manager;
+  path = path || UUID.uuid4() + '.txt';
+
+  return new Context({ manager, factory, path });
+}
+
+/**
+ * Create a context for a notebook.
+ */
+export async function createNotebookContext(
+  path?: string,
+  manager?: ServiceManager.IManager
+): Promise<Context<INotebookModel>> {
+  const factory = Private.notebookFactory;
+
+  manager = manager || Private.manager;
+  path = path || UUID.uuid4() + '.ipynb';
+  await manager.ready;
+
+  return new Context({
+    manager,
+    factory,
+    path,
+    kernelPreference: { name: manager.specs.default }
+  });
+}
+
+/**
+ * Wait for a dialog to be attached to an element.
+ */
+export function waitForDialog(
+  host?: HTMLElement,
+  timeout?: number
+): Promise<void> {
+  return new Promise<void>((resolve, reject) => {
+    let counter = 0;
+    const interval = 25;
+    const limit = Math.floor((timeout || 250) / interval);
+    const seek = () => {
+      if (++counter === limit) {
+        reject(new Error('Dialog not found'));
+        return;
+      }
+
+      if ((host || document.body).getElementsByClassName('jp-Dialog')[0]) {
+        resolve(undefined);
+        return;
+      }
+
+      setTimeout(seek, interval);
+    };
+
+    seek();
+  });
+}
+
+/**
+ * Accept a dialog after it is attached by accepting the default button.
+ */
+export async function acceptDialog(
+  host?: HTMLElement,
+  timeout?: number
+): Promise<void> {
+  host = host || document.body;
+  await waitForDialog(host, timeout);
+
+  const node = host.getElementsByClassName('jp-Dialog')[0];
+
+  if (node) {
+    simulate(node as HTMLElement, 'keydown', { keyCode: 13 });
+  }
+}
+
+/**
+ * Dismiss a dialog after it is attached.
+ *
+ * #### Notes
+ * This promise will always resolve successfully.
+ */
+export async function dismissDialog(
+  host?: HTMLElement,
+  timeout?: number
+): Promise<void> {
+  host = host || document.body;
+
+  try {
+    await waitForDialog(host, timeout);
+  } catch (error) {
+    return; // Ignore calls to dismiss the dialog if there is no dialog.
+  }
+
+  const node = host.getElementsByClassName('jp-Dialog')[0];
+
+  if (node) {
+    simulate(node as HTMLElement, 'keydown', { keyCode: 27 });
+  }
+}
+
+/**
+ * The default outputs used for testing.
+ */
+export const DEFAULT_OUTPUTS: nbformat.IOutput[] = [
+  {
+    name: 'stdout',
+    output_type: 'stream',
+    text: ['hello world\n', '0\n', '1\n', '2\n']
+  },
+  {
+    name: 'stderr',
+    output_type: 'stream',
+    text: ['output to stderr\n']
+  },
+  {
+    name: 'stderr',
+    output_type: 'stream',
+    text: ['output to stderr2\n']
+  },
+  {
+    output_type: 'execute_result',
+    execution_count: 1,
+    data: { 'text/plain': 'foo' },
+    metadata: {}
+  },
+  {
+    output_type: 'display_data',
+    data: { 'text/plain': 'hello, world' },
+    metadata: {}
+  },
+  {
+    output_type: 'error',
+    ename: 'foo',
+    evalue: 'bar',
+    traceback: ['fizz', 'buzz']
+  }
+];
+
+/**
+ * A namespace for private data.
+ */
+namespace Private {
+  export const manager = new ServiceManager();
+
+  export const textFactory = new TextModelFactory();
+
+  export const notebookFactory = new NotebookModelFactory({});
+
+  class JSONRenderer extends RenderedHTML {
+    mimeType = 'text/html';
+
+    renderModel(model: IRenderMime.IMimeModel): Promise<void> {
+      let source = model.data['application/json'];
+      model.setData({ data: { 'text/html': json2html(source) } });
+      return super.renderModel(model);
+    }
+  }
+
+  const jsonRendererFactory = {
+    mimeTypes: ['application/json'],
+    safe: true,
+    createRenderer(
+      options: IRenderMime.IRendererOptions
+    ): IRenderMime.IRenderer {
+      return new JSONRenderer(options);
+    }
+  };
+
+  export const rendermime = new RenderMimeRegistry({
+    initialFactories: standardRendererFactories
+  });
+  rendermime.addFactory(jsonRendererFactory, 10);
+}

+ 0 - 0
tests/typings/json-to-html/json-to-html.d.ts → testutils/src/json-to-html.d.ts


+ 7 - 0
testutils/tsconfig.json

@@ -0,0 +1,7 @@
+{
+  "extends": "../tsconfigbase",
+  "compilerOptions": {
+    "outDir": "./lib"
+  },
+  "include": ["src/*"]
+}