diff --git a/libraries/networking/src/AssetClient.cpp b/libraries/networking/src/AssetClient.cpp index 6fdfc5a42b..725975c9fb 100644 --- a/libraries/networking/src/AssetClient.cpp +++ b/libraries/networking/src/AssetClient.cpp @@ -20,6 +20,7 @@ #include #include +#include #include "AssetRequest.h" #include "AssetUpload.h" @@ -72,6 +73,30 @@ void AssetClient::init() { networkAccessManager.setCache(cache); qInfo() << "ResourceManager disk cache setup at" << _cacheDir << "(size:" << MAXIMUM_CACHE_SIZE / BYTES_PER_GIGABYTES << "GB)"; + } else { + auto cache = qobject_cast(networkAccessManager.cache()); + qInfo() << "ResourceManager disk cache already setup at" << cache->cacheDirectory() + << "(size:" << cache->maximumCacheSize() / BYTES_PER_GIGABYTES << "GB)"; + } + +} + +void AssetClient::cacheInfoRequest(MiniPromise::Promise deferred) { + if (QThread::currentThread() != thread()) { + if (!QMetaType::isRegistered(qMetaTypeId())) { + qRegisterMetaType(); + } + QMetaObject::invokeMethod(this, "cacheInfoRequest", Q_ARG(MiniPromise::Promise, deferred)); + return; + } + if (auto* cache = qobject_cast(NetworkAccessManager::getInstance().cache())) { + deferred->resolve({ + { "cacheDirectory", cache->cacheDirectory() }, + { "cacheSize", cache->cacheSize() }, + { "maximumCacheSize", cache->maximumCacheSize() }, + }); + } else { + deferred->reject("Cache not available"); } } diff --git a/libraries/networking/src/AssetClient.h b/libraries/networking/src/AssetClient.h index 8035aa886e..5d854390e2 100644 --- a/libraries/networking/src/AssetClient.h +++ b/libraries/networking/src/AssetClient.h @@ -19,6 +19,7 @@ #include #include +#include #include "AssetUtils.h" #include "ByteRange.h" @@ -66,6 +67,7 @@ public slots: void init(); void cacheInfoRequest(QObject* reciever, QString slot); + void cacheInfoRequest(MiniPromise::Promise deferred); void clearCache(); private slots: diff --git a/libraries/networking/src/AssetRequest.cpp b/libraries/networking/src/AssetRequest.cpp index 7fa563d4ad..6a2bcdca9c 100644 --- a/libraries/networking/src/AssetRequest.cpp +++ b/libraries/networking/src/AssetRequest.cpp @@ -134,3 +134,14 @@ void AssetRequest::start() { emit progress(totalReceived, total); }); } + + +const QString AssetRequest::getErrorString() const { + QString result; + if (_error != Error::NoError) { + QVariant v; + v.setValue(_error); + result = v.toString(); // courtesy of Q_ENUM + } + return result; +} diff --git a/libraries/networking/src/AssetRequest.h b/libraries/networking/src/AssetRequest.h index a7213a90d7..b469e2f012 100644 --- a/libraries/networking/src/AssetRequest.h +++ b/libraries/networking/src/AssetRequest.h @@ -42,7 +42,7 @@ public: NetworkError, UnknownError }; - + Q_ENUM(Error) AssetRequest(const QString& hash, const ByteRange& byteRange = ByteRange()); virtual ~AssetRequest() override; @@ -51,6 +51,7 @@ public: const QByteArray& getData() const { return _data; } const State& getState() const { return _state; } const Error& getError() const { return _error; } + const QString getErrorString() const; QUrl getUrl() const { return ::getATPUrl(_hash); } QString getHash() const { return _hash; } diff --git a/libraries/networking/src/AssetResourceRequest.cpp b/libraries/networking/src/AssetResourceRequest.cpp index 55d0ad4f75..e1a155a561 100644 --- a/libraries/networking/src/AssetResourceRequest.cpp +++ b/libraries/networking/src/AssetResourceRequest.cpp @@ -146,7 +146,7 @@ void AssetResourceRequest::requestHash(const AssetHash& hash) { _assetRequest = assetClient->createRequest(hash, _byteRange); connect(_assetRequest, &AssetRequest::progress, this, &AssetResourceRequest::onDownloadProgress); - connect(_assetRequest, &AssetRequest::finished, this, [this](AssetRequest* req) { + connect(_assetRequest, &AssetRequest::finished, this, [this, hash](AssetRequest* req) { Q_ASSERT(_state == InProgress); Q_ASSERT(req == _assetRequest); Q_ASSERT(req->getState() == AssetRequest::Finished); diff --git a/libraries/networking/src/AssetUtils.cpp b/libraries/networking/src/AssetUtils.cpp index 76fda6aed4..fa9885f053 100644 --- a/libraries/networking/src/AssetUtils.cpp +++ b/libraries/networking/src/AssetUtils.cpp @@ -15,6 +15,7 @@ #include #include +#include // for baseName #include #include "NetworkAccessManager.h" @@ -22,8 +23,36 @@ #include "ResourceManager.h" -QUrl getATPUrl(const QString& hash) { - return QUrl(QString("%1:%2").arg(URL_SCHEME_ATP, hash)); +// Extract the valid AssetHash portion from atp: URLs like "[atp:]HASH[.fbx][?query]" +// (or an invalid AssetHash if not found) +AssetHash extractAssetHash(const QString& input) { + if (isValidHash(input)) { + return input; + } + QString path = getATPUrl(input).path(); + QString baseName = QFileInfo(path).baseName(); + if (isValidHash(baseName)) { + return baseName; + } + return AssetHash(); +} + +// Get the normalized ATP URL for a raw hash, /path or "atp:" input string. +QUrl getATPUrl(const QString& input) { + QUrl url = input; + if (!url.scheme().isEmpty() && url.scheme() != URL_SCHEME_ATP) { + return QUrl(); + } + // this strips extraneous info from the URL (while preserving fragment/querystring) + QString path = url.toEncoded( + QUrl::RemoveAuthority | QUrl::RemoveScheme | + QUrl::StripTrailingSlash | QUrl::NormalizePathSegments + ); + QString baseName = QFileInfo(path).baseName(); + if (isValidPath(path) || isValidHash(baseName)) { + return QUrl(QString("%1:%2").arg(URL_SCHEME_ATP).arg(path)); + } + return QUrl(); } QByteArray hashData(const QByteArray& data) { diff --git a/libraries/networking/src/AssetUtils.h b/libraries/networking/src/AssetUtils.h index a7c053c3d6..8c89b4701c 100644 --- a/libraries/networking/src/AssetUtils.h +++ b/libraries/networking/src/AssetUtils.h @@ -71,7 +71,8 @@ struct MappingInfo { using AssetMapping = std::map; -QUrl getATPUrl(const QString& hash); +QUrl getATPUrl(const QString& input); +AssetHash extractAssetHash(const QString& input); QByteArray hashData(const QByteArray& data); @@ -84,4 +85,18 @@ bool isValidHash(const QString& hashString); QString bakingStatusToString(BakingStatus status); +// backwards-compatible namespace aliases +// (allows new code to be explicit -- eg: `AssetUtils::isValidPath(path)` vs. `isValidPath(path)`) +namespace AssetUtils { + static const auto& loadFromCache = ::loadFromCache; + static const auto& saveToCache = ::saveToCache; + static const auto& hashData = ::hashData; + static const auto& getATPUrl = ::getATPUrl; + static const auto& extractAssetHash = ::extractAssetHash; + static const auto& isValidFilePath = ::isValidFilePath; + static const auto& isValidPath = ::isValidPath; + static const auto& isValidHash = ::isValidHash; +}; + + #endif // hifi_AssetUtils_h diff --git a/libraries/networking/src/BaseAssetScriptingInterface.cpp b/libraries/networking/src/BaseAssetScriptingInterface.cpp new file mode 100644 index 0000000000..bfdc21c7d5 --- /dev/null +++ b/libraries/networking/src/BaseAssetScriptingInterface.cpp @@ -0,0 +1,399 @@ +// +// BaseAssetScriptingInterface.cpp +// libraries/networking/src +// +// 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 + +#include "BaseAssetScriptingInterface.h" + +#include +#include +#include +#include + +#include "AssetRequest.h" +#include "AssetUpload.h" +#include "AssetUtils.h" +#include "MappingRequest.h" +#include "NetworkLogging.h" + +#include + +#include +#include "Gzip.h" + +using Promise = MiniPromise::Promise; + +QSharedPointer BaseAssetScriptingInterface::assetClient() { + return DependencyManager::get(); +} + +BaseAssetScriptingInterface::BaseAssetScriptingInterface(QObject* parent) : QObject(parent), _cache(this) { +} + + +bool BaseAssetScriptingInterface::initializeCache() { + qDebug() << "BaseAssetScriptingInterface::getCacheStatus -- current values" << _cache.cacheDirectory() << _cache.cacheSize() << _cache.maximumCacheSize(); + + // NOTE: *instances* of QNetworkDiskCache are not thread-safe -- however, different threads can effectively + // use the same underlying cache if configured with identical settings. Once AssetClient's disk cache settings + // become available we configure our instance to match. + auto assets = assetClient(); + if (!assets) { + return false; // not yet possible to initialize the cache + } + if (_cache.cacheDirectory().size()) { + return true; // cache is ready + } + + // attempt to initialize the cache + qDebug() << "BaseAssetScriptingInterface::getCacheStatus -- invoking AssetClient::init" << assetClient().data(); + QMetaObject::invokeMethod(assetClient().data(), "init"); + + qDebug() << "BaseAssetScriptingInterface::getCacheStatus querying cache status" << QThread::currentThread(); + Promise deferred = makePromise("BaseAssetScriptingInterface--queryCacheStatus"); + deferred->then([&](QVariantMap result) { + qDebug() << "//queryCacheStatus" << QThread::currentThread(); + auto cacheDirectory = result.value("cacheDirectory").toString(); + auto cacheSize = result.value("cacheSize").toLongLong(); + auto maximumCacheSize = result.value("maximumCacheSize").toLongLong(); + qDebug() << "///queryCacheStatus" << cacheDirectory << cacheSize << maximumCacheSize; + _cache.setCacheDirectory(cacheDirectory); + _cache.setMaximumCacheSize(maximumCacheSize); + }); + deferred->fail([&](QString error) { + qDebug() << "//queryCacheStatus ERROR" << QThread::currentThread() << error; + }); + assets->cacheInfoRequest(deferred); + return false; // cache is not ready yet +} + +QVariantMap BaseAssetScriptingInterface::getCacheStatus() { + //assetClient()->cacheInfoRequest(this, "onCacheInfoResponse"); + return { + { "cacheDirectory", _cache.cacheDirectory() }, + { "cacheSize", _cache.cacheSize() }, + { "maximumCacheSize", _cache.maximumCacheSize() }, + }; +} + +QVariantMap BaseAssetScriptingInterface::queryCacheMeta(const QUrl& url) { + QNetworkCacheMetaData metaData = _cache.metaData(url); + QVariantMap attributes, rawHeaders; + + QHashIterator i(metaData.attributes()); + while (i.hasNext()) { + i.next(); + attributes[QString::number(i.key())] = i.value(); + } + for (const auto& i : metaData.rawHeaders()) { + rawHeaders[i.first] = i.second; + } + return { + { "isValid", metaData.isValid() }, + { "url", metaData.url() }, + { "expirationDate", metaData.expirationDate() }, + { "lastModified", metaData.lastModified().toString().isEmpty() ? QDateTime() : metaData.lastModified() }, + { "saveToDisk", metaData.saveToDisk() }, + { "attributes", attributes }, + { "rawHeaders", rawHeaders }, + }; +} + +QVariantMap BaseAssetScriptingInterface::loadFromCache(const QUrl& url) { + qDebug() << "loadFromCache" << url; + QVariantMap result = { + { "metadata", queryCacheMeta(url) }, + { "data", QByteArray() }, + }; + // caller is responsible for the deletion of the ioDevice, hence the unique_ptr + if (auto ioDevice = std::unique_ptr(_cache.data(url))) { + QByteArray data = ioDevice->readAll(); + qCDebug(asset_client) << url.toDisplayString() << "loaded from disk cache (" << data.size() << " bytes)"; + result["data"] = data; + } + return result; +} + +namespace { + // parse RFC 1123 HTTP date format + QDateTime parseHttpDate(const QString& dateString) { + QDateTime dt = QDateTime::fromString(dateString.left(25), "ddd, dd MMM yyyy HH:mm:ss"); + dt.setTimeSpec(Qt::UTC); + return dt; + } +} + +bool BaseAssetScriptingInterface::saveToCache(const QUrl& url, const QByteArray& data, const QVariantMap& headers) { + QDateTime lastModified = headers.contains("last-modified") ? + parseHttpDate(headers["last-modified"].toString()) : + QDateTime::currentDateTimeUtc(); + QDateTime expirationDate = headers.contains("expires") ? + parseHttpDate(headers["expires"].toString()) : + QDateTime(); // never expires + QNetworkCacheMetaData metaData; + metaData.setUrl(url); + metaData.setSaveToDisk(true); + metaData.setLastModified(lastModified); + metaData.setExpirationDate(expirationDate); + if (auto ioDevice = _cache.prepare(metaData)) { + ioDevice->write(data); + _cache.insert(ioDevice); + qCDebug(asset_client) << url.toDisplayString() << "saved to disk cache ("<< data.size()<<" bytes)"; + return true; + } + qCWarning(asset_client) << "Could not save" << url.toDisplayString() << "to disk cache."; + return false; +} + +Promise BaseAssetScriptingInterface::loadAsset(QString asset, bool decompress, QString responseType) { + auto hash = AssetUtils::extractAssetHash(asset); + auto url = AssetUtils::getATPUrl(hash).toString(); + + QVariantMap metaData = { + { "_asset", asset }, + { "_type", "download" }, + { "hash", hash }, + { "url", url }, + { "responseType", responseType }, + }; + + Promise fetched = makePromise("loadAsset::fetched"), + loaded = makePromise("loadAsset::loaded"); + + downloadBytes(hash) + ->mixin(metaData) + ->ready([=](QString error, QVariantMap result) { + Q_ASSERT(thread() == QThread::currentThread()); + fetched->mixin(result); + if (decompress) { + qDebug() << "loadAsset::decompressBytes..."; + decompressBytes(result.value("data").toByteArray()) + ->mixin(result) + ->ready([=](QString error, QVariantMap result) { + fetched->handle(error, result); + }); + } else { + fetched->handle(error, result); + } + }); + + fetched->ready([=](QString error, QVariantMap result) { + qDebug() << "loadAsset::fetched" << error; + if (responseType == "arraybuffer") { + loaded->resolve(NoError, result); + } else { + convertBytes(result.value("data").toByteArray(), responseType) + ->mixin(result) + ->ready([=](QString error, QVariantMap result) { + loaded->resolve(NoError, result); + }); + } + }); + + return loaded; +} + +Promise BaseAssetScriptingInterface::convertBytes(const QByteArray& dataByteArray, const QString& responseType) { + QVariantMap result; + Promise conversion = makePromise(__FUNCTION__); + if (dataByteArray.size() == 0) { + result["response"] = QString(); + } else if (responseType == "text") { + result["response"] = QString::fromUtf8(dataByteArray); + } else if (responseType == "json") { + QJsonParseError status; + auto parsed = QJsonDocument::fromJson(dataByteArray, &status); + if (status.error == QJsonParseError::NoError) { + result["response"] = parsed.isArray() ? + QVariant(parsed.array().toVariantList()) : + QVariant(parsed.object().toVariantMap()); + } else { + QVariantMap errorResult = { + { "error", status.error }, + { "offset", status.offset }, + }; + return conversion->reject("JSON Parse Error: " + status.errorString(), errorResult); + } + } else if (responseType == "arraybuffer") { + result["response"] = dataByteArray; + } + return conversion->resolve(NoError, result); +} + +Promise BaseAssetScriptingInterface::decompressBytes(const QByteArray& dataByteArray) { + QByteArray inflated; + auto start = usecTimestampNow(); + if (gunzip(dataByteArray, inflated)) { + auto end = usecTimestampNow(); + return makePromise(__FUNCTION__)->resolve(NoError, { + { "_compressedByteLength", dataByteArray.size() }, + { "_compressedContentType", QMimeDatabase().mimeTypeForData(dataByteArray).name() }, + { "_compressMS", (double)(end - start) / 1000.0 }, + { "decompressed", true }, + { "byteLength", inflated.size() }, + { "contentType", QMimeDatabase().mimeTypeForData(inflated).name() }, + { "data", inflated }, + }); + } else { + return makePromise(__FUNCTION__)->reject("gunzip error", {}); + } +} + +Promise BaseAssetScriptingInterface::compressBytes(const QByteArray& dataByteArray, int level) { + QByteArray deflated; + auto start = usecTimestampNow(); + if (gzip(dataByteArray, deflated, level)) { + auto end = usecTimestampNow(); + return makePromise(__FUNCTION__)->resolve(NoError, { + { "_uncompressedByteLength", dataByteArray.size() }, + { "_uncompressedContentType", QMimeDatabase().mimeTypeForData(dataByteArray).name() }, + { "_compressMS", (double)(end - start) / 1000.0 }, + { "compressed", true }, + { "byteLength", deflated.size() }, + { "contentType", QMimeDatabase().mimeTypeForData(deflated).name() }, + { "data", deflated }, + }); + } else { + return makePromise(__FUNCTION__)->reject("gzip error", {}); + } +} + +Promise BaseAssetScriptingInterface::downloadBytes(QString hash) { + auto assetClient = DependencyManager::get(); + QPointer assetRequest = assetClient->createRequest(hash); + Promise deferred = makePromise(__FUNCTION__); + + QObject::connect(assetRequest, &AssetRequest::finished, assetRequest, [this, deferred](AssetRequest* request) { + qDebug() << "...BaseAssetScriptingInterface::downloadBytes" << request->getErrorString(); + // note: we are now on the "Resource Manager" thread + Q_ASSERT(QThread::currentThread() == request->thread()); + Q_ASSERT(request->getState() == AssetRequest::Finished); + QString error; + QVariantMap result; + if (request->getError() == AssetRequest::Error::NoError) { + QByteArray data = request->getData(); + result = { + { "url", request->getUrl() }, + { "hash", request->getHash() }, + { "cached", request->loadedFromCache() }, + { "content-type", QMimeDatabase().mimeTypeForData(data).name() }, + { "data", data }, + }; + } else { + error = request->getError(); + result = { { "error", request->getError() } }; + } + qDebug() << "//BaseAssetScriptingInterface::downloadBytes" << error << result.keys(); + // forward thread-safe copies back to our thread + deferred->handle(error, result); + request->deleteLater(); + }); + assetRequest->start(); + return deferred; +} + +Promise BaseAssetScriptingInterface::uploadBytes(const QByteArray& bytes) { + Promise deferred = makePromise(__FUNCTION__); + QPointer upload = DependencyManager::get()->createUpload(bytes); + + QObject::connect(upload, &AssetUpload::finished, upload, [=](AssetUpload* upload, const QString& hash) { + Q_ASSERT(QThread::currentThread() == upload->thread()); + // note: we are now on the "Resource Manager" thread + QString error; + QVariantMap result; + if (upload->getError() == AssetUpload::NoError) { + result = { + { "hash", hash }, + { "url", AssetUtils::getATPUrl(hash).toString() }, + { "filename", upload->getFilename() }, + }; + } else { + error = upload->getErrorString(); + result = { { "error", upload->getError() } }; + } + // forward thread-safe copies back to our thread + deferred->handle(error, result); + upload->deleteLater(); + }); + upload->start(); + return deferred; +} + +Promise BaseAssetScriptingInterface::getAssetInfo(QString asset) { + auto deferred = makePromise(__FUNCTION__); + auto url = AssetUtils::getATPUrl(asset); + auto path = url.path(); + auto hash = AssetUtils::extractAssetHash(asset); + if (AssetUtils::isValidHash(hash)) { + // already a valid ATP hash -- nothing to do + deferred->resolve(NoError, { + { "hash", hash }, + { "path", path }, + { "url", url }, + }); + } else if (AssetUtils::isValidFilePath(path)) { + auto assetClient = DependencyManager::get(); + QPointer request = assetClient->createGetMappingRequest(path); + + QObject::connect(request, &GetMappingRequest::finished, request, [=]() { + Q_ASSERT(QThread::currentThread() == request->thread()); + // note: we are now on the "Resource Manager" thread + QString error; + QVariantMap result; + if (request->getError() == GetMappingRequest::NoError) { + result = { + { "_hash", hash }, + { "_path", path }, + { "_url", url }, + { "hash", request->getHash() }, + { "wasRedirected", request->wasRedirected() }, + { "path", request->wasRedirected() ? request->getRedirectedPath() : path }, + }; + } else { + error = request->getErrorString(); + result = { { "error", request->getError() } }; + } + // forward thread-safe copies back to our thread + deferred->handle(error, result); + request->deleteLater(); + }); + request->start(); + } else { + deferred->reject("invalid ATP file path: " + asset + "("+path+")", {}); + } + return deferred; +} + +Promise BaseAssetScriptingInterface::symlinkAsset(QString hash, QString path) { + auto deferred = makePromise(__FUNCTION__); + auto assetClient = DependencyManager::get(); + QPointer setMappingRequest = assetClient->createSetMappingRequest(path, hash); + + connect(setMappingRequest, &SetMappingRequest::finished, setMappingRequest, [=](SetMappingRequest* request) { + Q_ASSERT(QThread::currentThread() == request->thread()); + // we are now on the "Resource Manager" thread + QString error; + QVariantMap result; + if (request->getError() == SetMappingRequest::NoError) { + result = { + { "_hash", hash }, + { "_path", path }, + { "hash", request->getHash() }, + { "path", request->getPath() }, + { "url", AssetUtils::getATPUrl(request->getPath()).toString() }, + }; + } else { + error = request->getErrorString(); + result = { { "error", request->getError() } }; + } + // forward results back to our thread + deferred->handle(error, result); + request->deleteLater(); + }); + setMappingRequest->start(); + return deferred; +} diff --git a/libraries/networking/src/BaseAssetScriptingInterface.h b/libraries/networking/src/BaseAssetScriptingInterface.h new file mode 100644 index 0000000000..3aca3d394e --- /dev/null +++ b/libraries/networking/src/BaseAssetScriptingInterface.h @@ -0,0 +1,78 @@ +// +// BaseAssetScriptingInterface.h +// libraries/networking/src +// +// 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 +// + +// BaseAssetScriptingInterface contains the engine-agnostic support code that can be used from +// both QML JS and QScriptEngine JS engine implementations + +#ifndef hifi_BaseAssetScriptingInterface_h +#define hifi_BaseAssetScriptingInterface_h + +#include +#include +#include "AssetClient.h" +#include +#include "NetworkAccessManager.h" +#include + +/**jsdoc + * @namespace Assets + */ +class BaseAssetScriptingInterface : public QObject { + Q_OBJECT +public: + using Promise = MiniPromise::Promise; + QSharedPointer assetClient(); + + BaseAssetScriptingInterface(QObject* parent = nullptr); + +public slots: + /**jsdoc + * Get the current status of the disk cache (if available) + * @function Assets.uploadData + * @static + * @return {String} result.cacheDirectory (path to the current disk cache) + * @return {Number} result.cacheSize (used cache size in bytes) + * @return {Number} result.maximumCacheSize (maxmimum cache size in bytes) + */ + QVariantMap getCacheStatus(); + + /**jsdoc + * Initialize the disk cache (returns true if already initialized) + * @function Assets.initializeCache + * @static + */ + bool initializeCache(); + + virtual bool isValidPath(QString input) { return AssetUtils::isValidPath(input); } + virtual bool isValidFilePath(QString input) { return AssetUtils::isValidFilePath(input); } + QUrl getATPUrl(QString input) { return AssetUtils::getATPUrl(input); } + QString extractAssetHash(QString input) { return AssetUtils::extractAssetHash(input); } + bool isValidHash(QString input) { return AssetUtils::isValidHash(input); } + QByteArray hashData(const QByteArray& data) { return AssetUtils::hashData(data); } + + virtual QVariantMap queryCacheMeta(const QUrl& url); + virtual QVariantMap loadFromCache(const QUrl& url); + virtual bool saveToCache(const QUrl& url, const QByteArray& data, const QVariantMap& metadata = QVariantMap()); + +protected: + //void onCacheInfoResponse(QString cacheDirectory, qint64 cacheSize, qint64 maximumCacheSize); + QNetworkDiskCache _cache; + const QString NoError{}; + //virtual bool jsAssert(bool condition, const QString& error) = 0; + Promise loadAsset(QString asset, bool decompress, QString responseType); + Promise getAssetInfo(QString asset); + Promise downloadBytes(QString hash); + Promise uploadBytes(const QByteArray& bytes); + Promise compressBytes(const QByteArray& bytes, int level = -1); + Promise convertBytes(const QByteArray& dataByteArray, const QString& responseType); + Promise decompressBytes(const QByteArray& bytes); + Promise symlinkAsset(QString hash, QString path); +}; +#endif // hifi_BaseAssetScriptingInterface_h diff --git a/libraries/script-engine/src/AssetScriptingInterface.cpp b/libraries/script-engine/src/AssetScriptingInterface.cpp index 25e8c0dcf3..b5492db304 100644 --- a/libraries/script-engine/src/AssetScriptingInterface.cpp +++ b/libraries/script-engine/src/AssetScriptingInterface.cpp @@ -11,43 +11,70 @@ #include "AssetScriptingInterface.h" +#include +#include #include #include #include +#include #include #include #include -AssetScriptingInterface::AssetScriptingInterface(QScriptEngine* engine) : - _engine(engine) -{ +#include + +#include +#include "Gzip.h" +#include "ScriptEngine.h" + +//using Promise = MiniPromise::Promise; + +AssetScriptingInterface::AssetScriptingInterface(QObject* parent) : BaseAssetScriptingInterface(parent) { + if (auto engine = qobject_cast(parent)) { + registerMetaTypes(engine); + } } +#define JS_ASSERT(cond, error) { if (!this->jsAssert(cond, error)) { return; } } void AssetScriptingInterface::uploadData(QString data, QScriptValue callback) { + auto handler = makeScopedHandlerObject(thisObject(), callback); QByteArray dataByteArray = data.toUtf8(); auto upload = DependencyManager::get()->createUpload(dataByteArray); - QObject::connect(upload, &AssetUpload::finished, this, [this, callback](AssetUpload* upload, const QString& hash) mutable { - if (callback.isFunction()) { - QString url = "atp:" + hash; - QScriptValueList args { url, hash }; - callback.call(_engine->currentContext()->thisObject(), args); - } + Promise deferred = makePromise(__FUNCTION__) + ->ready([this, handler](QString error, QVariantMap result) { + auto url = result.value("url").toString(); + auto hash = result.value("hash").toString(); + jsCallback(handler, url, hash); + }); + + connect(upload, &AssetUpload::finished, upload, [this, deferred](AssetUpload* upload, const QString& hash) { + // we are now on the "Resource Manager" thread (and "hash" being a *reference* makes it unsafe to use directly) + Q_ASSERT(QThread::currentThread() == upload->thread()); + deferred->resolve(NoError, { + { "url", "atp:" + hash }, + { "hash", hash }, + }); upload->deleteLater(); }); upload->start(); } void AssetScriptingInterface::setMapping(QString path, QString hash, QScriptValue callback) { - auto setMappingRequest = DependencyManager::get()->createSetMappingRequest(path, hash); + auto handler = makeScopedHandlerObject(thisObject(), callback); + auto setMappingRequest = assetClient()->createSetMappingRequest(path, hash); + Promise deferred = makePromise(__FUNCTION__) + ->ready([=](QString error, QVariantMap result) { + jsCallback(handler, error, result); + }); - QObject::connect(setMappingRequest, &SetMappingRequest::finished, this, [this, callback](SetMappingRequest* request) mutable { - if (callback.isFunction()) { - QString error = request->getErrorString(); - QScriptValueList args { error }; - callback.call(_engine->currentContext()->thisObject(), args); - } + connect(setMappingRequest, &SetMappingRequest::finished, setMappingRequest, [this, deferred](SetMappingRequest* request) { + Q_ASSERT(QThread::currentThread() == request->thread()); + // we are now on the "Resource Manager" thread + QString error = request->getErrorString(); + // forward a thread-safe values back to our thread + deferred->handle(error, { { "error", request->getError() } }); request->deleteLater(); }); setMappingRequest->start(); @@ -55,48 +82,63 @@ void AssetScriptingInterface::setMapping(QString path, QString hash, QScriptValu void AssetScriptingInterface::downloadData(QString urlString, QScriptValue callback) { + // FIXME: historically this API method failed silently when given a non-atp prefixed + // urlString (or if the AssetRequest failed). + // .. is that by design or could we update without breaking things to provide better feedback to scripts? if (!urlString.startsWith(ATP_SCHEME)) { + // ... for now at least log a message so user can check logs + qDebug() << "AssetScriptingInterface::downloadData ERROR: url does not start with " << ATP_SCHEME; return; } - - // Make request to atp - auto path = urlString.right(urlString.length() - ATP_SCHEME.length()); - auto parts = path.split(".", QString::SkipEmptyParts); - auto hash = parts.length() > 0 ? parts[0] : ""; - + QString hash = AssetUtils::extractAssetHash(urlString); + auto handler = makeScopedHandlerObject(thisObject(), callback); auto assetClient = DependencyManager::get(); auto assetRequest = assetClient->createRequest(hash); - _pendingRequests << assetRequest; + Promise deferred = makePromise(__FUNCTION__) + ->ready([=](QString error, QVariantMap result) { + // FIXME: to remain backwards-compatible the signature here is "callback(data, n/a)" + jsCallback(handler, result.value("data").toString(), { { "errorMessage", error } }); + }); - connect(assetRequest, &AssetRequest::finished, this, [this, callback](AssetRequest* request) mutable { + connect(assetRequest, &AssetRequest::finished, assetRequest, [this, deferred](AssetRequest* request) { + Q_ASSERT(QThread::currentThread() == request->thread()); + // we are now on the "Resource Manager" thread Q_ASSERT(request->getState() == AssetRequest::Finished); if (request->getError() == AssetRequest::Error::NoError) { - if (callback.isFunction()) { - QString data = QString::fromUtf8(request->getData()); - QScriptValueList args { data }; - callback.call(_engine->currentContext()->thisObject(), args); - } + QString data = QString::fromUtf8(request->getData()); + // forward a thread-safe values back to our thread + deferred->resolve(NoError, { { "data", data } }); + } else { + // FIXME: propagate error to scripts? (requires changing signature or inverting param order above..) + //deferred->resolve(request->getErrorString(), { { "error", requet->getError() } }); + qDebug() << "AssetScriptingInterface::downloadData ERROR: " << request->getErrorString(); } request->deleteLater(); - _pendingRequests.remove(request); }); assetRequest->start(); } void AssetScriptingInterface::setBakingEnabled(QString path, bool enabled, QScriptValue callback) { + auto handler = makeScopedHandlerObject(thisObject(), callback); auto setBakingEnabledRequest = DependencyManager::get()->createSetBakingEnabledRequest({ path }, enabled); - QObject::connect(setBakingEnabledRequest, &SetBakingEnabledRequest::finished, this, [this, callback](SetBakingEnabledRequest* request) mutable { - if (callback.isFunction()) { - QString error = request->getErrorString(); - QScriptValueList args{ error }; - callback.call(_engine->currentContext()->thisObject(), args); - } + Promise deferred = makePromise(__FUNCTION__) + ->ready([=](QString error, QVariantMap result) { + jsCallback(handler, error, result); + }); + + connect(setBakingEnabledRequest, &SetBakingEnabledRequest::finished, setBakingEnabledRequest, [this, deferred](SetBakingEnabledRequest* request) { + Q_ASSERT(QThread::currentThread() == request->thread()); + // we are now on the "Resource Manager" thread + + QString error = request->getErrorString(); + // forward thread-safe values back to our thread + deferred->handle(error, {}); request->deleteLater(); }); setBakingEnabledRequest->start(); @@ -111,3 +153,238 @@ void AssetScriptingInterface::sendFakedHandshake() { } #endif + +void AssetScriptingInterface::getMapping(QString asset, QScriptValue callback) { + auto path = AssetUtils::getATPUrl(asset).path(); + auto handler = makeScopedHandlerObject(thisObject(), callback); + JS_ASSERT(AssetUtils::isValidFilePath(path), "invalid ATP file path: " + asset + "(path:"+path+")"); + JS_ASSERT(callback.isFunction(), "expected second parameter to be a callback function"); + qDebug() << ">>getMapping//getAssetInfo" << path; + getAssetInfo(path)->ready([this, handler](QString error, QVariantMap result) { + qDebug() << "//getMapping//getAssetInfo" << error << result.keys(); + jsCallback(handler, error, result.value("hash").toString()); + }); +} + +/////////////////////////// new APIS //////////////////////////////////// + +bool AssetScriptingInterface::jsAssert(bool condition, const QString& error) { + if (condition) { + return true; + } + if (context()) { + context()->throwError(error); + } else { + qDebug() << "WARNING -- jsAssert failed outside of a valid JS context: " + error; + } + return false; +} + +void AssetScriptingInterface::jsCallback(const QScriptValue& handler, + const QScriptValue& error, const QScriptValue& result) { + Q_ASSERT(thread() == QThread::currentThread()); + auto errorValue = !error.toBool() ? QScriptValue::NullValue : error; + JS_ASSERT(handler.isObject() && handler.property("callback").isFunction(), + QString("jsCallback -- .callback is not a function (%1)") + .arg(handler.property("callback").toVariant().typeName())); +#if 1 || DEGUG_JSCALLBACK + QScriptValue debug = result; + debug.setProperty("toString", handler.engine()->evaluate("1,function() { return JSON.stringify(this, 0, 2); }")); +#endif + ::callScopedHandlerObject(handler, errorValue, result); +} + +void AssetScriptingInterface::jsCallback(const QScriptValue& handler, + const QScriptValue& error, const QVariantMap& result) { + Q_ASSERT(thread() == QThread::currentThread()); + Q_ASSERT(handler.engine()); + auto engine = handler.engine(); + jsCallback(handler, error, engine->toScriptValue(result)); +} + +void AssetScriptingInterface::deleteAsset(QScriptValue options, QScriptValue scope, QScriptValue callback) { + jsAssert(false, "TODO: deleteAsset API"); +} + +void AssetScriptingInterface::getAsset(QScriptValue options, QScriptValue scope, QScriptValue callback) { + JS_ASSERT(options.isObject() || options.isString(), "expected request options Object or URL as first parameter"); + + auto decompress = options.property("decompress").toBool() || options.property("compressed").toBool(); + auto responseType = options.property("responseType").toString().toLower(); + auto url = options.property("url").toString(); + if (options.isString()) { + url = options.toString(); + } + if (responseType.isEmpty() || responseType == "string") { + responseType = "text"; + } + auto asset = AssetUtils::getATPUrl(url).path(); + auto handler = makeScopedHandlerObject(scope, callback); + + JS_ASSERT(handler.property("callback").isFunction(), + QString("Invalid callback function (%1)").arg(handler.property("callback").toVariant().typeName())); + JS_ASSERT(AssetUtils::isValidHash(asset) || AssetUtils::isValidFilePath(asset), + QString("Invalid ATP url '%1'").arg(url)); + JS_ASSERT(RESPONSE_TYPES.contains(responseType), + QString("Invalid responseType: '%1' (expected: %2)").arg(responseType).arg(RESPONSE_TYPES.join(" | "))); + + Promise resolved = makePromise("resolved"), + loaded = makePromise("loaded"); + + loaded->ready([=](QString error, QVariantMap result) { + qDebug() << "//loaded" << error; + jsCallback(handler, error, result); + }); + + resolved->ready([=](QString error, QVariantMap result) { + qDebug() << "//resolved" << result.value("hash"); + QString hash = result.value("hash").toString(); + if (!error.isEmpty() || !AssetUtils::isValidHash(hash)) { + loaded->reject(error.isEmpty() ? "internal hash error: " + hash : error, result); + } else { + loadAsset(hash, decompress, responseType) + ->mixin(result) + ->ready([this, loaded, hash](QString error, QVariantMap result) { + qDebug() << "//getAssetInfo/loadAsset" << error << hash; + loaded->resolve(NoError, result); + }); + } + }); + + if (AssetUtils::isValidHash(asset)) { + resolved->resolve(NoError, { { "hash", asset } }); + } else { + getAssetInfo(asset) + ->ready([this, resolved](QString error, QVariantMap result) { + qDebug() << "//getAssetInfo" << error << result.value("hash") << result.value("path"); + resolved->resolve(error, result); + }); + } +} + +void AssetScriptingInterface::resolveAsset(QScriptValue options, QScriptValue scope, QScriptValue callback) { + const QString& URL{ "url" }; + + auto url = (options.isString() ? options : options.property(URL)).toString(); + auto asset = AssetUtils::getATPUrl(url).path(); + auto handler = makeScopedHandlerObject(scope, callback); + + JS_ASSERT(AssetUtils::isValidFilePath(asset) || AssetUtils::isValidHash(asset), + "expected options to be an asset URL or request options containing .url property"); + JS_ASSERT(handler.property("callback").isFunction(), "invalid callback function"); + getAssetInfo(asset)->ready([=](QString error, QVariantMap result) { + qDebug() << "//resolveAsset/getAssetInfo" << error << result.value("hash"); + jsCallback(handler, error, result); + }); +} + +void AssetScriptingInterface::decompressData(QScriptValue options, QScriptValue scope, QScriptValue callback) { + auto data = options.property("data"); + QByteArray dataByteArray = qscriptvalue_cast(data); + auto handler = makeScopedHandlerObject(scope, callback); + auto responseType = options.property("responseType").toString().toLower(); + if (responseType.isEmpty()) { + responseType = "text"; + } + decompressBytes(dataByteArray) + ->ready([=](QString error, QVariantMap result) { + if (responseType == "arraybuffer") { + jsCallback(handler, error, result); + } else { + convertBytes(result.value("data").toByteArray(), responseType) + ->mixin(result) + ->ready([=](QString error, QVariantMap result) { + jsCallback(handler, error, result); + }); + } + }); +} + +void AssetScriptingInterface::compressData(QScriptValue options, QScriptValue scope, QScriptValue callback) { + auto data = options.property("data"); + QByteArray dataByteArray = data.isString() ? + data.toString().toUtf8() : + qscriptvalue_cast(data); + auto handler = makeScopedHandlerObject(scope, callback); + auto level = options.property("level").toInt32(); + if (level < -1 || level > 9) { + level = -1; + } + compressBytes(dataByteArray, level) + ->ready([=](QString error, QVariantMap result) { + jsCallback(handler, error, result); + }); +} + +void AssetScriptingInterface::putAsset(QScriptValue options, QScriptValue scope, QScriptValue callback) { + auto compress = options.property("compress").toBool() || + options.property("compressed").toBool(); + auto handler = makeScopedHandlerObject(scope, callback); + auto data = options.property("data"); + auto rawPath = options.property("path").toString(); + auto path = AssetUtils::getATPUrl(rawPath).path(); + + QByteArray dataByteArray = data.isString() ? + data.toString().toUtf8() : + qscriptvalue_cast(data); + //auto rawByteLength = dataByteArray.size(); + + JS_ASSERT(path.isEmpty() || AssetUtils::isValidFilePath(path), + QString("expected valid ATP file path '%1' ('%2')").arg(rawPath).arg(path)); + JS_ASSERT(handler.property("callback").isFunction(), + "invalid callback function"); + JS_ASSERT(dataByteArray.size() > 0, + QString("expected non-zero .data (got %1 / #%2 bytes)") + .arg(data.toVariant().typeName()) + .arg(dataByteArray.size())); + + + // [compressed] => uploaded to server => [mapped to path] + Promise prepared = makePromise("putAsset::prepared"), + uploaded = makePromise("putAsset::uploaded"), + finished = makePromise("putAsset::finished"); + + if (compress) { + qDebug() << "putAsset::compressBytes..."; + compressBytes(dataByteArray, -1) + ->finally([=](QString error, QVariantMap result) { + qDebug() << "//putAsset::compressedBytes" << error << result.keys(); + prepared->handle(error, result); + }); + } else { + prepared->resolve(NoError, {{ "data", dataByteArray }}); + } + + prepared->ready([=](QString error, QVariantMap result) { + qDebug() << "//putAsset::prepared" << error << result.value("data").toByteArray().size() << result.keys(); + uploadBytes(result.value("data").toByteArray()) + ->mixin(result) + ->ready([=](QString error, QVariantMap result) { + qDebug() << "===========//putAsset::prepared/uploadBytes" << error << result.keys(); + uploaded->handle(error, result); + }); + }); + + uploaded->ready([=](QString error, QVariantMap result) { + QString hash = result.value("hash").toString(); + qDebug() << "//putAsset::uploaded" << error << hash << result.keys(); + if (path.isEmpty()) { + finished->handle(error, result); + } else if (!AssetUtils::isValidHash(hash)) { + finished->reject("path mapping requested, but did not receive valid hash", result); + } else { + qDebug() << "symlinkAsset" << hash << path << QThread::currentThread(); + symlinkAsset(hash, path) + ->mixin(result) + ->ready([=](QString error, QVariantMap result) { + finished->handle(error, result); + qDebug() << "//symlinkAsset" << hash << path << result.keys(); + }); + } + }); + + finished->ready([=](QString error, QVariantMap result) { + qDebug() << "//putAsset::finished" << error << result.keys(); + jsCallback(handler, error, result); + }); +} diff --git a/libraries/script-engine/src/AssetScriptingInterface.h b/libraries/script-engine/src/AssetScriptingInterface.h index 2812be65f9..9022316634 100644 --- a/libraries/script-engine/src/AssetScriptingInterface.h +++ b/libraries/script-engine/src/AssetScriptingInterface.h @@ -15,17 +15,21 @@ #define hifi_AssetScriptingInterface_h #include +#include #include - +#include #include +#include +#include +#include /**jsdoc * @namespace Assets */ -class AssetScriptingInterface : public QObject { +class AssetScriptingInterface : public BaseAssetScriptingInterface, QScriptable { Q_OBJECT public: - AssetScriptingInterface(QScriptEngine* engine); + AssetScriptingInterface(QObject* parent = nullptr); /**jsdoc * Upload content to the connected domain's asset server. @@ -75,16 +79,37 @@ public: * @param {string} error */ Q_INVOKABLE void setMapping(QString path, QString hash, QScriptValue callback); - + Q_INVOKABLE void getMapping(QString path, QScriptValue callback); + Q_INVOKABLE void setBakingEnabled(QString path, bool enabled, QScriptValue callback); #if (PR_BUILD || DEV_BUILD) Q_INVOKABLE void sendFakedHandshake(); #endif + // Advanced APIs + // getAsset(options, scope[callback(error, result)]) -- fetches an Asset from the Server + // [options.url] an "atp:" style URL, hash, or relative mapped path to fetch + // [options.responseType] the desired reponse type (text | arraybuffer | json) + // [options.decompress] whether to apply gunzip decompression on the stream + // [scope[callback]] continuation-style (error, { responseType, data, byteLength, ... }) callback + const QStringList RESPONSE_TYPES{ "text", "arraybuffer", "json" }; + Q_INVOKABLE void getAsset(QScriptValue options, QScriptValue scope, QScriptValue callback = QScriptValue()); + // putAsset(options, scope[callback(error, result)]) -- upload a new Aset to the Server + // [options.data] -- (ArrayBuffer|String) + // [options.compress] -- (true|false) + // [options.path=undefined] -- option path mapping to set on the created hash result + // [ + Q_INVOKABLE void putAsset(QScriptValue options, QScriptValue scope, QScriptValue callback = QScriptValue()); + Q_INVOKABLE void deleteAsset(QScriptValue options, QScriptValue scope, QScriptValue callback = QScriptValue()); + Q_INVOKABLE void resolveAsset(QScriptValue options, QScriptValue scope, QScriptValue callback = QScriptValue()); + Q_INVOKABLE void decompressData(QScriptValue options, QScriptValue scope, QScriptValue callback = QScriptValue()); + Q_INVOKABLE void compressData(QScriptValue options, QScriptValue scope, QScriptValue callback = QScriptValue()); + protected: - QSet _pendingRequests; - QScriptEngine* _engine; + void jsCallback(const QScriptValue& handler, const QScriptValue& error, const QVariantMap& result); + void jsCallback(const QScriptValue& handler, const QScriptValue& error, const QScriptValue& result); + bool jsAssert(bool condition, const QString& error); }; #endif // hifi_AssetScriptingInterface_h diff --git a/scripts/developer/tests/unit_tests/assetUnitTests.js b/scripts/developer/tests/unit_tests/assetUnitTests.js new file mode 100644 index 0000000000..be8710e50d --- /dev/null +++ b/scripts/developer/tests/unit_tests/assetUnitTests.js @@ -0,0 +1,181 @@ +/* eslint-env jasmine */ + +instrument_testrunner(true); + +describe("Assets", function () { + var context = { + definedHash: null, + definedPath: null, + definedContent: null, + }; + var NULL_HASH = new Array(64+1).join('0'); // 64 hex + var SAMPLE_FOLDER = '/assetUnitTests'; + var SAMPLE_FILE_PATH = SAMPLE_FOLDER + "/test.json"; + var SAMPLE_CONTENTS = 'Test Run on ' + JSON.stringify(Date()); + var IS_ASSET_HASH_REGEX = /^[a-f0-9]{64}$/i; + var IS_ASSET_URL = /^atp:/; + + it('Entities.canWriteAssets', function() { + expect(Entities.canWriteAssets()).toBe(true); + }); + + it('Assets.extractAssetHash(input)', function() { + // new helper method that provides a catch-all way to get at the sha256 hash + // considering the multiple, different ways ATP hash URLs are found across the + // system and in content. + + var POSITIVE_TEST_URLS = [ + 'atp:HASH', + 'atp:HASH.obj', + 'atp:HASH.fbx?cache-buster', + 'atp:/.baked/HASH/asset.fbx', + 'HASH' + ]; + var NEGATIVE_TEST_URLS = [ + 'asdf', + 'http://domain.tld', + '/path/filename.fbx', + 'atp:/path/filename.fbx?#HASH', + '' + ]; + pending(); + }); + + // new APIs + + it('Assets.getAsset(options, {callback(error, result)})', function() { pending(); }); + it('Assets.putAsset(options, {callback(error, result)})', function() { pending(); }); + it('Assets.deleteAsset(options, {callback(error, result)})', function() { pending(); }); + it('Assets.resolveAsset(options, {callback(error, result)})', function() { pending(); }); + + + // existing APIs + it('Assets.getATPUrl(input)', function() { pending(); }); + it('Assets.isValidPath(input)', function() { pending(); }); + it('Assets.isValidFilePath(input)', function() { pending(); }); + it('Assets.isValidHash(input)', function() { pending(); }); + it('Assets.setBakingEnabled(path, enabled, {callback(error)})', function() { pending(); }); + + it("Assets.uploadData(data, {callback(url, hash)})", function (done) { + Assets.uploadData(SAMPLE_CONTENTS, function(url, hash) { + expect(url).toMatch(IS_ASSET_URL); + expect(hash).toMatch(IS_ASSET_HASH_REGEX); + context.definedHash = hash; // used in later tests + context.definedContent = SAMPLE_CONTENTS; + print('context.definedHash = ' + context.definedHash); + print('context.definedContent = ' + context.definedContent); + done(); + }); + }); + + it("Assets.setMapping(path, hash {callback(error)})", function (done) { + expect(context.definedHash).toMatch(IS_ASSET_HASH_REGEX); + Assets.setMapping(SAMPLE_FILE_PATH, context.definedHash, function(error) { + if (error) error += ' ('+JSON.stringify([SAMPLE_FILE_PATH, context.definedHash])+')'; + expect(error).toBe(null); + context.definedPath = SAMPLE_FILE_PATH; + print('context.definedPath = ' + context.definedPath); + done(); + }); + }); + + it("Assets.getMapping(path, {callback(error, hash)})", function (done) { + expect(context.definedHash).toMatch(IS_ASSET_HASH_REGEX, 'asfasdf'); + expect(context.definedPath).toMatch(/^\//, 'asfasdf'); + Assets.getMapping(context.definedPath, function(error, hash) { + if (error) error += ' ('+JSON.stringify([context.definedPath])+')'; + expect(error).toBe(null); + expect(hash).toMatch(IS_ASSET_HASH_REGEX); + expect(hash).toEqual(context.definedHash); + done(); + }); + }); + + it('Assets.getMapping(nullHash, {callback(data)})', function(done) { + // FIXME: characterization test -- current behavior is that downloadData silently fails + // when given an asset that doesn't exist + Assets.downloadData(NULL_HASH, function(result) { + throw new Error("this callback 'should' not be triggered"); + }); + setTimeout(function() { done(); }, 2000); + }); + + it('Assets.downloadData(hash, {callback(data)})', function(done) { + expect(context.definedHash).toMatch(IS_ASSET_HASH_REGEX, 'asfasdf'); + Assets.downloadData('atp:' + context.definedHash, function(result) { + expect(result).toEqual(context.definedContent); + done(); + }); + }); + + it('Assets.downloadData(nullHash, {callback(data)})', function(done) { + // FIXME: characterization test -- current behavior is that downloadData silently fails + // when given an asset doesn't exist + Assets.downloadData(NULL_HASH, function(result) { + throw new Error("this callback 'should' not be triggered"); + }); + setTimeout(function() { + done(); + }, 2000); + }); + + describe('exceptions', function() { + describe('Asset.setMapping', function() { + it('-- invalid path', function(done) { + Assets.setMapping('foo', NULL_HASH, function(error/*, result*/) { + expect(error).toEqual('Path is invalid'); + /* expect(result).not.toMatch(IS_ASSET_URL); */ + done(); + }); + }); + it('-- invalid hash', function(done) { + Assets.setMapping(SAMPLE_FILE_PATH, 'bar', function(error/*, result*/) { + expect(error).toEqual('Hash is invalid'); + /* expect(result).not.toMatch(IS_ASSET_URL); */ + done(); + }); + }); + }); + describe('Asset.getMapping', function() { + it('-- invalid path throws immediate', function() { + expect(function() { + Assets.getMapping('foo', function(error, hash) { + throw new Error("should never make it here..."); + }); + throw new Error("should never make it here..."); + }).toThrowError(/invalid.*path/i); + }); + it('-- non-existing path', function(done) { + Assets.getMapping('/foo/bar/'+Date.now(), function(error, hash) { + expect(error).toEqual('Asset not found'); + expect(hash).not.toMatch(IS_ASSET_HASH_REGEX); + done(); + }); + }); + }); + }); +}); + +// ---------------------------------------------------------------------------- +// this stuff allows the unit tests to be loaded indepenently and/or as part of testRunner.js execution +function run() {} +function instrument_testrunner(force) { + if (force || typeof describe === 'undefined') { + // Include testing library + Script.include('../../libraries/jasmine/jasmine.js'); + Script.include('../../libraries/jasmine/hifi-boot.js'); + + run = function() { + if (!/:console/.test(Script.resolvePath(''))) { + // invoke Script.stop (after any async tests complete) + jasmine.getEnv().addReporter({ jasmineDone: Script.stop }); + } else { + jasmine.getEnv().addReporter({ jasmineDone: function() { print("JASMINE DONE"); } }); + } + + // Run the tests + jasmine.getEnv().execute(); + }; + } +} +run();