Quellcode durchsuchen

Merge pull request #7406 from jasongrout/loglevel

Implement log console levels
Steven Silvester vor 5 Jahren
Ursprung
Commit
fa0a5bcb25

+ 1 - 0
dev_mode/package.json

@@ -371,6 +371,7 @@
       "@jupyterlab/test-fileeditor": "../tests/test-fileeditor",
       "@jupyterlab/test-imageviewer": "../tests/test-imageviewer",
       "@jupyterlab/test-inspector": "../tests/test-inspector",
+      "@jupyterlab/test-logconsole": "../tests/test-logconsole",
       "@jupyterlab/test-mainmenu": "../tests/test-mainmenu",
       "@jupyterlab/test-notebook": "../tests/test-notebook",
       "@jupyterlab/test-observables": "../tests/test-observables",

+ 33 - 13
packages/apputils/src/toolbar.tsx

@@ -20,6 +20,7 @@ import { PanelLayout, Widget } from '@phosphor/widgets';
 import { IClientSession } from './clientsession';
 
 import * as React from 'react';
+import { ReadonlyJSONObject } from '@phosphor/coreutils';
 
 /**
  * The class name added to toolbars.
@@ -315,15 +316,33 @@ export class Toolbar<T extends Widget = Widget> extends Widget {
   handleEvent(event: Event): void {
     switch (event.type) {
       case 'click':
-        if (!this.node.contains(document.activeElement) && this.parent) {
-          this.parent.activate();
-        }
+        this.handleClick(event);
         break;
       default:
         break;
     }
   }
 
+  /**
+   * Handle a DOM click event.
+   */
+  protected handleClick(event: Event) {
+    // Clicking a label focuses the corresponding control, so let it be.
+    if (event.target instanceof HTMLLabelElement) {
+      return;
+    }
+
+    // If this click already focused a control, let it be.
+    if (this.node.contains(document.activeElement)) {
+      return;
+    }
+
+    // Otherwise, activate the parent widget, which may take focus if desired.
+    if (this.parent) {
+      this.parent.activate();
+    }
+  }
+
   /**
    * Handle `after-attach` messages for the widget.
    */
@@ -520,6 +539,7 @@ export namespace CommandToolbarButtonComponent {
   export interface IProps {
     commands: CommandRegistry;
     id: string;
+    args?: ReadonlyJSONObject;
   }
 }
 
