diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..95dc2c4 --- /dev/null +++ b/.gitignore @@ -0,0 +1,4 @@ +node_modules/ +./tests/workspace.html +.module_cache +dist diff --git a/.npmignore b/.npmignore new file mode 100644 index 0000000..7c264bd --- /dev/null +++ b/.npmignore @@ -0,0 +1 @@ +.module_cache diff --git a/.travis.yml b/.travis.yml new file mode 100644 index 0000000..67dd8ab --- /dev/null +++ b/.travis.yml @@ -0,0 +1,13 @@ +language: node_js + +sudo: false + +node_js: + - "7" + +install: + - "npm install -g mocha-es6" + +notifications: + email: "robert.krahn@gmail.com" + slack: ycresearch:DHnjngxzOUybEggU2lAYdP9R diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..fd528a3 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,11 @@ +# lively.vm + +## 0.5.3 + +* adding transpiler option to runEval to transform code after capturing rewrites, e.g. via babel + +## 0.5.0 + +* improved dist build, removing unnecessary cruft +* config for lively.modules +* some es6-interface changes, es6 interface is deprecated as it was merged into lively.modules!!! diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..309c5dd --- /dev/null +++ b/LICENSE @@ -0,0 +1,20 @@ +Copyright 2008-2016 Robert Krahn +Copyright 2008-2016 Lively Kernel contributors (see https://raw.githubusercontent.com/LivelyKernel/LivelyKernel/master/AUTHORS) + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to +deal in the Software without restriction, including without limitation the +rights to use, copy, modify, merge, publish, distribute, sublicense, and/or +sell copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS +IN THE SOFTWARE. diff --git a/README.md b/README.md index 2ff48bc..88f48ed 100644 --- a/README.md +++ b/README.md @@ -1 +1,11 @@ -This repository has moved to https://github.com/LivelyKernel/lively.next/tree/master/lively.vm +# lively.vm [![Build Status](https://travis-ci.org/LivelyKernel/lively.vm.svg)](https://travis-ci.org/LivelyKernel/lively.vm) + +Controlled JavaScript code execution and instrumentation. + +## Documentation and demo + +...please see https://livelykernel.github.io/lively.vm/ + +## LICENSE + +[MIT](LICENSE) diff --git a/examples/in-node.js b/examples/in-node.js new file mode 100644 index 0000000..b763e07 --- /dev/null +++ b/examples/in-node.js @@ -0,0 +1,6 @@ +var vm = require("../dist/lively.vm.js") + +vm.runEval("1 + 2") + .then(result => console.log(`1 + 2 = ${JSON.stringify(result, null, 2)}`)) + .catch(err => console.error(err.stack)); + \ No newline at end of file diff --git a/index.html b/index.html new file mode 100644 index 0000000..4d89e77 --- /dev/null +++ b/index.html @@ -0,0 +1,201 @@ + + + + lively.vm + + + + + + Fork me on GitHub + +
+

lively.vm

+ +

lively.vm provides the ability to evaluate JavaScript code in an evaluation context. Normally the JavaScript eval() function uses the local scope of the function it was called in (however, there are some additional weird rules). It offers no further options about scope and bindings.

+ +

lively.vm has a range of options that control what bindings are available inside the executed code and also how top-level assignments inside the evaluated code are captured. The latter is helpful when you want to access intermediate results and assignments caused by the evaluation, e.g. to allow incremental development.

+ +

Example

+ +

When you press the eval button below the content of the text area is passed to lively.vm.runEval(source, options) and the result printed.

+ +
+ +

+
+
+      
+      
+
+      

Usage

+ +

+ In it's simplest form, use lively.vm.syncEval(code, options). The return value is a result object whose value is the return value of the last statement in the evaluated code; +

+lively.vm.syncEval("3 + 4"); +=> { + value: 7, + isError: false, + isPromise: false, + warnings: [] +} +
+

+ +

bindings

+ +

+ To capture the bindings of top-level variables inside the evaluated code, use the topLevelVarRecorder option. Note that this will not pollute the global namespace. +

+var bindings = {}; +lively.vm.syncEval( + "var x = 3; var y = 4; x + y;", + {topLevelVarRecorder: bindings}); +=> {value: 7} +bindings.x +=> 3 +bindings.y +=> 4 +
+

+ +

+ Similarly, you can make pass bindings into the evaluation: +

+var bindings = {x: 99}; +lively.vm.syncEval("x = x + 2;", {topLevelVarRecorder: bindings}); +=> {value: 101} +bindings.x +=> 101 +
+

+ +

callbacks and asynchronous evaluation, custom transpilers

+

+ lively.vm.runEval supports evaluation processes that are asynchronous. runEval itself will return a Promise that resolves to the eval result object. You can also specify an onEndEval handler to be notified when the evaluation is done. The following example also uses the transpileroption to allow top-level await (your JavaScript VM needs to support that, alternatively use a transpiler like babeljs in the transpiler function you pass to lively.vm). +

+
+await lively.vm.runEval( + "await new Promise((resolve, reject) => setTimeout(() => resolve(42), 1000));", { + topLevelVarRecorder: {}, + wrapInStartEndCall: true, + transpiler: source => "(async function() {" + source + "})()", + onEndEval: (err, value) => console.log("evaluation done", value) +}); +=> will print "42" after 1 sec. +
+ +

Using it with lively.modules

+

+ lively.modules is a module system that supports interactive runtime changes of source code. When lively.modules is loaded and SystemJS are loaded, you can pass a targetModule option to lively.vm that will run the evaluation in the context of the module, having access to all top-level module bindings (and being able to change those). +

+

Assuming we have a module "test.js" with the following code: +

+var x = 3; +export var y = x + 4; +
+

+

lively.vm can be used to access as well as modify exports and module internal state: +

+await lively.vm.runEval("x", {targetModule: "test.js"}); +=> {value: 3} +await lively.vm.runEval("y = 10", {targetModule: "test.js"}); +(await System.import("test.js")).y +=> 10 +
+

+ +

dynamic code completions

+

+To inspect objects from an editor or repl and get auto completions of properties and methods you can use lively.vm.completions.getCompletions. The result of the getCompletions call is a nested list, providing the prototype hierarchy of the completion target and their respective attributes/methods. +

+var someObject = { + aProperty: 23, + anotherProperty: "hello world", + xProperty: 99 +}, bindings = {topLevelVarRecorder: {someObject}} + +let result = await lively.vm.completions.getCompletions( + source => lively.vm.syncEval(source, bindings), + "someObject.a"); + +=> { + code: "someObject", + startLetters: "a", + completions: [ + ["[object Object]", + ["aProperty", + "anotherProperty", + "xProperty"] + ], + ["Object", + ["__defineGetter__()", + "__defineSetter__()", + "__lookupGetter__()", + "__lookupSetter__()", + "constructor()", + "hasOwnProperty()", + "isPrototypeOf()", + "propertyIsEnumerable()", + "toLocaleString()", + "toString()",...] + ]], +} +
+

+ + +

notifications

+ +

There are two types of system-wide notifications:

+ +{type: "lively.vm/doitrequest", code, targetModule, waitForPromise} +{type: "lively.vm/doitresult", code, targetModule, waitForPromise, result} + +

These notifications are all emitted with lively.notifications.

+ + +

License

