From 9d1d970b1f7007b1e703e13ce812f2dbb3170dd0 Mon Sep 17 00:00:00 2001 From: Yavor Georgiev Date: Wed, 1 Feb 2017 14:18:59 +0100 Subject: [PATCH] Sync Session API (#825) * Add Session class and expose it to JS * Add error property on the sync config object for the event handler * tests * refactor access token refresh * chrome debugging --- lib/browser/constants.js | 1 + lib/browser/index.js | 6 +- lib/browser/session.js | 48 ++++++++ lib/browser/user.js | 3 +- lib/user-methods.js | 38 +++--- scripts/download-object-server.sh | 5 +- src/js_realm.hpp | 19 +++ src/js_sync.hpp | 189 +++++++++++++++++++++++++----- src/rpc.cpp | 12 ++ tests/js/asserts.js | 6 + tests/js/index.js | 1 + tests/js/session-tests.js | 95 +++++++++++++++ 12 files changed, 365 insertions(+), 58 deletions(-) create mode 100644 lib/browser/session.js create mode 100644 tests/js/session-tests.js diff --git a/lib/browser/constants.js b/lib/browser/constants.js index bae8b693..fe7360fe 100644 --- a/lib/browser/constants.js +++ b/lib/browser/constants.js @@ -40,6 +40,7 @@ export const propTypes = {}; 'REALM', 'RESULTS', 'USER', + 'SESSION', 'UNDEFINED', ].forEach(function(type) { Object.defineProperty(objectTypes, type, { diff --git a/lib/browser/index.js b/lib/browser/index.js index 7ca3b8b4..2a6a31a8 100644 --- a/lib/browser/index.js +++ b/lib/browser/index.js @@ -25,6 +25,7 @@ import List, { createList } from './lists'; import Results, { createResults } from './results'; import RealmObject, * as objects from './objects'; import User, { createUser } from './user'; +import Session, { createSession } from './session'; import * as rpc from './rpc'; import * as util from './util'; import { static as staticUserMethods } from '../user-methods'; @@ -36,6 +37,7 @@ rpc.registerTypeConverter(objectTypes.RESULTS, createResults); rpc.registerTypeConverter(objectTypes.OBJECT, objects.createObject); rpc.registerTypeConverter(objectTypes.REALM, createRealm); rpc.registerTypeConverter(objectTypes.USER, createUser); +rpc.registerTypeConverter(objectTypes.SESSION, createSession); function createRealm(_, info) { let realm = Object.create(Realm.prototype); @@ -54,6 +56,7 @@ function setupRealm(realm, realmId) { 'readOnly', 'schema', 'schemaVersion', + 'syncSession', ].forEach((name) => { Object.defineProperty(realm, name, {get: util.getterForProperty(name)}); }); @@ -131,7 +134,8 @@ util.createMethods(Realm.prototype, objectTypes.REALM, [ ], true); const Sync = { - User + User, + Session }; Object.defineProperties(Realm, { diff --git a/lib/browser/session.js b/lib/browser/session.js new file mode 100644 index 00000000..ffe0a1d1 --- /dev/null +++ b/lib/browser/session.js @@ -0,0 +1,48 @@ +//////////////////////////////////////////////////////////////////////////// +// +// Copyright 2016 Realm Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// +//////////////////////////////////////////////////////////////////////////// + +'use strict'; + +import { keys, objectTypes } from './constants'; +import { getterForProperty, createMethods } from './util'; + +export default class Session { } + +Object.defineProperties(Session.prototype, { + url: getterForProperty('url'), + state: getterForProperty('state') +}); + +createMethods(Session.prototype, objectTypes.SESSION, [ + '_refreshAccessToken', + '_simulateError' +]); + +export function createSession(realmId, info) { + let sessionProxy = Object.create(Session.prototype); + + // FIXME: This is currently necessary because util/createMethod expects + // the realm id to be present on any object that is used over rpc + sessionProxy[keys.realm] = "(Session object)"; + + sessionProxy[keys.id] = info.id; + sessionProxy[keys.type] = objectTypes.SESSION; + Object.assign(sessionProxy, info.data); + + return sessionProxy; +} \ No newline at end of file diff --git a/lib/browser/user.js b/lib/browser/user.js index 70cabd12..1c6c8deb 100644 --- a/lib/browser/user.js +++ b/lib/browser/user.js @@ -34,7 +34,8 @@ export default class User { } createMethods(User.prototype, objectTypes.USER, [ - 'logout' + 'logout', + '_sessionForOnDiskPath' ]); export function createUser(realmId, info) { diff --git a/lib/user-methods.js b/lib/user-methods.js index e426f73a..b17e4fb5 100644 --- a/lib/user-methods.js +++ b/lib/user-methods.js @@ -40,35 +40,31 @@ function auth_url(server) { return server + 'auth'; } -function authenticateRealm(user, fileUrl, realmUrl, callback) { - var url = auth_url(user.server); - var options = { +function authenticateRealm(user, fileUrl, realmUrl) { + let parsedRealmUrl = url_parse(realmUrl); + const url = auth_url(user.server); + const options = { method: 'POST', body: JSON.stringify({ data: user.token, - path: url_parse(realmUrl).pathname, + path: parsedRealmUrl.pathname, provider: 'realm', app_id: '' }), headers: postHeaders }; - performFetch(url, options, function(error, response, body) { - if (error) { - callback(error); - } - else if (response.statusCode != 200) { - callback(new AuthError('Bad response: ' + response.statusCode)); - } - else { - // TODO: validate JSON - - callback(undefined, { - token: body.access_token.token, - file_url: url_parse(fileUrl).pathname, - resolved_realm_url: 'realm://' + url_parse(realmUrl).host + body.access_token.token_data.path - }); - } - }); + performFetch(url, options) + .then((response) => { + if (response.status != 200) { + //FIXME: propagate error to session error handler + } else { + return response.json().then((body) => { + parsedRealmUrl.set('pathname', body.access_token.token_data.path); + let session = user._sessionForOnDiskPath(fileUrl); + session._refreshAccessToken(body.access_token.token, parsedRealmUrl.href); + }); + } + }); } function _authenticate(userConstructor, server, json, callback) { diff --git a/scripts/download-object-server.sh b/scripts/download-object-server.sh index 9d286a9d..a1481143 100755 --- a/scripts/download-object-server.sh +++ b/scripts/download-object-server.sh @@ -6,9 +6,8 @@ set -eo pipefail . dependencies.list -if [ -f object-server-for-testing/node_modules/realm-object-server/CHANGELOG.md ]; then - current_version=$(head -n1 object-server-for-testing/node_modules/realm-object-server/CHANGELOG.md | cut -d" " -f2) - if [ "$REALM_OBJECT_SERVER_VERSION" = "$current_version" ]; then +if [ -f object-server-for-testing/node_modules/realm-object-server/package.json ]; then + if grep -q "\"version\": \"$REALM_OBJECT_SERVER_VERSION\"" object-server-for-testing/node_modules/realm-object-server/package.json; then echo -e "yes\n" | object-server-for-testing/reset-server-realms.command exit fi diff --git a/src/js_realm.hpp b/src/js_realm.hpp index fde35825..c34b7bd6 100644 --- a/src/js_realm.hpp +++ b/src/js_realm.hpp @@ -167,6 +167,9 @@ public: static void get_schema_version(ContextType, ObjectType, ReturnValue &); static void get_schema(ContextType, ObjectType, ReturnValue &); static void get_read_only(ContextType, ObjectType, ReturnValue &); +#if REALM_ENABLE_SYNC + static void get_sync_session(ContextType, ObjectType, ReturnValue &); +#endif // static methods static void constructor(ContextType, ObjectType, size_t, const ValueType[]); @@ -208,6 +211,9 @@ public: {"schemaVersion", {wrap, nullptr}}, {"schema", {wrap, nullptr}}, {"readOnly", {wrap, nullptr}}, +#if REALM_ENABLE_SYNC + {"syncSession", {wrap, nullptr}}, +#endif }; private: @@ -498,6 +504,19 @@ void RealmClass::get_read_only(ContextType ctx, ObjectType object, ReturnValu return_value.set(get_internal>(object)->get()->config().read_only()); } +#if REALM_ENABLE_SYNC +template +void RealmClass::get_sync_session(ContextType ctx, ObjectType object, ReturnValue &return_value) { + auto realm = *get_internal>(object); + if (std::shared_ptr session = SyncManager::shared().get_existing_active_session(realm->config().path)) { + return_value.set(create_object>(ctx, new WeakSession(session))); + } else { + return_value.set_null(); + } + +} +#endif + template void RealmClass::objects(ContextType ctx, ObjectType this_object, size_t argc, const ValueType arguments[], ReturnValue &return_value) { validate_argument_count(argc, 1); diff --git a/src/js_sync.hpp b/src/js_sync.hpp index 4962a8ce..21fe26a4 100644 --- a/src/js_sync.hpp +++ b/src/js_sync.hpp @@ -36,7 +36,8 @@ namespace realm { namespace js { -using SharedUser = std::shared_ptr; +using SharedUser = std::shared_ptr; +using WeakSession = std::weak_ptr; template class UserClass : public ClassDefinition { @@ -84,9 +85,11 @@ public: }; static void logout(ContextType, ObjectType, size_t, const ValueType[], ReturnValue &); + static void session_for_on_disk_path(ContextType, ObjectType, size_t, const ValueType[], ReturnValue &); MethodMap const methods = { - {"logout", wrap} + {"logout", wrap}, + {"_sessionForOnDiskPath", wrap} }; }; @@ -162,6 +165,126 @@ void UserClass::logout(ContextType ctx, ObjectType object, size_t, const Valu get_internal>(object)->get()->log_out(); } +template +class SessionClass : public ClassDefinition { + using ContextType = typename T::Context; + using FunctionType = typename T::Function; + using ObjectType = typename T::Object; + using ValueType = typename T::Value; + using String = js::String; + using Object = js::Object; + using Value = js::Value; + using ReturnValue = js::ReturnValue; + +public: + std::string const name = "Session"; + + static FunctionType create_constructor(ContextType); + + static void get_config(ContextType, ObjectType, ReturnValue &); + static void get_user(ContextType, ObjectType, ReturnValue &); + static void get_url(ContextType, ObjectType, ReturnValue &); + static void get_state(ContextType, ObjectType, ReturnValue &); + + static void simulate_error(ContextType, ObjectType, size_t, const ValueType[], ReturnValue &); + static void refresh_access_token(ContextType, ObjectType, size_t, const ValueType[], ReturnValue &); + + PropertyMap const properties = { + {"config", {wrap, nullptr}}, + {"user", {wrap, nullptr}}, + {"url", {wrap, nullptr}}, + {"state", {wrap, nullptr}} + }; + + MethodMap const methods = { + {"_simulateError", wrap}, + {"_refreshAccessToken", wrap} + }; +}; + +template +void UserClass::session_for_on_disk_path(ContextType ctx, ObjectType object, size_t argc, const ValueType arguments[], ReturnValue &return_value) { + auto user = *get_internal>(object); + if (auto session = user->session_for_on_disk_path(Value::validated_to_string(ctx, arguments[0]))) { + return_value.set(create_object>(ctx, new WeakSession(session))); + } else { + return_value.set_undefined(); + } +} + +template +void SessionClass::get_config(ContextType ctx, ObjectType object, ReturnValue &return_value) { + if (auto session = get_internal>(object)->lock()) { + ObjectType config = Object::create_empty(ctx); + Object::set_property(ctx, config, "user", create_object>(ctx, new SharedUser(session->config().user))); + Object::set_property(ctx, config, "url", Value::from_string(ctx, session->config().realm_url)); + return_value.set(config); + } else { + return_value.set_undefined(); + } +} + +template +void SessionClass::get_user(ContextType ctx, ObjectType object, ReturnValue &return_value) { + if (auto session = get_internal>(object)->lock()) { + return_value.set(create_object>(ctx, new SharedUser(session->config().user))); + } else { + return_value.set_undefined(); + } +} + +template +void SessionClass::get_url(ContextType ctx, ObjectType object, ReturnValue &return_value) { + if (auto session = get_internal>(object)->lock()) { + if (util::Optional url = session->full_realm_url()) { + return_value.set(*url); + return; + } + } + + return_value.set_undefined(); +} + +template +void SessionClass::get_state(ContextType ctx, ObjectType object, ReturnValue &return_value) { + static const std::string invalid("invalid"); + static const std::string inactive("inactive"); + static const std::string active("active"); + + return_value.set(invalid); + + if (auto session = get_internal>(object)->lock()) { + if (session->state() == SyncSession::PublicState::Inactive) { + return_value.set(inactive); + } else if (session->state() != SyncSession::PublicState::Error) { + return_value.set(active); + } + } +} + +template +void SessionClass::simulate_error(ContextType ctx, ObjectType object, size_t argc, const ValueType arguments[], ReturnValue &) { + validate_argument_count(argc, 2); + + if (auto session = get_internal>(object)->lock()) { + SyncError error; + error.error_code = std::error_code(Value::validated_to_number(ctx, arguments[0]), realm::sync::protocol_error_category()); + error.message = Value::validated_to_string(ctx, arguments[1]); + SyncSession::OnlyForTesting::handle_error(*session, std::move(error)); + } +} + +template +void SessionClass::refresh_access_token(ContextType ctx, ObjectType object, size_t argc, const ValueType arguments[], ReturnValue &) { + validate_argument_count(argc, 2); + + if (auto session = get_internal>(object)->lock()) { + std::string access_token = Value::validated_to_string(ctx, arguments[0], "accessToken"); + std::string realm_url = Value::validated_to_string(ctx, arguments[1], "realmUrl"); + session->refresh_access_token(std::move(access_token), std::move(realm_url)); + } +} + template class SyncClass : public ClassDefinition { using GlobalContextType = typename T::GlobalContext; @@ -185,14 +308,12 @@ public: static void set_verify_servers_ssl_certificate(ContextType, ObjectType, size_t, const ValueType[], ReturnValue &); // private - static void refresh_access_token(ContextType, ObjectType, size_t, const ValueType[], ReturnValue &); static void populate_sync_config(ContextType, ObjectType realm_constructor, ObjectType config_object, Realm::Config&); // static properties static void get_is_developer_edition(ContextType, ObjectType, ReturnValue &); MethodMap const static_methods = { - {"refreshAccessToken", wrap}, {"setLogLevel", wrap}, {"setVerifyServersSslCertificate", wrap} }; @@ -204,7 +325,8 @@ inline typename T::Function SyncClass::create_constructor(ContextType ctx) { PropertyAttributes attributes = ReadOnly | DontEnum | DontDelete; Object::set_property(ctx, sync_constructor, "User", ObjectWrap>::create_constructor(ctx), attributes); - + Object::set_property(ctx, sync_constructor, "Session", ObjectWrap>::create_constructor(ctx), attributes); + // setup synced realmFile paths ensure_directory_exists_for_file(default_realm_file_directory()); SyncManager::shared().configure_file_system(default_realm_file_directory(), SyncManager::MetadataMode::NoEncryption); @@ -233,28 +355,6 @@ void SyncClass::set_verify_servers_ssl_certificate(ContextType ctx, ObjectTyp realm::SyncManager::shared().set_client_should_validate_ssl(verify_servers_ssl_certificate); } -template -void SyncClass::refresh_access_token(ContextType ctx, ObjectType this_object, size_t argc, const ValueType arguments[], ReturnValue &return_value) { - validate_argument_count(argc, 2); - - static const String token_string = "token"; - static const String file_url_string = "file_url"; - static const String realm_url_string = "resolved_realm_url"; - - ObjectType json_arguments = Value::validated_to_object(ctx, arguments[1]); - std::string token = Object::validated_get_string(ctx, json_arguments, token_string); - std::string file_url = Object::validated_get_string(ctx, json_arguments, file_url_string); - std::string realm_url = Object::validated_get_string(ctx, json_arguments, realm_url_string); - - if (auto session = SyncManager::shared().get_existing_active_session(file_url)) { - session->refresh_access_token(token, realm_url); - return_value.set(true); - } - else { - return_value.set(false); - } -} - template void SyncClass::populate_sync_config(ContextType ctx, ObjectType realm_constructor, ObjectType config_object, Realm::Config& config) { @@ -263,7 +363,6 @@ void SyncClass::populate_sync_config(ContextType ctx, ObjectType realm_constr auto sync_config_object = Value::validated_to_object(ctx, sync_config_value); ObjectType sync_constructor = Object::validated_get_object(ctx, realm_constructor, std::string("Sync")); - Protected refresh(ctx, Object::validated_get_function(ctx, sync_constructor, std::string("refreshAccessToken"))); Protected protected_sync(ctx, sync_constructor); Protected protected_ctx(Context::get_global_context(ctx)); @@ -283,15 +382,41 @@ void SyncClass::populate_sync_config(ContextType ctx, ObjectType realm_constr ObjectType user_constructor = Object::validated_get_object(ctx, protected_sync, std::string("User")); FunctionType authenticate = Object::validated_get_function(ctx, user_constructor, std::string("_authenticateRealm")); - ValueType arguments[4]; + ValueType arguments[3]; arguments[0] = create_object>(ctx, new SharedUser(config.user)); arguments[1] = Value::from_string(protected_ctx, path.c_str()); arguments[2] = Value::from_string(protected_ctx, config.realm_url.c_str()); - arguments[3] = refresh; - Function::call(protected_ctx, authenticate, 4, arguments); + Function::call(protected_ctx, authenticate, 3, arguments); } }); + std::function error_handler; + ValueType error_func = Object::get_property(ctx, sync_config_object, "error"); + if (!Value::is_undefined(ctx, error_func)) { + Protected protected_error_func(ctx, Value::validated_to_function(ctx, error_func)); + error_handler = EventLoopDispatcher([=](auto session, auto error) { + HANDLESCOPE + + ObjectType error_object = Object::create_empty(protected_ctx); + Object::set_property(protected_ctx, error_object, "message", Value::from_string(protected_ctx, error.message)); + Object::set_property(protected_ctx, error_object, "isFatal", Value::from_boolean(protected_ctx, error.is_fatal)); + Object::set_property(protected_ctx, error_object, "category", Value::from_string(protected_ctx, error.error_code.category().name())); + Object::set_property(protected_ctx, error_object, "code", Value::from_number(protected_ctx, error.error_code.value())); + + ObjectType user_info = Object::create_empty(protected_ctx); + for (auto& kvp : error.user_info) { + Object::set_property(protected_ctx, user_info, kvp.first, Value::from_string(protected_ctx, kvp.second)); + } + Object::set_property(protected_ctx, error_object, "userInfo", user_info); + + ValueType arguments[2]; + arguments[0] = create_object>(protected_ctx, new WeakSession(session)); + arguments[1] = error_object; + + Function::call(protected_ctx, protected_error_func, 2, arguments); + }); + } + ObjectType user = Object::validated_get_object(ctx, sync_config_object, "user"); SharedUser shared_user = *get_internal>(user); if (shared_user->state() != SyncUser::State::Active) { @@ -303,7 +428,7 @@ void SyncClass::populate_sync_config(ContextType ctx, ObjectType realm_constr // FIXME - use make_shared config.sync_config = std::shared_ptr(new SyncConfig{shared_user, raw_realm_url, SyncSessionStopPolicy::AfterChangesUploaded, - std::move(bind), [=](auto, SyncError) {}}); + std::move(bind), std::move(error_handler)}); config.schema_mode = SchemaMode::Additive; config.path = realm::SyncManager::shared().path_for_realm(shared_user->identity(), raw_realm_url); } diff --git a/src/rpc.cpp b/src/rpc.cpp index 525228e7..c133f100 100644 --- a/src/rpc.cpp +++ b/src/rpc.cpp @@ -43,6 +43,7 @@ static const char * const RealmObjectTypesObject = "object"; static const char * const RealmObjectTypesResults = "results"; static const char * const RealmObjectTypesRealm = "realm"; static const char * const RealmObjectTypesUser = "user"; +static const char * const RealmObjectTypesSession = "session"; static const char * const RealmObjectTypesUndefined = "undefined"; static RPCServer*& get_rpc_server(JSGlobalContextRef ctx) { @@ -389,6 +390,17 @@ json RPCServer::serialize_json_value(JSValueRef js_value) { {"data", user_dict} }; } + else if (jsc::Object::is_instance>(m_context, js_object)) { + json session_dict { + {"user", serialize_json_value(jsc::Object::get_property(m_context, js_object, "user"))}, + {"config", serialize_json_value(jsc::Object::get_property(m_context, js_object, "config"))} + }; + return { + {"type", RealmObjectTypesSession}, + {"id", store_object(js_object)}, + {"data", session_dict} + }; + } else if (jsc::Value::is_array(m_context, js_object)) { uint32_t length = jsc::Object::validated_get_length(m_context, js_object); std::vector array; diff --git a/tests/js/asserts.js b/tests/js/asserts.js index ea92694b..a3a3026e 100644 --- a/tests/js/asserts.js +++ b/tests/js/asserts.js @@ -142,6 +142,12 @@ module.exports = { } }, + assertNull: function(value, errorMessage) { + if (value !== null) { + throw new TestFailureError(errorMessage || `Value ${value} expected to be null`); + } + }, + isNode: function() { // eslint-disable-next-line no-undef return typeof process == 'object' && Object.prototype.toString.call(process) == '[object process]'; diff --git a/tests/js/index.js b/tests/js/index.js index 0de361b9..0743df21 100644 --- a/tests/js/index.js +++ b/tests/js/index.js @@ -33,6 +33,7 @@ var TESTS = { // If sync is enabled, run the user tests if (Realm.Sync) { TESTS.UserTests = require('./user-tests'); + TESTS.SessionTests = require('./session-tests'); } function node_require(module) { return require(module); } diff --git a/tests/js/session-tests.js b/tests/js/session-tests.js new file mode 100644 index 00000000..4a763610 --- /dev/null +++ b/tests/js/session-tests.js @@ -0,0 +1,95 @@ +//////////////////////////////////////////////////////////////////////////// +// +// Copyright 2016 Realm Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// +//////////////////////////////////////////////////////////////////////////// + +/* eslint-env es6, node */ + +'use strict'; + +const Realm = require('realm'); +const TestCase = require('./asserts'); + +function uuid() { + return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, function (c) { + var r = Math.random() * 16 | 0, v = c == 'x' ? r : (r & 0x3 | 0x8); + return v.toString(16); + }); +} + +function promisifiedRegister(server, username, password) { + return new Promise((resolve, reject) => { + Realm.Sync.User.register(server, username, password, (error, user) => { + if (error) { + reject(error); + } else { + resolve(user); + } + }); + }); +} + +function wait(delay) { + return new Promise((resolve, reject) => setTimeout(resolve, delay)); +} + +module.exports = { + testLocalRealmHasNoSession() { + let realm = new Realm(); + TestCase.assertNull(realm.syncSession); + }, + + testProperties() { + return promisifiedRegister('http://localhost:9080', uuid(), 'password').then(user => { + let config = { sync: { user, url: 'realm://localhost:9080/~/myrealm' } }; + let realm = new Realm(config); + let session = realm.syncSession; + + TestCase.assertInstanceOf(session, Realm.Sync.Session); + TestCase.assertEqual(session.user.identity, user.identity); + TestCase.assertEqual(session.config.url, config.sync.url); + TestCase.assertEqual(session.config.user.identity, config.sync.user.identity); + TestCase.assertUndefined(session.url); + TestCase.assertEqual(session.state, 'active'); + + // give the session enough time to refresh its access token and bind itself + return wait(500).then(() => { + TestCase.assertEqual(session.url, `realm://localhost:9080/${user.identity}/myrealm`); + }); + }); + }, + + testErrorHandling() { + return promisifiedRegister('http://localhost:9080', uuid(), 'password').then(user => { + let errors = []; + let config = { sync: { user, + url: 'realm://localhost:9080/~/myrealm', + error: (sender, error) => errors.push([sender, error]) + } }; + let realm = new Realm(config); + let session = realm.syncSession; + + session._simulateError(123, 'simulated error'); + + return wait(100).then(() => { + TestCase.assertArrayLength(errors, 1); + TestCase.assertEqual(errors[0][0].config.url, session.config.url); + TestCase.assertEqual(errors[0][1].message, 'simulated error'); + TestCase.assertEqual(errors[0][1].code, 123); + }); + }); + } +} \ No newline at end of file