diff --git a/package-lock.json b/package-lock.json index 5c2b4e73c..e99a0c20d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -64,6 +64,7 @@ "@typescript/vfs": "^1.3.4", "chai": "^4.3.6", "classnames": "^2.3.1", + "console-feed": "^3.3.0", "cross-env": "^7.0.3", "es-module-shims": "1.4.3", "fake-indexeddb": "^3.1.2", @@ -72,6 +73,7 @@ "frontend-collective-react-dnd-scrollzone": "1.0.2", "glob": "^7.2.0", "lodash": "^4.17.21", + "logdown": "^3.3.1", "lowlight": "^1.20.0", "lz-string": "^1.4.4", "markdown-it": "^12.0.2", @@ -10974,6 +10976,31 @@ "integrity": "sha1-PXz0Rk22RG6mRL9LOVB/mFEAjo4=", "dev": true }, + "node_modules/console-feed": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/console-feed/-/console-feed-3.3.0.tgz", + "integrity": "sha512-GS0EtpiLyAZGEYBtTih+uI3s3NDmOsfkgpNGhr7UWeM5BzDT+dKgit2nEMFwDb2w7NaT95774/cwAztA1BxrHQ==", + "dependencies": { + "@emotion/core": "^10.0.10", + "@emotion/styled": "^10.0.12", + "emotion-theming": "^10.0.10", + "linkifyjs": "^2.1.6", + "react-inspector": "^5.1.0" + }, + "peerDependencies": { + "react": "^15.x || ^16.x || ^17.x" + } + }, + "node_modules/console-feed/node_modules/linkifyjs": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/linkifyjs/-/linkifyjs-2.1.9.tgz", + "integrity": "sha512-74ivurkK6WHvHFozVaGtQWV38FzBwSTGNmJolEgFp7QgR2bl6ArUWlvT4GcHKbPe1z3nWYi+VUdDZk16zDOVug==", + "peerDependencies": { + "jquery": ">= 1.11.0", + "react": ">= 0.14.0", + "react-dom": ">= 0.14.0" + } + }, "node_modules/constants-browserify": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/constants-browserify/-/constants-browserify-1.0.0.tgz", @@ -12742,6 +12769,20 @@ "node": ">= 4" } }, + "node_modules/emotion-theming": { + "version": "10.3.0", + "resolved": "https://registry.npmjs.org/emotion-theming/-/emotion-theming-10.3.0.tgz", + "integrity": "sha512-mXiD2Oj7N9b6+h/dC6oLf9hwxbtKHQjoIqtodEyL8CpkN4F3V4IK/BT4D0C7zSs4BBFOu4UlPJbvvBLa88SGEA==", + "dependencies": { + "@babel/runtime": "^7.5.5", + "@emotion/weak-memoize": "0.2.5", + "hoist-non-react-statics": "^3.3.0" + }, + "peerDependencies": { + "@emotion/core": "^10.0.27", + "react": ">=16.3.0" + } + }, "node_modules/encodeurl": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-1.0.2.tgz", @@ -19294,6 +19335,12 @@ "resolved": "https://registry.npmjs.org/jpeg-js/-/jpeg-js-0.4.3.tgz", "integrity": "sha512-ru1HWKek8octvUHFHvE5ZzQ1yAsJmIvRdGWvSoKV52XKyuyYA437QWDttXT8eZXDSbuMpHlLzPDZUPd6idIz+Q==" }, + "node_modules/jquery": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/jquery/-/jquery-3.6.0.tgz", + "integrity": "sha512-JVzAR/AjBvVt2BmYhxRCSYysDsPcssdmTFnzyLEts9qNwmjmu4JTAMYubEfwVOSwpQ1I1sKKFcxhZCI2buerfw==", + "peer": true + }, "node_modules/js-tokens": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", @@ -20005,6 +20052,14 @@ "url": "https://github.com/chalk/chalk?sponsor=1" } }, + "node_modules/logdown": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/logdown/-/logdown-3.3.1.tgz", + "integrity": "sha512-pjX0vlIJsYQlgVzFba2amXI1wZZnhrEorL68MdLI7B0/sN1TNUozBNFaHfcPHMM3A+INZ0OXFDxtnoaEgOmGjQ==", + "dependencies": { + "chalk": "^2.3.0" + } + }, "node_modules/loglevel": { "version": "1.7.1", "resolved": "https://registry.npmjs.org/loglevel/-/loglevel-1.7.1.tgz", @@ -41428,6 +41483,26 @@ "integrity": "sha1-PXz0Rk22RG6mRL9LOVB/mFEAjo4=", "dev": true }, + "console-feed": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/console-feed/-/console-feed-3.3.0.tgz", + "integrity": "sha512-GS0EtpiLyAZGEYBtTih+uI3s3NDmOsfkgpNGhr7UWeM5BzDT+dKgit2nEMFwDb2w7NaT95774/cwAztA1BxrHQ==", + "requires": { + "@emotion/core": "^10.0.10", + "@emotion/styled": "^10.0.12", + "emotion-theming": "^10.0.10", + "linkifyjs": "^2.1.6", + "react-inspector": "^5.1.0" + }, + "dependencies": { + "linkifyjs": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/linkifyjs/-/linkifyjs-2.1.9.tgz", + "integrity": "sha512-74ivurkK6WHvHFozVaGtQWV38FzBwSTGNmJolEgFp7QgR2bl6ArUWlvT4GcHKbPe1z3nWYi+VUdDZk16zDOVug==", + "requires": {} + } + } + }, "constants-browserify": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/constants-browserify/-/constants-browserify-1.0.0.tgz", @@ -42868,6 +42943,16 @@ "resolved": "https://registry.npmjs.org/emojis-list/-/emojis-list-3.0.0.tgz", "integrity": "sha512-/kyM18EfinwXZbno9FyUGeFh87KC8HRQBQGildHZbEuRyWFOmv1U10o9BBp8XVZDVNNuQKyIGIu5ZYAAXJ0V2Q==" }, + "emotion-theming": { + "version": "10.3.0", + "resolved": "https://registry.npmjs.org/emotion-theming/-/emotion-theming-10.3.0.tgz", + "integrity": "sha512-mXiD2Oj7N9b6+h/dC6oLf9hwxbtKHQjoIqtodEyL8CpkN4F3V4IK/BT4D0C7zSs4BBFOu4UlPJbvvBLa88SGEA==", + "requires": { + "@babel/runtime": "^7.5.5", + "@emotion/weak-memoize": "0.2.5", + "hoist-non-react-statics": "^3.3.0" + } + }, "encodeurl": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-1.0.2.tgz", @@ -47801,6 +47886,12 @@ "resolved": "https://registry.npmjs.org/jpeg-js/-/jpeg-js-0.4.3.tgz", "integrity": "sha512-ru1HWKek8octvUHFHvE5ZzQ1yAsJmIvRdGWvSoKV52XKyuyYA437QWDttXT8eZXDSbuMpHlLzPDZUPd6idIz+Q==" }, + "jquery": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/jquery/-/jquery-3.6.0.tgz", + "integrity": "sha512-JVzAR/AjBvVt2BmYhxRCSYysDsPcssdmTFnzyLEts9qNwmjmu4JTAMYubEfwVOSwpQ1I1sKKFcxhZCI2buerfw==", + "peer": true + }, "js-tokens": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", @@ -48383,6 +48474,14 @@ } } }, + "logdown": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/logdown/-/logdown-3.3.1.tgz", + "integrity": "sha512-pjX0vlIJsYQlgVzFba2amXI1wZZnhrEorL68MdLI7B0/sN1TNUozBNFaHfcPHMM3A+INZ0OXFDxtnoaEgOmGjQ==", + "requires": { + "chalk": "^2.3.0" + } + }, "loglevel": { "version": "1.7.1", "resolved": "https://registry.npmjs.org/loglevel/-/loglevel-1.7.1.tgz", diff --git a/packages/editor/package.json b/packages/editor/package.json index c6c233d9a..560be7734 100644 --- a/packages/editor/package.json +++ b/packages/editor/package.json @@ -99,7 +99,8 @@ "y-protocols": "^1.0.5", "yjs": "^13.5.16", "zxcvbn": "^4.4.2", - "react-router-dom": "^6.2.2" + "react-router-dom": "^6.2.2", + "console-feed": "^3.3.0" }, "scripts": { "copytypes:self": "rimraf public/types && tsc --declaration --stripInternal --emitDeclarationOnly --noEmit false --declarationDir public/types/@typecell-org/editor", diff --git a/packages/editor/src/runtime/executor/components/Console.tsx b/packages/editor/src/runtime/executor/components/Console.tsx new file mode 100644 index 000000000..31690205d --- /dev/null +++ b/packages/editor/src/runtime/executor/components/Console.tsx @@ -0,0 +1,60 @@ +import { ObservableMap } from "mobx"; +import { observer } from "mobx-react-lite"; +import React from "react"; +import { ConsoleOutput } from "./ConsoleOutput"; +import { Console as ConsoleComponent } from "console-feed"; + +type Props = { + modelPath: string; + outputs: ObservableMap; +}; + +const Console: React.FC = observer((props) => { + const consoleOutput = props.outputs.get(props.modelPath); + + let output = (consoleOutput?.events || []).map((event, i) => { + return { + id: event.id, + data: event.arguments, + method: event.method, + }; + }); + + // Return blank in case there are no console events + if (!output.length) { + return <>; + } + + return ( + <> +
+
+ +
+ + ); +}); + +const consoleStyle = { + borderLeft: "1px solid #eeeeee", + width: "40%", + maxHeight: "100%", + height: "100%", + overflow: "auto", + display: "flex", + "flex-direction": "column-reverse", + position: "absolute" as "absolute", + bottom: "-1px", + right: "0", + backgroundColor: "white", +}; + +export default Console; diff --git a/packages/editor/src/runtime/executor/components/ConsoleOutput.ts b/packages/editor/src/runtime/executor/components/ConsoleOutput.ts new file mode 100644 index 000000000..67223d5b2 --- /dev/null +++ b/packages/editor/src/runtime/executor/components/ConsoleOutput.ts @@ -0,0 +1,50 @@ +import { makeObservable, observable, runInAction } from "mobx"; +import { lifecycle } from "vscode-lib"; +import { ConsolePayload } from "../../../../../engine/types/Engine"; + +interface ConsoleEvent extends ConsolePayload { + id: string; +} + +/** + * Keeps track of console output for a cell. Appends new events to the events array. + */ +export class ConsoleOutput extends lifecycle.Disposable { + private autorunDisposer: (() => void) | undefined; + // Keep track of id's so every new event always has a unique id. + private idIncrement = 1; + public events: ConsoleEvent[] = []; + + constructor() { + super(); + makeObservable(this, { + events: observable.shallow, + }); + } + + public async appendEvent(consolePayload: ConsolePayload) { + runInAction(() => { + if (consolePayload.method === "clear") { + this.events = []; + } else { + if (this.events.length >= 999) { + // Remove the first event when this arbitrary limit is reached to prevent memory issues. + this.events.shift(); + } + + this.idIncrement++; + this.events.push({ + id: this.idIncrement.toString(), + ...consolePayload, + }); + } + }); + } + + public dispose() { + if (this.autorunDisposer) { + this.autorunDisposer(); + } + super.dispose(); + } +} diff --git a/packages/editor/src/runtime/executor/executionHosts/sandboxed/iframesandbox/Frame.tsx b/packages/editor/src/runtime/executor/executionHosts/sandboxed/iframesandbox/Frame.tsx index e525f6f08..5b18370f7 100644 --- a/packages/editor/src/runtime/executor/executionHosts/sandboxed/iframesandbox/Frame.tsx +++ b/packages/editor/src/runtime/executor/executionHosts/sandboxed/iframesandbox/Frame.tsx @@ -3,6 +3,7 @@ import { useCallback, useEffect, useRef } from "react"; import Output from "../../../components/Output"; import { FrameConnection } from "./FrameConnection"; import "./Frame.css"; +import Console from "../../../components/Console"; // The sandbox frame where end-user code gets evaluated. // It is loaded from index.iframe.ts @@ -106,8 +107,9 @@ export const Frame = observer((props: {}) => { style={getOutputOuterStyle(positions.x, positions.y)} onMouseMove={onMouseMoveOutput}>
- +
+ ); })} @@ -122,11 +124,12 @@ const getOutputOuterStyle = (x: number, y: number) => ({ position: "absolute" as "absolute", padding: "10px", width: "100%", + display: "flex", }); const outputInnerStyle = { - maxWidth: "100%", - width: "100%", + overflow: "auto", + flex: "1", }; const containerStyle = { position: "relative" as "relative" }; diff --git a/packages/editor/src/runtime/executor/executionHosts/sandboxed/iframesandbox/FrameConnection.ts b/packages/editor/src/runtime/executor/executionHosts/sandboxed/iframesandbox/FrameConnection.ts index 8f65c5814..cc2dfd13f 100644 --- a/packages/editor/src/runtime/executor/executionHosts/sandboxed/iframesandbox/FrameConnection.ts +++ b/packages/editor/src/runtime/executor/executionHosts/sandboxed/iframesandbox/FrameConnection.ts @@ -5,11 +5,11 @@ import { lifecycle } from "vscode-lib"; import { CompiledCodeModel } from "../../../../../models/CompiledCodeModel"; import { getTypeCellResolver } from "../../../resolver/resolver"; import { ModelOutput } from "../../../components/ModelOutput"; - import { ModelReceiver } from "./ModelReceiver"; import type { VisualizersByPath } from "../../../../extensions/visualizer/VisualizerExtension"; import { IframeBridgeMethods } from "./IframeBridgeMethods"; import { HostBridgeMethods } from "../HostBridgeMethods"; +import { ConsoleOutput } from "../../../components/ConsoleOutput"; let ENGINE_ID = 0; @@ -21,11 +21,24 @@ export class FrameConnection extends lifecycle.Disposable { public readonly id = ENGINE_ID++; /** - * Map of that keeps track of the variables exported by every cell + * Map of that keeps track of the generated output for every cell */ - public readonly outputs = observable.map(undefined, { - deep: false, - }); + public readonly modelOutputs = observable.map( + undefined, + { + deep: false, + } + ); + + /** + * Map of that keeps track of console output for every cell + */ + public readonly consoleOutputs = observable.map( + undefined, + { + deep: false, + } + ); /** * Map of that keeps track of the positions of every cell. @@ -80,15 +93,26 @@ export class FrameConnection extends lifecycle.Disposable { // pass the code to the engine by acting as a ModelProvider this.engine.registerModelProvider(mainModelReceiver); + this._register( + this.engine.onConsole(({ model, payload }) => { + let consoleOutput = this.consoleOutputs.get(model.path); + if (!consoleOutput) { + consoleOutput = this._register(new ConsoleOutput()); + this.consoleOutputs.set(model.path, consoleOutput); + } + consoleOutput.appendEvent(payload); + }) + ); + // Listen to outputs of evaluated cells this._register( this.engine.onOutput(({ model, output }) => { - let modelOutput = this.outputs.get(model.path); + let modelOutput = this.modelOutputs.get(model.path); if (!modelOutput) { modelOutput = this._register( new ModelOutput(this.engine.observableContext.context) ); - this.outputs.set(model.path, modelOutput); + this.modelOutputs.set(model.path, modelOutput); } modelOutput.updateValue(output); }) @@ -231,7 +255,7 @@ export class FrameConnection extends lifecycle.Disposable { // For type visualizers (experimental) updateVisualizers: async (e: VisualizersByPath) => { for (let [path, visualizers] of Object.entries(e)) { - this.outputs.get(path)!.updateVisualizers(visualizers); + this.modelOutputs.get(path)!.updateVisualizers(visualizers); } }, }; diff --git a/packages/engine/package.json b/packages/engine/package.json index 36920228c..93cc6d5df 100644 --- a/packages/engine/package.json +++ b/packages/engine/package.json @@ -5,6 +5,7 @@ "dependencies": { "es-module-shims": "1.4.3", "lodash": "^4.17.21", + "logdown": "^3.3.1", "mobx": "^6.2.0", "react": "^17.0.2", "vscode-lib": "^0.1.2" diff --git a/packages/engine/src/CellEvaluator.ts b/packages/engine/src/CellEvaluator.ts index fb9cb9af4..2b522c1b6 100644 --- a/packages/engine/src/CellEvaluator.ts +++ b/packages/engine/src/CellEvaluator.ts @@ -1,4 +1,5 @@ import { TypeCellContext } from "./context"; +import { ConsolePayload } from "./Engine"; import { ModuleExecution, runModule } from "./executor"; import { HookExecution } from "./HookExecution"; import { createExecutionScope, getModulesFromTypeCellCode } from "./modules"; @@ -10,7 +11,8 @@ export function createCellEvaluator( typecellContext: TypeCellContext, resolveImport: (module: string) => Promise, setAndWatchOutput = true, - onOutputChanged: (output: any) => void, + onOutputEvent: (output: any) => void, + onConsoleEvent: (console: ConsolePayload) => void, beforeExecuting: () => void ) { function onExecuted(exports: any) { @@ -42,15 +44,15 @@ export function createCellEvaluator( }); } } - onOutputChanged(newExports); + onOutputEvent(newExports); } function onError(error: any) { // log.warn("cellEvaluator onError", cell.path, error); - onOutputChanged(error); + onOutputEvent(error); } - const hookExecution = new HookExecution(); + const hookExecution = new HookExecution(onConsoleEvent); const executionScope = createExecutionScope( typecellContext, hookExecution.scopeHooks @@ -84,7 +86,7 @@ export function createCellEvaluator( } catch (e) { console.error(e); // log.warn("cellEvaluator error evaluating", cell.path, e); - onOutputChanged(e); + onOutputEvent(e); } } diff --git a/packages/engine/src/Engine.test.ts b/packages/engine/src/Engine.test.ts index f4e5164a4..442e7f23e 100644 --- a/packages/engine/src/Engine.test.ts +++ b/packages/engine/src/Engine.test.ts @@ -7,92 +7,187 @@ import { toAMDFormat, waitTillEvent, } from "./tests/util/helpers"; - -const getModel1 = () => - buildMockedModel( - "model1", - `let x = 4; +import { CodeModelMock } from "./tests/util/CodeModelMock"; + +describe("engine class execution", function () { + describe("basic model execution", () => { + const getModel1 = () => + buildMockedModel( + "model1", + `let x = 4; let y = 6; let sum = x + y; exports.sum = sum; exports.default = sum;` - ); + ); -const getModel2 = () => - buildMockedModel("model2", `exports.default = $.sum - 5;`); + const getModel2 = () => + buildMockedModel("model2", `exports.default = $.sum - 5;`); -describe("engine class", () => { - it("should execute a single model", async () => { - const engine = new Engine(importResolver); - engine.registerModel(getModel1()); + it("should execute a single model", async () => { + const engine = new Engine(importResolver); + engine.registerModel(getModel1()); - const { model, output } = await event.Event.toPromise(engine.onOutput); + const { model, output } = await event.Event.toPromise(engine.onOutput); - expect(model.path).toBe("model1"); - expect(output.sum).toBe(10); - expect(output.default).toBe(10); - }); + expect(model.path).toBe("model1"); + expect(output.sum).toBe(10); + expect(output.default).toBe(10); + }); - it("should read exported variables from other models", async () => { - const engine = new Engine(importResolver); - engine.registerModel(getModel1()); - await event.Event.toPromise(engine.onOutput); + it("should read exported variables from other models", async () => { + const engine = new Engine(importResolver); + engine.registerModel(getModel1()); + await event.Event.toPromise(engine.onOutput); - engine.registerModel(getModel2()); - const { model, output } = await event.Event.toPromise(engine.onOutput); + engine.registerModel(getModel2()); + const { model, output } = await event.Event.toPromise(engine.onOutput); - expect(model.path).toBe("model2"); - expect(output.default).toBe(5); - }); + expect(model.path).toBe("model2"); + expect(output.default).toBe(5); + }); - it("should re-evaluate code after change", async () => { - const engine = new Engine(importResolver); - const model1 = getModel1(); + it("should re-evaluate code after change", async () => { + const engine = new Engine(importResolver); + const model1 = getModel1(); - engine.registerModel(model1); - await event.Event.toPromise(engine.onOutput); + engine.registerModel(model1); + await event.Event.toPromise(engine.onOutput); - model1.updateCode( - toAMDFormat(`let x = 0; + model1.updateCode( + toAMDFormat(`let x = 0; let y = 6; let sum = x + y; exports.sum = sum; exports.default = sum;`) - ); + ); - const { output } = await event.Event.toPromise(engine.onOutput); + const { output } = await event.Event.toPromise(engine.onOutput); - expect(output.sum).toBe(6); - expect(output.default).toBe(6); - }); + expect(output.sum).toBe(6); + expect(output.default).toBe(6); + }); - it("should re-evaluate other models when global variable changes", async () => { - const engine = new Engine(importResolver); - // TODO: Expected 4 events. Figure out why model 2 re-evaluates. - const eventsPromise = waitTillEvent(engine.onOutput, 5); - const model1 = getModel1(); - const model2 = getModel2(); + it("should re-evaluate other models when global variable changes", async () => { + const engine = new Engine(importResolver); + // TODO: Expected 4 events. Figure out why model 2 re-evaluates. + const eventsPromise = waitTillEvent(engine.onOutput, 5); + const model1 = getModel1(); + const model2 = getModel2(); - engine.registerModel(model1); - engine.registerModel(model2); + engine.registerModel(model1); + engine.registerModel(model2); - model1.updateCode( - toAMDFormat(`let x = 0; + model1.updateCode( + toAMDFormat(`let x = 0; let y = 6; let sum = x + y; exports.sum = sum; exports.default = sum;`) - ); - - const events = await eventsPromise; - const eventsSnapshot = events.map((event) => ({ - path: event.model.path, - output: event.output, - })); - const finalEvent = eventsSnapshot[eventsSnapshot.length - 1]; - - expect(finalEvent.path).toBe("model2"); - expect(finalEvent.output.default).toBe(1); - expect(eventsSnapshot).toMatchSnapshot(); + ); + + const events = await eventsPromise; + const eventsSnapshot = events.map((event) => ({ + path: event.model.path, + output: event.output, + })); + const finalEvent = eventsSnapshot[eventsSnapshot.length - 1]; + + expect(finalEvent.path).toBe("model2"); + expect(finalEvent.output.default).toBe(1); + expect(eventsSnapshot).toMatchSnapshot(); + }); + }); + + describe("console messages", () => { + const getModel1 = () => buildMockedModel("model1", `console.log('hi!');`); + const getModel2 = () => + buildMockedModel( + "model2", + `console.info('info'); console.warn('warn'); console.error('error');` + ); + const getModel3 = () => + buildMockedModel( + "model3", + `console.log('before'); + await new Promise((resolve)=> { + setTimeout(()=> { + resolve(); + }, 1) + }); + console.log('after');` + ); + const getModel4 = () => + new CodeModelMock( + "javascript", + "model4", + `define(["require", "exports", "logdown"], function(require, exports, logdown) { + "use strict"; + Object.defineProperty(exports, "__esModule", { value: true }); + + let logger = logdown("logger 1"); + logger.state.isEnabled = true; + logger.log("message 1"); + + setTimeout(() => { + logger.state.isEnabled = true; + logger.log("message 2"); + }, 1); + });` + ); + + it("should capture console.log message", async () => { + const engine = new Engine(importResolver); + const eventsPromise = waitTillEvent(engine.onConsole, 1); + const model1 = getModel1(); + + engine.registerModel(model1); + + const consoleEvents = await eventsPromise; + + expect(consoleEvents[0].payload.method).toBe("log"); + expect(consoleEvents[0].payload.arguments[0]).toBe("hi!"); + }); + + it("should capture console.warn/info/error messages", async () => { + const engine = new Engine(importResolver); + const eventsPromise = waitTillEvent(engine.onConsole, 3); + const model2 = getModel2(); + + engine.registerModel(model2); + + const events = await eventsPromise; + const eventsSnapshot = events.map((event) => { + return { + path: event.model.path, + console: event.payload, + }; + }); + expect(eventsSnapshot).toMatchSnapshot(); + }); + + it("should capture console.log messages after async", async () => { + const engine = new Engine(importResolver); + const eventsPromise = waitTillEvent(engine.onConsole, 2); + const model3 = getModel3(); + + engine.registerModel(model3); + + const events = await eventsPromise; + expect(events[0].payload.arguments[0]).toBe("before"); + expect(events[1].payload.arguments[0]).toBe("after"); + }); + + it("should capture console.log messages from library (sync only)", async () => { + const engine = new Engine(importResolver); + const eventsPromise = waitTillEvent(engine.onConsole, 2); + const model4 = getModel4(); + + engine.registerModel(model4); + + const events = await eventsPromise; + expect(events[0].payload.arguments[1]).toBe("message 1"); + expect(events[1].payload.arguments[1]).toBe("message 2"); + }); }); }); diff --git a/packages/engine/src/Engine.ts b/packages/engine/src/Engine.ts index 1b92df8bf..742870c82 100644 --- a/packages/engine/src/Engine.ts +++ b/packages/engine/src/Engine.ts @@ -8,6 +8,32 @@ export type ResolvedImport = { module: any; } & lifecycle.IDisposable; +export type OutputEvent = { + model: T; + output: any; +}; + +export type ConsolePayload = { + method: + | "log" + | "debug" + | "info" + | "warn" + | "error" + | "table" + | "clear" + | "time" + | "timeEnd" + | "count" + | "assert"; + arguments: any[]; +}; + +export type ConsoleEvent = { + model: T; + payload: ConsolePayload; +}; + /** * The engine automatically runs models registered to it. * The code of the models is passed a context ($) provided by the engine. @@ -26,17 +52,30 @@ export class Engine extends lifecycle.Disposable { ReturnType >(); - private readonly _onOutput: event.Emitter<{ model: T; output: any }> = - this._register(new event.Emitter<{ model: T; output: any }>()); + private readonly _onOutput: event.Emitter> = this._register( + new event.Emitter>() + ); + + private readonly _onConsole: event.Emitter> = this._register( + new event.Emitter>() + ); /** * Raised whenever a model is (re)evaluated, with the exports by that model * - * @type {event.Event<{ model: T; output: any }>} + * @type {event.Event>} + * @memberof Engine + */ + public readonly onOutput: event.Event> = this._onOutput.event; + + /** + * Raised whenever a model calls console.* functions + * + * @type {event.Event>} * @memberof Engine */ - public readonly onOutput: event.Event<{ model: T; output: any }> = - this._onOutput.event; + public readonly onConsole: event.Event> = + this._onConsole.event; private readonly _onBeforeExecution: event.Emitter<{ model: T }> = this._register(new event.Emitter<{ model: T }>()); @@ -103,15 +142,24 @@ export class Engine extends lifecycle.Disposable { } return ret.module; }, - (model, output) => this._onOutput.fire({ model, output }) + (event) => this._onOutput.fire(event), + (event) => this._onConsole.fire(event) ); // catch errors? }; let prevValue: string | undefined = model.getValue(); // TODO: maybe only debounce (or increase debounce timeout) if an execution is still pending? const reEvaluate = _.debounce(() => { + // make sure there were actual changes from the previous value if (model.getValue() !== prevValue) { - // make sure there were actual changes from the previous value + // Clear the console upon re-evaluation + this._onConsole.fire({ + model, + payload: { + method: "clear", + arguments: [], + }, + }); prevValue = model.getValue(); evaluate(); @@ -152,7 +200,8 @@ export class Engine extends lifecycle.Disposable { model: T, typecellContext: TypeCellContext, resolveImport: (module: string) => Promise, - onOutput: (model: T, output: any) => void + onOutput: (event: OutputEvent) => void, + onConsole: (event: ConsoleEvent) => void ) { if (!this.evaluatorCache.has(model)) { this.evaluatorCache.set( @@ -161,7 +210,8 @@ export class Engine extends lifecycle.Disposable { typecellContext, resolveImport, true, - (output) => onOutput(model, output), + (output) => onOutput({ model, output }), + (console) => onConsole({ model, payload: console }), () => this._onBeforeExecution.fire({ model }) ) ); diff --git a/packages/engine/src/HookExecution.ts b/packages/engine/src/HookExecution.ts index a182d85a9..340c512b8 100644 --- a/packages/engine/src/HookExecution.ts +++ b/packages/engine/src/HookExecution.ts @@ -1,3 +1,5 @@ +import { ConsolePayload } from "./Engine"; + const glob = typeof window === "undefined" ? global : window; // These functions will be added to the scope of the cell @@ -21,6 +23,10 @@ export type ScopeHooks = { [K in typeof onScopeFunctions[number]]: any }; export type WindowHooks = { [K in typeof onWindowFunctions[number]]: any }; +/** + * Sets object property based on a given path and value. + * E.g. path could be level1.level2.prop + */ function setProperty(base: Object, path: string, value: any) { const layers = path.split("."); if (layers.length > 1) { @@ -44,10 +50,61 @@ export class HookExecution { }), console: { ...originalReferences.console, - log: (...args: any) => { - // TODO: broadcast output to console view - originalReferences.console.log(...args); - }, + log: (...args: any) => + this.onConsoleEvent({ + method: "log", + arguments: args, + }), + debug: (...args: any) => + this.onConsoleEvent({ + method: "debug", + arguments: args, + }), + info: (...args: any) => + this.onConsoleEvent({ + method: "info", + arguments: args, + }), + warn: (...args: any) => + this.onConsoleEvent({ + method: "warn", + arguments: args, + }), + error: (...args: any) => + this.onConsoleEvent({ + method: "error", + arguments: args, + }), + table: (...args: any) => + this.onConsoleEvent({ + method: "table", + arguments: args, + }), + clear: (...args: any) => + this.onConsoleEvent({ + method: "clear", + arguments: args, + }), + time: (...args: any) => + this.onConsoleEvent({ + method: "time", + arguments: args, + }), + timeEnd: (...args: any) => + this.onConsoleEvent({ + method: "timeEnd", + arguments: args, + }), + count: (...args: any) => + this.onConsoleEvent({ + method: "count", + arguments: args, + }), + assert: (...args: any) => + this.onConsoleEvent({ + method: "assert", + arguments: args, + }), }, }; @@ -56,7 +113,7 @@ export class HookExecution { ["EventTarget.prototype.addEventListener"]: undefined, }; - constructor() { + constructor(private onConsoleEvent: (console: ConsolePayload) => void) { if (typeof EventTarget !== "undefined") { this.windowHooks["EventTarget.prototype.addEventListener"] = this.createHookedFunction( @@ -85,7 +142,7 @@ export class HookExecution { } private createHookedFunction( - original: (...args: T[]) => Y, + original: (...args: any[]) => Y, disposer: (ret: Y, args: T[]) => void ) { const self = this; diff --git a/packages/engine/src/__snapshots__/Engine.test.ts.snap b/packages/engine/src/__snapshots__/Engine.test.ts.snap index 7deb1487a..fba8fdf73 100644 --- a/packages/engine/src/__snapshots__/Engine.test.ts.snap +++ b/packages/engine/src/__snapshots__/Engine.test.ts.snap @@ -1,6 +1,6 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP -exports[`engine class should re-evaluate other models when global variable changes 1`] = ` +exports[`engine class execution basic model execution should re-evaluate other models when global variable changes 1`] = ` Array [ Object { "output": Object { @@ -34,3 +34,35 @@ Array [ }, ] `; + +exports[`engine class execution console messages should capture console.warn/info/error messages 1`] = ` +Array [ + Object { + "console": Object { + "arguments": Array [ + "info", + ], + "method": "info", + }, + "path": "model2", + }, + Object { + "console": Object { + "arguments": Array [ + "warn", + ], + "method": "warn", + }, + "path": "model2", + }, + Object { + "console": Object { + "arguments": Array [ + "error", + ], + "method": "error", + }, + "path": "model2", + }, +] +`; diff --git a/packages/engine/src/tests/util/helpers.ts b/packages/engine/src/tests/util/helpers.ts index 79a7bf409..e51c5086e 100644 --- a/packages/engine/src/tests/util/helpers.ts +++ b/packages/engine/src/tests/util/helpers.ts @@ -1,6 +1,7 @@ import { CodeModel } from "../../CodeModel"; import { ResolvedImport } from "../../Engine"; import { CodeModelMock } from "./CodeModelMock"; +import * as logdown from "logdown"; export function waitTillEvent( e: (listener: (arg0: T) => void) => void, @@ -20,9 +21,18 @@ export function waitTillEvent( } export async function importResolver( - _module: string, + module: string, _forModel: CodeModel ): Promise { + if (module === "logdown") { + return (async () => { + return { + module: logdown.default, + dispose: () => {}, + }; + })(); + } + const res = async () => { return { module: {