+ MIT +
+ + + + + + diff --git a/index.js b/index.js new file mode 100644 index 0000000..4d87263 --- /dev/null +++ b/index.js @@ -0,0 +1,38 @@ +import * as completions from "./lib/completions.js"; +export { completions } + +import * as globalEval from "./lib/eval.js"; +import * as esmEval from "./lib/esm-eval.js"; +export { defaultTopLevelVarRecorderName } from "./lib/eval.js"; +export { + defaultClassToFunctionConverterName, + evalCodeTransform, + evalCodeTransformOfSystemRegisterSetters +} from "./lib/eval-support.js"; + +export { runEval, syncEval } + +function runEval(code, options) { + var {format, System: S, targetModule} = options = { + format: "esm", + System: null, + targetModule: null, + ...options + } + + if (!S && typeof System !== "undefined") S = System; + if (!S && targetModule) { + return Promise.reject(new Error("options to runEval have targetModule but cannot find system loader!")); + } + + return targetModule && (["esm", "es6", "register"].includes(format))? + esmEval.runEval(S, code, options) : + globalEval.runEval(code, options); +} + +function syncEval(code, options) { + return globalEval.syncEval(code, options); +} + +import * as evalStrategies from "./lib/eval-strategies.js"; +export { evalStrategies } diff --git a/lib/completions.js b/lib/completions.js new file mode 100644 index 0000000..219fa9c --- /dev/null +++ b/lib/completions.js @@ -0,0 +1,139 @@ +/*global require, __dirname*/ + +import * as lang from "lively.lang"; +import { signatureOf, pluck, safeToString, printSymbol } from "./util.js"; + + +// helper + +export function getObjectForCompletion(evalFunc, stringToEval) { + var startLetters = ''; + return Promise.resolve().then(() => { + // thenDo = function(err, obj, startLetters) + var idx = stringToEval.lastIndexOf('.'); + if (idx >= 0) { + startLetters = stringToEval.slice(idx+1); + stringToEval = stringToEval.slice(0,idx); + } else { + startLetters = stringToEval; + stringToEval = '(typeof window === "undefined" ? global : window)'; + } + return evalFunc(stringToEval); + }) + .then(evalResult => ({ + evalResult: evalResult, + startLetters: startLetters, + code: stringToEval + })); +} + +function propertyExtract(excludes, obj, extractor) { + return Object.getOwnPropertyNames(obj) + .concat(Object.getOwnPropertySymbols(obj).map(printSymbol)) + .filter(key => excludes.indexOf(key) === -1) + .map(extractor) + .filter(ea => !!ea) + .sort((a,b) => a.name < b.name ? -1 : (a.name > b.name ? 1 : 0)); +} + +function getMethodsOf(excludes, obj) { + return propertyExtract(excludes, obj, function(key) { + if ((obj.__lookupGetter__ && obj.__lookupGetter__(key)) || typeof obj[key] !== 'function') return null; + return {name: key, completion: signatureOf(key, obj[key])}; }) +} + +function getAttributesOf(excludes, obj) { + return propertyExtract(excludes, obj, function(key) { + if ((obj.__lookupGetter__ && !obj.__lookupGetter__(key)) && typeof obj[key] === 'function') return null; + return {name: key, completion: key}; }) +} + +function getProtoChain(obj) { + var protos = [], proto = obj; + while (obj) { protos.push(obj); obj = obj.__proto__ } + return protos; +} + +function getDescriptorOf(originalObj, proto) { + function shorten(s, len) { + if (s.length > len) s = s.slice(0,len) + '...'; + return s.replace(/\n/g, '').replace(/\s+/g, ' '); + } + + if (originalObj === proto) { + if (typeof originalObj !== 'function') return shorten(safeToString(originalObj), 50); + var funcString = originalObj.toString(), + body = shorten(funcString.slice(funcString.indexOf('{')+1, funcString.lastIndexOf('}')), 50); + return signatureOf(originalObj.displayName || originalObj.name || 'function', originalObj) + ' {' + body + '}'; + } + + var klass = proto.hasOwnProperty('constructor') && proto.constructor; + if (!klass) return 'prototype'; + if (typeof klass.type === 'string' && klass.type.length) return shorten(klass.type, 50); + if (typeof klass.name === 'string' && klass.name.length) return shorten(klass.name, 50); + return "anonymous class"; +} + +// FIXME unused? +function getCompletionsOfObj(obj, thenDo) { + var err, completions; + try { + var excludes = []; + completions = getProtoChain(obj).map(function(proto) { + var descr = getDescriptorOf(obj, proto), + methodsAndAttributes = getMethodsOf(excludes, proto) + .concat(getAttributesOf(excludes, proto)); + excludes = excludes.concat(pluck(methodsAndAttributes, 'name')); + return [descr, pluck(methodsAndAttributes, 'completion')]; + }); + } catch (e) { err = e; } + thenDo(err, completions); +} + +function descriptorsOfObjAndProtoProperties(obj) { + var excludes = [], + completions = getProtoChain(obj) + .map(function(proto) { + var descr = getDescriptorOf(obj, proto), + methodsAndAttributes = getMethodsOf(excludes, proto) + .concat(getAttributesOf(excludes, proto)); + excludes = excludes.concat(pluck(methodsAndAttributes, 'name')); + return [descr, pluck(methodsAndAttributes, 'completion')]; + }); + return completions; +} + +// -=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=- +// the main deal +function getCompletions(evalFunc, string, thenDo) { + // thendo = function(err, completions/*ARRAY*/) + // eval string and for the resulting object find attributes and methods, + // grouped by its prototype / class chain + // if string is something like "foo().bar.baz" then treat "baz" as start + // letters = filter for properties of foo().bar + // ("foo().bar.baz." for props of the result of the complete string) + var promise = getObjectForCompletion(evalFunc, string) + .then(evalResultAndStartLetters => { + var evalResult = evalResultAndStartLetters.evalResult, + value = evalResult && evalResult.isEvalResult ? evalResult.value : evalResult, + result = { + completions: descriptorsOfObjAndProtoProperties(value), + startLetters: evalResultAndStartLetters.startLetters, + code: evalResultAndStartLetters.code + }; + + if (evalResult && evalResult.isPromise) { + if (evalResult.promiseStatus === "fulfilled") + result.promiseResolvedCompletions = descriptorsOfObjAndProtoProperties(evalResult.promisedValue) + else if (evalResult.promiseStatus === "rejected") + result.promiseRejectedCompletions = descriptorsOfObjAndProtoProperties(evalResult.promisedValue) + } + return result; + }); + if (typeof thenDo === "function") { + promise.then(result => thenDo(null, result)).catch(err => thenDo(err)); + } + return promise; +} + +export { getCompletions } diff --git a/lib/esm-eval.js b/lib/esm-eval.js new file mode 100644 index 0000000..ff602c3 --- /dev/null +++ b/lib/esm-eval.js @@ -0,0 +1,249 @@ +import { parse, nodes } from "lively.ast"; +import { emit } from "lively.notifications"; + +const { funcCall, member, id, literal } = nodes; + +import { obj, promise, arr } from "lively.lang"; +import { runEval as vmRunEval } from "./eval.js"; + +// -=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=- +// load support + +function ensureImportsAreImported(System, code, parentModule) { + // FIXME do we have to do a reparse? We should be able to get the ast from + // the rewriter... + var body = parse(code).body, + imports = body.filter(node => node.type === "ImportDeclaration"); + return Promise.all(imports.map(node => + System.normalize(node.source.value, parentModule) + .then(fullName => System.get(fullName) || System.import(fullName)))) + .catch(err => { console.error("Error ensuring imports: " + err.message); throw err; }); +} + +function hasUnimportedImports(System, code, parentModule) { + var body = lively.ast.parse(code).body, + imports = body.filter(node => node.type === "ImportDeclaration"), + importedModules = arr.uniq(imports.map(ea => ea.source.value)).filter(Boolean), + unloadedImports = importedModules.filter(ea => + !System.get(System.decanonicalize(ea, parentModule))); + return unloadedImports.length > 0; +} + +// -=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=- +// transpiler to make es next work + +function babelTranspilerForAsyncAwaitCode(System, babel, filename, env) { + // The function wrapper is needed b/c we need toplevel awaits and babel + // converts "this" => "undefined" for modules + return (source, options) => { + options = Object.assign({ + sourceMap: undefined, // 'inline' || true || false + inputSourceMap: undefined, + filename: filename, + code: true, + ast: false + }, options); + var sourceForBabel = `(async function(__rec) {\n${source}\n}).call(this);`, + transpiled = babel.transform(sourceForBabel, options).code; + transpiled = transpiled.replace(/\}\)\.call\(undefined\);$/, "}).call(this)"); + return transpiled; + } +} + +function babelPluginTranspilerForAsyncAwaitCode(System, babelWrapper, filename, env) { + + // The function wrapper is needed b/c we need toplevel awaits and babel + // converts "this" => "undefined" for modules + return (source, options) => { + var babelOptions = System.babelOptions || {}, + presets = []; + presets.push(babelWrapper.presetES2015) + if (babelOptions.stage3) + presets.push({plugins: babelWrapper.pluginsStage3}); + if (babelOptions.stage2) + presets.push({plugins: babelWrapper.pluginsStage2}); + if (babelOptions.stage1) + presets.push({plugins: babelWrapper.pluginsStage1}); + + options = Object.assign({ + sourceMap: undefined, // 'inline' || true || false + inputSourceMap: undefined, + filename: filename, + babelrc: false, + // plugins: plugins, + presets: presets, + moduleIds: false, + code: true, + ast: false + }, options); + var sourceForBabel = `(async function(__rec) {\n${source}\n}).call(this);`, + transpiled = babelWrapper.babel.transform(sourceForBabel, options).code; + transpiled = transpiled.replace(/\}\)\.call\(undefined\);$/, "}).call(this)"); + return transpiled; + } + +} + +function getEs6Transpiler(System, options, env) { + if (options.transpiler) return options.transpiler + if (!options.es6Transpile) return null; + + if (System.transpiler === "babel") { + var babel = System.global[System.transpiler] + || System.get(System.decanonicalize(System.transpiler)); + + return babel ? + babelTranspilerForAsyncAwaitCode(System, babel, options.targetModule, env) : + System.import(System.transpiler).then(babel => + babelTranspilerForAsyncAwaitCode(System, babel, options.targetModule, env)); + } + + if (System.transpiler === "plugin-babel") { + var babelPluginPath = System.decanonicalize("plugin-babel"), + babelPath = babelPluginPath + .split("/").slice(0, -1) + .concat("systemjs-babel-browser.js") + .join("/"), + babelPlugin = System.get(babelPath); + + return babelPlugin ? + babelPluginTranspilerForAsyncAwaitCode(System, babelPlugin, options.targetModule, env) : + System.import(babelPath).then(babelPlugin => + babelPluginTranspilerForAsyncAwaitCode(System, babelPlugin, options.targetModule, env)); + } + + if (System.transpiler === "lively.transpiler") { + var Transpiler = System.get(System.decanonicalize("lively.transpiler")).default, + transpiler = new Transpiler(System, options.targetModule, env); + return (source, options) => transpiler.transpileDoit(source, options); + } + + throw new Error("Sorry, currently only babel is supported as es6 transpiler for runEval!"); +} + + +export function runEval(System, code, options) { + options = { + targetModule: null, parentModule: null, + es6Transpile: true, + transpiler: null, // function with params: source, options + transpilerOptions: null, + format: "esm", + ...options + } + var defaultSourceAccessorName = "__lvOriginalCode"; + var originalSource = code; + + System.debug && console.log(`[lively.module] runEval: ${code.slice(0,100).replace(/\n/mg, " ")}...`); + + var {format, targetModule, parentModule} = options; + targetModule = System.decanonicalize(targetModule || "*scratch*", parentModule); + options.targetModule = targetModule; + + if (format) { + var meta = System.getConfig().meta[targetModule]; + if (!meta) meta = {}; + if (!meta[targetModule]) meta[targetModule] = {}; + if (!meta[targetModule].format) { + meta[targetModule].format = format; + System.config(meta); + } + } + + var module = System.get("@lively-env").moduleEnv(targetModule), + {recorder, recorderName, dontTransform} = module, + transpiler = getEs6Transpiler(System, options, module), + header = `var _moduleExport = ${recorderName}._moduleExport,\n` + + ` _moduleImport = ${recorderName}._moduleImport;\n`; + + options = { + waitForPromise: true, + sync: false, + evalId: options.evalId || module.nextEvalId(), + sourceAccessorName: + (options.hasOwnProperty("embedOriginalCode") ? options.embedOriginalCode : true) ? + defaultSourceAccessorName : undefined, + originalSource, + ...options, + header, + recordGlobals: true, + dontTransform, + varRecorderName: recorderName, + topLevelVarRecorder: recorder, + sourceURL: options.sourceURL || options.targetModule, + context: options.context === undefined ? recorder : options.context, + wrapInStartEndCall: true, // for async / await eval support + es6ExportFuncId: "_moduleExport", + es6ImportFuncId: "_moduleImport", + transpiler, + declarationWrapperName: module.varDefinitionCallbackName, + currentModuleAccessor: funcCall( + member( + funcCall( + member(member("__lvVarRecorder", "System"), "get"), + literal("@lively-env")), + "moduleEnv"), + literal(options.targetModule)) + }; + + // delay eval to ensure imports + if (!options.sync + && !options.importsEnsured + && hasUnimportedImports(System, code, targetModule)) { + return ensureImportsAreImported(System, code, targetModule) + .then(() => runEval(System, originalSource, {...options, importsEnsured: true})); + } + + // delay eval to ensure SystemJS module record + if (!module.record()) { + if (!options.sync && !options._moduleImported) + return System.import(targetModule) + .catch(err => null) + .then(() => runEval(System, originalSource, {...options, _moduleImported: true})); + + module.ensureRecord(); // so we can record dependent modules + } + + // delay eval to ensure transpiler is loaded + if (options.es6Transpile && options.transpiler instanceof Promise) { + if (!options.sync && !options._transpilerLoaded) { + return options.transpiler + .catch(err => console.error(err)) + .then(transpiler => runEval(System, originalSource, + {...options, transpiler, _transpilerLoaded: true})); + } else { + console.warn(`[lively.vm] sync eval requested but transpiler is not yet loaded, will continue without transpilation!`); + options.transpiler = null; + } + } + + System.debug && console.log(`[lively.module] runEval in module ${targetModule} started`); + + emit("lively.vm/doitrequest", { + code: originalSource, + waitForPromise: options.waitForPromise, + targetModule: options.targetModule + }, Date.now(), System); + + System.get("@lively-env").evaluationStart(targetModule); + + var result = vmRunEval(code, options); + + return options.sync ? + evalEnd(System, originalSource, options, result) : + Promise.resolve(result).then(result => evalEnd(System, originalSource, options, result)); +} + +function evalEnd(System, code, options, result) { + + System.get("@lively-env").evaluationEnd(options.targetModule); + System.debug && console.log(`[lively.module] runEval in module ${options.targetModule} done`); + + emit("lively.vm/doitresult", { + code: code, result, + waitForPromise: options.waitForPromise, + targetModule: options.targetModule + }, Date.now(), System); + + return result; +} diff --git a/lib/eval-strategies.js b/lib/eval-strategies.js new file mode 100644 index 0000000..aa774d2 --- /dev/null +++ b/lib/eval-strategies.js @@ -0,0 +1,242 @@ +/*global System,location*/ +import { obj } from "lively.lang"; + +class EvalStrategy { + + async runEval(source, options) { + return Promise.reject(`runEval(source, options) not yet implemented for ${this.constructor.name}`); + } + + async keysOfObject(prefix, options) { + return Promise.reject(`keysOfObject(prefix, options) not yet implemented for ${this.constructor.name}`); + } + +} + +class SimpleEvalStrategy extends EvalStrategy { + + async runEval(source, options) { + return Promise.resolve().then(() => { + try { + return Promise.resolve({value: eval(source)}); + } catch (err) { + return {isError: true, value: err} + } + }); + } + + async keysOfObject(prefix, options) { + // for dynamic object completions + var result = await lively.vm.completions.getCompletions( + code => this.runEval(code, options), prefix); + return {completions: result.completions, prefix: result.startLetters}; + } + +} + +class LivelyVmEvalStrategy extends EvalStrategy { + + normalizeOptions(options) { + if (!options.targetModule) + throw new Error("runEval called but options.targetModule not specified!"); + + return Object.assign({ + sourceURL: options.targetModule + "_doit_" + Date.now(), + }, options); + } + + async runEval(source, options) { + options = this.normalizeOptions(options) + var System = options.System || lively.modules.System; + System.config({meta: {[options.targetModule]: {format: "esm"}}}); + return lively.vm.runEval(source, options); + } + + async keysOfObject(prefix, options) { + // for dynamic object completions + var result = await lively.vm.completions.getCompletions( + code => lively.vm.runEval(code, options), prefix); + return {completions: result.completions, prefix: result.startLetters} + } + +} + + +export class RemoteEvalStrategy extends LivelyVmEvalStrategy { + + sourceForRemote(action, arg, options) { + const contextFetch = obj.isString(options.context) ? options.context : false; + options = obj.dissoc(options, ["systemInterface", "System", "context"]); + return ` +(function() { + var arg = ${JSON.stringify(arg)}, + options = ${JSON.stringify(options)}; + if (typeof lively === "undefined" || !lively.vm) { + return Promise.resolve({ + isEvalResult: true, + isError: true, + value: 'lively.vm not available!' + }); + } + var hasSystem = typeof System !== "undefined" + options.context = ${contextFetch} + if (!options.context) { + options.context = hasSystem + ? System.global + : typeof window !== "undefined" + ? window + : typeof global !== "undefined" + ? global + : typeof self !== "undefined" ? self : this; + } + function evalFunction(source, options) { + if (hasSystem) { + var conf = {meta: {}}; conf.meta[options.targetModule] = {format: "esm"}; + System.config(conf); + } else { + options = Object.assign({topLevelVarRecorderName: "GLOBAL"}, options); + delete options.targetModule; + } + return lively.vm.runEval(source, options); + } + function keysOfObjectFunction(prefix, options) { + return lively.vm.completions.getCompletions(code => evalFunction(code, options), prefix) + .then(result => ({isEvalResult: true, completions: result.completions, prefix: result.startLetters})); + } + return ${action === "eval" ? "evalFunction" : "keysOfObjectFunction"}(arg, options) + .catch(err => ({isEvalResult: true, isError: true, value: String(err.stack || err)})); +})(); +`; + } + + async runEval(source, options) { + return this.remoteEval(this.sourceForRemote("eval", source, options), options) + } + + async keysOfObject(prefix, options) { + return this.remoteEval(this.sourceForRemote("keysOfObject", prefix, options), options) + } + + async remoteEval(source, options) { + try { + var result = await this.basicRemoteEval(source, options) + return typeof result === "string" ? JSON.parse(result) : result; + } catch (e) { + return {isError: true, value: `Remote eval failed: ${result || e}`}; + } + } + + async basicRemoteEval(source, options) { + throw new Error("Not yet implemented"); + } + +} + + +class HttpEvalStrategy extends RemoteEvalStrategy { + + static get defaultURL() { return "http://localhost:3000/lively" } + + constructor(url) { + super(); + this.url = url || this.constructor.defaultURL; + } + + normalizeOptions(options) { + options = super.normalizeOptions(options); + return Object.assign( + {serverEvalURL: this.url}, + options, + {context: null}); + } + + async basicRemoteEval(source, options) { + options = this.normalizeOptions(options); + var method = "basicRemoteEval_" + (System.get("@system-env").node ? "node" : "web"); + return await this[method]({method: "POST", body: source}, options.serverEvalURL); + } + + async basicRemoteEval_web(payload, url) { + let [domain] = url.match(/[^:]+:\/\/[^\/]+/) || [url], + loc, crossDomain; + + // fixme: this should be replaced by accessing the location + // in a canonical way + if (System.get("@system-env").worker) + loc = window.location; + else { + loc = document.location; + } + + crossDomain = loc.origin !== domain; + + if (crossDomain) { // use lively.server proxy plugin + payload.headers = { + ...payload.headers, + 'pragma': 'no-cache', + 'cache-control': 'no-cache', + "x-lively-proxy-request": url + } + url = loc.origin; + } + + var res; + try { + res = await window.fetch(url, payload); + } catch (e) { + throw new Error(`Cannot reach server at ${url}: ${e.message}`) + } + + if (!res.ok) { + throw new Error(`Server at ${url}: ${res.statusText}`) + } + + return res.text(); + } + + async basicRemoteEval_node(payload, url) { + var urlParse = System._nodeRequire("url").parse, + http = System._nodeRequire(url.startsWith("https:") ? "https" : "http"), + opts = Object.assign({method: payload.method || "GET"}, urlParse(url)); + return new Promise((resolve, reject) => { + var request = http.request(opts, res => { + res.setEncoding('utf8'); + var data = ""; + res.on('data', (chunk) => data += chunk); + res.on('end', () => resolve(data)); + res.on('error', err => reject(err)); + }) + request.on('error', err => reject(err)); + request.end(payload.body) + }); + } + +} + +class L2LEvalStrategy extends RemoteEvalStrategy { + + constructor(l2lClient, targetId) { + super(); + this.l2lClient = l2lClient; + this.targetId = targetId; + } + + async basicRemoteEval(source, options) { + let {l2lClient, targetId} = this, + {data: evalResult} = await new Promise((resolve, reject) => + l2lClient.sendTo(targetId, "remote-eval", {source}, resolve)); + if (evalResult && evalResult.value && evalResult.value.isEvalResult) + evalResult = evalResult.value; + return evalResult; + } + +} + + +export { + EvalStrategy, + SimpleEvalStrategy, + LivelyVmEvalStrategy, + HttpEvalStrategy, + L2LEvalStrategy +} diff --git a/lib/eval-support.js b/lib/eval-support.js new file mode 100644 index 0000000..17d4d8e --- /dev/null +++ b/lib/eval-support.js @@ -0,0 +1,170 @@ +import { arr, Path } from "lively.lang"; +import { parse, stringify, transform, query, nodes } from "lively.ast"; +import { capturing } from "lively.source-transform"; +import { runtime as classRuntime } from "lively.classes"; +import { getGlobal } from "./util.js"; + +var {id, literal, member, objectLiteral} = nodes; + +export const defaultDeclarationWrapperName = "lively.capturing-declaration-wrapper", + defaultClassToFunctionConverterName = "initializeES6ClassForLively"; + +function processInlineCodeTransformOptions(parsed, options) { + if (!parsed.comments) return options; + var livelyComment = parsed.comments.find(ea => ea.text.startsWith("lively.vm ")); + if (!livelyComment) return options; + try { + var inlineOptions = eval("inlineOptions = {" + livelyComment.text.slice("lively.vm ".length) + "};"); + return Object.assign(options, inlineOptions); + } catch (err) { return options; } +} + +export function evalCodeTransform(code, options) { + // variable declaration and references in the the source code get + // transformed so that they are bound to `varRecorderName` aren't local + // state. THis makes it possible to capture eval results, e.g. for + // inspection, watching and recording changes, workspace vars, and + // incrementally evaluating var declarations and having values bound later. + + // 1. Allow evaluation of function expressions and object literals + code = transform.transformSingleExpression(code); + var parsed = parse(code, {withComments: true}); + + options = processInlineCodeTransformOptions(parsed, options); + + // 2. Annotate definitions with code location. This is being used by the + // function-wrapper-source transform. + var {classDecls, funcDecls, varDecls} = query.topLevelDeclsAndRefs(parsed), + annotation = {}; + + if (options.hasOwnProperty("evalId")) annotation.evalId = options.evalId; + if (options.sourceAccessorName) annotation.sourceAccessorName = options.sourceAccessorName; + [...classDecls, ...funcDecls].forEach(node => + node["x-lively-object-meta"] = {...annotation, start: node.start, end: node.end}); + varDecls.forEach(node => + node.declarations.forEach(decl => + decl["x-lively-object-meta"] = {...annotation, start: decl.start, end: decl.end})); + + + // transforming experimental ES features into accepted es6 form... + parsed = transform.objectSpreadTransform(parsed); + + // 3. capture top level vars into topLevelVarRecorder "environment" + + if (!options.topLevelVarRecorder && options.topLevelVarRecorderName) { + let G = getGlobal(); + if (options.topLevelVarRecorderName === "GLOBAL") { // "magic" + options.topLevelVarRecorder = getGlobal(); + } else { + options.topLevelVarRecorder = Path(options.topLevelVarRecorderName).get(G); + } + } + + if (options.topLevelVarRecorder) { + + // capture and wrap logic + var blacklist = (options.dontTransform || []).concat(["arguments"]), + undeclaredToTransform = !!options.recordGlobals ? + null/*all*/ : arr.withoutAll(Object.keys(options.topLevelVarRecorder), blacklist), + varRecorder = id(options.varRecorderName || '__lvVarRecorder'), + es6ClassToFunctionOptions = undefined; + + if (options.declarationWrapperName || typeof options.declarationCallback === "function") { + // 2.1 declare a function that wraps all definitions, i.e. all var + // decls, functions, classes etc that get captured will be wrapped in this + // function. This allows to define some behavior that is run whenever + // variables get initialized or changed as well as transform values. + // The parameters passed are: + // name, kind, value, recorder + // Note that the return value of declarationCallback is used as the + // actual value in the code being executed. This allows to transform the + // value as necessary but also means that declarationCallback needs to + // return sth meaningful! + let declarationWrapperName = options.declarationWrapperName || defaultDeclarationWrapperName; + + options.declarationWrapper = member( + id(options.varRecorderName || '__lvVarRecorder'), + literal(declarationWrapperName), true); + + if (options.declarationCallback) + options.topLevelVarRecorder[declarationWrapperName] = options.declarationCallback; + } + + var transformES6Classes = options.hasOwnProperty("transformES6Classes") ? + options.transformES6Classes : true; + if (transformES6Classes) { + // Class declarations and expressions are converted into a function call + // to `createOrExtendClass`, a helper that will produce (or extend an + // existing) constructor function in a way that allows us to redefine + // methods and properties of the class while keeping the class object + // identical + if (!(defaultClassToFunctionConverterName in options.topLevelVarRecorder)) + options.topLevelVarRecorder[defaultClassToFunctionConverterName] = classRuntime.initializeClass; + es6ClassToFunctionOptions = { + currentModuleAccessor: options.currentModuleAccessor, + classHolder: varRecorder, + functionNode: member(varRecorder, defaultClassToFunctionConverterName), + declarationWrapper: options.declarationWrapper, + evalId: options.evalId, + sourceAccessorName: options.sourceAccessorName + }; + } + + // 3.2 Here we call out to the actual code transformation that installs the captured top level vars + parsed = capturing.rewriteToCaptureTopLevelVariables( + parsed, varRecorder, + { + es6ImportFuncId: options.es6ImportFuncId, + es6ExportFuncId: options.es6ExportFuncId, + ignoreUndeclaredExcept: undeclaredToTransform, + exclude: blacklist, + declarationWrapper: options.declarationWrapper || undefined, + classToFunction: es6ClassToFunctionOptions, + evalId: options.evalId, + sourceAccessorName: options.sourceAccessorName, + keepTopLevelVarDecls: options.keepTopLevelVarDecls + }); + } + + + if (options.wrapInStartEndCall) { + parsed = transform.wrapInStartEndCall(parsed, { + startFuncNode: options.startFuncNode, + endFuncNode: options.endFuncNode + }); + } + + var result = stringify(parsed); + + if (options.sourceURL) result += "\n//# sourceURL=" + options.sourceURL.replace(/\s/g, "_"); + + return result; +} + +export function evalCodeTransformOfSystemRegisterSetters(code, options = {}) { + if (!options.topLevelVarRecorder) return code; + + if (typeof options.declarationCallback === "function" || options.declarationWrapperName) { + let declarationWrapperName = options.declarationWrapperName || defaultDeclarationWrapperName; + options.declarationWrapper = member( + id(options.varRecorderName), + literal(declarationWrapperName), true); + if (options.declarationCallback) + options.topLevelVarRecorder[declarationWrapperName] = options.declarationCallback; + } + + var parsed = parse(code), + blacklist = (options.dontTransform || []).concat(["arguments"]), + undeclaredToTransform = !!options.recordGlobals ? + null/*all*/ : arr.withoutAll(Object.keys(options.topLevelVarRecorder), blacklist), + result = capturing.rewriteToRegisterModuleToCaptureSetters( + parsed, id(options.varRecorderName || '__lvVarRecorder'), {exclude: blacklist, ...options}); + return stringify(result); +} + +function copyProperties(source, target, exceptions = []) { + Object.getOwnPropertyNames(source).concat(Object.getOwnPropertySymbols(source)) + .forEach(name => + exceptions.indexOf(name) === -1 + && Object.defineProperty(target, name, Object.getOwnPropertyDescriptor(source, name))); +} diff --git a/lib/eval.js b/lib/eval.js new file mode 100644 index 0000000..ce7fa45 --- /dev/null +++ b/lib/eval.js @@ -0,0 +1,264 @@ +/*global: global, System*/ + +import { arr, obj, string, Path, promise } from "lively.lang"; +import { evalCodeTransform } from "./eval-support.js"; +import { printEvalResult, getGlobal } from "./util.js"; + +// -=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=- +// options +// -=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=- + +const defaultTopLevelVarRecorderName = '__lvVarRecorder', + startEvalFunctionName = "lively.vm-on-eval-start", + endEvalFunctionName = "lively.vm-on-eval-end" + +function _normalizeEvalOptions(opts) { + if (!opts) opts = {}; + + opts = { + targetModule: null, + sourceURL: opts.targetModule, + runtime: null, + context: getGlobal(), + varRecorderName: defaultTopLevelVarRecorderName, + dontTransform: [], // blacklist vars + recordGlobals: null, + returnPromise: true, + promiseTimeout: 200, + waitForPromise: true, + wrapInStartEndCall: false, + onStartEval: null, + onEndEval: null, + ...opts + }; + + if (opts.targetModule) { + var moduleEnv = opts.runtime + && opts.runtime.modules + && opts.runtime.modules[opts.targetModule]; + if (moduleEnv) opts = Object.assign(opts, moduleEnv); + } + + if (opts.wrapInStartEndCall) { + opts.startFuncNode = { + type: "MemberExpression", + object: {type: "Identifier", name: opts.varRecorderName}, + property: {type: "Literal", value: startEvalFunctionName}, + computed: true + } + opts.endFuncNode = { + type: "MemberExpression", + object: {type: "Identifier", name: opts.varRecorderName}, + property: {type: "Literal", value: endEvalFunctionName}, + computed: true + } + } + + return opts; +} + + +// -=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=- +// eval +// -=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=- + +function _eval(__lvEvalStatement, __lvVarRecorder/*needed as arg for capturing*/, __lvOriginalCode) { + return eval(__lvEvalStatement); +} + +function runEval(code, options, thenDo) { + // The main function where all eval options are configured. + // options can be: { + // runtime: { + // modules: {[MODULENAME: PerModuleOptions]} + // } + // } + // or directly, PerModuleOptions = { + // varRecorderName: STRING, // default is '__lvVarRecorder' + // topLevelVarRecorder: OBJECT, + // context: OBJECT, + // sourceURL: STRING, + // recordGlobals: BOOLEAN, // also transform free vars? default is false + // transpiler: FUNCTION(source, options) // for transforming the source after the lively xfm + // wrapInStartEndCall: BOOLEAN + // onStartEval: FUNCTION()?, + // onEndEval: FUNCTION(err, value)? // note: we pass in the value of last expr, not EvalResult! + // } + + if (typeof options === 'function' && arguments.length === 2) { + thenDo = options; options = null; + } + + var result = new EvalResult(), + returnedError, returnedValue, + onEvalEndError, onEvalEndValue, + onEvalStartCalled = false, onEvalEndCalled = false; + options = _normalizeEvalOptions(options); + + // 1. In case we rewrite the code with on-start and on-end calls we prepare + // the environment with actual function handlers that will get called once + // the code is evaluated + + var evalDone = promise.deferred(), + recorder = options.topLevelVarRecorder || getGlobal(), + originalSource = code; + + if (options.wrapInStartEndCall) { + if (recorder[startEvalFunctionName]) + console.warn(result.addWarning(`startEvalFunctionName ${startEvalFunctionName} already exists in recorder!`)); + + if (recorder[endEvalFunctionName]) + console.warn(result.addWarning(`endEvalFunctionName ${endEvalFunctionName} already exists in recorder!`)) + + recorder[startEvalFunctionName] = function() { + if (onEvalStartCalled) { console.warn(result.addWarning("onEvalStartCalled multiple times!")); return; } + onEvalStartCalled = true; + if (typeof options.onStartEval === "function") options.onStartEval(); + } + + recorder[endEvalFunctionName] = function(err, value) { + if (onEvalEndCalled) { console.warn(result.addWarning("onEvalEndCalled multiple times!")); return; } + onEvalEndCalled = true; + finishEval(err, value, result, options, recorder, evalDone, thenDo); + } + } + + // 2. Transform the code to capture top-level variables, inject function calls, ... + try { + code = evalCodeTransform(code, options); + if (options.header) code = options.header + code; + if (options.footer) code = code + options.footer; + if (options.transpiler) code = options.transpiler(code, options.transpilerOptions); + // console.log(code); + } catch (e) { + console.warn(result.addWarning("lively.vm evalCodeTransform not working: " + e)); + } + + // 3. Now really run eval! + try { + typeof $world !== "undefined" && $world.get('log') && ($world.get('log').textString = code); + returnedValue = _eval.call(options.context, code, options.topLevelVarRecorder, options.originalSource || originalSource); + } catch (e) { returnedError = e; } + + // 4. Wrapping up: if we inject a on-eval-end call we let it handle the + // wrap-up, otherwise we firectly call finishEval() + if (options.wrapInStartEndCall) { + if (returnedError && !onEvalEndCalled) + recorder[endEvalFunctionName](returnedError, undefined); + } else { + finishEval(returnedError, returnedError || returnedValue, result, options, recorder, evalDone, thenDo); + } + + return options.sync ? result : evalDone.promise; +} + +function finishEval(err, value, result, options, recorder, evalDone, thenDo) { + // 5. Here we end the evaluation. Note that if we are in sync mode we cannot + // use any Promise since promises always run on next tick. That's why we have + // to slightly duplicate the finish logic... + + if (options.wrapInStartEndCall) { + delete recorder[startEvalFunctionName]; + delete recorder[endEvalFunctionName]; + } + + if (err) { result.isError = true; result.value = err; } + else result.value = value; + if (result.value instanceof Promise) result.isPromise = true; + + if (options.sync) { + result.processSync(options); + if (typeof options.onEndEval === "function") options.onEndEval(err, value); + } else { + result.process(options) + .then(() => { + typeof thenDo === "function" && thenDo(null, result); + typeof options.onEndEval === "function" && options.onEndEval(err, value); + return result; + }, + (err) => { + typeof thenDo === "function" && thenDo(err, undefined); + typeof options.onEndEval === "function" && options.onEndEval(err, undefined); + return result; + }) + .then(evalDone.resolve, evalDone.reject) + } +} + + +function syncEval(string, options) { + // See #runEval for options. + // Although the defaul eval is synchronous we assume that the general + // evaluation might not return immediatelly. This makes is possible to + // change the evaluation backend, e.g. to be a remotely attached runtime + options = Object.assign(options || {}, {sync: true}); + return runEval(string, options); +} + + +// -=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=- +// EvalResult +// -=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=- + +class EvalResult { + + constructor() { + this.isEvalResult = true; + this.value = undefined; + this.warnings = []; + this.isError = false; + this.isPromise = false; + this.promisedValue = undefined; + this.promiseStatus = "unknown"; + } + + addWarning(warn) { this.warnings.push(warn); return warn; } + + printed(options = {}) { + this.value = printEvalResult(this, options); + } + + processSync(options) { + if (options.inspect || options.asString) + this.value = this.print(this.value, options); + return this; + } + + process(options) { + var result = this; + if (result.isPromise && options.waitForPromise) { + return tryToWaitForPromise(result, options.promiseTimeout) + .then(() => { + if (options.inspect || options.asString) result.printed(options); + return result; + }); + } + if (options.inspect || options.asString) result.printed(options); + return Promise.resolve(result); + } + +} + +function tryToWaitForPromise(evalResult, timeoutMs) { + console.assert(evalResult.isPromise, "no promise in tryToWaitForPromise???"); + var timeout = {}, + timeoutP = new Promise(resolve => setTimeout(resolve, timeoutMs, timeout)); + return Promise.race([timeoutP, evalResult.value]) + .then(resolved => Object.assign(evalResult, resolved !== timeout ? + {promiseStatus: "fulfilled", promisedValue: resolved} : + {promiseStatus: "pending"})) + .catch(rejected => Object.assign(evalResult, + {promiseStatus: "rejected", promisedValue: rejected})) +} + + +// -=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=- +// export +// -=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=- + +export { + defaultTopLevelVarRecorderName, + getGlobal, + runEval, + syncEval +} \ No newline at end of file diff --git a/lib/util.js b/lib/util.js new file mode 100644 index 0000000..2c37a55 --- /dev/null +++ b/lib/util.js @@ -0,0 +1,172 @@ +/*global System,global,Global,self,Node,ImageData*/ +import { obj, string } from "lively.lang"; + +export function getGlobal() { + if (typeof System !== "undefined") return System.global; + if (typeof window !== "undefined") return window; + if (typeof global !== "undefined") return global; + if (typeof Global !== "undefined") return Global; + if (typeof self !== "undefined") return self; + return (function() { return this; })(); +} + +export function signatureOf(name, func) { + var source = String(func), + match = source.match(/function\s*[a-zA-Z0-9_$]*\s*\(([^\)]*)\)/), + params = (match && match[1]) || ''; + return name + '(' + params + ')'; +} + +export function isClass(obj) { + if (obj === obj + || obj === Array + || obj === Function + || obj === String + || obj === Boolean + || obj === Date + || obj === RegExp + || obj === Number + || obj === Promise) return true; + return (obj instanceof Function) + && ((obj.superclass !== undefined) + || (obj._superclass !== undefined)); +} + + +export function pluck(list, prop) { return list.map(function(ea) { return ea[prop]; }); } + +var knownSymbols = (() => + Object.getOwnPropertyNames(Symbol) + .filter(ea => typeof Symbol[ea] === "symbol") + .reduce((map, ea) => map.set(Symbol[ea], "Symbol." + ea), new Map()))(); + +var symMatcher = /^Symbol\((.*)\)$/; + +export function printSymbol(sym) { + if (Symbol.keyFor(sym)) return `Symbol.for("${Symbol.keyFor(sym)}")`; + if (knownSymbols.get(sym)) return knownSymbols.get(sym) + var matched = String(sym).match(symMatcher); + return String(sym); +} + +export function safeToString(value) { + if (!value) return String(value); + if (Array.isArray(value)) return `[${value.map(safeToString).join(",")}]`; + if (typeof value === "symbol") return printSymbol(value); + try { + return String(value); + } catch (e) { + throw new Error(`Cannot print object: ${e.stack}`); + } +} + + +// -=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=- + +export function printEvalResult(evalResult, options = {}) { + let {value, isError, isPromise, promisedValue, promiseStatus} = evalResult; + + if (isError || value instanceof Error) { + var err = value, + stringified = String(err), + stack = err.stack || ""; + if (stack && err.message !== err.stack) { + stack = String(stack); + var errInStackIdx = stack.indexOf(stringified); + if (errInStackIdx === 0) + stack = stack.slice(stringified.length); + stringified += "\n" + stack; + } + return stringified; + } + + if (isPromise) { + var status = string.print(promiseStatus), + printed = promiseStatus === "pending" ? undefined : + printEvalResult({value: promisedValue}, options); + return `Promise({status: ${status}, ${(value === undefined ? "" : "value: " + printed)}})`; + } + + if (value instanceof Promise) + return 'Promise({status: "unknown"})'; + + if (options.inspect) + return printInspectEvalValue(value, options.inspectDepth || 2); + + // options.asString + return String(value); +} + +export var printInspectEvalValue = (function() { + var itSym = typeof Symbol !== "undefined" && Symbol.iterator, + maxIterLength = 10, + maxStringLength = 100, + maxNumberOfKeys = 100, + maxNumberOfLines = 1000; + + return printInspect; + + function printInspect(object, maxDepth) { + if (typeof maxDepth === "object") + maxDepth = maxDepth.maxDepth || 2; + + if (!object) return String(object); + if (typeof object === "string") { + var mark = object.includes("\n") ? "`" : '"' + object = object.split('\n').slice(0, maxNumberOfLines).join('\n'); + return mark + object + mark; + } + if (object instanceof Error) return object.stack || safeToString(object); + if (!obj.isObject(object)) return safeToString(object); + try { + var inspected = obj.inspect(object, { + customPrinter: inspectPrinter, maxNumberOfKeys, + maxDepth, printFunctionSource: true + }); + } catch (e) {} + // return inspected; + return inspected === "{}" ? safeToString(object) : inspected; + } + + function printIterable(val, ignore) { + var isIterable = typeof val !== "string" + && !Array.isArray(val) + && itSym && typeof val[itSym] === "function"; + if (!isIterable) return ignore; + var hasEntries = typeof val.entries === "function", + it = hasEntries ? val.entries() : val[itSym](), + values = [], + open = hasEntries ? "{" : "[", close = hasEntries ? "}" : "]", + name = val.constructor && val.constructor.name || "Iterable"; + for (var i = 0, next; i < maxIterLength; i++) { + next = it.next(); + if (next.done) break; + values.push(next.value); + } + var printed = values.map(ea => hasEntries ? + `${String(ea[0])}: ${String(ea[1])}` : + printInspect(ea, 2)).join(", "); + return `${name}(${open}${printed}${close})`; + } + + function inspectPrinter(val, ignore, continueInspectFn) { + if (!val) return ignore; + if (typeof val === "symbol") return printSymbol(val); + if (typeof val === "string") return string.print(string.truncate(val, maxStringLength)); + if (val.isMorph) return safeToString(val); + if (val instanceof Promise) return "Promise()"; + if (typeof Node !== "undefined" && val instanceof Node) return safeToString(val); + if (typeof ImageData !== "undefined" && val instanceof ImageData) return safeToString(val); + var length = val.length || val.byteLength; + if (length !== undefined && length > maxIterLength && val.slice) { + var printed = typeof val === "string" || val.byteLength ? + safeToString(val.slice(0, maxIterLength)) : + val.slice(0,maxIterLength).map(continueInspectFn); + return "[" + printed + ",...]"; + } + var iterablePrinted = printIterable(val, ignore); + if (iterablePrinted !== ignore) return iterablePrinted; + return ignore; + } + +})(); \ No newline at end of file diff --git a/package.json b/package.json new file mode 100644 index 0000000..526d09c --- /dev/null +++ b/package.json @@ -0,0 +1,48 @@ +{ + "name": "lively.vm", + "version": "0.9.17", + "description": "Controlled JavaScript code execution and instrumentation.", + "main": "dist/lively.vm.js", + "systemjs": { + "main": "index.js" + }, + "scripts": { + "test": "mocha-es6 tests/*-test.js", + "build": "node tools/build.js" + }, + "repository": { + "type": "git", + "url": "https://github.com/LivelyKernel/lively.vm.git" + }, + "keywords": [ + "LivelyWeb", + "JavaScript" + ], + "author": "Robert Krahn", + "license": "MIT", + "bugs": { + "url": "https://github.com/LivelyKernel/lively.vm/issues" + }, + "homepage": "https://github.com/LivelyKernel/lively.vm", + "dependencies": { + "lively.ast": "^0.9.0", + "lively.lang": "^1.0.0", + "lively.notifications": "^0.4.0", + "lively.classes": "*", + "lively.source-transform": "*" + }, + "devDependencies": { + "babel-core": "^6.16.0", + "babel-plugin-external-helpers": "^6.8.0", + "babel-plugin-syntax-object-rest-spread": "^6.13.0", + "babel-plugin-transform-async-to-generator": "^6.22.0", + "babel-plugin-transform-object-rest-spread": "^6.20.2", + "babel-preset-es2015": "^6.24.0", + "babel-preset-es2015-rollup": "^3.0.0", + "babel-regenerator-runtime": "^6.5.0", + "mocha-es6": "^0.5", + "rollup": "^0.36.1", + "rollup-plugin-babel": "^2.6.1", + "uglify-js": "^2.8.22" + } +} diff --git a/tests/completion-test.js b/tests/completion-test.js new file mode 100644 index 0000000..deb30f9 --- /dev/null +++ b/tests/completion-test.js @@ -0,0 +1,53 @@ +/*global beforeEach, afterEach, describe, it*/ + +import { expect } from "mocha-es6"; +import * as vm from "lively.vm"; +import lang from "lively.lang"; + +describe("completion", () => { + + it("can compute properties and method completions of an object", () => + vm.completions.getCompletions(code => vm.syncEval(code, {topLevelVarRecorder: {foo: {bar: 23}}}), "foo.") + .then(result => { + expect(result).property("startLetters").to.equal(""); + expect(result).property("completions").property(0).to.deep.equal(["[object Object]", ["bar"]]); + expect(result).property("completions").property(1).to.containSubset(["Object", [ + "__defineGetter__()", + "__defineSetter__()", + "__lookupGetter__()", + "__lookupSetter__()", + "constructor()", + "hasOwnProperty()", + "isPrototypeOf()", + "propertyIsEnumerable()", + "toLocaleString()", + "toString()", + "valueOf()"]]); + })); + + it("finds inherited props", () => + vm.completions.getCompletions( + vm.syncEval, + "var obj1 = {m2: function() {}, m3:function(a, b, c) {}},\n" + + "obj2 = {a: 3, m1: function(a) {}, m2:function(x) {}, __proto__: obj1};\n" + + "obj2.") + .then(result => { + expect(result).property("startLetters").equal(""); + expect(result.startLetters).to.equal(""); + const compls = result.completions, + objectCompletions = compls.slice(0,2), + expected = [["[object Object]", ["m1(a)","m2(x)","a"]], + ["prototype", ["m3(a, b, c)"]]]; + expect(compls).to.have.length(3); + expect(compls[2][0]).to.equal("Object"); + expect(objectCompletions).to.deep.equal(expected); + })); + + it("of resolved promise", () => + vm.completions.getCompletions( + code => vm.runEval(code, {waitForPromise: true}), + "Promise.resolve(23).") + .then(result => + expect(result).property("promiseResolvedCompletions").property(0) + .to.deep.equal(["23", []]))) + }); diff --git a/tests/esm-eval-test.js b/tests/esm-eval-test.js new file mode 100644 index 0000000..9d79012 --- /dev/null +++ b/tests/esm-eval-test.js @@ -0,0 +1,87 @@ +/*global System, beforeEach, afterEach, describe, it*/ + +import { expect } from "mocha-es6"; +import { subscribe, unsubscribe } from "lively.notifications"; +import { runEval } from "../index.js"; + +var modules = typeof lively !== "undefined" && lively.modules; + +var dir = System.normalizeSync("lively.vm/tests/test-resources/"), + testProjectDir = dir + "es6-project/", + module1 = testProjectDir + "file1.js", + module2 = testProjectDir + "file2.js", + module3 = testProjectDir + "file3.js", + module4 = testProjectDir + "file4.js"; + +describe("eval", () => { + + var S; + beforeEach(function() { + S = modules ? modules.getSystem('test', { baseURL: dir }) : System; + return S.import(module1); + }); + + afterEach(() => modules && modules.removeSystem("test")); + + it("inside of module", async () => { + var result = await runEval("1 + z + x", {System: S, targetModule: module1}); + expect(result.value).equals(6) + }); + + it("sets this", async () => { + var result = await runEval("1 + this.x", {System: S, targetModule: module1, context: {x: 2}}); + expect(result.value).equals(3); + }) + + it("**", () => + S.import(module1) + .then(() => runEval("z ** 4", {System: S, targetModule: module1}) + .then(result => expect(result).property("value").to.equal(16)))); + + it("awaits async function", async () => { + await S.import(module4); + var result = await runEval("await foo(3)", {System: S, targetModule: module4}); + await expect(result).property("value").to.equal(3); + }) + + it("nests await", async () => { + await S.import(module4) + var result = await runEval("await ('a').toUpperCase()", {System: S, targetModule: module4}); + expect(result).property("value").to.equal("A") + }) + + it("notifies request", async() => { + const doitrequest = []; + function onDoItRequest(msg) { doitrequest.push(msg); } + console.log("subscribed"); + subscribe("lively.vm/doitrequest", onDoItRequest, S); + + expect(doitrequest).to.deep.equal([]); + await runEval("1 + z + x", {System: S, targetModule: module1}); + unsubscribe("lively.vm/doitrequest", onDoItRequest, S); + expect(doitrequest).to.containSubset([{ + type: "lively.vm/doitrequest", + code: "1 + z + x", + targetModule: module1 + }]); + }); + + it("notifies result", async() => { + const doitresult = []; + function onDoItResult(msg) { doitresult.push(msg); } + subscribe("lively.vm/doitresult", onDoItResult, S); + + expect(doitresult).to.deep.equal([]); + await runEval("1 + z + x", {System: S, targetModule: module1}); + unsubscribe("lively.vm/doitresult", onDoItResult, S); + expect(doitresult).to.containSubset([{ + type: "lively.vm/doitresult", + code: "1 + z + x", + result: { + value: 6 + }, + targetModule: module1 + }]); + }); + +}); diff --git a/tests/eval-support-test.js b/tests/eval-support-test.js new file mode 100644 index 0000000..35f4f4e --- /dev/null +++ b/tests/eval-support-test.js @@ -0,0 +1,61 @@ +/*global beforeEach, afterEach, describe, it*/ + +import { expect } from "mocha-es6"; + +import { stringify, parse } from "lively.ast"; +import { evalCodeTransform } from "../lib/eval-support.js"; + +describe("eval code transform", function() { + + it("allows object expressions", () => + expect(evalCodeTransform("{x: 23}", {})).equals("({ x: 23 });")); + + it("allows function expressions", () => + expect(evalCodeTransform("function() {}", {})).equals("(function () {\n});")); + + it("does nothing without options", () => + expect(evalCodeTransform("3 + 4", {})).equals("3 + 4;")); + + it("captures toplevel vars", () => + expect(evalCodeTransform("const x = 23;", {topLevelVarRecorder: {}, varRecorderName: "__foo"})) + .equals("__foo.x = 23;")); + + it("wraps in start end call", () => + expect(evalCodeTransform("var x = 23;", {wrapInStartEndCall: true})) + .equals("try {\n" + + " __start_execution();\n" + + " var x = 23;\n" + + " __end_execution(null, x);\n" + + "} catch (err) {\n" + + " __end_execution(err, undefined);\n" + + "}")); + + it("start / end + capturing", () => + expect(evalCodeTransform("var x = 23;", {topLevelVarRecorder: {}, wrapInStartEndCall: true})) + .equals("try {\n" + + " __start_execution();\n" + + " __end_execution(null, __lvVarRecorder.x = 23);\n" + + "} catch (err) {\n" + + " __end_execution(err, undefined);\n" + + "}")); + + it("source code and evalId passed into define", () => + expect(evalCodeTransform("var x = 23;", { + evalId: "testEval", + sourceAccessorName: "__source", + declarationWrapper: {type: "Identifier", name: "__define"}, + topLevelVarRecorder: {} + })).equals(`__lvVarRecorder.x = __define("x", "var", 23, __lvVarRecorder, {\n` + + ` start: 4,\n` + + ` end: 10,\n` + + ` evalId: "testEval",\n` + + ` moduleSource: __source\n` + + `});`)); + + it("allows for inline options", () => + expect(evalCodeTransform(`/*lively.vm varRecorderName:"foo"*/var x = 23;`, { + topLevelVarRecorder: {} + })).equals(`foo.x = 23;`)); + + +}); diff --git a/tests/eval-test.js b/tests/eval-test.js new file mode 100644 index 0000000..1cd2498 --- /dev/null +++ b/tests/eval-test.js @@ -0,0 +1,384 @@ +/*global beforeEach, afterEach, describe, it, global,System,xdescribe*/ + +import { expect } from "mocha-es6"; +import { runEval, syncEval, defaultTopLevelVarRecorderName } from "../index.js"; +import * as lang from "lively.lang"; + +var Global = typeof global !== "undefined" ? global : window; + +describe("evaluation", function() { + + it("syncEval", function() { + expect(syncEval('1 + 2')).property("value").equals(3); + expect(syncEval('this.foo + 2;', {context: {foo: 3}})).property("value").equals(5); + expect(syncEval('throw new Error("foo");')) + .to.containSubset({isError: true}) + .property("value").matches(/Error.*foo/); + }); + + it("runEval", () => + runEval('1+2') + .then(result => expect(result.value).equal(3)) + .catch(err => expect(false, "got error in runEval " + err))); + + it("runEval promise-rejects on error", () => + runEval('throw new Error("foo");') + .then(result => { + expect(result).property("isError").to.be.true; + expect(result).property("value").to.match(/error.*foo/i) + }) + .catch(err => expect(false, "got error in runEval " + err))) + + it("eval and capture topLevelVars", function() { + var varMapper = {}, + code = "var x = 3 + 2", + result = syncEval(code, {topLevelVarRecorder: varMapper}); + expect(result.value).equals(5); + expect(varMapper.x).equals(5); + }); + + it("eval single expressions", function() { + var result = syncEval('function() {}'); + expect(result.value).to.be.a("function"); + var result = syncEval('{x: 3}'); + expect(result.value).to.an('object'); + expect(result.value.x).equals(3); + }); + + it("evalCapturedVarsReplaceVarRefs", function() { + var varMapper = {}; + var code = "var x = 3; var y = foo() + x; function foo() { return x++ }; y"; + var result = syncEval(code, {topLevelVarRecorder: varMapper}); + + expect(result.value).equals(7); + expect(varMapper.x).equals(4); + expect(varMapper.y).equals(7); + }); + + it("only capture whitelisted globals", function() { + var varMapper = {y: undefined}, + code = "var x = 3; y = 5; z = 4;"; + syncEval(code, {topLevelVarRecorder: varMapper}); + + expect(varMapper.x).equals(3); + expect(varMapper.y).equals(5); + + expect(!varMapper.hasOwnProperty('z')).equals(true, 'Global "z" was recorded'); + // don't leave globals laying around + delete Global.z; + }); + + it("dont capture blacklisted", function() { + var varMapper = {}, + code = "var x = 3, y = 5;"; + syncEval(code, { + topLevelVarRecorder: varMapper, + dontTransform: ['y'] + }); + expect(varMapper.x).equals(3); + expect(!varMapper.hasOwnProperty('y')).equals(true, 'y recorded?'); + }); + + it("dont transform var decls in for loop", function() { + var code = "var sum = 0;\n" + + "for (var i = 0; i < 5; i ++) { sum += i; }\n" + + "sum", recorder = {}; + syncEval(code, {topLevelVarRecorder: recorder}); + expect(recorder.sum).equals(10); + }); + + it("eval captured vars replace var refs", function() { + var code = 'try { throw new Error("foo") } catch (e) { e.message }', + result = syncEval(code, {topLevelVarRecorder: {}}); + expect(result.value).equals('foo'); + }); + + it("dont undefine vars", function() { + var code = "var x; var y = x + 3;", + rec = {x: 23}; + syncEval(code, {topLevelVarRecorder: rec}); + expect(rec.y).equals(26); + }); + + it("eval can set source url", function() { + if ((System.get("@system-env").node) || navigator.userAgent.match(/PhantomJS/)) { + console.log("FIXME sourceURL currently only works for web browser"); + return; + } + var code = "throw new Error('test');", + result = syncEval(code, {sourceURL: "my-great-source!"}); + expect(result.value.stack).to.match( + /at eval.*my-great-source!:1/, + "stack does not have sourceURL info:\n" + + lang.string.lines(result.value.stack).slice(0,3).join('\n')); + }); + + + describe("promises", () => { + + it("runEval returns promise", () => + runEval("3+5").then(result => expect(result).property("value").to.equal(8))); + + var code = "new Promise(function(resolve, reject) { return setTimeout(resolve, 200, 23); });" + + it("run eval waits for promise", () => + runEval(code, {waitForPromise: true}) + .then(result => expect(result).to.containSubset({ + isPromise: true, promiseStatus: "fulfilled", promisedValue: 23}))); + }); + + describe("printed", () => { + + it("asString", () => + runEval("3 + 4", {asString: true}) + .then(printed => expect(printed).to.containSubset({value: "7"}))); + + it("inspect", () => + runEval("({foo: {bar: {baz: 42}, zork: 'graul'}})", {inspect: true, printDepth: 2}) + .then(printed => expect(printed).to.containSubset({value: "{\n foo: {\n bar: {/*...*/},\n zork: \"graul\"\n }\n}"}))); + + it("prints promises", () => + runEval("Promise.resolve(23)", {asString: true}) + .then(printed => expect(printed).to.containSubset({value: 'Promise({status: "fulfilled", value: 23})'}))); + + }); + + describe("transpiler", () => { + + it("transforms code after lively rewriting", () => + runEval("x + 3", { + topLevelVarRecorder: {x: 2}, + // ".x" will only appear in rewritten code + transpiler: (source, opts) => source.replace(/\.x/, ".x + 1") + }) + .then(({value}) => expect(value).to.equal(6))); + + }); +}); + +describe("context recording", () => { + + describe("record free variables", () => { + + it("adds them to var recorder", () => { + var varMapper = {}, + code = "x = 3 + 2", + result = syncEval(code, {topLevelVarRecorder: varMapper, recordGlobals: true}); + expect(result).property("value").equals(5); + expect(varMapper.x).equals(5); + }); + + }); + +}); + +describe("wrap in start-end calls", () => { + + var evalCount, evalEndRecordings, varMapper, opts; + function onStartExecution() { evalCount++; } + function onEndExecution(err, result) { evalEndRecordings.push({error: err, result: result}); } + beforeEach(() => { + evalCount = 0; + evalEndRecordings = []; + varMapper = {}; + opts = { + topLevelVarRecorder: varMapper, + recordGlobals: true, + wrapInStartEndCall: true, + onStartEval: onStartExecution, + onEndEval: onEndExecution + } + }); + + describe("async", () => { + + it("calls start and end handlers", () => + runEval("var x = 3 + 2", opts).then(result => { + expect(result).property("value").equals(5); + expect(varMapper.x).equals(5); + expect(evalCount).to.equal(1); + expect(evalEndRecordings).to.have.length(1); + expect(evalEndRecordings).to.deep.equal([{error: null, result: 5}]); + })); + + it("works with errors", () => + runEval("foo.bar()", opts).then(result => { + expect(result.isError).equals(true); + expect(result.value).to.match(/TypeError/); + expect(evalCount).to.equal(1); + expect(evalEndRecordings).to.have.length(1); + expect(evalEndRecordings[0]).to.have.property("error").to.match(/TypeError/); + })); + + }); + + describe("sync", () => { + + it("calls injected function before and after eval, using value of last expression", () => { + var result = syncEval("var x = 3 + 2", opts); + expect(result).property("value").equals(5); + expect(varMapper.x).equals(5); + expect(evalCount).to.equal(1); + expect(evalEndRecordings).to.have.length(1); + expect(evalEndRecordings).to.deep.equal([{error: null, result: 5}]); + }); + + it("works with errors", () => { + var result = syncEval("foo.bar()", opts); + expect(result.isError).equals(true); + expect(result.value).to.match(/TypeError/); + expect(evalCount).to.equal(1); + expect(evalEndRecordings).to.have.length(1); + expect(evalEndRecordings[0]).to.have.property("error").to.match(/TypeError/); + }); + }); + +}); + +describe("runtime", () => { + + it("evaluation uses runtime", () => { + var runtime = { + modules: { + "foo/bar.js": { + topLevelVarRecorder: {}, + recordGlobals: true + } + } + } + + syncEval("var x = 3 + 2; y = 2", {runtime: runtime, targetModule: "foo/bar.js"}); + expect(runtime.modules["foo/bar.js"].topLevelVarRecorder.x).equals(5); + expect(runtime.modules["foo/bar.js"].topLevelVarRecorder.y).equals(2); + }); + +}); + +describe("declaration callback", () => { + + it("invokes function when declaration is evaluated", async () => { + var recordings = []; + await runEval("var a = 1, b = 'foo'; a = 2", { + topLevelVarRecorder: {}, + declarationCallback: (name, kind, val, recorder) => recordings.push([name, val]) + }); + expect(recordings).deep.equals([["a", 1], ["b", 'foo'], ["a", 2]]) + }); +}); + +describe("persistent definitions", () => { + + var varMapper, opts; + beforeEach(() => { + varMapper = {}; + opts = {topLevelVarRecorder: varMapper} + }); + + describe("primitives", () => { + + it("redefines number, strings, regexp", async () => { + await runEval("var a = 1, b = '2', c = /foo/", opts); + await runEval("var a = 2, b = '3', c = /bar/", opts); + expect(varMapper.a).equals(2, "number"); + expect(varMapper.b).equals('3', "string"); + expect(varMapper.c.test("bar")).equals(true, "regexp"); + }); + + }); + + xdescribe("objects", () => { + + it("keeps identy", async () => { + var result1 = await runEval("var x = {y: 23}", opts), + x1 = varMapper.x; + expect(result1.value).deep.equals({y: 23}); + expect(x1).deep.equals({y: 23}); + var result2 = await runEval("var x = {y: 24}", opts), + x2 = varMapper.x; + expect(result2.value).deep.equals({y: 24}, "eval result"); + expect(x2).deep.equals({y: 24}, "var mapper value"); + expect(x1).equals(x2, "identity"); + }); + + it("keeps symbols", async () => { + var sym = Symbol.for('y'), + result1 = await runEval("var x = {[Symbol.for('y')]: 23}", opts), + x1 = varMapper.x; + expect(result1.value).deep.equals({[sym]: 23}); + expect(x1).deep.equals({[sym]: 23}); + var result2 = await runEval("var x = {[Symbol.for('y')]: 24}", opts), + x2 = varMapper.x; + expect(result2.value).deep.equals({[sym]: 24}); + expect(x2).deep.equals({[sym]: 24}); + expect(x1).equals(x2); + }); + + it("keeps identity of generated object", async () => { + await runEval("var a = (function() { return {x: 23}; })();", opts); + var a1 = varMapper.a; + await runEval("var a = (function() { return {x: 24}; })();", opts); + var a2 = varMapper.a; + expect(a2).deep.equals({x: 24}); + expect(a2).equals(a1); + }); + + it("copies getters and setters", async () => { + await runEval("var a = {get x() { return this._x; }, set x(v) { this._x = v; }}; a.x = 3;", opts); + var a1 = varMapper.a; + expect(a1.x).equals(3); + await runEval("var a = {get x() { return this._x; }, set x(v) { this._x = v + 1; }};", opts); + var a2 = varMapper.a; + expect(a1).equals(a2); + expect(a2.x).equals(3); + a2.x = 4; + expect(a2.x).equals(5); + }); + }); + + + describe("class", () => { + + beforeEach(() => + runEval("class Foo {a() { return 1 } get b() { return 2 } static c() { return 3; }}", opts)) + + it("keeps identity", async () => { + var Foo1 = varMapper.Foo; + await runEval("class Foo {a() { return 2 }}", opts); + var Foo2 = varMapper.Foo; + expect(new Foo2().a()).equals(2); + expect(Foo1).equals(Foo2); + }); + + it("redefines props", async () => { + await runEval("class Foo {get b() { return 3 }}", opts); + expect(new varMapper.Foo().b).equals(3); + }); + + it("redefines class side", async () => { + await runEval("class Foo {static c() { return 4 }}", opts); + expect(varMapper.Foo.c()).equals(4); + }); + + it("class identical to instance constructor", async () => { + var isIdentical = (await runEval("class Bar {}; Bar === new Bar().constructor", opts)).value; + expect(isIdentical).equals(true); + }); + + it("redefines class twice and keeps identity", async () => { + var isIdentical = (await runEval("class Bar {}; class Bar {}; Bar === new Bar().constructor", opts)).value; + expect(isIdentical).equals(true); + }); + + it("class methods don't shadow similar named functions in lexical scope", async () => { + expect(await runEval("function m() {return 3}; class Bar {m() { return m() }}; new Bar().m()", opts)) + .property("value").equals(3); + }); + + it("class is only captured in toplevel scope", async () => { + await runEval("(function() { class InnerClass {a() { return 2 }} })()", opts); + expect(varMapper).to.not.have.property("InnerClass"); + }); + + }); + +}); \ No newline at end of file diff --git a/tests/helper.js b/tests/helper.js new file mode 100644 index 0000000..382a60c --- /dev/null +++ b/tests/helper.js @@ -0,0 +1,18 @@ +function loadUncached(urls, thenDo) { + if (!urls.length) { thenDo && thenDo(); return; } + var url = urls.shift(); + loadViaScript(url, urls, thenDo) +} + +function loadUncached_es5Compat(urls, thenDo) { + if (!urls.length) { thenDo && thenDo(); return; } + babel.load(urls[0], function() { loadUncached_es5Compat(urls.slice(1), thenDo); }); +} + +function loadViaScript(url, urls, thenDo) { + var script = document.createElement('script'); + script.src = url + (url.indexOf('?') > -1 ? '&' : '?' + Date.now()); + script.type = "application/javascript"; + document.head.appendChild(script); + script.addEventListener('load', function() { loadUncached(urls, thenDo); }); +} \ No newline at end of file diff --git a/tests/lively-compat-test.js b/tests/lively-compat-test.js new file mode 100644 index 0000000..7a32b8d --- /dev/null +++ b/tests/lively-compat-test.js @@ -0,0 +1,28 @@ +/*global process, beforeEach, afterEach, describe, it, expect*/ + +import { expect } from "mocha-es6"; +import * as vm from "lively.vm"; +import lang from "lively.lang"; + +var Global = typeof global !== "undefined" ? global : window; + +describe("lively compat", function() { + + it("addScriptWithVarMapping", function() { + Global.fun = (lang || lively.lang).fun; + var src = "var obj = {c: 3};\n" + + "lively.lang.fun.asScriptOf(function(a) { return a + b + this.c; }, obj, 'm', {b: 2});\n" + + "obj.m(1);\n"; + delete Global.fun; + + var result1 = vm.syncEval(src); + expect(result1).property("value").equals(6, 'simple eval not working'); + + var result2 = vm.syncEval(src, {topLevelVarRecorder: varMapper}); + var varMapper = {}; + expect(result2).property("value").equals(6, 'capturing eval not working'); + + expect(Object.keys(varMapper).length).equals(0, 'varMApper captured stuff'); + }); + +}); \ No newline at end of file diff --git a/tests/run-tests.html b/tests/run-tests.html new file mode 100644 index 0000000..b0ac802 --- /dev/null +++ b/tests/run-tests.html @@ -0,0 +1,27 @@ + + + + Tests + + + + +
+ + + + + + + + diff --git a/tests/test-resources/cjs/module1.js b/tests/test-resources/cjs/module1.js new file mode 100644 index 0000000..4f85163 --- /dev/null +++ b/tests/test-resources/cjs/module1.js @@ -0,0 +1,15 @@ +var fs = require("fs"); + +function someFunction() { + return 3 + 4; +} + +// console.log("running " + __filename); + +var internalState = 23; +var externalState = 42; + +global.someModuleGlobal = 99; + +exports.foo = someFunction; +exports.state = externalState; diff --git a/tests/test-resources/cjs/module2.js b/tests/test-resources/cjs/module2.js new file mode 100644 index 0000000..d749c2e --- /dev/null +++ b/tests/test-resources/cjs/module2.js @@ -0,0 +1,10 @@ +// var other = module.require("./some-cjs-module") +var other = require("./module1") + +var someVal = other.state + 1; + +// module.exports = { +// val: someVal +// } + +exports.val = someVal; diff --git a/tests/test-resources/cjs/module3.js b/tests/test-resources/cjs/module3.js new file mode 100644 index 0000000..e73535d --- /dev/null +++ b/tests/test-resources/cjs/module3.js @@ -0,0 +1,9 @@ +var other = require("./module2"); + +// console.log("running " + __filename); + +var myVal = other.val + 1; + +module.exports = { + myVal: myVal +} diff --git a/tests/test-resources/es6-project/file1.js b/tests/test-resources/es6-project/file1.js new file mode 100644 index 0000000..ab4c613 --- /dev/null +++ b/tests/test-resources/es6-project/file1.js @@ -0,0 +1,3 @@ +import { y } from './file2.js'; +var z = 2; +export var x = y + z; diff --git a/tests/test-resources/es6-project/file2.js b/tests/test-resources/es6-project/file2.js new file mode 100644 index 0000000..0c9359c --- /dev/null +++ b/tests/test-resources/es6-project/file2.js @@ -0,0 +1 @@ +import { z } from './file3.js'; export var y = z; diff --git a/tests/test-resources/es6-project/file3.js b/tests/test-resources/es6-project/file3.js new file mode 100644 index 0000000..fd191d7 --- /dev/null +++ b/tests/test-resources/es6-project/file3.js @@ -0,0 +1 @@ +export var z = 1; diff --git a/tests/test-resources/es6-project/file4.js b/tests/test-resources/es6-project/file4.js new file mode 100644 index 0000000..a191861 --- /dev/null +++ b/tests/test-resources/es6-project/file4.js @@ -0,0 +1 @@ +export async function foo(arg) { return new Promise((resolve, reject) => setTimeout(resolve, 200, arg)); } diff --git a/tests/test-resources/es6-with-cjs/module1.js b/tests/test-resources/es6-with-cjs/module1.js new file mode 100644 index 0000000..d06d5f6 --- /dev/null +++ b/tests/test-resources/es6-with-cjs/module1.js @@ -0,0 +1,7 @@ +var loaded = false; + +System.import("./tests/test-resources/es6/module1.js") + .then(() => loaded = true) + .catch(err => console.error(err.stack || err)) + +exports.x = 1; \ No newline at end of file diff --git a/tests/test-resources/es6-with-cjs/module2.js b/tests/test-resources/es6-with-cjs/module2.js new file mode 100644 index 0000000..854eb43 --- /dev/null +++ b/tests/test-resources/es6-with-cjs/module2.js @@ -0,0 +1,7 @@ +console.log("........ in es6-with-cjs/module2.js") + +import { val } from '../cjs/module2.js'; + +console.log("es6-with-cjs/module2.js y: ", val); + +export var y = val; \ No newline at end of file diff --git a/tests/test-resources/es6/module1.js b/tests/test-resources/es6/module1.js new file mode 100644 index 0000000..f4f3ac9 --- /dev/null +++ b/tests/test-resources/es6/module1.js @@ -0,0 +1,7 @@ +console.log("running es6 module1"); + +function someFunction() { return 3 + internalState; } + +var internalState = 1; + +export var x = internalState * 2 + 1; diff --git a/tests/test-resources/es6/module2.js b/tests/test-resources/es6/module2.js new file mode 100644 index 0000000..2ff894b --- /dev/null +++ b/tests/test-resources/es6/module2.js @@ -0,0 +1,7 @@ +var foo = 23; + +console.log("running es6 module2"); + +import { x } from "./module1.js"; + +export var y = x + 2; diff --git a/tests/test-resources/es6/module3.js b/tests/test-resources/es6/module3.js new file mode 100644 index 0000000..087f155 --- /dev/null +++ b/tests/test-resources/es6/module3.js @@ -0,0 +1,5 @@ +import * as another from "./module2.js" + +console.log("running es6 module3"); + +export var z = another.y * 3; diff --git a/tools/build.js b/tools/build.js new file mode 100644 index 0000000..a528958 --- /dev/null +++ b/tools/build.js @@ -0,0 +1,97 @@ +/*global require, process*/ + +var fs = require("fs"); +var path = require("path"); +var rollup = require('rollup'); +var babel = require('rollup-plugin-babel'); +var uglify = require("uglify-js"); + +var targetFile = "dist/lively.vm.js"; +var targetFile2 = "dist/lively.vm_standalone.js"; +var targetFileMin = "dist/lively.vm_standalone.min.js"; + +var placeholderSrc = "throw new Error('Not yet read')"; + +var parts = { + "lively.notifications": {source: placeholderSrc, path: require.resolve("lively.notifications/dist/lively.notifications.js")}, + "lively.ast": {source: placeholderSrc, path: require.resolve("lively.ast/dist/lively.ast.js")}, + "lively.classes": {source: placeholderSrc, path: require.resolve("lively.classes/dist/lively.classes.js")}, + "lively.lang": {source: placeholderSrc, path: require.resolve("lively.lang/dist/lively.lang.js")}, + "lively.source-transform": {source: placeholderSrc, path: require.resolve("lively.source-transform/dist/lively.source-transform.js")}, + "babel-regenerator": {source: placeholderSrc, path: require.resolve("babel-regenerator-runtime/runtime.js")} +} + +if (!fs.existsSync('./dist')) { + fs.mkdirSync('./dist'); +} + +Object.keys(parts).forEach(name => + parts[name].source = fs.readFileSync(parts[name].path)); + +// output format - 'amd', 'cjs', 'es6', 'iife', 'umd' +module.exports = Promise.resolve() + .then(() => rollup.rollup({ + entry: "index.js", + plugins: [ + babel({ + exclude: 'node_modules/**', sourceMap: false, + "presets": [["es2015", {modules: false}]], + "plugins": [ + 'transform-async-to-generator', + "syntax-object-rest-spread", + "transform-object-rest-spread", + "external-helpers" + ], + babelrc: false + })] + })) + .then(bundle => + bundle.generate({ + format: 'iife', + moduleName: 'lively.vm', + globals: { + "lively.lang": "lively.lang", + "lively.ast": "lively.ast", + "lively.notifications": "lively.notifications", + "lively.classes": "lively.classes", + "lively.source-transform": "lively.sourceTransform", + "module": "typeof module !== 'undefined' ? module.constructor : {}", + "fs": "typeof module !== 'undefined' && typeof module.require === 'function' ? module.require('fs') : {readFile: () => { throw new Error('fs module not available'); }}" + }, + })) + + // 3. massage code a little + .then(bundled => { + +var noDeps = `(function() { + var GLOBAL = typeof window !== "undefined" ? window : + typeof global!=="undefined" ? global : + typeof self!=="undefined" ? self : this; + ${bundled.code} + if (typeof module !== "undefined" && module.exports) module.exports = GLOBAL.lively.vm; +})();`; + +var complete = [ + "babel-regenerator", + "lively.lang", + "lively.notifications", + "lively.ast", + "lively.classes", + "lively.source-transform", +].map(key => { + return ` +// INLINED ${parts[key].path} +${parts[key].source} +// INLINED END ${parts[key].path}`; }).join("\n") + "\n" + noDeps; + +return {noDeps: noDeps, complete: complete}; + + }) + + // 4. inject dependencies + .then(sources => { + fs.writeFileSync(targetFile, sources.noDeps); + fs.writeFileSync(targetFile2, sources.complete); + // fs.writeFileSync(targetFileMin, uglify.minify(sources.complete, {fromString: true}).code); + }) + .catch(err => { console.error(err.stack || err); throw err; })