Merge pull request #9686 from humbletim/21114-part3

CR 21114 -- Implement modules/require support into hifi
This commit is contained in:
Chris Collins 2017-03-20 06:22:35 -07:00 committed by GitHub
commit 308c134119
29 changed files with 1538 additions and 138 deletions

View file

@ -146,6 +146,7 @@ void EntityTreeRenderer::clear() {
void EntityTreeRenderer::reloadEntityScripts() {
_entitiesScriptEngine->unloadAllEntityScripts();
_entitiesScriptEngine->resetModuleCache();
foreach(auto entity, _entitiesInScene) {
if (!entity->getScript().isEmpty()) {
_entitiesScriptEngine->loadEntityScript(entity->getEntityItemID(), entity->getScript(), true);

View file

@ -15,11 +15,13 @@
#define hifi_EntitiesScriptEngineProvider_h
#include <QtCore/QString>
#include <QFuture>
#include "EntityItemID.h"
class EntitiesScriptEngineProvider {
public:
virtual void callEntityScriptMethod(const EntityItemID& entityID, const QString& methodName, const QStringList& params = QStringList()) = 0;
virtual QFuture<QVariant> getLocalEntityScriptDetails(const EntityItemID& entityID) = 0;
};
#endif // hifi_EntitiesScriptEngineProvider_h
#endif // hifi_EntitiesScriptEngineProvider_h

View file

@ -10,6 +10,9 @@
//
#include "EntityScriptingInterface.h"
#include <QFutureWatcher>
#include <QtConcurrent/QtConcurrentRun>
#include "EntityItemID.h"
#include <VariantMapToScriptValue.h>
#include <SharedUtil.h>
@ -680,6 +683,118 @@ bool EntityScriptingInterface::reloadServerScripts(QUuid entityID) {
return client->reloadServerScript(entityID);
}
bool EntityPropertyMetadataRequest::script(EntityItemID entityID, QScriptValue handler) {
using LocalScriptStatusRequest = QFutureWatcher<QVariant>;
LocalScriptStatusRequest* request = new LocalScriptStatusRequest;
QObject::connect(request, &LocalScriptStatusRequest::finished, _engine, [=]() mutable {
auto details = request->result().toMap();
QScriptValue err, result;
if (details.contains("isError")) {
if (!details.contains("message")) {
details["message"] = details["errorInfo"];
}
err = _engine->makeError(_engine->toScriptValue(details));
} else {
details["success"] = true;
result = _engine->toScriptValue(details);
}
callScopedHandlerObject(handler, err, result);
request->deleteLater();
});
auto entityScriptingInterface = DependencyManager::get<EntityScriptingInterface>();
entityScriptingInterface->withEntitiesScriptEngine([&](EntitiesScriptEngineProvider* entitiesScriptEngine) {
if (entitiesScriptEngine) {
request->setFuture(entitiesScriptEngine->getLocalEntityScriptDetails(entityID));
}
});
if (!request->isStarted()) {
request->deleteLater();
callScopedHandlerObject(handler, _engine->makeError("Entities Scripting Provider unavailable", "InternalError"), QScriptValue());
return false;
}
return true;
}
bool EntityPropertyMetadataRequest::serverScripts(EntityItemID entityID, QScriptValue handler) {
auto client = DependencyManager::get<EntityScriptClient>();
auto request = client->createScriptStatusRequest(entityID);
QPointer<BaseScriptEngine> engine = _engine;
QObject::connect(request, &GetScriptStatusRequest::finished, _engine, [=](GetScriptStatusRequest* request) mutable {
auto engine = _engine;
if (!engine) {
qCDebug(entities) << __FUNCTION__ << " -- engine destroyed while inflight" << entityID;
return;
}
QVariantMap details;
details["success"] = request->getResponseReceived();
details["isRunning"] = request->getIsRunning();
details["status"] = EntityScriptStatus_::valueToKey(request->getStatus()).toLower();
details["errorInfo"] = request->getErrorInfo();
QScriptValue err, result;
if (!details["success"].toBool()) {
if (!details.contains("message") && details.contains("errorInfo")) {
details["message"] = details["errorInfo"];
}
if (details["message"].toString().isEmpty()) {
details["message"] = "entity server script details not found";
}
err = engine->makeError(engine->toScriptValue(details));
} else {
result = engine->toScriptValue(details);
}
callScopedHandlerObject(handler, err, result);
request->deleteLater();
});
request->start();
return true;
}
bool EntityScriptingInterface::queryPropertyMetadata(QUuid entityID, QScriptValue property, QScriptValue scopeOrCallback, QScriptValue methodOrName) {
auto name = property.toString();
auto handler = makeScopedHandlerObject(scopeOrCallback, methodOrName);
QPointer<BaseScriptEngine> engine = dynamic_cast<BaseScriptEngine*>(handler.engine());
if (!engine) {
qCDebug(entities) << "queryPropertyMetadata without detectable engine" << entityID << name;
return false;
}
#ifdef DEBUG_ENGINE_STATE
connect(engine, &QObject::destroyed, this, [=]() {
qDebug() << "queryPropertyMetadata -- engine destroyed!" << (!engine ? "nullptr" : "engine");
});
#endif
if (!handler.property("callback").isFunction()) {
qDebug() << "!handler.callback.isFunction" << engine;
engine->raiseException(engine->makeError("callback is not a function", "TypeError"));
return false;
}
// NOTE: this approach is a work-in-progress and for now just meant to work 100% correctly and provide
// some initial structure for organizing metadata adapters around.
// The extra layer of indirection is *essential* because in real world conditions errors are often introduced
// by accident and sometimes without exact memory of "what just changed."
// Here the scripter only needs to know an entityID and a property name -- which means all scripters can
// level this method when stuck in dead-end scenarios or to learn more about "magic" Entity properties
// like .script that work in terms of side-effects.
// This is an async callback pattern -- so if needed C++ can easily throttle or restrict queries later.
EntityPropertyMetadataRequest request(engine);
if (name == "script") {
return request.script(entityID, handler);
} else if (name == "serverScripts") {
return request.serverScripts(entityID, handler);
} else {
engine->raiseException(engine->makeError("metadata for property " + name + " is not yet queryable"));
engine->maybeEmitUncaughtException(__FUNCTION__);
return false;
}
}
bool EntityScriptingInterface::getServerScriptStatus(QUuid entityID, QScriptValue callback) {
auto client = DependencyManager::get<EntityScriptClient>();
auto request = client->createScriptStatusRequest(entityID);

View file

@ -34,9 +34,24 @@
#include "EntitiesScriptEngineProvider.h"
#include "EntityItemProperties.h"
#include "BaseScriptEngine.h"
class EntityTree;
class MeshProxy;
// helper factory to compose standardized, async metadata queries for "magic" Entity properties
// like .script and .serverScripts. This is used for automated testing of core scripting features
// as well as to provide early adopters a self-discoverable, consistent way to diagnose common
// problems with their own Entity scripts.
class EntityPropertyMetadataRequest {
public:
EntityPropertyMetadataRequest(BaseScriptEngine* engine) : _engine(engine) {};
bool script(EntityItemID entityID, QScriptValue handler);
bool serverScripts(EntityItemID entityID, QScriptValue handler);
private:
QPointer<BaseScriptEngine> _engine;
};
class RayToEntityIntersectionResult {
public:
RayToEntityIntersectionResult();
@ -68,6 +83,7 @@ class EntityScriptingInterface : public OctreeScriptingInterface, public Depende
Q_PROPERTY(float costMultiplier READ getCostMultiplier WRITE setCostMultiplier)
Q_PROPERTY(QUuid keyboardFocusEntity READ getKeyboardFocusEntity WRITE setKeyboardFocusEntity)
friend EntityPropertyMetadataRequest;
public:
EntityScriptingInterface(bool bidOnSimulationOwnership);
@ -212,6 +228,26 @@ public slots:
Q_INVOKABLE RayToEntityIntersectionResult findRayIntersectionBlocking(const PickRay& ray, bool precisionPicking = false, const QScriptValue& entityIdsToInclude = QScriptValue(), const QScriptValue& entityIdsToDiscard = QScriptValue());
Q_INVOKABLE bool reloadServerScripts(QUuid entityID);
/**jsdoc
* Query additional metadata for "magic" Entity properties like `script` and `serverScripts`.
*
* @function Entities.queryPropertyMetadata
* @param {EntityID} entityID The ID of the entity.
* @param {string} property The name of the property extended metadata is wanted for.
* @param {ResultCallback} callback Executes callback(err, result) with the query results.
*/
/**jsdoc
* Query additional metadata for "magic" Entity properties like `script` and `serverScripts`.
*
* @function Entities.queryPropertyMetadata
* @param {EntityID} entityID The ID of the entity.
* @param {string} property The name of the property extended metadata is wanted for.
* @param {Object} thisObject The scoping "this" context that callback will be executed within.
* @param {ResultCallback} callbackOrMethodName Executes thisObject[callbackOrMethodName](err, result) with the query results.
*/
Q_INVOKABLE bool queryPropertyMetadata(QUuid entityID, QScriptValue property, QScriptValue scopeOrCallback, QScriptValue methodOrName = QScriptValue());
Q_INVOKABLE bool getServerScriptStatus(QUuid entityID, QScriptValue callback);
Q_INVOKABLE void setLightsArePickable(bool value);
@ -325,6 +361,11 @@ signals:
void webEventReceived(const EntityItemID& entityItemID, const QVariant& message);
protected:
void withEntitiesScriptEngine(std::function<void(EntitiesScriptEngineProvider*)> function) {
std::lock_guard<std::recursive_mutex> lock(_entitiesScriptEngineLock);
function(_entitiesScriptEngine);
};
private:
bool actionWorker(const QUuid& entityID, std::function<bool(EntitySimulationPointer, EntityItemPointer)> actor);
bool polyVoxWorker(QUuid entityID, std::function<bool(PolyVoxEntityItem&)> actor);

View file

@ -1,67 +0,0 @@
//
// BaseScriptEngine.h
// libraries/script-engine/src
//
// Created by Timothy Dedischew on 02/01/17.
// Copyright 2017 High Fidelity, Inc.
//
// Distributed under the Apache License, Version 2.0.
// See the accompanying file LICENSE or http://www.apache.org/licenses/LICENSE-2.0.html
//
#ifndef hifi_BaseScriptEngine_h
#define hifi_BaseScriptEngine_h
#include <functional>
#include <QtCore/QDebug>
#include <QtScript/QScriptEngine>
#include "SettingHandle.h"
// common base class for extending QScriptEngine itself
class BaseScriptEngine : public QScriptEngine {
Q_OBJECT
public:
static const QString SCRIPT_EXCEPTION_FORMAT;
static const QString SCRIPT_BACKTRACE_SEP;
BaseScriptEngine() {}
Q_INVOKABLE QScriptValue evaluateInClosure(const QScriptValue& locals, const QScriptProgram& program);
Q_INVOKABLE QScriptValue lintScript(const QString& sourceCode, const QString& fileName, const int lineNumber = 1);
Q_INVOKABLE QScriptValue makeError(const QScriptValue& other = QScriptValue(), const QString& type = "Error");
Q_INVOKABLE QString formatException(const QScriptValue& exception);
QScriptValue cloneUncaughtException(const QString& detail = QString());
signals:
void unhandledException(const QScriptValue& exception);
protected:
void _emitUnhandledException(const QScriptValue& exception);
QScriptValue newLambdaFunction(std::function<QScriptValue(QScriptContext *context, QScriptEngine* engine)> operation, const QScriptValue& data = QScriptValue(), const QScriptEngine::ValueOwnership& ownership = QScriptEngine::AutoOwnership);
static const QString _SETTINGS_ENABLE_EXTENDED_EXCEPTIONS;
Setting::Handle<bool> _enableExtendedJSExceptions { _SETTINGS_ENABLE_EXTENDED_EXCEPTIONS, true };
#ifdef DEBUG_JS
static void _debugDump(const QString& header, const QScriptValue& object, const QString& footer = QString());
#endif
};
// Lambda helps create callable QScriptValues out of std::functions:
// (just meant for use from within the script engine itself)
class Lambda : public QObject {
Q_OBJECT
public:
Lambda(QScriptEngine *engine, std::function<QScriptValue(QScriptContext *context, QScriptEngine* engine)> operation, QScriptValue data);
~Lambda();
public slots:
QScriptValue call();
QString toString() const;
private:
QScriptEngine* engine;
std::function<QScriptValue(QScriptContext *context, QScriptEngine* engine)> operation;
QScriptValue data;
};
#endif // hifi_BaseScriptEngine_h

View file

@ -19,6 +19,9 @@
#include <QtCore/QThread>
#include <QtCore/QRegularExpression>
#include <QtCore/QFuture>
#include <QtConcurrent/QtConcurrentRun>
#include <QtWidgets/QMainWindow>
#include <QtWidgets/QApplication>
@ -72,13 +75,18 @@
#include "MIDIEvent.h"
const QString ScriptEngine::_SETTINGS_ENABLE_EXTENDED_EXCEPTIONS {
"com.highfidelity.experimental.enableExtendedJSExceptions"
};
static const int MAX_MODULE_ID_LENGTH { 4096 };
static const int MAX_DEBUG_VALUE_LENGTH { 80 };
static const QScriptEngine::QObjectWrapOptions DEFAULT_QOBJECT_WRAP_OPTIONS =
QScriptEngine::ExcludeDeleteLater | QScriptEngine::ExcludeChildObjects;
static const QScriptValue::PropertyFlags READONLY_PROP_FLAGS { QScriptValue::ReadOnly | QScriptValue::Undeletable };
static const QScriptValue::PropertyFlags READONLY_HIDDEN_PROP_FLAGS { READONLY_PROP_FLAGS | QScriptValue::SkipInEnumeration };
static const bool HIFI_AUTOREFRESH_FILE_SCRIPTS { true };
Q_DECLARE_METATYPE(QScriptEngine::FunctionSignature)
@ -86,7 +94,7 @@ int functionSignatureMetaID = qRegisterMetaType<QScriptEngine::FunctionSignature
Q_LOGGING_CATEGORY(scriptengineScript, "hifi.scriptengine.script")
static QScriptValue debugPrint(QScriptContext* context, QScriptEngine* engine){
static QScriptValue debugPrint(QScriptContext* context, QScriptEngine* engine) {
QString message = "";
for (int i = 0; i < context->argumentCount(); i++) {
if (i > 0) {
@ -143,7 +151,7 @@ QString encodeEntityIdIntoEntityUrl(const QString& url, const QString& entityID)
}
QString ScriptEngine::logException(const QScriptValue& exception) {
auto message = formatException(exception);
auto message = formatException(exception, _enableExtendedJSExceptions.get());
scriptErrorMessage(message);
return message;
}
@ -335,7 +343,7 @@ void ScriptEngine::runInThread() {
// The thread interface cannot live on itself, and we want to move this into the thread, so
// the thread cannot have this as a parent.
QThread* workerThread = new QThread();
workerThread->setObjectName(QString("Script Thread:") + getFilename());
workerThread->setObjectName(QString("js:") + getFilename().replace("about:",""));
moveToThread(workerThread);
// NOTE: If you connect any essential signals for proper shutdown or cleanup of
@ -534,6 +542,40 @@ static QScriptValue createScriptableResourcePrototype(QScriptEngine* engine) {
return prototype;
}
void ScriptEngine::resetModuleCache(bool deleteScriptCache) {
if (QThread::currentThread() != thread()) {
executeOnScriptThread([=]() { resetModuleCache(deleteScriptCache); });
return;
}
auto jsRequire = globalObject().property("Script").property("require");
auto cache = jsRequire.property("cache");
auto cacheMeta = jsRequire.data();
if (deleteScriptCache) {
QScriptValueIterator it(cache);
while (it.hasNext()) {
it.next();
if (it.flags() & QScriptValue::SkipInEnumeration) {
continue;
}
qCDebug(scriptengine) << "resetModuleCache(true) -- staging " << it.name() << " for cache reset at next require";
cacheMeta.setProperty(it.name(), true);
}
}
cache = newObject();
if (!cacheMeta.isObject()) {
cacheMeta = newObject();
cacheMeta.setProperty("id", "Script.require.cacheMeta");
cacheMeta.setProperty("type", "cacheMeta");
jsRequire.setData(cacheMeta);
}
cache.setProperty("__created__", (double)QDateTime::currentMSecsSinceEpoch(), QScriptValue::SkipInEnumeration);
#if DEBUG_JS_MODULES
cache.setProperty("__meta__", cacheMeta, READONLY_HIDDEN_PROP_FLAGS);
#endif
jsRequire.setProperty("cache", cache, READONLY_PROP_FLAGS);
}
void ScriptEngine::init() {
if (_isInitialized) {
return; // only initialize once
@ -587,6 +629,15 @@ void ScriptEngine::init() {
registerGlobalObject("Script", this);
{
// set up Script.require.resolve and Script.require.cache
auto Script = globalObject().property("Script");
auto require = Script.property("require");
auto resolve = Script.property("_requireResolve");
require.setProperty("resolve", resolve, READONLY_PROP_FLAGS);
resetModuleCache();
}
registerGlobalObject("Audio", &AudioScriptingInterface::getInstance());
registerGlobalObject("Entities", entityScriptingInterface.data());
registerGlobalObject("Quat", &_quatLibrary);
@ -859,6 +910,11 @@ void ScriptEngine::addEventHandler(const EntityItemID& entityID, const QString&
handlersForEvent << handlerData; // Note that the same handler can be added many times. See removeEntityEventHandler().
}
// this is not redundant -- the version in BaseScriptEngine is specifically not Q_INVOKABLE
QScriptValue ScriptEngine::evaluateInClosure(const QScriptValue& closure, const QScriptProgram& program) {
return BaseScriptEngine::evaluateInClosure(closure, program);
}
QScriptValue ScriptEngine::evaluate(const QString& sourceCode, const QString& fileName, int lineNumber) {
if (DependencyManager::get<ScriptEngines>()->isStopped()) {
return QScriptValue(); // bail early
@ -881,29 +937,26 @@ QScriptValue ScriptEngine::evaluate(const QString& sourceCode, const QString& fi
// Check syntax
auto syntaxError = lintScript(sourceCode, fileName);
if (syntaxError.isError()) {
if (isEvaluating()) {
currentContext()->throwValue(syntaxError);
} else {
if (!isEvaluating()) {
syntaxError.setProperty("detail", "evaluate");
emit unhandledException(syntaxError);
}
raiseException(syntaxError);
maybeEmitUncaughtException("lint");
return syntaxError;
}
QScriptProgram program { sourceCode, fileName, lineNumber };
if (program.isNull()) {
// can this happen?
auto err = makeError("could not create QScriptProgram for " + fileName);
emit unhandledException(err);
raiseException(err);
maybeEmitUncaughtException("compile");
return err;
}
QScriptValue result;
{
result = BaseScriptEngine::evaluate(program);
if (!isEvaluating() && hasUncaughtException()) {
emit unhandledException(cloneUncaughtException(__FUNCTION__));
clearExceptions();
}
maybeEmitUncaughtException("evaluate");
}
return result;
}
@ -926,10 +979,7 @@ void ScriptEngine::run() {
{
evaluate(_scriptContents, _fileNameString);
if (!isEvaluating() && hasUncaughtException()) {
emit unhandledException(cloneUncaughtException(__FUNCTION__));
clearExceptions();
}
maybeEmitUncaughtException(__FUNCTION__);
}
#ifdef _WIN32
// VS13 does not sleep_until unless it uses the system_clock, see:
@ -1300,11 +1350,361 @@ void ScriptEngine::print(const QString& message) {
emit printedMessage(message);
}
// Script.require.resolve -- like resolvePath, but performs more validation and throws exceptions on invalid module identifiers (for consistency with Node.js)
QString ScriptEngine::_requireResolve(const QString& moduleId, const QString& relativeTo) {
if (!IS_THREADSAFE_INVOCATION(thread(), __FUNCTION__)) {
return QString();
}
QUrl defaultScriptsLoc = defaultScriptsLocation();
QUrl url(moduleId);
auto displayId = moduleId;
if (displayId.length() > MAX_DEBUG_VALUE_LENGTH) {
displayId = displayId.mid(0, MAX_DEBUG_VALUE_LENGTH) + "...";
}
auto message = QString("Cannot find module '%1' (%2)").arg(displayId);
auto throwResolveError = [&](const QScriptValue& error) -> QString {
raiseException(error);
maybeEmitUncaughtException("require.resolve");
return QString();
};
// de-fuzz the input a little by restricting to rational sizes
auto idLength = url.toString().length();
if (idLength < 1 || idLength > MAX_MODULE_ID_LENGTH) {
auto details = QString("rejecting invalid module id size (%1 chars [1,%2])")
.arg(idLength).arg(MAX_MODULE_ID_LENGTH);
return throwResolveError(makeError(message.arg(details), "RangeError"));
}
// this regex matches: absolute, dotted or path-like URLs
// (ie: the kind of stuff ScriptEngine::resolvePath already handles)
QRegularExpression qualified ("^\\w+:|^/|^[.]{1,2}(/|$)");
// this is for module.require (which is a bound version of require that's always relative to the module path)
if (!relativeTo.isEmpty()) {
url = QUrl(relativeTo).resolved(moduleId);
url = resolvePath(url.toString());
} else if (qualified.match(moduleId).hasMatch()) {
url = resolvePath(moduleId);
} else {
// check if the moduleId refers to a "system" module
QString systemPath = defaultScriptsLoc.path();
QString systemModulePath = QString("%1/modules/%2.js").arg(systemPath).arg(moduleId);
url = defaultScriptsLoc;
url.setPath(systemModulePath);
if (!QFileInfo(url.toLocalFile()).isFile()) {
if (!moduleId.contains("./")) {
// the user might be trying to refer to a relative file without anchoring it
// let's do them a favor and test for that case -- offering specific advice if detected
auto unanchoredUrl = resolvePath("./" + moduleId);
if (QFileInfo(unanchoredUrl.toLocalFile()).isFile()) {
auto msg = QString("relative module ids must be anchored; use './%1' instead")
.arg(moduleId);
return throwResolveError(makeError(message.arg(msg)));
}
}
return throwResolveError(makeError(message.arg("system module not found")));
}
}
if (url.isRelative()) {
return throwResolveError(makeError(message.arg("could not resolve module id")));
}
// if it looks like a local file, verify that it's an allowed path and really a file
if (url.isLocalFile()) {
QFileInfo file(url.toLocalFile());
QUrl canonical = url;
if (file.exists()) {
canonical.setPath(file.canonicalFilePath());
}
bool disallowOutsideFiles = !defaultScriptsLocation().isParentOf(canonical) && !currentSandboxURL.isLocalFile();
if (disallowOutsideFiles && !PathUtils::isDescendantOf(canonical, currentSandboxURL)) {
return throwResolveError(makeError(message.arg(
QString("path '%1' outside of origin script '%2' '%3'")
.arg(PathUtils::stripFilename(url))
.arg(PathUtils::stripFilename(currentSandboxURL))
.arg(canonical.toString())
)));
}
if (!file.exists()) {
return throwResolveError(makeError(message.arg("path does not exist: " + url.toLocalFile())));
}
if (!file.isFile()) {
return throwResolveError(makeError(message.arg("path is not a file: " + url.toLocalFile())));
}
}
maybeEmitUncaughtException(__FUNCTION__);
return url.toString();
}
// retrieves the current parent module from the JS scope chain
QScriptValue ScriptEngine::currentModule() {
if (!IS_THREADSAFE_INVOCATION(thread(), __FUNCTION__)) {
return unboundNullValue();
}
auto jsRequire = globalObject().property("Script").property("require");
auto cache = jsRequire.property("cache");
auto candidate = QScriptValue();
for (auto c = currentContext(); c && !candidate.isObject(); c = c->parentContext()) {
QScriptContextInfo contextInfo { c };
candidate = cache.property(contextInfo.fileName());
}
if (!candidate.isObject()) {
return QScriptValue();
}
return candidate;
}
// replaces or adds "module" to "parent.children[]" array
// (for consistency with Node.js and userscript cache invalidation without "cache busters")
bool ScriptEngine::registerModuleWithParent(const QScriptValue& module, const QScriptValue& parent) {
auto children = parent.property("children");
if (children.isArray()) {
auto key = module.property("id");
auto length = children.property("length").toInt32();
for (int i = 0; i < length; i++) {
if (children.property(i).property("id").strictlyEquals(key)) {
qCDebug(scriptengine_module) << key.toString() << " updating parent.children[" << i << "] = module";
children.setProperty(i, module);
return true;
}
}
qCDebug(scriptengine_module) << key.toString() << " appending parent.children[" << length << "] = module";
children.setProperty(length, module);
return true;
} else if (parent.isValid()) {
qCDebug(scriptengine_module) << "registerModuleWithParent -- unrecognized parent" << parent.toVariant().toString();
}
return false;
}
// creates a new JS "module" Object with default metadata properties
QScriptValue ScriptEngine::newModule(const QString& modulePath, const QScriptValue& parent) {
auto closure = newObject();
auto exports = newObject();
auto module = newObject();
qCDebug(scriptengine_module) << "newModule" << modulePath << parent.property("filename").toString();
closure.setProperty("module", module, READONLY_PROP_FLAGS);
// note: this becomes the "exports" free variable, so should not be set read only
closure.setProperty("exports", exports);
// make the closure available to module instantiation
module.setProperty("__closure__", closure, READONLY_HIDDEN_PROP_FLAGS);
// for consistency with Node.js Module
module.setProperty("id", modulePath, READONLY_PROP_FLAGS);
module.setProperty("filename", modulePath, READONLY_PROP_FLAGS);
module.setProperty("exports", exports); // not readonly
module.setProperty("loaded", false, READONLY_PROP_FLAGS);
module.setProperty("parent", parent, READONLY_PROP_FLAGS);
module.setProperty("children", newArray(), READONLY_PROP_FLAGS);
// module.require is a bound version of require that always resolves relative to that module's path
auto boundRequire = QScriptEngine::evaluate("(function(id) { return Script.require(Script.require.resolve(id, this.filename)); })", "(boundRequire)");
module.setProperty("require", boundRequire, READONLY_PROP_FLAGS);
return module;
}
// synchronously fetch a module's source code using BatchLoader
QVariantMap ScriptEngine::fetchModuleSource(const QString& modulePath, const bool forceDownload) {
using UrlMap = QMap<QUrl, QString>;
auto scriptCache = DependencyManager::get<ScriptCache>();
QVariantMap req;
qCDebug(scriptengine_module) << "require.fetchModuleSource: " << QUrl(modulePath).fileName() << QThread::currentThread();
auto onload = [=, &req](const UrlMap& data, const UrlMap& _status) {
auto url = modulePath;
auto status = _status[url];
auto contents = data[url];
qCDebug(scriptengine_module) << "require.fetchModuleSource.onload: " << QUrl(url).fileName() << status << QThread::currentThread();
if (isStopping()) {
req["status"] = "Stopped";
req["success"] = false;
} else {
req["url"] = url;
req["status"] = status;
req["success"] = ScriptCache::isSuccessStatus(status);
req["contents"] = contents;
}
};
if (forceDownload) {
qCDebug(scriptengine_module) << "require.requestScript -- clearing cache for" << modulePath;
scriptCache->deleteScript(modulePath);
}
BatchLoader* loader = new BatchLoader(QList<QUrl>({ modulePath }));
connect(loader, &BatchLoader::finished, this, onload);
connect(this, &QObject::destroyed, loader, &QObject::deleteLater);
// fail faster? (since require() blocks the engine thread while resolving dependencies)
const int MAX_RETRIES = 1;
loader->start(MAX_RETRIES);
if (!loader->isFinished()) {
QTimer monitor;
QEventLoop loop;
QObject::connect(loader, &BatchLoader::finished, this, [this, &monitor, &loop]{
monitor.stop();
loop.quit();
});
// this helps detect the case where stop() is invoked during the download
// but not seen in time to abort processing in onload()...
connect(&monitor, &QTimer::timeout, this, [this, &loop, &loader]{
if (isStopping()) {
loop.exit(-1);
}
});
monitor.start(500);
loop.exec();
}
loader->deleteLater();
return req;
}
// evaluate a pending module object using the fetched source code
QScriptValue ScriptEngine::instantiateModule(const QScriptValue& module, const QString& sourceCode) {
QScriptValue result;
auto modulePath = module.property("filename").toString();
auto closure = module.property("__closure__");
qCDebug(scriptengine_module) << QString("require.instantiateModule: %1 / %2 bytes")
.arg(QUrl(modulePath).fileName()).arg(sourceCode.length());
if (module.property("content-type").toString() == "application/json") {
qCDebug(scriptengine_module) << "... parsing as JSON";
closure.setProperty("__json", sourceCode);
result = evaluateInClosure(closure, { "module.exports = JSON.parse(__json)", modulePath });
} else {
// scoped vars for consistency with Node.js
closure.setProperty("require", module.property("require"));
closure.setProperty("__filename", modulePath, READONLY_HIDDEN_PROP_FLAGS);
closure.setProperty("__dirname", QString(modulePath).replace(QRegExp("/[^/]*$"), ""), READONLY_HIDDEN_PROP_FLAGS);
result = evaluateInClosure(closure, { sourceCode, modulePath });
}
maybeEmitUncaughtException(__FUNCTION__);
return result;
}
// CommonJS/Node.js like require/module support
QScriptValue ScriptEngine::require(const QString& moduleId) {
qCDebug(scriptengine_module) << "ScriptEngine::require(" << moduleId.left(MAX_DEBUG_VALUE_LENGTH) << ")";
if (!IS_THREADSAFE_INVOCATION(thread(), __FUNCTION__)) {
return unboundNullValue();
}
auto jsRequire = globalObject().property("Script").property("require");
auto cacheMeta = jsRequire.data();
auto cache = jsRequire.property("cache");
auto parent = currentModule();
auto throwModuleError = [&](const QString& modulePath, const QScriptValue& error) {
cache.setProperty(modulePath, nullValue());
if (!error.isNull()) {
#ifdef DEBUG_JS_MODULES
qCWarning(scriptengine_module) << "throwing module error:" << error.toString() << modulePath << error.property("stack").toString();
#endif
raiseException(error);
}
maybeEmitUncaughtException("module");
return unboundNullValue();
};
// start by resolving the moduleId into a fully-qualified path/URL
QString modulePath = _requireResolve(moduleId);
if (modulePath.isNull() || hasUncaughtException()) {
// the resolver already threw an exception -- bail early
maybeEmitUncaughtException(__FUNCTION__);
return unboundNullValue();
}
// check the resolved path against the cache
auto module = cache.property(modulePath);
// modules get cached in `Script.require.cache` and (similar to Node.js) users can access it
// to inspect particular entries and invalidate them by deleting the key:
// `delete Script.require.cache[Script.require.resolve(moduleId)];`
// cacheMeta is just used right now to tell deleted keys apart from undefined ones
bool invalidateCache = module.isUndefined() && cacheMeta.property(moduleId).isValid();
// reset the cacheMeta record so invalidation won't apply next time, even if the module fails to load
cacheMeta.setProperty(modulePath, QScriptValue());
auto exports = module.property("exports");
if (!invalidateCache && exports.isObject()) {
// we have found a cached module -- just need to possibly register it with current parent
qCDebug(scriptengine_module) << QString("require - using cached module '%1' for '%2' (loaded: %3)")
.arg(modulePath).arg(moduleId).arg(module.property("loaded").toString());
registerModuleWithParent(module, parent);
maybeEmitUncaughtException("cached module");
return exports;
}
// bootstrap / register new empty module
module = newModule(modulePath, parent);
registerModuleWithParent(module, parent);
// add it to the cache (this is done early so any cyclic dependencies pick up)
cache.setProperty(modulePath, module);
// download the module source
auto req = fetchModuleSource(modulePath, invalidateCache);
if (!req.contains("success") || !req["success"].toBool()) {
auto error = QString("error retrieving script (%1)").arg(req["status"].toString());
return throwModuleError(modulePath, error);
}
#if DEBUG_JS_MODULES
qCDebug(scriptengine_module) << "require.loaded: " <<
QUrl(req["url"].toString()).fileName() << req["status"].toString();
#endif
auto sourceCode = req["contents"].toString();
if (QUrl(modulePath).fileName().endsWith(".json", Qt::CaseInsensitive)) {
module.setProperty("content-type", "application/json");
} else {
module.setProperty("content-type", "application/javascript");
}
// evaluate the module
auto result = instantiateModule(module, sourceCode);
if (result.isError() && !result.strictlyEquals(module.property("exports"))) {
qCWarning(scriptengine_module) << "-- result.isError --" << result.toString();
return throwModuleError(modulePath, result);
}
// mark as fully-loaded
module.setProperty("loaded", true, READONLY_PROP_FLAGS);
// set up a new reference point for detecting cache key deletion
cacheMeta.setProperty(modulePath, module);
qCDebug(scriptengine_module) << "//ScriptEngine::require(" << moduleId << ")";
maybeEmitUncaughtException(__FUNCTION__);
return module.property("exports");
}
// If a callback is specified, the included files will be loaded asynchronously and the callback will be called
// when all of the files have finished loading.
// If no callback is specified, the included files will be loaded synchronously and will block execution until
// all of the files have finished loading.
void ScriptEngine::include(const QStringList& includeFiles, QScriptValue callback) {
if (!IS_THREADSAFE_INVOCATION(thread(), __FUNCTION__)) {
return;
}
if (DependencyManager::get<ScriptEngines>()->isStopped()) {
scriptWarningMessage("Script.include() while shutting down is ignored... includeFiles:"
+ includeFiles.join(",") + "parent script:" + getFilename());
@ -1367,7 +1767,7 @@ void ScriptEngine::include(const QStringList& includeFiles, QScriptValue callbac
doWithEnvironment(capturedEntityIdentifier, capturedSandboxURL, operation);
if (hasUncaughtException()) {
emit unhandledException(cloneUncaughtException(__FUNCTION__));
emit unhandledException(cloneUncaughtException("evaluateInclude"));
clearExceptions();
}
} else {
@ -1414,6 +1814,9 @@ void ScriptEngine::include(const QString& includeFile, QScriptValue callback) {
// as a stand-alone script. To accomplish this, the ScriptEngine class just emits a signal which
// the Application or other context will connect to in order to know to actually load the script
void ScriptEngine::load(const QString& loadFile) {
if (!IS_THREADSAFE_INVOCATION(thread(), __FUNCTION__)) {
return;
}
if (DependencyManager::get<ScriptEngines>()->isStopped()) {
scriptWarningMessage("Script.load() while shutting down is ignored... loadFile:"
+ loadFile + "parent script:" + getFilename());
@ -1483,6 +1886,52 @@ void ScriptEngine::updateEntityScriptStatus(const EntityItemID& entityID, const
emit entityScriptDetailsUpdated();
}
QVariant ScriptEngine::cloneEntityScriptDetails(const EntityItemID& entityID) {
static const QVariant NULL_VARIANT { qVariantFromValue((QObject*)nullptr) };
QVariantMap map;
if (entityID.isNull()) {
// TODO: find better way to report JS Error across thread/process boundaries
map["isError"] = true;
map["errorInfo"] = "Error: getEntityScriptDetails -- invalid entityID";
} else {
#ifdef DEBUG_ENTITY_STATES
qDebug() << "cloneEntityScriptDetails" << entityID << QThread::currentThread();
#endif
EntityScriptDetails scriptDetails;
if (getEntityScriptDetails(entityID, scriptDetails)) {
#ifdef DEBUG_ENTITY_STATES
qDebug() << "gotEntityScriptDetails" << scriptDetails.status << QThread::currentThread();
#endif
map["isRunning"] = isEntityScriptRunning(entityID);
map["status"] = EntityScriptStatus_::valueToKey(scriptDetails.status).toLower();
map["errorInfo"] = scriptDetails.errorInfo;
map["entityID"] = entityID.toString();
#ifdef DEBUG_ENTITY_STATES
{
auto debug = QVariantMap();
debug["script"] = scriptDetails.scriptText;
debug["scriptObject"] = scriptDetails.scriptObject.toVariant();
debug["lastModified"] = (qlonglong)scriptDetails.lastModified;
debug["sandboxURL"] = scriptDetails.definingSandboxURL;
map["debug"] = debug;
}
#endif
} else {
#ifdef DEBUG_ENTITY_STATES
qDebug() << "!gotEntityScriptDetails" << QThread::currentThread();
#endif
map["isError"] = true;
map["errorInfo"] = "Entity script details unavailable";
map["entityID"] = entityID.toString();
}
}
return map;
}
QFuture<QVariant> ScriptEngine::getLocalEntityScriptDetails(const EntityItemID& entityID) {
return QtConcurrent::run(this, &ScriptEngine::cloneEntityScriptDetails, entityID);
}
bool ScriptEngine::getEntityScriptDetails(const EntityItemID& entityID, EntityScriptDetails &details) const {
auto it = _entityScripts.constFind(entityID);
if (it == _entityScripts.constEnd()) {
@ -1621,10 +2070,10 @@ void ScriptEngine::loadEntityScript(const EntityItemID& entityID, const QString&
auto scriptCache = DependencyManager::get<ScriptCache>();
// note: see EntityTreeRenderer.cpp for shared pointer lifecycle management
QWeakPointer<ScriptEngine> weakRef(sharedFromThis());
QWeakPointer<BaseScriptEngine> weakRef(sharedFromThis());
scriptCache->getScriptContents(entityScript,
[this, weakRef, entityScript, entityID](const QString& url, const QString& contents, bool isURL, bool success, const QString& status) {
QSharedPointer<ScriptEngine> strongRef(weakRef);
QSharedPointer<BaseScriptEngine> strongRef(weakRef);
if (!strongRef) {
qCWarning(scriptengine) << "loadEntityScript.contentAvailable -- ScriptEngine was deleted during getScriptContents!!";
return;
@ -1743,13 +2192,12 @@ void ScriptEngine::entityScriptContentAvailable(const EntityItemID& entityID, co
timeout.setSingleShot(true);
timeout.start(SANDBOX_TIMEOUT);
connect(&timeout, &QTimer::timeout, [&sandbox, SANDBOX_TIMEOUT, scriptOrURL]{
auto context = sandbox.currentContext();
if (context) {
qCDebug(scriptengine) << "ScriptEngine::entityScriptContentAvailable timeout(" << scriptOrURL << ")";
// Guard against infinite loops and non-performant code
context->throwError(QString("Timed out (entity constructors are limited to %1ms)").arg(SANDBOX_TIMEOUT));
}
sandbox.raiseException(
sandbox.makeError(QString("Timed out (entity constructors are limited to %1ms)").arg(SANDBOX_TIMEOUT))
);
});
testConstructor = sandbox.evaluate(program);
@ -1765,7 +2213,7 @@ void ScriptEngine::entityScriptContentAvailable(const EntityItemID& entityID, co
if (exception.isError()) {
// create a local copy using makeError to decouple from the sandbox engine
exception = makeError(exception);
setError(formatException(exception), EntityScriptStatus::ERROR_RUNNING_SCRIPT);
setError(formatException(exception, _enableExtendedJSExceptions.get()), EntityScriptStatus::ERROR_RUNNING_SCRIPT);
emit unhandledException(exception);
return;
}
@ -1777,9 +2225,8 @@ void ScriptEngine::entityScriptContentAvailable(const EntityItemID& entityID, co
testConstructorType = "empty";
}
QString testConstructorValue = testConstructor.toString();
const int maxTestConstructorValueSize = 80;
if (testConstructorValue.size() > maxTestConstructorValueSize) {
testConstructorValue = testConstructorValue.mid(0, maxTestConstructorValueSize) + "...";
if (testConstructorValue.size() > MAX_DEBUG_VALUE_LENGTH) {
testConstructorValue = testConstructorValue.mid(0, MAX_DEBUG_VALUE_LENGTH) + "...";
}
auto message = QString("failed to load entity script -- expected a function, got %1, %2")
.arg(testConstructorType).arg(testConstructorValue);
@ -1817,7 +2264,7 @@ void ScriptEngine::entityScriptContentAvailable(const EntityItemID& entityID, co
if (entityScriptObject.isError()) {
auto exception = entityScriptObject;
setError(formatException(exception), EntityScriptStatus::ERROR_RUNNING_SCRIPT);
setError(formatException(exception, _enableExtendedJSExceptions.get()), EntityScriptStatus::ERROR_RUNNING_SCRIPT);
emit unhandledException(exception);
return;
}
@ -1861,10 +2308,12 @@ void ScriptEngine::unloadEntityScript(const EntityItemID& entityID, bool shouldR
const EntityScriptDetails &oldDetails = _entityScripts[entityID];
if (isEntityScriptRunning(entityID)) {
callEntityScriptMethod(entityID, "unload");
} else {
}
#ifdef DEBUG_ENTITY_STATES
else {
qCDebug(scriptengine) << "unload called while !running" << entityID << oldDetails.status;
}
#endif
if (shouldRemoveFromMap) {
// this was a deleted entity, we've been asked to remove it from the map
_entityScripts.remove(entityID);
@ -1956,10 +2405,7 @@ void ScriptEngine::doWithEnvironment(const EntityItemID& entityID, const QUrl& s
#else
operation();
#endif
if (!isEvaluating() && hasUncaughtException()) {
emit unhandledException(cloneUncaughtException(!entityID.isNull() ? entityID.toString() : __FUNCTION__));
clearExceptions();
}
maybeEmitUncaughtException(!entityID.isNull() ? entityID.toString() : __FUNCTION__);
currentEntityIdentifier = oldIdentifier;
currentSandboxURL = oldSandboxURL;
}

View file

@ -41,6 +41,7 @@
#include "ScriptCache.h"
#include "ScriptUUID.h"
#include "Vec3.h"
#include "SettingHandle.h"
class QScriptEngineDebugger;
@ -78,7 +79,7 @@ public:
QUrl definingSandboxURL { QUrl("about:EntityScript") };
};
class ScriptEngine : public BaseScriptEngine, public EntitiesScriptEngineProvider, public QEnableSharedFromThis<ScriptEngine> {
class ScriptEngine : public BaseScriptEngine, public EntitiesScriptEngineProvider {
Q_OBJECT
Q_PROPERTY(QString context READ getContext)
public:
@ -137,6 +138,8 @@ public:
/// evaluate some code in the context of the ScriptEngine and return the result
Q_INVOKABLE QScriptValue evaluate(const QString& program, const QString& fileName, int lineNumber = 1); // this is also used by the script tool widget
Q_INVOKABLE QScriptValue evaluateInClosure(const QScriptValue& locals, const QScriptProgram& program);
/// if the script engine is not already running, this will download the URL and start the process of seting it up
/// to run... NOTE - this is used by Application currently to load the url. We don't really want it to be exposed
/// to scripts. we may not need this to be invokable
@ -157,6 +160,16 @@ public:
Q_INVOKABLE void include(const QStringList& includeFiles, QScriptValue callback = QScriptValue());
Q_INVOKABLE void include(const QString& includeFile, QScriptValue callback = QScriptValue());
////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
// MODULE related methods
Q_INVOKABLE QScriptValue require(const QString& moduleId);
Q_INVOKABLE void resetModuleCache(bool deleteScriptCache = false);
QScriptValue currentModule();
bool registerModuleWithParent(const QScriptValue& module, const QScriptValue& parent);
QScriptValue newModule(const QString& modulePath, const QScriptValue& parent = QScriptValue());
QVariantMap fetchModuleSource(const QString& modulePath, const bool forceDownload = false);
QScriptValue instantiateModule(const QScriptValue& module, const QString& sourceCode);
Q_INVOKABLE QObject* setInterval(const QScriptValue& function, int intervalMS);
Q_INVOKABLE QObject* setTimeout(const QScriptValue& function, int timeoutMS);
Q_INVOKABLE void clearInterval(QObject* timer) { stopTimer(reinterpret_cast<QTimer*>(timer)); }
@ -170,6 +183,8 @@ public:
Q_INVOKABLE bool isEntityScriptRunning(const EntityItemID& entityID) {
return _entityScripts.contains(entityID) && _entityScripts[entityID].status == EntityScriptStatus::RUNNING;
}
QVariant cloneEntityScriptDetails(const EntityItemID& entityID);
QFuture<QVariant> getLocalEntityScriptDetails(const EntityItemID& entityID) override;
Q_INVOKABLE void loadEntityScript(const EntityItemID& entityID, const QString& entityScript, bool forceRedownload);
Q_INVOKABLE void unloadEntityScript(const EntityItemID& entityID, bool shouldRemoveFromMap = false); // will call unload method
Q_INVOKABLE void unloadAllEntityScripts();
@ -237,6 +252,9 @@ signals:
protected:
void init();
Q_INVOKABLE void executeOnScriptThread(std::function<void()> function, const Qt::ConnectionType& type = Qt::QueuedConnection );
// note: this is not meant to be called directly, but just to have QMetaObject take care of wiring it up in general;
// then inside of init() we just have to do "Script.require.resolve = Script._requireResolve;"
Q_INVOKABLE QString _requireResolve(const QString& moduleId, const QString& relativeTo = QString());
QString logException(const QScriptValue& exception);
void timerFired();
@ -290,11 +308,16 @@ protected:
AssetScriptingInterface _assetScriptingInterface{ this };
std::function<bool()> _emitScriptUpdates{ [](){ return true; } };
std::function<bool()> _emitScriptUpdates{ []() { return true; } };
std::recursive_mutex _lock;
std::chrono::microseconds _totalTimerExecution { 0 };
static const QString _SETTINGS_ENABLE_EXTENDED_MODULE_COMPAT;
static const QString _SETTINGS_ENABLE_EXTENDED_EXCEPTIONS;
Setting::Handle<bool> _enableExtendedJSExceptions { _SETTINGS_ENABLE_EXTENDED_EXCEPTIONS, true };
};
#endif // hifi_ScriptEngine_h

View file

@ -12,3 +12,4 @@
#include "ScriptEngineLogging.h"
Q_LOGGING_CATEGORY(scriptengine, "hifi.scriptengine")
Q_LOGGING_CATEGORY(scriptengine_module, "hifi.scriptengine.module")

View file

@ -15,6 +15,7 @@
#include <QLoggingCategory>
Q_DECLARE_LOGGING_CATEGORY(scriptengine)
Q_DECLARE_LOGGING_CATEGORY(scriptengine_module)
#endif // hifi_ScriptEngineLogging_h

View file

@ -10,6 +10,7 @@
//
#include "BaseScriptEngine.h"
#include "SharedLogging.h"
#include <QtCore/QString>
#include <QtCore/QThread>
@ -18,18 +19,27 @@
#include <QtScript/QScriptValueIterator>
#include <QtScript/QScriptContextInfo>
#include "ScriptEngineLogging.h"
#include "Profile.h"
const QString BaseScriptEngine::_SETTINGS_ENABLE_EXTENDED_EXCEPTIONS {
"com.highfidelity.experimental.enableExtendedJSExceptions"
};
const QString BaseScriptEngine::SCRIPT_EXCEPTION_FORMAT { "[%0] %1 in %2:%3" };
const QString BaseScriptEngine::SCRIPT_BACKTRACE_SEP { "\n " };
bool BaseScriptEngine::IS_THREADSAFE_INVOCATION(const QThread *thread, const QString& method) {
if (QThread::currentThread() == thread) {
return true;
}
qCCritical(shared) << QString("Scripting::%1 @ %2 -- ignoring thread-unsafe call from %3")
.arg(method).arg(thread ? thread->objectName() : "(!thread)").arg(QThread::currentThread()->objectName());
qCDebug(shared) << "(please resolve on the calling side by using invokeMethod, executeOnScriptThread, etc.)";
Q_ASSERT(false);
return false;
}
// engine-aware JS Error copier and factory
QScriptValue BaseScriptEngine::makeError(const QScriptValue& _other, const QString& type) {
if (!IS_THREADSAFE_INVOCATION(thread(), __FUNCTION__)) {
return unboundNullValue();
}
auto other = _other;
if (other.isString()) {
other = newObject();
@ -41,7 +51,7 @@ QScriptValue BaseScriptEngine::makeError(const QScriptValue& _other, const QStri
}
if (!proto.isFunction()) {
#ifdef DEBUG_JS_EXCEPTIONS
qCDebug(scriptengine) << "BaseScriptEngine::makeError -- couldn't find constructor for" << type << " -- using Error instead";
qCDebug(shared) << "BaseScriptEngine::makeError -- couldn't find constructor for" << type << " -- using Error instead";
#endif
proto = globalObject().property("Error");
}
@ -64,6 +74,9 @@ QScriptValue BaseScriptEngine::makeError(const QScriptValue& _other, const QStri
// check syntax and when there are issues returns an actual "SyntaxError" with the details
QScriptValue BaseScriptEngine::lintScript(const QString& sourceCode, const QString& fileName, const int lineNumber) {
if (!IS_THREADSAFE_INVOCATION(thread(), __FUNCTION__)) {
return unboundNullValue();
}
const auto syntaxCheck = checkSyntax(sourceCode);
if (syntaxCheck.state() != QScriptSyntaxCheckResult::Valid) {
auto err = globalObject().property("SyntaxError")
@ -82,13 +95,16 @@ QScriptValue BaseScriptEngine::lintScript(const QString& sourceCode, const QStri
}
return err;
}
return undefinedValue();
return QScriptValue();
}
// this pulls from the best available information to create a detailed snapshot of the current exception
QScriptValue BaseScriptEngine::cloneUncaughtException(const QString& extraDetail) {
if (!IS_THREADSAFE_INVOCATION(thread(), __FUNCTION__)) {
return unboundNullValue();
}
if (!hasUncaughtException()) {
return QScriptValue();
return unboundNullValue();
}
auto exception = uncaughtException();
// ensure the error object is engine-local
@ -144,7 +160,10 @@ QScriptValue BaseScriptEngine::cloneUncaughtException(const QString& extraDetail
return err;
}
QString BaseScriptEngine::formatException(const QScriptValue& exception) {
QString BaseScriptEngine::formatException(const QScriptValue& exception, bool includeExtendedDetails) {
if (!IS_THREADSAFE_INVOCATION(thread(), __FUNCTION__)) {
return QString();
}
QString note { "UncaughtException" };
QString result;
@ -156,8 +175,8 @@ QString BaseScriptEngine::formatException(const QScriptValue& exception) {
const auto lineNumber = exception.property("lineNumber").toString();
const auto stacktrace = exception.property("stack").toString();
if (_enableExtendedJSExceptions.get()) {
// This setting toggles display of the hints now being added during the loading process.
if (includeExtendedDetails) {
// Display additional exception / troubleshooting hints that can be added via the custom Error .detail property
// Example difference:
// [UncaughtExceptions] Error: Can't find variable: foobar in atp:/myentity.js\n...
// [UncaughtException (construct {1eb5d3fa-23b1-411c-af83-163af7220e3f})] Error: Can't find variable: foobar in atp:/myentity.js\n...
@ -173,14 +192,39 @@ QString BaseScriptEngine::formatException(const QScriptValue& exception) {
return result;
}
bool BaseScriptEngine::raiseException(const QScriptValue& exception) {
if (!IS_THREADSAFE_INVOCATION(thread(), __FUNCTION__)) {
return false;
}
if (currentContext()) {
// we have an active context / JS stack frame so throw the exception per usual
currentContext()->throwValue(makeError(exception));
return true;
} else {
// we are within a pure C++ stack frame (ie: being called directly by other C++ code)
// in this case no context information is available so just emit the exception for reporting
emit unhandledException(makeError(exception));
}
return false;
}
bool BaseScriptEngine::maybeEmitUncaughtException(const QString& debugHint) {
if (!IS_THREADSAFE_INVOCATION(thread(), __FUNCTION__)) {
return false;
}
if (!isEvaluating() && hasUncaughtException()) {
emit unhandledException(cloneUncaughtException(debugHint));
clearExceptions();
return true;
}
return false;
}
QScriptValue BaseScriptEngine::evaluateInClosure(const QScriptValue& closure, const QScriptProgram& program) {
PROFILE_RANGE(script, "evaluateInClosure");
if (QThread::currentThread() != thread()) {
qCCritical(scriptengine) << "*** CRITICAL *** ScriptEngine::evaluateInClosure() is meant to be called from engine thread only.";
// note: a recursive mutex might be needed around below code if this method ever becomes Q_INVOKABLE
return QScriptValue();
if (!IS_THREADSAFE_INVOCATION(thread(), __FUNCTION__)) {
return unboundNullValue();
}
const auto fileName = program.fileName();
const auto shortName = QUrl(fileName).fileName();
@ -189,7 +233,7 @@ QScriptValue BaseScriptEngine::evaluateInClosure(const QScriptValue& closure, co
auto global = closure.property("global");
if (global.isObject()) {
#ifdef DEBUG_JS
qCDebug(scriptengine) << " setting global = closure.global" << shortName;
qCDebug(shared) << " setting global = closure.global" << shortName;
#endif
oldGlobal = globalObject();
setGlobalObject(global);
@ -200,34 +244,34 @@ QScriptValue BaseScriptEngine::evaluateInClosure(const QScriptValue& closure, co
auto thiz = closure.property("this");
if (thiz.isObject()) {
#ifdef DEBUG_JS
qCDebug(scriptengine) << " setting this = closure.this" << shortName;
qCDebug(shared) << " setting this = closure.this" << shortName;
#endif
context->setThisObject(thiz);
}
context->pushScope(closure);
#ifdef DEBUG_JS
qCDebug(scriptengine) << QString("[%1] evaluateInClosure %2").arg(isEvaluating()).arg(shortName);
qCDebug(shared) << QString("[%1] evaluateInClosure %2").arg(isEvaluating()).arg(shortName);
#endif
{
result = BaseScriptEngine::evaluate(program);
if (hasUncaughtException()) {
auto err = cloneUncaughtException(__FUNCTION__);
#ifdef DEBUG_JS_EXCEPTIONS
qCWarning(scriptengine) << __FUNCTION__ << "---------- hasCaught:" << err.toString() << result.toString();
qCWarning(shared) << __FUNCTION__ << "---------- hasCaught:" << err.toString() << result.toString();
err.setProperty("_result", result);
#endif
result = err;
}
}
#ifdef DEBUG_JS
qCDebug(scriptengine) << QString("[%1] //evaluateInClosure %2").arg(isEvaluating()).arg(shortName);
qCDebug(shared) << QString("[%1] //evaluateInClosure %2").arg(isEvaluating()).arg(shortName);
#endif
popContext();
if (oldGlobal.isValid()) {
#ifdef DEBUG_JS
qCDebug(scriptengine) << " restoring global" << shortName;
qCDebug(shared) << " restoring global" << shortName;
#endif
setGlobalObject(oldGlobal);
}
@ -236,7 +280,6 @@ QScriptValue BaseScriptEngine::evaluateInClosure(const QScriptValue& closure, co
}
// Lambda
QScriptValue BaseScriptEngine::newLambdaFunction(std::function<QScriptValue(QScriptContext *, QScriptEngine*)> operation, const QScriptValue& data, const QScriptEngine::ValueOwnership& ownership) {
auto lambda = new Lambda(this, operation, data);
auto object = newQObject(lambda, ownership);
@ -262,26 +305,57 @@ Lambda::Lambda(QScriptEngine *engine, std::function<QScriptValue(QScriptContext
#endif
}
QScriptValue Lambda::call() {
if (!BaseScriptEngine::IS_THREADSAFE_INVOCATION(engine->thread(), __FUNCTION__)) {
return BaseScriptEngine::unboundNullValue();
}
return operation(engine->currentContext(), engine);
}
QScriptValue makeScopedHandlerObject(QScriptValue scopeOrCallback, QScriptValue methodOrName) {
auto engine = scopeOrCallback.engine();
if (!engine) {
return scopeOrCallback;
}
auto scope = QScriptValue();
auto callback = scopeOrCallback;
if (scopeOrCallback.isObject()) {
if (methodOrName.isString()) {
scope = scopeOrCallback;
callback = scope.property(methodOrName.toString());
} else if (methodOrName.isFunction()) {
scope = scopeOrCallback;
callback = methodOrName;
}
}
auto handler = engine->newObject();
handler.setProperty("scope", scope);
handler.setProperty("callback", callback);
return handler;
}
QScriptValue callScopedHandlerObject(QScriptValue handler, QScriptValue err, QScriptValue result) {
return handler.property("callback").call(handler.property("scope"), QScriptValueList({ err, result }));
}
#ifdef DEBUG_JS
void BaseScriptEngine::_debugDump(const QString& header, const QScriptValue& object, const QString& footer) {
if (!IS_THREADSAFE_INVOCATION(thread(), __FUNCTION__)) {
return;
}
if (!header.isEmpty()) {
qCDebug(scriptengine) << header;
qCDebug(shared) << header;
}
if (!object.isObject()) {
qCDebug(scriptengine) << "(!isObject)" << object.toVariant().toString() << object.toString();
qCDebug(shared) << "(!isObject)" << object.toVariant().toString() << object.toString();
return;
}
QScriptValueIterator it(object);
while (it.hasNext()) {
it.next();
qCDebug(scriptengine) << it.name() << ":" << it.value().toString();
qCDebug(shared) << it.name() << ":" << it.value().toString();
}
if (!footer.isEmpty()) {
qCDebug(scriptengine) << footer;
qCDebug(shared) << footer;
}
}
#endif

View file

@ -0,0 +1,90 @@
//
// BaseScriptEngine.h
// libraries/script-engine/src
//
// Created by Timothy Dedischew on 02/01/17.
// Copyright 2017 High Fidelity, Inc.
//
// Distributed under the Apache License, Version 2.0.
// See the accompanying file LICENSE or http://www.apache.org/licenses/LICENSE-2.0.html
//
#ifndef hifi_BaseScriptEngine_h
#define hifi_BaseScriptEngine_h
#include <functional>
#include <QtCore/QDebug>
#include <QtScript/QScriptEngine>
// common base class for extending QScriptEngine itself
class BaseScriptEngine : public QScriptEngine, public QEnableSharedFromThis<BaseScriptEngine> {
Q_OBJECT
public:
static const QString SCRIPT_EXCEPTION_FORMAT;
static const QString SCRIPT_BACKTRACE_SEP;
// threadsafe "unbound" version of QScriptEngine::nullValue()
static const QScriptValue unboundNullValue() { return QScriptValue(0, QScriptValue::NullValue); }
BaseScriptEngine() {}
Q_INVOKABLE QScriptValue lintScript(const QString& sourceCode, const QString& fileName, const int lineNumber = 1);
Q_INVOKABLE QScriptValue makeError(const QScriptValue& other = QScriptValue(), const QString& type = "Error");
Q_INVOKABLE QString formatException(const QScriptValue& exception, bool includeExtendedDetails);
QScriptValue cloneUncaughtException(const QString& detail = QString());
QScriptValue evaluateInClosure(const QScriptValue& locals, const QScriptProgram& program);
// if there is a pending exception and we are at the top level (non-recursive) stack frame, this emits and resets it
bool maybeEmitUncaughtException(const QString& debugHint = QString());
// if the currentContext() is valid then throw the passed exception; otherwise, immediately emit it.
// note: this is used in cases where C++ code might call into JS API methods directly
bool raiseException(const QScriptValue& exception);
// helper to detect and log warnings when other code invokes QScriptEngine/BaseScriptEngine in thread-unsafe ways
static bool IS_THREADSAFE_INVOCATION(const QThread *thread, const QString& method);
signals:
void unhandledException(const QScriptValue& exception);
protected:
// like `newFunction`, but allows mapping inline C++ lambdas with captures as callable QScriptValues
// even though the context/engine parameters are redundant in most cases, the function signature matches `newFunction`
// anyway so that newLambdaFunction can be used to rapidly prototype / test utility APIs and then if becoming
// permanent more easily promoted into regular static newFunction scenarios.
QScriptValue newLambdaFunction(std::function<QScriptValue(QScriptContext *context, QScriptEngine* engine)> operation, const QScriptValue& data = QScriptValue(), const QScriptEngine::ValueOwnership& ownership = QScriptEngine::AutoOwnership);
#ifdef DEBUG_JS
static void _debugDump(const QString& header, const QScriptValue& object, const QString& footer = QString());
#endif
};
// Standardized CPS callback helpers (see: http://fredkschott.com/post/2014/03/understanding-error-first-callbacks-in-node-js/)
// These two helpers allow async JS APIs that use a callback parameter to be more friendly to scripters by accepting thisObject
// context and adopting a consistent and intuitable callback signature:
// function callback(err, result) { if (err) { ... } else { /* do stuff with result */ } }
//
// To use, first pass the user-specified callback args in the same order used with optionally-scoped Qt signal connections:
// auto handler = makeScopedHandlerObject(scopeOrCallback, optionalMethodOrName);
// And then invoke the scoped handler later per CPS conventions:
// auto result = callScopedHandlerObject(handler, err, result);
QScriptValue makeScopedHandlerObject(QScriptValue scopeOrCallback, QScriptValue methodOrName);
QScriptValue callScopedHandlerObject(QScriptValue handler, QScriptValue err, QScriptValue result);
// Lambda helps create callable QScriptValues out of std::functions:
// (just meant for use from within the script engine itself)
class Lambda : public QObject {
Q_OBJECT
public:
Lambda(QScriptEngine *engine, std::function<QScriptValue(QScriptContext *context, QScriptEngine* engine)> operation, QScriptValue data);
~Lambda();
public slots:
QScriptValue call();
QString toString() const;
private:
QScriptEngine* engine;
std::function<QScriptValue(QScriptContext *context, QScriptEngine* engine)> operation;
QScriptValue data;
};
#endif // hifi_BaseScriptEngine_h

View file

@ -6,7 +6,7 @@
var lastSpecStartTime;
function ConsoleReporter(options) {
var startTime = new Date().getTime();
var errorCount = 0;
var errorCount = 0, pending = [];
this.jasmineStarted = function (obj) {
print('Jasmine started with ' + obj.totalSpecsDefined + ' tests.');
};
@ -15,11 +15,14 @@
var endTime = new Date().getTime();
print('<hr />');
if (errorCount === 0) {
print ('<span style="color:green">All tests passed!</span>');
print ('<span style="color:green">All enabled tests passed!</span>');
} else {
print('<span style="color:red">Tests completed with ' +
errorCount + ' ' + ERROR + '.<span>');
}
if (pending.length)
print ('<span style="color:darkorange">disabled: <br />&nbsp;&nbsp;&nbsp;'+
pending.join('<br />&nbsp;&nbsp;&nbsp;')+'</span>');
print('Tests completed in ' + (endTime - startTime) + 'ms.');
};
this.suiteStarted = function(obj) {
@ -32,6 +35,10 @@
lastSpecStartTime = new Date().getTime();
};
this.specDone = function(obj) {
if (obj.status === 'pending') {
pending.push(obj.fullName);
return print('...(pending ' + obj.fullName +')');
}
var specEndTime = new Date().getTime();
var symbol = obj.status === PASSED ?
'<span style="color:green">' + CHECKMARK + '</span>' :
@ -55,7 +62,7 @@
clearTimeout = Script.clearTimeout;
clearInterval = Script.clearInterval;
var jasmine = jasmineRequire.core(jasmineRequire);
var jasmine = this.jasmine = jasmineRequire.core(jasmineRequire);
var env = jasmine.getEnv();

View file

@ -0,0 +1,10 @@
/* eslint-env node */
var a = exports;
a.done = false;
var b = require('./b.js');
a.done = true;
a.name = 'a';
a['a.done?'] = a.done;
a['b.done?'] = b.done;
print('from a.js a.done =', a.done, '/ b.done =', b.done);

View file

@ -0,0 +1,10 @@
/* eslint-env node */
var b = exports;
b.done = false;
var a = require('./a.js');
b.done = true;
b.name = 'b';
b['a.done?'] = a.done;
b['b.done?'] = b.done;
print('from b.js a.done =', a.done, '/ b.done =', b.done);

View file

@ -0,0 +1,17 @@
/* eslint-env node */
/* global print */
/* eslint-disable comma-dangle */
print('main.js');
var a = require('./a.js'),
b = require('./b.js');
print('from main.js a.done =', a.done, 'and b.done =', b.done);
module.exports = {
name: 'main',
a: a,
b: b,
'a.done?': a.done,
'b.done?': b.done,
};

View file

@ -0,0 +1,13 @@
/* eslint-disable comma-dangle */
// test module method exception being thrown within main constructor
(function() {
var apiMethod = Script.require('../exceptions/exceptionInFunction.js');
print(Script.resolvePath(''), "apiMethod", apiMethod);
// this next line throws from within apiMethod
print(apiMethod());
return {
preload: function(uuid) {
print("entityConstructorAPIException::preload -- never seen --", uuid, Script.resolvePath(''));
},
};
});

View file

@ -0,0 +1,23 @@
/* global module */
/* eslint-disable comma-dangle */
// test dual-purpose module and standalone Entity script
function MyEntity(filename) {
return {
preload: function(uuid) {
print("entityConstructorModule.js::preload");
if (typeof module === 'object') {
print("module.filename", module.filename);
print("module.parent.filename", module.parent && module.parent.filename);
}
},
clickDownOnEntity: function(uuid, evt) {
print("entityConstructorModule.js::clickDownOnEntity");
},
};
}
try {
module.exports = MyEntity;
} catch (e) {} // eslint-disable-line no-empty
print('entityConstructorModule::MyEntity', typeof MyEntity);
(MyEntity);

View file

@ -0,0 +1,14 @@
/* global module */
// test Entity constructor based on inherited constructor from a module
function constructor() {
print("entityConstructorNested::constructor");
var MyEntity = Script.require('./entityConstructorModule.js');
return new MyEntity("-- created from entityConstructorNested --");
}
try {
module.exports = constructor;
} catch (e) {
constructor;
}

View file

@ -0,0 +1,25 @@
/* global module */
// test Entity constructor based on nested, inherited module constructors
function constructor() {
print("entityConstructorNested2::constructor");
// inherit from entityConstructorNested
var MyEntity = Script.require('./entityConstructorNested.js');
function SubEntity() {}
SubEntity.prototype = new MyEntity('-- created from entityConstructorNested2 --');
// create new instance
var entity = new SubEntity();
// "override" clickDownOnEntity for just this new instance
entity.clickDownOnEntity = function(uuid, evt) {
print("entityConstructorNested2::clickDownOnEntity");
SubEntity.prototype.clickDownOnEntity.apply(this, arguments);
};
return entity;
}
try {
module.exports = constructor;
} catch (e) {
constructor;
}

View file

@ -0,0 +1,10 @@
/* eslint-disable comma-dangle */
// test module-related exception from within "require" evaluation itself
(function() {
var mod = Script.require('../exceptions/exception.js');
return {
preload: function(uuid) {
print("entityConstructorRequireException::preload (never happens)", uuid, Script.resolvePath(''), mod);
},
};
});

View file

@ -0,0 +1,13 @@
/* eslint-disable comma-dangle */
// test module method exception being thrown within preload
(function() {
var apiMethod = Script.require('../exceptions/exceptionInFunction.js');
print(Script.resolvePath(''), "apiMethod", apiMethod);
return {
preload: function(uuid) {
// this next line throws from within apiMethod
print(apiMethod());
print("entityPreloadAPIException::preload -- never seen --", uuid, Script.resolvePath(''));
},
};
});

View file

@ -0,0 +1,11 @@
/* eslint-disable comma-dangle */
// test requiring a module from within preload
(function constructor() {
return {
preload: function(uuid) {
print("entityPreloadRequire::preload");
var example = Script.require('../example.json');
print("entityPreloadRequire::example::name", example.name);
},
};
});

View file

@ -0,0 +1,9 @@
{
"name": "Example JSON Module",
"last-modified": 1485789862,
"config": {
"title": "My Title",
"width": 800,
"height": 600
}
}

View file

@ -0,0 +1,4 @@
/* eslint-env node */
module.exports = "n/a";
throw new Error('exception on line 2');

View file

@ -0,0 +1,38 @@
/* eslint-env node */
// dummy lines to make sure exception line number is well below parent test script
//
//
//
//
//
//
//
//
//
//
//
//
//
//
//
//
//
//
//
//
//
//
//
//
//
//
//
function myfunc() {
throw new Error('exception on line 32 in myfunc');
}
module.exports = myfunc;
if (Script[module.filename] === 'throw') {
myfunc();
}

View file

@ -0,0 +1,378 @@
/* eslint-env jasmine, node */
/* global print:true, Script:true, global:true, require:true */
/* eslint-disable comma-dangle */
var isNode = instrumentTestrunner(),
runInterfaceTests = !isNode,
runNetworkTests = true;
// describe wrappers (note: `xdescribe` indicates a disabled or "pending" jasmine test)
var INTERFACE = { describe: runInterfaceTests ? describe : xdescribe },
NETWORK = { describe: runNetworkTests ? describe : xdescribe };
describe('require', function() {
describe('resolve', function() {
it('should resolve relative filenames', function() {
var expected = Script.resolvePath('./moduleTests/example.json');
expect(require.resolve('./moduleTests/example.json')).toEqual(expected);
});
describe('exceptions', function() {
it('should reject blank "" module identifiers', function() {
expect(function() {
require.resolve('');
}).toThrowError(/Cannot find/);
});
it('should reject excessive identifier sizes', function() {
expect(function() {
require.resolve(new Array(8193).toString());
}).toThrowError(/Cannot find/);
});
it('should reject implicitly-relative filenames', function() {
expect(function() {
var mod = require.resolve('example.js');
mod.exists;
}).toThrowError(/Cannot find/);
});
it('should reject unanchored, existing filenames with advice', function() {
expect(function() {
var mod = require.resolve('moduleTests/example.json');
mod.exists;
}).toThrowError(/use '.\/moduleTests\/example\.json'/);
});
it('should reject unanchored, non-existing filenames', function() {
expect(function() {
var mod = require.resolve('asdfssdf/example.json');
mod.exists;
}).toThrowError(/Cannot find.*system module not found/);
});
it('should reject non-existent filenames', function() {
expect(function() {
require.resolve('./404error.js');
}).toThrowError(/Cannot find/);
});
it('should reject identifiers resolving to a directory', function() {
expect(function() {
var mod = require.resolve('.');
mod.exists;
// console.warn('resolved(.)', mod);
}).toThrowError(/Cannot find/);
expect(function() {
var mod = require.resolve('..');
mod.exists;
// console.warn('resolved(..)', mod);
}).toThrowError(/Cannot find/);
expect(function() {
var mod = require.resolve('../');
mod.exists;
// console.warn('resolved(../)', mod);
}).toThrowError(/Cannot find/);
});
(isNode ? xit : it)('should reject non-system, extensionless identifiers', function() {
expect(function() {
require.resolve('./example');
}).toThrowError(/Cannot find/);
});
});
});
describe('JSON', function() {
it('should import .json modules', function() {
var example = require('./moduleTests/example.json');
expect(example.name).toEqual('Example JSON Module');
});
// noet: support for loading JSON via content type workarounds reverted
// (leaving these tests intact in case ever revisited later)
// INTERFACE.describe('interface', function() {
// NETWORK.describe('network', function() {
// xit('should import #content-type=application/json modules', function() {
// var results = require('https://jsonip.com#content-type=application/json');
// expect(results.ip).toMatch(/^[.0-9]+$/);
// });
// xit('should import content-type: application/json modules', function() {
// var scope = { 'content-type': 'application/json' };
// var results = require.call(scope, 'https://jsonip.com');
// expect(results.ip).toMatch(/^[.0-9]+$/);
// });
// });
// });
});
INTERFACE.describe('system', function() {
it('require("vec3")', function() {
expect(require('vec3')).toEqual(jasmine.any(Function));
});
it('require("vec3").method', function() {
expect(require('vec3')().isValid).toEqual(jasmine.any(Function));
});
it('require("vec3") as constructor', function() {
var vec3 = require('vec3');
var v = vec3(1.1, 2.2, 3.3);
expect(v).toEqual(jasmine.any(Object));
expect(v.isValid).toEqual(jasmine.any(Function));
expect(v.isValid()).toBe(true);
expect(v.toString()).toEqual('[Vec3 (1.100,2.200,3.300)]');
});
});
describe('cache', function() {
it('should cache modules by resolved module id', function() {
var value = new Date;
var example = require('./moduleTests/example.json');
// earmark the module object with a unique value
example['.test'] = value;
var example2 = require('../../tests/unit_tests/moduleTests/example.json');
expect(example2).toBe(example);
// verify earmark is still the same after a second require()
expect(example2['.test']).toBe(example['.test']);
});
it('should reload cached modules set to null', function() {
var value = new Date;
var example = require('./moduleTests/example.json');
example['.test'] = value;
require.cache[require.resolve('../../tests/unit_tests/moduleTests/example.json')] = null;
var example2 = require('../../tests/unit_tests/moduleTests/example.json');
// verify the earmark is *not* the same as before
expect(example2).not.toBe(example);
expect(example2['.test']).not.toBe(example['.test']);
});
it('should reload when module property is deleted', function() {
var value = new Date;
var example = require('./moduleTests/example.json');
example['.test'] = value;
delete require.cache[require.resolve('../../tests/unit_tests/moduleTests/example.json')];
var example2 = require('../../tests/unit_tests/moduleTests/example.json');
// verify the earmark is *not* the same as before
expect(example2).not.toBe(example);
expect(example2['.test']).not.toBe(example['.test']);
});
});
describe('cyclic dependencies', function() {
describe('should allow lazy-ref cyclic module resolution', function() {
var main;
beforeEach(function() {
// eslint-disable-next-line
try { this._print = print; } catch (e) {}
// during these tests print() is no-op'd so that it doesn't disrupt the reporter output
print = function() {};
Script.resetModuleCache();
});
afterEach(function() {
print = this._print;
});
it('main is requirable', function() {
main = require('./moduleTests/cycles/main.js');
expect(main).toEqual(jasmine.any(Object));
});
it('transient a and b done values', function() {
expect(main.a['b.done?']).toBe(true);
expect(main.b['a.done?']).toBe(false);
});
it('ultimate a.done?', function() {
expect(main['a.done?']).toBe(true);
});
it('ultimate b.done?', function() {
expect(main['b.done?']).toBe(true);
});
});
});
describe('JS', function() {
it('should throw catchable local file errors', function() {
expect(function() {
require('file:///dev/null/non-existent-file.js');
}).toThrowError(/path not found|Cannot find.*non-existent-file/);
});
it('should throw catchable invalid id errors', function() {
expect(function() {
require(new Array(4096 * 2).toString());
}).toThrowError(/invalid.*size|Cannot find.*,{30}/);
});
it('should throw catchable unresolved id errors', function() {
expect(function() {
require('foobar:/baz.js');
}).toThrowError(/could not resolve|Cannot find.*foobar:/);
});
NETWORK.describe('network', function() {
// note: depending on retries these tests can take up to 60 seconds each to timeout
var timeout = 75 * 1000;
it('should throw catchable host errors', function() {
expect(function() {
var mod = require('http://non.existent.highfidelity.io/moduleUnitTest.js');
print("mod", Object.keys(mod));
}).toThrowError(/error retrieving script .ServerUnavailable.|Cannot find.*non.existent/);
}, timeout);
it('should throw catchable network timeouts', function() {
expect(function() {
require('http://ping.highfidelity.io:1024');
}).toThrowError(/error retrieving script .Timeout.|Cannot find.*ping.highfidelity/);
}, timeout);
});
});
INTERFACE.describe('entity', function() {
var sampleScripts = [
'entityConstructorAPIException.js',
'entityConstructorModule.js',
'entityConstructorNested2.js',
'entityConstructorNested.js',
'entityConstructorRequireException.js',
'entityPreloadAPIError.js',
'entityPreloadRequire.js',
].filter(Boolean).map(function(id) {
return Script.require.resolve('./moduleTests/entity/'+id);
});
var uuids = [];
function cleanup() {
uuids.splice(0,uuids.length).forEach(function(uuid) {
Entities.deleteEntity(uuid);
});
}
afterAll(cleanup);
// extra sanity check to avoid lingering entities
Script.scriptEnding.connect(cleanup);
for (var i=0; i < sampleScripts.length; i++) {
maketest(i);
}
function maketest(i) {
var script = sampleScripts[ i % sampleScripts.length ];
var shortname = '['+i+'] ' + script.split('/').pop();
var position = MyAvatar.position;
position.y -= i/2;
// define a unique jasmine test for the current entity script
it(shortname, function(done) {
var uuid = Entities.addEntity({
text: shortname,
description: Script.resolvePath('').split('/').pop(),
type: 'Text',
position: position,
rotation: MyAvatar.orientation,
script: script,
scriptTimestamp: +new Date,
lifetime: 20,
lineHeight: 1/8,
dimensions: { x: 2, y: 0.5, z: 0.01 },
backgroundColor: { red: 0, green: 0, blue: 0 },
color: { red: 0xff, green: 0xff, blue: 0xff },
}, !Entities.serversExist() || !Entities.canRezTmp());
uuids.push(uuid);
function stopChecking() {
if (ii) {
Script.clearInterval(ii);
ii = 0;
}
}
var ii = Script.setInterval(function() {
Entities.queryPropertyMetadata(uuid, "script", function(err, result) {
if (err) {
stopChecking();
throw new Error(err);
}
if (result.success) {
stopChecking();
if (/Exception/.test(script)) {
expect(result.status).toMatch(/^error_(loading|running)_script$/);
} else {
expect(result.status).toEqual("running");
}
Entities.deleteEntity(uuid);
done();
} else {
print('!result.success', JSON.stringify(result));
}
});
}, 100);
Script.setTimeout(stopChecking, 4900);
}, 5000 /* jasmine async timeout */);
}
});
});
// support for isomorphic Node.js / Interface unit testing
// note: run `npm install` from unit_tests/ and then `node moduleUnitTests.js`
function run() {}
function instrumentTestrunner() {
var isNode = typeof process === 'object' && process.title === 'node';
if (typeof describe === 'function') {
// already running within a test runner; assume jasmine is ready-to-go
return isNode;
}
if (isNode) {
/* eslint-disable no-console */
// Node.js test mode
// to keep things consistent Node.js uses the local jasmine.js library (instead of an npm version)
var jasmineRequire = require('../../libraries/jasmine/jasmine.js');
var jasmine = jasmineRequire.core(jasmineRequire);
var env = jasmine.getEnv();
var jasmineInterface = jasmineRequire.interface(jasmine, env);
for (var p in jasmineInterface) {
global[p] = jasmineInterface[p];
}
env.addReporter(new (require('jasmine-console-reporter')));
// testing mocks
Script = {
resetModuleCache: function() {
module.require.cache = {};
},
setTimeout: setTimeout,
clearTimeout: clearTimeout,
resolvePath: function(id) {
// this attempts to accurately emulate how Script.resolvePath works
var trace = {}; Error.captureStackTrace(trace);
var base = trace.stack.split('\n')[2].replace(/^.*[(]|[)].*$/g,'').replace(/:[0-9]+:[0-9]+.*$/,'');
if (!id) {
return base;
}
var rel = base.replace(/[^\/]+$/, id);
console.info('rel', rel);
return require.resolve(rel);
},
require: function(mod) {
return require(Script.require.resolve(mod));
},
};
Script.require.cache = require.cache;
Script.require.resolve = function(mod) {
if (mod === '.' || /^\.\.($|\/)/.test(mod)) {
throw new Error("Cannot find module '"+mod+"' (is dir)");
}
var path = require.resolve(mod);
// console.info('node-require-reoslved', mod, path);
try {
if (require('fs').lstatSync(path).isDirectory()) {
throw new Error("Cannot find module '"+path+"' (is directory)");
}
// console.info('!path', path);
} catch (e) {
console.error(e);
}
return path;
};
print = console.info.bind(console, '[print]');
/* eslint-enable no-console */
} else {
// Interface test mode
global = this;
Script.require('../../../system/libraries/utils.js');
this.jasmineRequire = Script.require('../../libraries/jasmine/jasmine.js');
Script.require('../../libraries/jasmine/hifi-boot.js');
require = Script.require;
// polyfill console
/* global console:true */
console = {
log: print,
info: print.bind(this, '[info]'),
warn: print.bind(this, '[warn]'),
error: print.bind(this, '[error]'),
debug: print.bind(this, '[debug]'),
};
}
// eslint-disable-next-line
run = function() { global.jasmine.getEnv().execute(); };
return isNode;
}
run();

View file

@ -0,0 +1,6 @@
{
"name": "unit_tests",
"devDependencies": {
"jasmine-console-reporter": "^1.2.7"
}
}

View file

@ -15,10 +15,20 @@ describe('Script', function () {
// characterization tests
// initially these are just to capture how the app works currently
var testCases = {
// special relative resolves
'': filename,
'.': dirname,
'..': parentdir,
// local file "magic" tilde path expansion
'/~/defaultScripts.js': ScriptDiscoveryService.defaultScriptsPath + '/defaultScripts.js',
// these schemes appear to always get resolved to empty URLs
'qrc://test': '',
'about:Entities 1': '',
'ftp://host:port/path': '',
'data:text/html;text,foo': '',
'Entities 1': dirname + 'Entities 1',
'./file.js': dirname + 'file.js',
'c:/temp/': 'file:///c:/temp/',
@ -31,6 +41,12 @@ describe('Script', function () {
'/~/libraries/utils.js': 'file:///~/libraries/utils.js',
'/temp/file.js': 'file:///temp/file.js',
'/~/': 'file:///~/',
// these schemes appear to always get resolved to the same URL again
'http://highfidelity.com': 'http://highfidelity.com',
'atp:/highfidelity': 'atp:/highfidelity',
'atp:c2d7e3a48cadf9ba75e4f8d9f4d80e75276774880405a093fdee36543aa04f':
'atp:c2d7e3a48cadf9ba75e4f8d9f4d80e75276774880405a093fdee36543aa04f',
};
describe('resolvePath', function () {
Object.keys(testCases).forEach(function(input) {
@ -42,7 +58,7 @@ describe('Script', function () {
describe('include', function () {
var old_cache_buster;
var cache_buster = '#' + +new Date;
var cache_buster = '#' + new Date().getTime().toString(36);
beforeAll(function() {
old_cache_buster = Settings.getValue('cache_buster');
Settings.setValue('cache_buster', cache_buster);

69
scripts/modules/vec3.js Normal file
View file

@ -0,0 +1,69 @@
// Example of using a "system module" to decouple Vec3's implementation details.
//
// Users would bring Vec3 support in as a module:
// var vec3 = Script.require('vec3');
//
// (this example is compatible with using as a Script.include and as a Script.require module)
try {
// Script.require
module.exports = vec3;
} catch(e) {
// Script.include
Script.registerValue("vec3", vec3);
}
vec3.fromObject = function(v) {
//return new vec3(v.x, v.y, v.z);
//... this is even faster and achieves the same effect
v.__proto__ = vec3.prototype;
return v;
};
vec3.prototype = {
multiply: function(v2) {
// later on could support overrides like so:
// if (v2 instanceof quat) { [...] }
// which of the below is faster (C++ or JS)?
// (dunno -- but could systematically find out and go with that version)
// pure JS option
// return new vec3(this.x * v2.x, this.y * v2.y, this.z * v2.z);
// hybrid C++ option
return vec3.fromObject(Vec3.multiply(this, v2));
},
// detects any NaN and Infinity values
isValid: function() {
return isFinite(this.x) && isFinite(this.y) && isFinite(this.z);
},
// format Vec3's, eg:
// var v = vec3();
// print(v); // outputs [Vec3 (0.000, 0.000, 0.000)]
toString: function() {
if (this === vec3.prototype) {
return "{Vec3 prototype}";
}
function fixed(n) { return n.toFixed(3); }
return "[Vec3 (" + [this.x, this.y, this.z].map(fixed) + ")]";
},
};
vec3.DEBUG = true;
function vec3(x, y, z) {
if (!(this instanceof vec3)) {
// if vec3 is called as a function then re-invoke as a constructor
// (so that `value instanceof vec3` holds true for created values)
return new vec3(x, y, z);
}
// unfold default arguments (vec3(), vec3(.5), vec3(0,1), etc.)
this.x = x !== undefined ? x : 0;
this.y = y !== undefined ? y : this.x;
this.z = z !== undefined ? z : this.y;
if (vec3.DEBUG && !this.isValid())
throw new Error('vec3() -- invalid initial values ['+[].slice.call(arguments)+']');
};