@@ -578,23 +598,23 @@ namespace Private {
   export function propsFromCommand(
     options: CommandToolbarButtonComponent.IProps
   ): ToolbarButtonComponent.IProps {
-    let { commands, id } = options;
-    const iconClassName = commands.iconClass(id);
-    const iconLabel = commands.iconLabel(id);
-    const label = commands.label(id);
-    let className = commands.className(id);
+    let { commands, id, args } = options;
+    const iconClassName = commands.iconClass(id, args);
+    const iconLabel = commands.iconLabel(id, args);
+    const label = commands.label(id, args);
+    let className = commands.className(id, args);
     // Add the boolean state classes.
-    if (commands.isToggled(id)) {
+    if (commands.isToggled(id, args)) {
       className += ' p-mod-toggled';
     }
-    if (!commands.isVisible(id)) {
+    if (!commands.isVisible(id, args)) {
       className += ' p-mod-hidden';
     }
-    const tooltip = commands.caption(id) || label || iconLabel;
+    const tooltip = commands.caption(id, args) || label || iconLabel;
     const onClick = () => {
-      void commands.execute(id);
+      void commands.execute(id, args);
     };
-    const enabled = commands.isEnabled(id);
+    const enabled = commands.isEnabled(id, args);
     return { className, iconClassName, tooltip, onClick, enabled, label };
   }
 

+ 2 - 2
packages/coreutils/src/interfaces.ts

@@ -12,11 +12,11 @@ import { ISignal } from '@phosphor/signaling';
 /**
  * A generic interface for change emitter payloads.
  */
-export interface IChangedArgs<T> {
+export interface IChangedArgs<T, U extends string = string> {
   /**
    * The name of the changed attribute.
    */
-  name: string;
+  name: U;
 
   /**
    * The old value of the changed attribute.

+ 2 - 0
packages/logconsole-extension/package.json

@@ -40,8 +40,10 @@
     "@jupyterlab/mainmenu": "^2.0.0-alpha.1",
     "@jupyterlab/notebook": "^2.0.0-alpha.1",
     "@jupyterlab/rendermime": "^2.0.0-alpha.1",
+    "@jupyterlab/services": "^5.0.0-alpha.1",
     "@jupyterlab/statusbar": "^2.0.0-alpha.1",
     "@jupyterlab/ui-components": "^2.0.0-alpha.1",
+    "@phosphor/coreutils": "^1.3.1",
     "@phosphor/signaling": "^1.3.0",
     "@phosphor/widgets": "^1.9.0",
     "react": "~16.8.4"

+ 135 - 17
packages/logconsole-extension/src/index.tsx

@@ -12,15 +12,17 @@ import {
   CommandToolbarButton,
   ICommandPalette,
   MainAreaWidget,
-  WidgetTracker
+  WidgetTracker,
+  ReactWidget
 } from '@jupyterlab/apputils';
 
-import { ISettingRegistry } from '@jupyterlab/coreutils';
+import { ISettingRegistry, IChangedArgs } from '@jupyterlab/coreutils';
 
 import {
   ILoggerRegistry,
   LogConsolePanel,
-  LoggerRegistry
+  LoggerRegistry,
+  LogLevel
 } from '@jupyterlab/logconsole';
 
 import { IMainMenu } from '@jupyterlab/mainmenu';
@@ -31,8 +33,16 @@ import { IRenderMimeRegistry } from '@jupyterlab/rendermime';
 
 import { IStatusBar } from '@jupyterlab/statusbar';
 
+import { HTMLSelect } from '@jupyterlab/ui-components';
+
+import { UUID } from '@phosphor/coreutils';
+
 import { DockLayout, Widget } from '@phosphor/widgets';
 
+import * as React from 'react';
+
+import { logNotebookOutput } from './nboutput';
+
 import { LogConsoleStatus } from './status';
 
 const LOG_CONSOLE_PLUGIN_ID = '@jupyterlab/logconsole-extension:plugin';
@@ -41,9 +51,10 @@ const LOG_CONSOLE_PLUGIN_ID = '@jupyterlab/logconsole-extension:plugin';
  * The command IDs used by the plugin.
  */
 namespace CommandIDs {
-  export const addTimestamp = 'logconsole:add-timestamp';
+  export const addCheckpoint = 'logconsole:add-checkpoint';
   export const clear = 'logconsole:clear';
   export const open = 'logconsole:open';
+  export const setLevel = 'logconsole:set-level';
 }
 
 /**
@@ -134,9 +145,9 @@ function activateLogConsole(
     logConsoleWidget.title.label = 'Log Console';
     logConsoleWidget.title.iconClass = 'jp-LogConsoleIcon';
 
-    const addTimestampButton = new CommandToolbarButton({
+    const addCheckpointButton = new CommandToolbarButton({
       commands: app.commands,
-      id: CommandIDs.addTimestamp
+      id: CommandIDs.addCheckpoint
     });
 
     const clearButton = new CommandToolbarButton({
@@ -145,10 +156,15 @@ function activateLogConsole(
     });
 
     logConsoleWidget.toolbar.addItem(
-      'lab-output-console-add-timestamp',
-      addTimestampButton
+      'lab-log-console-add-checkpoint',
+      addCheckpointButton
+    );
+    logConsoleWidget.toolbar.addItem('lab-log-console-clear', clearButton);
+
+    logConsoleWidget.toolbar.addItem(
+      'level',
+      new LogLevelSwitcher(logConsoleWidget.content)
     );
-    logConsoleWidget.toolbar.addItem('lab-output-console-clear', clearButton);
 
     logConsolePanel.sourceChanged.connect(() => {
       app.commands.notifyCommandChanged();
@@ -189,11 +205,10 @@ function activateLogConsole(
     }
   });
 
-  app.commands.addCommand(CommandIDs.addTimestamp, {
-    label: 'Add Timestamp',
+  app.commands.addCommand(CommandIDs.addCheckpoint, {
+    label: 'Add Checkpoint',
     execute: () => {
-      const logger = loggerRegistry.getLogger(logConsolePanel.source);
-      logger.log({ type: 'html', data: '<hr>' });
+      logConsolePanel.logger.checkpoint();
     },
     isEnabled: () => logConsolePanel && logConsolePanel.source !== null,
     iconClass: 'jp-AddIcon'
@@ -202,11 +217,24 @@ function activateLogConsole(
   app.commands.addCommand(CommandIDs.clear, {
     label: 'Clear Log',
     execute: () => {
-      const logger = loggerRegistry.getLogger(logConsolePanel.source);
-      logger.clear();
+      logConsolePanel.logger.clear();
     },
     isEnabled: () => logConsolePanel && logConsolePanel.source !== null,
-    iconClass: 'fa fa-ban clear-icon'
+    // TODO: figure out how this jp-clearIcon class should work, analagous to jp-AddIcon
+    iconClass: 'fa fa-ban jp-ClearIcon'
+  });
+
+  function toTitleCase(value: string) {
+    return value.length === 0 ? value : value[0].toUpperCase() + value.slice(1);
+  }
+
+  app.commands.addCommand(CommandIDs.setLevel, {
+    label: args => `Set Log Level to ${toTitleCase(args.level as string)}`,
+    execute: (args: { level: LogLevel }) => {
+      logConsolePanel.logger.level = args.level;
+    },
+    isEnabled: () => logConsolePanel && logConsolePanel.source !== null
+    // TODO: find good icon class
   });
 
   app.contextMenu.addItem({
@@ -274,4 +302,94 @@ function activateLogConsole(
   return loggerRegistry;
 }
 
-export default [logConsolePlugin];
+/**
+ * A toolbar widget that switches log levels.
+ */
+export class LogLevelSwitcher extends ReactWidget {
+  /**
+   * Construct a new cell type switcher.
+   */
+  constructor(widget: LogConsolePanel) {
+    super();
+    this.addClass('jp-LogConsole-toolbarLogLevel');
+    this._logConsole = widget;
+    if (widget.source) {
+      this.update();
+    }
+    widget.sourceChanged.connect(this._updateSource, this);
+  }
+
+  private _updateSource(
+    sender: LogConsolePanel,
+    { oldValue, newValue }: IChangedArgs<string | null>
+  ) {
+    // Transfer stateChanged handler to new source logger
+    if (oldValue !== null) {
+      const logger = sender.loggerRegistry.getLogger(oldValue);
+      logger.stateChanged.disconnect(this.update, this);
+    }
+    if (newValue !== null) {
+      const logger = sender.loggerRegistry.getLogger(newValue);
+      logger.stateChanged.connect(this.update, this);
+    }
+    this.update();
+  }
+
+  /**
+   * Handle `change` events for the HTMLSelect component.
+   */
+  handleChange = (event: React.ChangeEvent<HTMLSelectElement>): void => {
+    this._logConsole.logger.level = event.target.value as LogLevel;
+  };
+
+  /**
+   * Handle `keydown` events for the HTMLSelect component.
+   */
+  handleKeyDown = (event: React.KeyboardEvent): void => {
+    if (event.keyCode === 13) {
+      this._logConsole.activate();
+    }
+  };
+
+  render() {
+    let logger = this._logConsole.logger;
+    return (
+      <>
+        <label
+          htmlFor={this._id}
+          className={
+            logger === null
+              ? 'jp-LogConsole-toolbarLogLevel-disabled'
+              : undefined
+          }
+        >
+          Log Level:
+        </label>
+        <HTMLSelect
+          id={this._id}
+          className="jp-LogConsole-toolbarLogLevelDropdown"
+          onChange={this.handleChange}
+          onKeyDown={this.handleKeyDown}
+          value={logger !== null && logger.level}
+          iconProps={{
+            icon: <span className="jp-MaterialIcon jp-DownCaretIcon bp3-icon" />
+          }}
+          aria-label="Log level"
+          minimal
+          disabled={logger === null}
+          options={
+            logger === null
+              ? []
+              : ['Critical', 'Error', 'Warning', 'Info', 'Debug'].map(
+                  label => ({ label, value: label.toLowerCase() })
+                )
+          }
+        />
+      </>
+    );
+  }
+  private _logConsole: LogConsolePanel = null;
+  private _id = `level-${UUID.uuid4()}`;
+}
+
+export default [logConsolePlugin, logNotebookOutput];

+ 74 - 0
packages/logconsole-extension/src/nboutput.ts

@@ -0,0 +1,74 @@
+// Copyright (c) Jupyter Development Team.
+// Distributed under the terms of the Modified BSD License.
+
+import {
+  JupyterFrontEnd,
+  JupyterFrontEndPlugin
+} from '@jupyterlab/application';
+
+import { nbformat } from '@jupyterlab/coreutils';
+
+import { ILoggerRegistry, LogLevel } from '@jupyterlab/logconsole';
+
+import { INotebookTracker, NotebookPanel } from '@jupyterlab/notebook';
+
+import { KernelMessage } from '@jupyterlab/services';
+
+/**
+ * The Log Console extension.
+ */
+export const logNotebookOutput: JupyterFrontEndPlugin<void> = {
+  activate: activateNBOutput,
+  id: 'logconsole:nboutput',
+  requires: [ILoggerRegistry, INotebookTracker],
+  autoStart: true
+};
+
+function activateNBOutput(
+  app: JupyterFrontEnd,
+  loggerRegistry: ILoggerRegistry,
+  nbtracker: INotebookTracker
+) {
+  function registerNB(nb: NotebookPanel) {
+    function logOutput(
+      msg: KernelMessage.IIOPubMessage,
+      levelNormal: LogLevel,
+      levelError: LogLevel
+    ) {
+      if (
+        KernelMessage.isDisplayDataMsg(msg) ||
+        KernelMessage.isStreamMsg(msg) ||
+        KernelMessage.isErrorMsg(msg) ||
+        KernelMessage.isExecuteResultMsg(msg)
+      ) {
+        const logger = loggerRegistry.getLogger(nb.context.path);
+        logger.rendermime = nb.content.rendermime;
+        const data: nbformat.IOutput = {
+          ...msg.content,
+          output_type: msg.header.msg_type
+        };
+        let level: LogLevel = levelNormal;
+        if (
+          KernelMessage.isErrorMsg(msg) ||
+          (KernelMessage.isStreamMsg(msg) && msg.content.name === 'stderr')
+        ) {
+          level = levelError;
+        }
+        logger.log({ type: 'output', data, level });
+      }
+    }
+
+    // There is overlap here since unhandled messages are also emitted in the
+    // iopubMessage signal. However, unhandled messages warrant a higher log
+    // severity, so we'll accept that they are logged twice.
+    nb.context.session.iopubMessage.connect(
+      (_, msg: KernelMessage.IIOPubMessage) => logOutput(msg, 'info', 'info')
+    );
+    nb.context.session.unhandledMessage.connect(
+      (_, msg: KernelMessage.IIOPubMessage) =>
+        logOutput(msg, 'warning', 'error')
+    );
+  }
+  nbtracker.forEach(nb => registerNB(nb));
+  nbtracker.widgetAdded.connect((_, nb) => registerNB(nb));
+}

+ 7 - 4
packages/logconsole-extension/src/status.tsx

@@ -5,8 +5,8 @@ import { VDomModel, VDomRenderer } from '@jupyterlab/apputils';
 
 import {
   ILogger,
-  ILoggerChange,
-  ILoggerRegistry
+  ILoggerRegistry,
+  IContentChange
 } from '@jupyterlab/logconsole';
 
 import { GroupItem, TextItem, interactiveItem } from '@jupyterlab/statusbar';
@@ -289,7 +289,7 @@ export namespace LogConsoleStatus {
       const loggers = this._loggerRegistry.getLoggers();
       for (let logger of loggers) {
         if (!this._sourceVersion.has(logger.source)) {
-          logger.logChanged.connect(this._handleLogChange, this);
+          logger.contentChanged.connect(this._handleLogContentChange, this);
           this._sourceVersion.set(logger.source, {
             lastDisplayed: 0,
             lastNotified: 0
@@ -298,7 +298,10 @@ export namespace LogConsoleStatus {
       }
     }
 
-    private _handleLogChange({ source }: ILogger, change: ILoggerChange) {
+    private _handleLogContentChange(
+      { source }: ILogger,
+      change: IContentChange
+    ) {
       if (source === this._source) {
         this.stateChanged.emit();
       }

+ 26 - 1
packages/logconsole-extension/style/base.css

@@ -35,7 +35,32 @@
   color: white;
 }
 
-.jp-LogConsole .clear-icon {
+.jp-LogConsole .jp-ClearIcon {
   transform: rotate(90deg);
   margin-top: -1px;
 }
+
+.jp-Toolbar-item.jp-LogConsole-toolbarLogLevel {
+  align-items: center;
+  padding: 0px 6px;
+}
+
+/* Matches the disabled style elsewhere in JupyterLab */
+.jp-LogConsole-toolbarLogLevel-disabled {
+  opacity: 0.4;
+  cursor: not-allowed;
+}
+
+/* Copied from the notebook cell type dropdown styling */
+.jp-LogConsole-toolbarLogLevelDropdown select {
+  height: 24px;
+  font-size: var(--jp-ui-font-size1);
+  line-height: 14px;
+  border-radius: 0;
+  display: block;
+}
+
+/* Copied from the notebook cell type dropdown styling */
+.jp-LogConsole-toolbarLogLevelDropdown span {
+  top: 5px !important;
+}

+ 3 - 0
packages/logconsole-extension/tsconfig.json

@@ -27,6 +27,9 @@
     {
       "path": "../rendermime"
     },
+    {
+      "path": "../services"
+    },
     {
       "path": "../statusbar"
     },

+ 1 - 0
packages/logconsole/package.json

@@ -37,6 +37,7 @@
     "@jupyterlab/rendermime": "^2.0.0-alpha.1",
     "@jupyterlab/services": "^5.0.0-alpha.1",
     "@phosphor/coreutils": "^1.3.1",
+    "@phosphor/disposable": "^1.3.0",
     "@phosphor/messaging": "^1.3.0",
     "@phosphor/signaling": "^1.3.0",
     "@phosphor/widgets": "^1.9.0"

+ 158 - 37
packages/logconsole/src/logger.ts

@@ -15,31 +15,50 @@ import { ISignal, Signal } from '@phosphor/signaling';
 
 import {
   ILogger,
-  ILoggerChange,
+  IContentChange,
+  IStateChange,
   ILoggerOutputAreaModel,
-  ILogPayload
+  ILogPayload,
+  LogLevel
 } from './tokens';
 
 /**
- * Custom Notebook Output with timestamp member.
+ * All severity levels, including an internal one for metadata.
  */
-interface ITimestampedOutput extends nbformat.IBaseOutput {
+type FullLogLevel = LogLevel | 'metadata';
+
+/**
+ * Custom Notebook Output with log info.
+ */
+type ILogOutput = nbformat.IOutput & {
   /**
    * Date & time when output is logged in integer representation.
    */
   timestamp: number;
-}
 
-/**
- * Custom Notebook Output with optional timestamp.
- */
-type IOutputWithTimestamp = nbformat.IOutput | ITimestampedOutput;
+  /**
+   * Log level
+   */
+  level: FullLogLevel;
+};
+
+export interface ILogOutputModel extends IOutputModel {
+  /**
+   * Date & time when output is logged.
+   */
+  readonly timestamp: Date;
+
+  /**
+   * Log level
+   */
+  readonly level: FullLogLevel;
+}
 
 /**
  * Log Output Model with timestamp which provides
  * item information for Output Area Model.
  */
-export class LogOutputModel extends OutputModel {
+export class LogOutputModel extends OutputModel implements ILogOutputModel {
   /**
    * Construct a LogOutputModel.
    *
@@ -48,13 +67,19 @@ export class LogOutputModel extends OutputModel {
   constructor(options: LogOutputModel.IOptions) {
     super(options);
 
-    this.timestamp = new Date(options.value.timestamp as number);
+    this.timestamp = new Date(options.value.timestamp);
+    this.level = options.value.level;
   }
 
   /**
    * Date & time when output is logged.
    */
-  timestamp: Date = null;
+  readonly timestamp: Date = null;
+
+  /**
+   * Log level
+   */
+  readonly level: FullLogLevel;
 }
 
 /**
@@ -62,7 +87,7 @@ export class LogOutputModel extends OutputModel {
  */
 namespace LogOutputModel {
   export interface IOptions extends IOutputModel.IOptions {
-    value: IOutputWithTimestamp;
+    value: ILogOutput;
   }
 }
 
@@ -74,7 +99,7 @@ class LogConsoleModelContentFactory extends OutputAreaModel.ContentFactory {
   /**
    * Create a rendermime output model from notebook output.
    */
-  createOutputModel(options: IOutputModel.IOptions): LogOutputModel {
+  createOutputModel(options: LogOutputModel.IOptions): LogOutputModel {
     return new LogOutputModel(options);
   }
 }
@@ -100,12 +125,19 @@ export class LoggerOutputAreaModel extends OutputAreaModel
    * are combined. The oldest outputs are possibly removed to ensure the total
    * number of outputs is at most `.maxLength`.
    */
-  add(output: nbformat.IOutput): number {
+  add(output: ILogOutput): number {
     super.add(output);
     this._applyMaxLength();
     return this.length;
   }
 
+  /**
+   * Get an item at the specified index.
+   */
+  get(index: number): ILogOutputModel {
+    return super.get(index) as ILogOutputModel;
+  }
+
   /**
    * Maximum number of outputs to store in the model.
    */
@@ -169,6 +201,30 @@ export class Logger implements ILogger {
     this.outputAreaModel.maxLength = value;
   }
 
+  /**
+   * The level of outputs logged
+   */
+  get level(): LogLevel {
+    return this._level;
+  }
+  set level(newValue: LogLevel) {
+    let oldValue = this._level;
+    if (oldValue === newValue) {
+      return;
+    }
+    this._level = newValue;
+    this._log({
+      output: {
+        output_type: 'display_data',
+        data: {
+          'text/plain': `Log level set to ${newValue}`
+        }
+      },
+      level: 'metadata'
+    });
+    this._stateChanged.emit({ name: 'level', oldValue, newValue });
+  }
+
   /**
    * Number of outputs logged.
    */
@@ -179,15 +235,15 @@ export class Logger implements ILogger {
   /**
    * A signal emitted when the list of log messages changes.
    */
-  get logChanged(): ISignal<this, ILoggerChange> {
-    return this._logChanged;
+  get contentChanged(): ISignal<this, IContentChange> {
+    return this._contentChanged;
   }
 
   /**
-   * A signal emitted when the rendermime changes.
+   * A signal emitted when the log state changes.
    */
-  get rendermimeChanged(): ISignal<this, void> {
-    return this._rendermimeChanged;
+  get stateChanged(): ISignal<this, IStateChange> {
+    return this._stateChanged;
   }
 
   /**
@@ -198,8 +254,9 @@ export class Logger implements ILogger {
   }
   set rendermime(value: IRenderMimeRegistry | null) {
     if (value !== this._rendermime) {
-      this._rendermime = value;
-      this._rendermimeChanged.emit();
+      let oldValue = this._rendermime;
+      let newValue = (this._rendermime = value);
+      this._stateChanged.emit({ name: 'rendermime', oldValue, newValue });
     }
   }
 
@@ -230,9 +287,14 @@ export class Logger implements ILogger {
    * @param log - The output to be logged.
    */
   log(log: ILogPayload) {
-    const timestamp = new Date();
+    // Filter by our current log level
+    if (
+      Private.LogLevel[log.level as keyof typeof Private.LogLevel] <
+      Private.LogLevel[this._level as keyof typeof Private.LogLevel]
+    ) {
+      return;
+    }
     let output: nbformat.IOutput = null;
-
     switch (log.type) {
       case 'text':
         output = {
@@ -258,16 +320,10 @@ export class Logger implements ILogger {
     }
 
     if (output) {
-      // First, make sure our version reflects the new message so things
-      // triggering from the signals below have the correct version.
-      this._version++;
-
-      // Next, trigger any displays of the message
-      this.outputAreaModel.add({ ...output, timestamp: timestamp.valueOf() });
-
-      // Finally, tell people that the message was appended (and possibly
-      // already displayed).
-      this._logChanged.emit('append');
+      this._log({
+        output,
+        level: log.level
+      });
     }
   }
 
@@ -276,13 +332,67 @@ export class Logger implements ILogger {
    */
   clear() {
     this.outputAreaModel.clear(false);
-    this._logChanged.emit('clear');
+    this._contentChanged.emit('clear');
+  }
+
+  /**
+   * Add a checkpoint to the log.
+   */
+  checkpoint() {
+    this._log({
+      output: {
+        output_type: 'display_data',
+        data: {
+          'text/html': '<hr/>'
+        }
+      },
+      level: 'metadata'
+    });
+  }
+
+  /**
+   * Whether the logger is disposed.
+   */
+  get isDisposed() {
+    return this._isDisposed;
+  }
+
+  /**
+   * Dispose the logger.
+   */
+  dispose() {
+    if (this.isDisposed) {
+      return;
+    }
+    this._isDisposed = true;
+    this.clear();
+    this._rendermime = null;
+    Signal.clearData(this);
+  }
+
+  private _log(options: { output: nbformat.IOutput; level: FullLogLevel }) {
+    // First, make sure our version reflects the new message so things
+    // triggering from the signals below have the correct version.
+    this._version++;
+
+    // Next, trigger any displays of the message
+    this.outputAreaModel.add({
+      ...options.output,
+      timestamp: Date.now(),
+      level: options.level
+    });
+
+    // Finally, tell people that the message was appended (and possibly
+    // already displayed).
+    this._contentChanged.emit('append');
   }
 
-  private _logChanged = new Signal<this, ILoggerChange>(this);
+  private _isDisposed = false;
+  private _contentChanged = new Signal<this, IContentChange>(this);
+  private _stateChanged = new Signal<this, IStateChange>(this);
   private _rendermime: IRenderMimeRegistry | null = null;
-  private _rendermimeChanged = new Signal<this, void>(this);
   private _version = 0;
+  private _level: LogLevel = 'warning';
 }
 
 export namespace Logger {
@@ -297,3 +407,14 @@ export namespace Logger {
     maxLength: number;
   }
 }
+
+namespace Private {
+  export enum LogLevel {
+    debug,
+    info,
+    warning,
+    error,
+    critical,
+    metadata
+  }
+}

+ 21 - 1
packages/logconsole/src/registry.ts

@@ -76,10 +76,30 @@ export class LoggerRegistry implements ILoggerRegistry {
     });
   }
 
+  /**
+   * Whether the register is disposed.
+   */
+  get isDisposed() {
+    return this._isDisposed;
+  }
+
+  /**
+   * Dispose the registry and all loggers.
+   */
+  dispose() {
+    if (this.isDisposed) {
+      return;
+    }
+    this._isDisposed = true;
+    this._loggers.forEach(x => x.dispose());
+    Signal.clearData(this);
+  }
+
   private _defaultRendermime: IRenderMimeRegistry = null;
-  private _loggers = new Map<string, Logger>();
+  private _loggers = new Map<string, ILogger>();
   private _maxLength: number;
   private _registryChanged = new Signal<this, ILoggerRegistryChange>(this);
+  private _isDisposed = false;
 }
 
 export namespace LoggerRegistry {

+ 29 - 6
packages/logconsole/src/tokens.ts

@@ -1,7 +1,7 @@
 // Copyright (c) Jupyter Development Team.
 // Distributed under the terms of the Modified BSD License.
 
-import { nbformat } from '@jupyterlab/coreutils';
+import { nbformat, IChangedArgs } from '@jupyterlab/coreutils';
 
 import { IRenderMimeRegistry } from '@jupyterlab/rendermime';
 
@@ -10,6 +10,7 @@ import { IOutputAreaModel } from '@jupyterlab/outputarea';
 import { Token } from '@phosphor/coreutils';
 
 import { ISignal } from '@phosphor/signaling';
+import { IDisposable } from '@phosphor/disposable';
 
 /* tslint:disable */
 /**
@@ -24,7 +25,7 @@ export type ILoggerRegistryChange = 'append';
 /**
  * A Logger Registry that registers and provides loggers by source.
  */
-export interface ILoggerRegistry {
+export interface ILoggerRegistry extends IDisposable {
   /**
    * Get the logger for the specified source.
    *
@@ -46,6 +47,11 @@ export interface ILoggerRegistry {
   readonly registryChanged: ISignal<this, ILoggerRegistryChange>;
 }
 
+/**
+ * Log severity level
+ */
+export type LogLevel = 'critical' | 'error' | 'warning' | 'info' | 'debug';
+
 /**
  * The base log payload type.
  */
@@ -55,6 +61,11 @@ export interface ILogPayloadBase {
    */
   type: string;
 
+  /**
+   * Log level
+   */
+  level: LogLevel;
+
   /**
    * Data
    */
@@ -108,7 +119,11 @@ export interface IOutputLog extends ILogPayloadBase {
  */
 export type ILogPayload = ITextLog | IHtmlLog | IOutputLog;
 
-export type ILoggerChange = 'append' | 'clear';
+export type IContentChange = 'append' | 'clear';
+
+export type IStateChange =
+  | IChangedArgs<IRenderMimeRegistry, 'rendermime'>
+  | IChangedArgs<LogLevel, 'level'>;
 
 export interface ILoggerOutputAreaModel extends IOutputAreaModel {
   /**
@@ -120,7 +135,7 @@ export interface ILoggerOutputAreaModel extends IOutputAreaModel {
 /**
  * A Logger that manages logs from a particular source.
  */
-export interface ILogger {
+export interface ILogger extends IDisposable {
   /**
    * Number of outputs logged.
    */
@@ -129,6 +144,10 @@ export interface ILogger {
    * Max number of messages.
    */
   maxLength: number;
+  /**
+   * Log level.
+   */
+  level: LogLevel;
   /**
    * Rendermime to use when rendering outputs logged.
    */
@@ -136,11 +155,11 @@ export interface ILogger {
   /**
    * A signal emitted when the log model changes.
    */
-  readonly logChanged: ISignal<this, ILoggerChange>;
+  readonly contentChanged: ISignal<this, IContentChange>;
   /**
    * A signal emitted when the rendermime changes.
    */
-  readonly rendermimeChanged: ISignal<this, void>;
+  readonly stateChanged: ISignal<this, IStateChange>;
   /**
    * The name of the log source.
    */
@@ -159,6 +178,10 @@ export interface ILogger {
    * @param log - The output to be logged.
    */
   log(log: ILogPayload): void;
+  /**
+   * Add a checkpoint in the log.
+   */
+  checkpoint(): void;
   /**
    * Clear all outputs logged.
    */

+ 71 - 22
packages/logconsole/src/widget.ts

@@ -1,7 +1,7 @@
 // Copyright (c) Jupyter Development Team.
 // Distributed under the terms of the Modified BSD License.
 
-import { nbformat } from '@jupyterlab/coreutils';
+import { nbformat, IChangedArgs } from '@jupyterlab/coreutils';
 
 import { OutputArea, IOutputPrompt } from '@jupyterlab/outputarea';
 
@@ -19,18 +19,28 @@ import { LogOutputModel, LoggerOutputAreaModel } from './logger';
 
 import {
   ILogger,
-  ILoggerChange,
+  IContentChange,
   ILoggerRegistry,
-  ILoggerRegistryChange
+  ILoggerRegistryChange,
+  LogLevel,
+  IStateChange
 } from './tokens';
 
+function toTitleCase(value: string) {
+  return value.length === 0 ? value : value[0].toUpperCase() + value.slice(1);
+}
+
+/**
+ * All severity levels, including an internal one for metadata.
+ */
+type FullLogLevel = LogLevel | 'metadata';
+
 /**
  * Log console output prompt implementation
  */
 class LogConsoleOutputPrompt extends Widget implements IOutputPrompt {
   constructor() {
     super();
-
     this._timestampNode = document.createElement('div');
     this.node.append(this._timestampNode);
   }
@@ -42,6 +52,14 @@ class LogConsoleOutputPrompt extends Widget implements IOutputPrompt {
     this._timestampNode.innerHTML = value.toLocaleTimeString();
   }
 
+  /**
+   * Log level
+   */
+  set level(value: FullLogLevel) {
+    this.node.dataset.logLevel = value;
+    this.node.title = `${toTitleCase(value)} message`;
+  }
+
   /**
    * The execution count for the prompt.
    */
@@ -69,8 +87,15 @@ class LogConsoleOutputArea extends OutputArea {
    */
   protected createOutputItem(model: LogOutputModel): Widget | null {
     const panel = super.createOutputItem(model) as Panel;
+    if (panel === null) {
+      // Could not render model
+      return null;
+    }
+
     // first widget in panel is prompt of type LoggerOutputPrompt
-    (panel.widgets[0] as LogConsoleOutputPrompt).timestamp = model.timestamp;
+    let prompt = panel.widgets[0] as LogConsoleOutputPrompt;
+    prompt.timestamp = model.timestamp;
+    prompt.level = model.level;
     return panel;
   }
 
@@ -136,17 +161,21 @@ export class ScrollingWidget<T extends Widget> extends Widget {
     });
 
     // Set up intersection observer for the sentinel
-    this._observer = new IntersectionObserver(
-      args => {
-        this._handleScroll(args);
-      },
-      { root: this.node, threshold: 1 }
-    );
-    this._observer.observe(this._sentinel);
+    if (typeof IntersectionObserver !== 'undefined') {
+      this._observer = new IntersectionObserver(
+        args => {
+          this._handleScroll(args);
+        },
+        { root: this.node, threshold: 1 }
+      );
+      this._observer.observe(this._sentinel);
+    }
   }
 
   protected onBeforeDetach(msg: Message) {
-    this._observer.disconnect();
+    if (this._observer) {
+      this._observer.disconnect();
+    }
   }
 
   protected onAfterShow(msg: Message) {
@@ -173,7 +202,7 @@ export class ScrollingWidget<T extends Widget> extends Widget {
   }
 
   private _content: T;
-  private _observer: IntersectionObserver;
+  private _observer: IntersectionObserver = null;
   private _scrollHeight: number;
   private _sentinel: HTMLDivElement;
   private _tracking: boolean;
@@ -223,6 +252,16 @@ export class LogConsolePanel extends StackedPanel {
     return this._loggerRegistry;
   }
 
+  /**
+   * The current logger.
+   */
+  get logger(): ILogger | null {
+    if (this.source === null) {
+      return null;
+    }
+    return this.loggerRegistry.getLogger(this.source);
+  }
+
   /**
    * The log source displayed
    */
@@ -230,10 +269,14 @@ export class LogConsolePanel extends StackedPanel {
     return this._source;
   }
   set source(name: string | null) {
-    this._source = name;
-    this._showOutputFromSource(this._source);
+    if (name === this._source) {
+      return;
+    }
+    const oldValue = this._source;
+    const newValue = (this._source = name);
+    this._showOutputFromSource(newValue);
     this._handlePlaceholder();
-    this._sourceChanged.emit(name);
+    this._sourceChanged.emit({ oldValue, newValue, name: 'source' });
   }
 
   /**
@@ -247,7 +290,7 @@ export class LogConsolePanel extends StackedPanel {
   /**
    * Signal for source changes
    */
-  get sourceChanged(): ISignal<this, string | null> {
+  get sourceChanged(): ISignal<this, IChangedArgs<string | null, 'source'>> {
     return this._sourceChanged;
   }
 
@@ -282,16 +325,19 @@ export class LogConsolePanel extends StackedPanel {
         continue;
       }
 
-      logger.logChanged.connect((sender: ILogger, args: ILoggerChange) => {
+      logger.contentChanged.connect((sender: ILogger, args: IContentChange) => {
         this._updateOutputAreas();
         this._handlePlaceholder();
       }, this);
 
-      logger.rendermimeChanged.connect((sender: ILogger) => {
+      logger.stateChanged.connect((sender: ILogger, change: IStateChange) => {
+        if (change.name !== 'rendermime') {
+          return;
+        }
         const viewId = `source:${sender.source}`;
         const outputArea = this._outputAreas.get(viewId);
         if (outputArea) {
-          outputArea.rendermime = sender.rendermime;
+          outputArea.rendermime = change.newValue;
         }
       }, this);
 
@@ -403,7 +449,10 @@ export class LogConsolePanel extends StackedPanel {
   private _loggerRegistry: ILoggerRegistry;
   private _outputAreas = new Map<string, LogConsoleOutputArea>();
   private _source: string | null = null;
-  private _sourceChanged = new Signal<this, string | null>(this);
+  private _sourceChanged = new Signal<
+    this,
+    IChangedArgs<string | null, 'source'>
+  >(this);
   private _sourceDisplayed = new Signal<this, ISourceDisplayed>(this);
   private _placeholder: Widget;
   private _loggersWatched: Set<string> = new Set();

+ 20 - 0
packages/logconsole/style/base.css

@@ -18,6 +18,26 @@
   padding: 2px;
 }
 
+.jp-LogConsolePanel .jp-OutputArea-prompt[data-log-level='info'] {
+  background-color: var(--jp-info-color1);
+  color: var(--jp-ui-inverse-font-color1);
+}
+
+.jp-LogConsolePanel .jp-OutputArea-prompt[data-log-level='warning'] {
+  background-color: var(--jp-warn-color1);
+  color: var(--jp-ui-inverse-font-color1);
+}
+
+.jp-LogConsolePanel .jp-OutputArea-prompt[data-log-level='error'] {
+  background-color: var(--jp-error-color1);
+  color: var(--jp-ui-inverse-font-color1);
+}
+
+.jp-LogConsolePanel .jp-OutputArea-prompt[data-log-level='critical'] {
+  background-color: var(--jp-error-color0);
+  color: var(--jp-ui-inverse-font-color0);
+}
+
 .jp-LogConsoleListPlaceholder {
   padding: 5px;
   font-size: 13px;

+ 3 - 0
packages/outputarea/src/widget.ts

@@ -420,6 +420,9 @@ export class OutputArea extends Widget {
 
   /**
    * Create an output item with a prompt and actual output
+   *
+   * @returns a rendered widget, or null if we cannot render
+   * #### Notes
    */
   protected createOutputItem(model: IOutputModel): Widget | null {
     let output = this.createRenderedMimetype(model);

+ 1 - 0
tests/test-logconsole/babel.config.js

@@ -0,0 +1 @@
+module.exports = require('@jupyterlab/testutils/lib/babel.config');

+ 2 - 0
tests/test-logconsole/jest.config.js

@@ -0,0 +1,2 @@
+const func = require('@jupyterlab/testutils/lib/jest-config');
+module.exports = func('logconsole', __dirname);

+ 30 - 0
tests/test-logconsole/package.json

@@ -0,0 +1,30 @@
+{
+  "name": "@jupyterlab/test-logconsole",
+  "version": "2.0.0-alpha.1",
+  "private": true,
+  "scripts": {
+    "build": "tsc -b",
+    "clean": "rimraf build && rimraf coverage",
+    "coverage": "python run.py --coverage",
+    "test": "python run.py",
+    "watch": "python run.py --debug",
+    "watch:all": "python run.py --debug --watchAll",
+    "watch:src": "tsc -b --watch"
+  },
+  "dependencies": {
+    "@jupyterlab/logconsole": "^1.0.0-alpha.1",
+    "@jupyterlab/rendermime": "^2.0.0-alpha.1",
+    "@jupyterlab/testutils": "^2.0.0-alpha.1",
+    "@phosphor/signaling": "^1.3.0",
+    "@phosphor/widgets": "^1.9.0",
+    "jest": "^24.7.1",
+    "jest-junit": "^6.3.0",
+    "ts-jest": "^24.0.2"
+  },
+  "devDependencies": {
+    "@types/chai": "^4.1.7",
+    "@types/jest": "^24.0.13",
+    "rimraf": "~2.6.2",
+    "typescript": "~3.5.1"
+  }
+}

+ 8 - 0
tests/test-logconsole/run.py

@@ -0,0 +1,8 @@
+# Copyright (c) Jupyter Development Team.
+# Distributed under the terms of the Modified BSD License.
+
+import os.path as osp
+from jupyterlab.tests.test_app import run_jest
+
+if __name__ == '__main__':
+    run_jest(osp.dirname(osp.realpath(__file__)))

+ 394 - 0
tests/test-logconsole/src/logger.spec.ts

@@ -0,0 +1,394 @@
+// Copyright (c) Jupyter Development Team.
+// Distributed under the terms of the Modified BSD License.
+
+import {
+  Logger,
+  LoggerOutputAreaModel,
+  ILogPayload,
+  LogLevel
+} from '@jupyterlab/logconsole';
+
+import { RenderMimeRegistry } from '@jupyterlab/rendermime';
+
+import { Signal, ISignal } from '@phosphor/signaling';
+
+class SignalLogger<SENDER, ARGS> {
+  constructor(signal: ISignal<SENDER, ARGS>) {
+    signal.connect(this.slot, this);
+  }
+
+  slot(sender: SENDER, args: ARGS) {
+    this.args.push(args);
+  }
+
+  dispose() {
+    Signal.disconnectAll(this);
+  }
+  args: ARGS[] = [];
+}
+
+describe('LoggerOutputAreaModel', () => {
+  let model: LoggerOutputAreaModel;
+  beforeEach(() => {
+    model = new LoggerOutputAreaModel({ maxLength: 10 });
+  });
+  afterEach(() => {
+    model.dispose();
+  });
+
+  describe('#constructor()', () => {
+    it('should create an LoggerOutputAreaModel', () => {
+      expect(model).toBeInstanceOf(LoggerOutputAreaModel);
+    });
+
+    it('should set the max length', async () => {
+      const model = new LoggerOutputAreaModel({ maxLength: 10 });
+      expect(model.maxLength).toEqual(10);
+      model.dispose();
+    });
+  });
+
+  describe('#maxLength', () => {
+    it('should set the maximum number of messages in the first-in first-out queue', () => {
+      for (let i = 0; i < 12; i++) {
+        model.add({
+          output_type: 'display_data',
+          data: { 'text/plain': i.toString() },
+          timestamp: Date.now(),
+          level: 'info'
+        });
+      }
+      expect(model.length).toEqual(10);
+      expect(model.get(0).data['text/plain']).toEqual('2');
+    });
+
+    it('setting maxLength should immediately apply and trim the message list', () => {
+      for (let i = 0; i < 12; i++) {
+        model.add({
+          output_type: 'display_data',
+          data: { 'text/plain': i.toString() },
+          timestamp: Date.now(),
+          level: 'info'
+        });
+      }
+      expect(model.maxLength).toEqual(10);
+      expect(model.length).toEqual(10);
+      model.maxLength = 5;
+      expect(model.maxLength).toEqual(5);
+      expect(model.length).toEqual(5);
+      expect(model.get(0).data['text/plain']).toEqual('7');
+    });
+  });
+});
+
+describe('Logger', () => {
+  let logger: Logger;
+  beforeEach(() => {
+    logger = new Logger({ source: 'test source', maxLength: 10 });
+  });
+  afterEach(() => {
+    logger.dispose();
+  });
+
+  describe('#constructor()', () => {
+    it('should create a Logger with initial properties', () => {
+      expect(logger).toBeInstanceOf(Logger);
+      expect(logger.source).toEqual('test source');
+      expect(logger.maxLength).toEqual(10);
+    });
+  });
+
+  describe('#maxLength', () => {
+    it('should set the maximum number of messages in the first-in first-out queue', () => {
+      for (let i = 0; i < 12; i++) {
+        logger.log({ type: 'text', data: i.toString(), level: 'critical' });
+      }
+      expect(logger.length).toEqual(10);
+      expect(logger.outputAreaModel.get(0).data['text/plain']).toEqual('2');
+    });
+
+    it('setting maxLength should immediately apply and trim the message list', () => {
+      for (let i = 0; i < 12; i++) {
+        logger.log({ type: 'text', data: i.toString(), level: 'critical' });
+      }
+      const model = logger.outputAreaModel;
+      expect(logger.maxLength).toEqual(10);
+      expect(logger.length).toEqual(10);
+      logger.maxLength = 5;
+      expect(logger.maxLength).toEqual(5);
+      expect(logger.length).toEqual(5);
+      expect(model.get(0).data['text/plain']).toEqual('7');
+    });
+  });
+
+  describe('#level', () => {
+    let levels: LogLevel[] = ['critical', 'error', 'warning', 'info', 'debug'];
+    it('should default to "warning"', () => {
+      expect(logger.level).toEqual('warning');
+    });
+
+    it.each(levels)('filters for messages: %s', (level: LogLevel) => {
+      logger.level = level;
+      const messages: ILogPayload[] = levels.map(level => ({
+        type: 'text',
+        data: level,
+        level
+      }));
+      messages.forEach(m => logger.log({ ...m }));
+      const logged: string[] = [];
+      for (let i = 0; i < logger.length; i++) {
+        const msg = logger.outputAreaModel.get(i);
+        logged.push(msg.level);
+      }
+      const shouldInclude = levels.slice(0, levels.indexOf(level) + 1);
+      const shouldExclude = levels.slice(levels.indexOf(level) + 1);
+      shouldInclude.forEach(x => {
+        expect(logged).toContain(x);
+      });
+      shouldExclude.forEach(x => {
+        expect(logged).not.toContain(x);
+      });
+    });
+
+    it('logs a "metadata" level text message if changed', () => {
+      logger.level = 'info';
+      const msg = logger.outputAreaModel.get(0);
+      expect(msg.level).toBe('metadata');
+      expect(msg.data['text/plain']).toContain('info');
+    });
+
+    it('emits a stateChanged signal when changing', () => {
+      const s = new SignalLogger(logger.stateChanged);
+      logger.level = 'info';
+      expect(s.args).toEqual([
+        {
+          name: 'level',
+          oldValue: 'warning',
+          newValue: 'info'
+        }
+      ]);
+      s.dispose();
+    });
+
+    it('setting to its current value has no effect', () => {
+      const s = new SignalLogger(logger.stateChanged);
+      logger.level = logger.level;
+      expect(s.args.length).toBe(0);
+      expect(logger.length).toBe(0);
+      s.dispose();
+    });
+  });
+
+  describe('#length', () => {
+    it('records how many messages are stored', () => {
+      logger.log({ type: 'text', data: 'message 1', level: 'warning' });
+      logger.log({ type: 'text', data: 'message 2', level: 'warning' });
+      expect(logger.length).toBe(2);
+      logger.clear();
+      expect(logger.length).toBe(0);
+    });
+
+    it('may be less than the messages logged if messages were combined', () => {
+      logger.log({
+        type: 'output',
+        data: { output_type: 'stream', name: 'stdout', text: 'message 1' },
+        level: 'critical'
+      });
+      logger.log({
+        type: 'output',
+        data: { output_type: 'stream', name: 'stdout', text: 'message 2' },
+        level: 'critical'
+      });
+      expect(logger.length).toBe(1);
+    });
+  });
+
+  describe('#rendermime', () => {
+    it('initially is null', () => {
+      expect(logger.rendermime).toBe(null);
+    });
+
+    it('sets the rendermime attribute', () => {
+      const value = new RenderMimeRegistry();
+      logger.rendermime = value;
+      expect(logger.rendermime).toBe(value);
+    });
+
+    it('emits a stateChanged signal when changed', () => {
+      const oldValue = (logger.rendermime = new RenderMimeRegistry());
+      const newValue = oldValue.clone();
+      const s = new SignalLogger(logger.stateChanged);
+      logger.rendermime = newValue;
+      expect(s.args).toEqual([{ name: 'rendermime', oldValue, newValue }]);
+      s.dispose();
+    });
+
+    it('setting to current value has no effect', () => {
+      logger.rendermime = new RenderMimeRegistry();
+      const s = new SignalLogger(logger.stateChanged);
+      logger.rendermime = logger.rendermime;
+      expect(s.args).toEqual([]);
+      s.dispose();
+    });
+  });
+
+  describe('#version', () => {
+    it('starts at zero', () => {
+      expect(logger.version).toBe(0);
+    });
+
+    it('increments every time a message is logged', () => {
+      logger.log({ type: 'text', data: 'message 1', level: 'warning' });
+      logger.log({ type: 'text', data: 'message 2', level: 'warning' });
+      expect(logger.version).toBe(2);
+    });
+
+    it('increments even if messages are combined', () => {
+      logger.log({
+        type: 'output',
+        data: { output_type: 'stream', name: 'stdout', text: 'message 1' },
+        level: 'critical'
+      });
+      logger.log({
+        type: 'output',
+        data: { output_type: 'stream', name: 'stdout', text: 'message 2' },
+        level: 'critical'
+      });
+      expect(logger.length).toBe(1);
+      expect(logger.version).toBe(2);
+    });
+
+    it('does not increment on clearing messages', () => {
+      logger.log({ type: 'text', data: 'message 1', level: 'warning' });
+      logger.log({ type: 'text', data: 'message 2', level: 'warning' });
+      expect(logger.version).toBe(2);
+      logger.clear();
+      expect(logger.length).toBe(0);
+      expect(logger.version).toBe(2);
+    });
+  });
+
+  describe('#log()', () => {
+    it('logs text messages', () => {
+      logger.log({ type: 'text', data: 'message', level: 'warning' });
+      expect(logger.length).toBe(1);
+    });
+
+    it('logs html messages', () => {
+      logger.log({ type: 'html', data: 'message', level: 'warning' });
+      expect(logger.length).toBe(1);
+    });
+
+    it('logs output stream messages', () => {
+      logger.log({
+        type: 'output',
+        data: { output_type: 'stream', name: 'stdout', text: 'message' },
+        level: 'warning'
+      });
+      expect(logger.length).toBe(1);
+    });
+
+    it('logs display_data messages', () => {
+      logger.log({
+        type: 'output',
+        data: {
+          output_type: 'display_data',
+          data: { 'text/plain': 'message' }
+        },
+        level: 'warning'
+      });
+      expect(logger.length).toBe(1);
+    });
+
+    it('logs execute_result messages', () => {
+      logger.log({
+        type: 'output',
+        data: {
+          output_type: 'execute_result',
+          data: { 'text/plain': 'message', execution_count: 5 }
+        },
+        level: 'warning'
+      });
+      expect(logger.length).toBe(1);
+    });
+
+    it('logs error messages', () => {
+      logger.log({
+        type: 'output',
+        data: {
+          output_type: 'error',
+          ename: 'Error',
+          evalue: 'Error',
+          traceback: ['level 1', 'level 2']
+        },
+        level: 'warning'
+      });
+      expect(logger.length).toBe(1);
+    });
+
+    it('emits an "append" content changed signal', () => {
+      const s = new SignalLogger(logger.contentChanged);
+      logger.log({ type: 'text', data: 'message 1', level: 'warning' });
+      expect(s.args).toEqual(['append']);
+      s.dispose();
+    });
+
+    it('emits an "append" content changed signal', () => {
+      const s = new SignalLogger(logger.contentChanged);
+      logger.log({
+        type: 'output',
+        data: { output_type: 'stream', name: 'stdout', text: 'message 1' },
+        level: 'critical'
+      });
+      logger.log({
+        type: 'output',
+        data: { output_type: 'stream', name: 'stdout', text: 'message 2' },
+        level: 'critical'
+      });
+      expect(s.args).toEqual(['append', 'append']);
+      expect(logger.length).toBe(1);
+      s.dispose();
+    });
+
+    it('adds a timestamp to the message', () => {
+      const before = Date.now();
+      logger.log({ type: 'text', data: 'message 1', level: 'warning' });
+      const after = Date.now();
+      const msgTime = logger.outputAreaModel.get(0).timestamp.getTime();
+      expect(msgTime).toBeGreaterThanOrEqual(before);
+      expect(msgTime).toBeLessThanOrEqual(after);
+    });
+  });
+
+  describe('#clear()', () => {
+    it('clears messages', () => {
+      logger.log({ type: 'text', data: 'message 1', level: 'warning' });
+      logger.log({ type: 'text', data: 'message 2', level: 'warning' });
+      expect(logger.length).toBe(2);
+      logger.clear();
+      expect(logger.length).toBe(0);
+    });
+
+    it('emits a "clear" content changed signal', () => {
+      const s = new SignalLogger(logger.contentChanged);
+      logger.log({ type: 'text', data: 'message 1', level: 'warning' });
+      logger.clear();
+      expect(s.args).toEqual(['append', 'clear']);
+      s.dispose();
+    });
+  });
+
+  describe('#checkpoint()', () => {
+    it('adds a metadata message to the message list', () => {
+      logger.checkpoint();
+      expect(logger.outputAreaModel.get(0).level).toBe('metadata');
+    });
+
+    it('emits an "append" content changed signal', () => {
+      const s = new SignalLogger(logger.contentChanged);
+      logger.checkpoint();
+      expect(s.args).toEqual(['append']);
+      s.dispose();
+    });
+  });
+});

+ 90 - 0
tests/test-logconsole/src/registry.spec.ts

@@ -0,0 +1,90 @@
+// Copyright (c) Jupyter Development Team.
+// Distributed under the terms of the Modified BSD License.
+
+import { LoggerRegistry } from '@jupyterlab/logconsole';
+
+import {
+  RenderMimeRegistry,
+  IRenderMimeRegistry
+} from '@jupyterlab/rendermime';
+
+import { Signal, ISignal } from '@phosphor/signaling';
+
+class SignalLogger<SENDER, ARGS> {
+  constructor(signal: ISignal<SENDER, ARGS>) {
+    signal.connect(this.slot, this);
+  }
+
+  slot(sender: SENDER, args: ARGS) {
+    this.args.push(args);
+  }
+
+  dispose() {
+    Signal.disconnectAll(this);
+  }
+  args: ARGS[] = [];
+}
+
+describe('LoggerRegistry', () => {
+  let defaultRendermime: IRenderMimeRegistry;
+  let registry: LoggerRegistry;
+  beforeEach(() => {
+    defaultRendermime = new RenderMimeRegistry();
+    registry = new LoggerRegistry({
+      defaultRendermime,
+      maxLength: 10
+    });
+  });
+
+  afterEach(() => {
+    registry.dispose();
+  });
+
+  describe('#constructor()', () => {
+    it('should create a registry with initial parameters', () => {
+      expect(registry).toBeInstanceOf(LoggerRegistry);
+      expect(registry.maxLength).toBe(10);
+    });
+  });
+
+  describe('#getLogger()', () => {
+    it('gets a specific logger', () => {
+      const A = registry.getLogger('A');
+      const B = registry.getLogger('B');
+      expect(registry.getLogger('A')).toEqual(A);
+      expect(registry.getLogger('B')).toEqual(B);
+    });
+    it('creates a new logger on demand if needed with default parameters', () => {
+      const A = registry.getLogger('A');
+      expect(A.rendermime).toBe(defaultRendermime);
+      expect(A.maxLength).toBe(registry.maxLength);
+    });
+    it('emits a registry changed "append" signal', () => {
+      const s = new SignalLogger(registry.registryChanged);
+      registry.getLogger('A');
+      expect(s.args).toEqual(['append']);
+      s.dispose();
+    });
+  });
+
+  describe('#getLoggers', () => {
+    it('gets all current loggers', () => {
+      const A = registry.getLogger('A');
+      expect(registry.getLoggers()).toEqual([A]);
+    });
+  });
+
+  describe('#maxLength', () => {
+    it('overrides the max length for all loggers', () => {
+      const A = registry.getLogger('A');
+      const B = registry.getLogger('B');
+      A.maxLength = 5;
+      B.maxLength = 20;
+      expect(A.maxLength).toEqual(5);
+      expect(B.maxLength).toEqual(20);
+      registry.maxLength = 12;
+      expect(A.maxLength).toEqual(12);
+      expect(B.maxLength).toEqual(12);
+    });
+  });
+});

+ 231 - 0
tests/test-logconsole/src/widget.spec.ts

@@ -0,0 +1,231 @@
+// Copyright (c) Jupyter Development Team.
+// Distributed under the terms of the Modified BSD License.
+
+import { LoggerRegistry, LogConsolePanel } from '@jupyterlab/logconsole';
+
+import {
+  RenderMimeRegistry,
+  IRenderMimeRegistry,
+  standardRendererFactories as initialFactories
+} from '@jupyterlab/rendermime';
+
+import { Signal, ISignal } from '@phosphor/signaling';
+import { Widget } from '@phosphor/widgets';
+
+class SignalLogger<SENDER, ARGS> {
+  constructor(signal: ISignal<SENDER, ARGS>) {
+    signal.connect(this.slot, this);
+  }
+
+  slot(sender: SENDER, args: ARGS) {
+    this.args.push(args);
+  }
+
+  clear() {
+    this.args.length = 0;
+  }
+
+  dispose() {
+    Signal.disconnectAll(this);
+  }
+  args: ARGS[] = [];
+}
+
+function anyAncestor(el: Element, test: (el: Element) => boolean) {
+  while (el) {
+    if (test(el)) {
+      return true;
+    }
+    if (!el.parentElement || el === el.parentNode) {
+      break;
+    }
+    el = el.parentElement;
+  }
+  return false;
+}
+
+function isHiddenPhosphor(el: Element) {
+  return el.classList.contains('p-mod-hidden');
+}
+
+describe('LogConsolePanel', () => {
+  let defaultRendermime: IRenderMimeRegistry;
+  let registry: LoggerRegistry;
+  let logConsole: LogConsolePanel;
+  beforeEach(() => {
+    defaultRendermime = new RenderMimeRegistry({ initialFactories });
+    registry = new LoggerRegistry({
+      defaultRendermime,
+      maxLength: 10
+    });
+    logConsole = new LogConsolePanel(registry);
+  });
+  afterEach(() => {
+    registry.dispose();
+    logConsole.dispose();
+  });
+
+  describe('#constructor()', () => {
+    it('should create a console with initial parameters', () => {
+      expect(logConsole).toBeInstanceOf(LogConsolePanel);
+      expect(logConsole.loggerRegistry).toBe(registry);
+    });
+  });
+
+  describe('#loggerRegistry', () => {
+    it('returns the logger registry', () => {
+      expect(logConsole.loggerRegistry).toBe(registry);
+    });
+  });
+
+  describe('#source', () => {
+    it('sets the current source', () => {
+      expect(logConsole.source).toBe(null);
+      registry.getLogger('A');
+      logConsole.source = 'A';
+      expect(logConsole.source).toBe('A');
+    });
+
+    it('displays output only from the current source', () => {
+      const loggerA = registry.getLogger('A');
+      const loggerB = registry.getLogger('B');
+      loggerA.log({
+        type: 'html',
+        data: '<div id="A"></div>',
+        level: 'warning'
+      });
+      loggerB.log({
+        type: 'html',
+        data: '<div id="B"></div>',
+        level: 'warning'
+      });
+      logConsole.source = 'A';
+      const nodeA = logConsole.node.querySelector('#A');
+      const nodeB = logConsole.node.querySelector('#B');
+      expect(nodeA).not.toBeNull();
+      expect(anyAncestor(nodeA, isHiddenPhosphor)).toBe(false);
+      expect(nodeB).not.toBeNull();
+      expect(anyAncestor(nodeB, isHiddenPhosphor)).toBe(true);
+
+      logConsole.source = 'B';
+      expect(anyAncestor(nodeA, isHiddenPhosphor)).toBe(true);
+      expect(anyAncestor(nodeB, isHiddenPhosphor)).toBe(false);
+    });
+
+    it('emits a source changed signal if changed', () => {
+      const s = new SignalLogger(logConsole.sourceChanged);
+      logConsole.source = 'A';
+      logConsole.source = null;
+      expect(s.args).toEqual([
+        { name: 'source', oldValue: null, newValue: 'A' },
+        { name: 'source', oldValue: 'A', newValue: null }
+      ]);
+      s.dispose();
+    });
+
+    it('has no effect if not changed', () => {
+      const s = new SignalLogger(logConsole.sourceChanged);
+      logConsole.source = null;
+      expect(s.args).toEqual([]);
+
+      registry.getLogger('A');
+      logConsole.source = 'A';
+
+      s.clear();
+      logConsole.source = 'A';
+      expect(s.args).toEqual([]);
+      s.dispose();
+    });
+  });
+
+  describe('#sourceVersion', () => {
+    it('gives the version for the current source', () => {
+      const A = registry.getLogger('A');
+      A.log({ type: 'text', data: 'message', level: 'warning' });
+      A.log({ type: 'text', data: 'message', level: 'warning' });
+      logConsole.source = 'A';
+      expect(logConsole.sourceVersion).toBe(A.version);
+    });
+    it('is null if the source is null', () => {
+      expect(logConsole.source).toBe(null);
+      expect(logConsole.sourceVersion).toBe(null);
+    });
+  });
+
+  describe('#logger', () => {
+    it('gives the logger for the current source', () => {
+      const A = registry.getLogger('A');
+      A.log({ type: 'text', data: 'message', level: 'warning' });
+      A.log({ type: 'text', data: 'message', level: 'warning' });
+      logConsole.source = 'A';
+      expect(logConsole.logger).toBe(A);
+    });
+    it('is null if the source is null', () => {
+      expect(logConsole.source).toBe(null);
+      expect(logConsole.logger).toBe(null);
+    });
+  });
+
+  describe('#sourceDisplayed', () => {
+    it('emits when console is attached', () => {
+      const s = new SignalLogger(logConsole.sourceDisplayed);
+      const loggerA = registry.getLogger('A');
+      loggerA.log({ type: 'text', data: 'A1', level: 'warning' });
+      logConsole.source = 'A';
+      expect(s.args).toEqual([]);
+
+      Widget.attach(logConsole, document.body);
+      expect(s.args).toEqual([{ source: 'A', version: 1 }]);
+      s.dispose();
+    });
+
+    it('emits when console is shown', () => {
+      const s = new SignalLogger(logConsole.sourceDisplayed);
+      const loggerA = registry.getLogger('A');
+      loggerA.log({ type: 'text', data: 'A1', level: 'warning' });
+      logConsole.source = 'A';
+      logConsole.hide();
+      Widget.attach(logConsole, document.body);
+      expect(s.args).toEqual([]);
+      logConsole.show();
+      expect(s.args).toEqual([{ source: 'A', version: 1 }]);
+      s.dispose();
+    });
+
+    it('emits when source is selected', () => {
+      const s = new SignalLogger(logConsole.sourceDisplayed);
+      const loggerA = registry.getLogger('A');
+      const loggerB = registry.getLogger('B');
+      loggerA.log({ type: 'text', data: 'A1', level: 'warning' });
+      loggerB.log({ type: 'text', data: 'B1', level: 'warning' });
+      Widget.attach(logConsole, document.body);
+      expect(s.args).toEqual([]);
+
+      logConsole.source = 'A';
+      expect(s.args).toEqual([{ source: 'A', version: 1 }]);
+      s.clear();
+
+      loggerB.log({ type: 'text', data: 'B2', level: 'warning' });
+      expect(s.args).toEqual([]);
+      logConsole.source = 'B';
+      expect(s.args).toEqual([{ source: 'B', version: 2 }]);
+      s.dispose();
+    });
+
+    it('emits when logging to displayed source', () => {
+      const s = new SignalLogger(logConsole.sourceDisplayed);
+      const loggerA = registry.getLogger('A');
+      loggerA.log({ type: 'text', data: 'A1', level: 'warning' });
+      Widget.attach(logConsole, document.body);
+      expect(s.args).toEqual([]);
+
+      logConsole.source = 'A';
+      expect(s.args).toEqual([{ source: 'A', version: 1 }]);
+      s.clear();
+
+      loggerA.log({ type: 'text', data: 'A2', level: 'warning' });
+      expect(s.args).toEqual([{ source: 'A', version: 2 }]);
+      s.dispose();
+    });
+  });
+});

+ 21 - 0
tests/test-logconsole/tsconfig.json

@@ -0,0 +1,21 @@
+{
+  "extends": "../../tsconfigbase",
+  "compilerOptions": {
+    "outDir": "build",
+    "types": ["jest"],
+    "composite": false,
+    "rootDir": "src"
+  },
+  "include": ["src/*"],
+  "references": [
+    {
+      "path": "../../packages/logconsole"
+    },
+    {
+      "path": "../../packages/rendermime"
+    },
+    {
+      "path": "../../testutils"
+    }
+  ]
+}