Publish Vulpes Preview 15 sources for desktop, Android and Tundra

Share Gaia assets and adapters, keep private profiles and development documents local, and update both public READMEs.
This commit is contained in:
OverSu 2026-09-30 23:13:03 +02:00
commit b38dea0597
25901 changed files with 9982925 additions and 369 deletions

View file

@ -0,0 +1,103 @@
/* Any copyright is dedicated to the Public Domain.
* http://creativecommons.org/publicdomain/zero/1.0/ */
/**
* See ../js/sync-engine/syncengine.js for an explanation of how DataAdapters
* are used by the SyncEngine.
*
* There needs to be one DataAdapter for each collection (history, passwords,
* bookmarks, tabs). If the user enabled syncing for a specific collection in
* Settings, the DataAdapter's `update` method will be called each time sync
* runs. The Kinto collection passed to `update` will contain a local copy of
* the remote data. It can be queried using its `list` and `get` methods.
*
* It is the task of the DataAdapter to compare this data with wherever the
* corresponding data lives on the device (e.g. the 'places' DataStore for
* history, the 'bookmarks_store' DataStore for bookmarks, etc.), and to update
* the device's live data based on the data in the Kinto collection.
*
* Subsequently, or at the same time, the DataAdapter should detect when data
* from the device needs to be added or changed in the Kinto collection, so that
* the SyncEngine can propagate these changes to the user's Firefox Sync
* account on the server.
* The update method should return a Promise for `true` if any changes were made
* to `kintoCollection` and a Promise for `false` otherwise. That way, the
* SyncEngine knows whether it's necessary to sync a second time, to upload such
* changes to the FxSync server.
*
* Before and after `update` is called, the SyncEngine synchronizes the Kinto
* collection with the remote server. In both cases, conflicts may occur. If
* they do, the `handleConflict` method on the DataAdapter is called once for
* each conflict, and has the opportunity to let the local copy win, let the
* remote copy win, or return a merged version of the two.
*/
'use strict';
/* exported ExampleAdapter */
var ExampleAdapter = {
/*
* update - Import incoming changes to the appropriate place on the device,
* and (optionally) export outgoing changes to SyncEngine.
*
* @param {Object} kintoCollection - A Kinto collection, see
* http://kintojs.readthedocs.org/en/latest/api/#collections
*
* @param {Object} options - either `{}` or `{ readonly: true }`
*
* @returns {Promise} A promise for a Boolean, indicating whether or not
* any outgoing changes were made that need to be synced up
* to FxSync.
*/
update: function(kintoCollection, options) {
// E.g. read all records from kintoCollection (last modified first):
kintoCollection.list().then(list => {
for(var i = 0; i < list.data.length; i++) {
console.log(`Record ${i}:`, list.data[i].payload);
}
});
if (options.readonly) {
// TODO: Import data from Kinto collection into the appropriate DataStore
// or Web API:
return Promise.resolve(false);
} else {
var kintoCollectionChanged = false;
// TODO: Sync changes both ways between Kinto collection and the
// appropriate DataStore or Web API.
// Whenever you use kintoCollection.add, kintoCollection.update,
// kintoCollection.delete, etc., set `kintoCollectionChanged` to true, and
// then:
return Promise.resolve(kintoCollectionChanged);
}
},
/*
* handleConflict - Decide how to resolve a Kinto conflict.
*
* @param {Object} conflict - A Kinto conflict, see
* http://kintojs.readthedocs.org/en/latest/api/#resolving-conflicts-manually
*
* @returns {Promise} A Promise for the desired resolution (could be
* conflict.local, conflict.remote, or a merge of the two).
*/
handleConflict: function(conflict) {
console.log('Example adapter handleConflict function called with:',
conflict.local,
conflict.remote);
return Promise.resolve(conflict.local);
},
/*
* reset - will be called in case of panic after an unrecoverable error, to
* reset any data the DataAdapter stores for efficiency of incremental
* consecutive sync runs (for instance, some DataAdapters will store
* the last modified time of unsynced records, and will only check
* records that were changed since then).
*/
reset: function(options) {
return Promise.resolve(`Reset check points for ${options.userid}.`);
}
};

22
gaia/apps/sync/index.html Executable file
View file

@ -0,0 +1,22 @@
<!DOCTYPE html>
<html lang="en">
<head>
<title>Sync</title>
<meta charset="utf-8">
<!-- Lazy loaded shared
<script defer src="shared/js/async_storage.js"></script>
<script defer src="shared/js/sync/engine.js"></script>
<script defer src="shared/js/sync/errors.js"></script>
<script defer src="shared/js/sync/crypto/fxsyncwebcrypto.js"></script>
<script defer src="shared/js/sync/crypto/keyderivation.js"></script>
<script defer src="shared/js/sync/crypto/stringconversion.js"></script>
<script defer src="shared/js/sync/ext/kinto.min.js"></script>
-->
</head>
<body>
</body>
<script src="shared/js/dump.js"></script>
<script src="shared/js/lazy_loader.js"></script>
<script src="shared/js/iac_handler.js"></script>
<script src="shared/js/sync/bootstrap.js"></script>
</html>

View file

@ -0,0 +1,515 @@
/* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */
/**
Bookmarks Data Adapter
The purpose of the Bookmarks adapter is to retrieve bookmark records from
Firefox Sync and merge them in a local bookmarks DataStore
('sync_bookmarks_store') which will be hosting Firefox Sync records only.
This adapter implements the API defined by SyncEngine. For now the
implementation allows only to retrieve bookmarks from Sync but it is not ready
for pushing any changes. The high level algorithm implemented here is the
following:
- Traverse all the records coming from Firefox Sync and stored locally
in Kinto.
- Convert any new bookmark from the Firefox Sync format to the
'sync_bookmarks_store' DataStore one.
- For existing bookmarks merge the remote with the local bookmark after
converting them to the proper 'sync_bookmarks_store' DS format.
- Remove any existing bookmark that has been remotely deleted (these records
come with a 'deleted: true' property).
**/
'use strict';
/* global
asyncStorage,
DataAdapters,
ERROR_SYNC_APP_RACE_CONDITION,
LazyLoader
*/
const BOOKMARKS_COLLECTION_MTIME = '::collections::bookmarks::mtime';
const BOOKMARKS_LAST_REVISIONID = '::collections::bookmarks::revisionid';
const BOOKMARKS_SYNCTOID_PREFIX = '::synctoid::bookmarks::';
var BookmarksHelper = (() => {
var _store;
function _ensureStore() {
if (_store) {
return Promise.resolve(_store);
}
return navigator.getDataStores('bookmarks_store').then(stores => {
_store = stores[0];
return _store;
});
}
/* SyncedCollectionMTime is the time of the last successful sync run.
* Subsequent sync runs will not check any records from the Kinto collection
* that have not been modified since then. This value is stored separately for
* each user (userid uniquely defines the FxSync account we're syncing with).
*/
function setSyncedCollectionMtime(mtime, userid) {
return new Promise(resolve => {
asyncStorage.setItem(userid + BOOKMARKS_COLLECTION_MTIME, mtime, resolve);
});
}
function removeSyncedCollectionMtime(userid) {
return new Promise(resolve =>
asyncStorage.removeItem(userid + BOOKMARKS_COLLECTION_MTIME, resolve));
}
function getSyncedCollectionMtime(userid) {
return new Promise(resolve => {
asyncStorage.getItem(userid + BOOKMARKS_COLLECTION_MTIME, resolve);
});
}
/* LastRevisionId is the revisionId the DataStore had at the beginning of the
* last sync run. Even though there is only one DataStore, it is stored once
* for each userid, because a sync run only syncs with the FxSync account of
* the currently logged in user.
*/
function getLastRevisionId(userid) {
return new Promise(resolve => {
asyncStorage.getItem(userid + BOOKMARKS_LAST_REVISIONID, resolve);
});
}
function setLastRevisionId(revisionId, userid) {
return new Promise(resolve => {
asyncStorage.setItem(userid + BOOKMARKS_LAST_REVISIONID, revisionId,
resolve);
});
}
function removeLastRevisionId(userid) {
return new Promise(resolve => {
asyncStorage.removeItem(userid + BOOKMARKS_LAST_REVISIONID, resolve);
});
}
/*
* setDataStoreId and getDataStoreId are used to create a table for caching
* SynctoId to DataStoreId matching. When a `deleted: true` record comes from
* FxSync, getDataStoreId can help to get DataStoreId easily. So a new record
* comes, the adapter has to use setDataStoreId to store the ID matching.
* Since both the synctoId and the dataStoreId for a given URL are unique to
* the currently logged in user, we store these values prefixed per `userid`
* (`xClientState` of the currently logged in user).
*/
function setDataStoreId(synctoId, dataStoreId, userid) {
return new Promise(resolve => {
asyncStorage.setItem(userid + BOOKMARKS_SYNCTOID_PREFIX + synctoId,
dataStoreId,
resolve);
});
}
function getDataStoreId(synctoId, userid) {
return new Promise(resolve => {
asyncStorage.getItem(userid + BOOKMARKS_SYNCTOID_PREFIX + synctoId,
resolve);
});
}
function mergeRecordsToDataStore(localRecord, remoteRecord, fxsyncId) {
if (!localRecord || !remoteRecord ||
localRecord.id !== remoteRecord.id ||
(remoteRecord.type === 'url' && localRecord.url !== remoteRecord.url)) {
// Local and remote records have different url(id).
console.error('Inconsistent records', localRecord, remoteRecord);
throw new Error('Inconsistent records');
}
localRecord.name = remoteRecord.name;
if (!localRecord.fxsyncRecords) {
localRecord.fxsyncRecords = {};
// We remember if a record had already been created locally before we got
// remote data for that URL, so that we know not to remove it even when
// the remote data is deleted. This applies only to readonly sync, and
// will be removed when sync becomes read-write.
localRecord.createdLocally = true;
}
localRecord.fxsyncRecords[fxsyncId] = remoteRecord.fxsyncRecords[fxsyncId];
return localRecord;
}
function addBookmark(remoteRecord, userid) {
// 1. Get bookmark by url(id of DataStore)
// 2.A. If the bookmark already exists locally,
// we merge it with the remote one.
// 2.B Add a new record with RevisionId.
// 3. Add the DataStore record ID into LocalID <-> RemoteID matching table.
var id = remoteRecord.id;
var revisionId;
return _ensureStore().then(store => {
revisionId = store.revisionId;
var fxsyncId = remoteRecord.fxsyncId;
delete remoteRecord.fxsyncId;
return store.get(id).then(localRecord => {
if (localRecord) {
var newBookmark = mergeRecordsToDataStore(localRecord, remoteRecord,
fxsyncId);
return store.put(newBookmark, id, revisionId);
}
// Setting createdLocally to false will cause the record to be deleted
// again if it's deleted remotely. This applies only to readonly sync,
// and will be removed when sync becomes read-write.
remoteRecord.createdLocally = false;
return store.add(remoteRecord, id, revisionId);
}).then(() => {
return setDataStoreId(fxsyncId, id, userid);
});
}).catch(e => {
if (e.name === 'ConstraintError' &&
e.message === 'RevisionId is not up-to-date') {
return LazyLoader.load(['shared/js/sync/errors.js']).then(() => {
throw new Error(ERROR_SYNC_APP_RACE_CONDITION);
});
}
console.error(e);
});
}
function deleteBookmark(fxsyncId, userid) {
var url;
return getDataStoreId(fxsyncId, userid).then(id => {
if (!id) {
console.warn('Ignoring incoming tombstone for unknown FxSyncID',
fxsyncId);
return Promise.resolve();
}
url = id;
return _ensureStore().then(store => {
var revisionId = store.revisionId;
return store.get(url).then(localRecord => {
localRecord.fxsyncRecords[fxsyncId] = {
deleted: true,
id: fxsyncId
};
var isEmpty = Object.keys(localRecord.fxsyncRecords).every(value => {
return localRecord.fxsyncRecords[value].deleted;
});
// Do not delete records that were originally created locally, even if
// they are deleted remotely. This applies only for readonly sync, and
// will be removed in the future when we switch to two-way sync.
if (isEmpty && !localRecord.createdLocally) {
return store.remove(url, revisionId);
} else {
return store.put(localRecord, url, revisionId);
}
});
});
});
}
function checkIfClearedSince(lastRevisionId, userid) {
return _ensureStore().then(store => {
if (lastRevisionId === null) {
var cursor = store.sync();
// Skip first task which is always { id: null, operation: 'clear' }
cursor.next().then(() => {
return cursor;
});
}
return store.sync(lastRevisionId);
}).then(cursor => {
var wasCleared = false;
return new Promise(resolve => {
function runNextTask(cursor) {
cursor.next().then(task => {
if (task.operation === 'done') {
resolve({
newRevisionId: task.revisionId,
wasCleared
});
} else {
// In readonly mode, if the DataStore was cleared, or some records
// were removed, it's possible that previously imported data was
// lost. Therefore, we return wasCleared: true after playing the
// DataStore history to its current revisionId, so that
// removeSyncedCollectionMtime will be called, and a full
// re-import is triggered.
// If only one record was removed then it would not be necessary
// to re-import the whole Kinto collection, but right now we have
// no efficient way to retrieve just one record from the Kinto
// collection based on URL, because we don't have a mapping from
// URL to fxsyncId. Since readonly sync is idempotent, there is
// not much harm in this, but it could possibly be made more
// efficient, see
// https://bugzilla.mozilla.org/show_bug.cgi?id=1223418.
if (['clear', 'remove'].indexOf(task.operation) !== -1) {
wasCleared = true;
}
// Avoid stack overflow:
setTimeout(() => {
// Will eventually get to a 'done' task:
runNextTask(cursor);
});
}
});
}
runNextTask(cursor);
});
});
}
/*
* handleClear - trigger re-import if DataStore was cleared
*
* In the future, the bookmarks and history DataAdapters will support
* two-way sync, so they will not only import data from the kinto.js
* collection into the DataStore, but also check what changes have
* been made recently in the DataStore, and reflect these in the kinto.js
* collection. Until then, the only thing we check from the DataStore is
* whether it has been cleared. If a `clear` operation was executed on the
* DataStore since the last time we checked (`lastRevisionId`), then the
* lastModifiedTime of the kinto.js collection is removed from asyncStorage,
* triggering a full import.
*/
function handleClear(userid) {
var newRevisionId;
return getLastRevisionId(userid).then(lastRevisionId => {
return checkIfClearedSince(lastRevisionId, userid);
}).then(result => {
newRevisionId = result.newRevisionId;
if(result.wasCleared) {
// Removing this from asyncStorage will trigger a full re-import.
return removeSyncedCollectionMtime(userid);
}
return Promise.resolve();
}).then(() => {
return setLastRevisionId(newRevisionId, userid);
});
}
function reset(userid) {
return Promise.all([
removeSyncedCollectionMtime(userid),
removeLastRevisionId(userid)
]);
}
return {
mergeRecordsToDataStore,
setSyncedCollectionMtime,
getSyncedCollectionMtime,
deleteBookmark,
addBookmark,
handleClear,
reset
};
})();
DataAdapters.bookmarks = {
/**
KintoCollection.list() provides a list containing all the remotely retrieved
Firefox Sync records sorted by "last_modified" property in descending order.
After each sync request we save the "last_modified" property of the last
processed record so we avoid going through the same records on following
operations.
Bookmark records are stored locally in a DataStore with format [1] while
bookmark records coming from Firefox Sync (via Kinto collection) have
format[2]. [4] is the detailed information for 6 different types defined in
FxSync BC. We need to convert from [1] to [2] and viceversa. Also, we need to
add the remote record ID[1.1] from the Kinto collection to its corresponding
match in the local DataStore, so we can remove the local record afterwards
when any deleting record requests with the format [3] are coming from FxSync.
[1] Records stored in Bookmarks DataStore (BDS): {
"id": "http://mozilla.org/", // KEY in PDS
"url": "http://mozilla.org/",
"name": "Mozilla",
"type": "url",
"iconable": false,
"icon": "http://www.lego.com/favicon.ico",
"fxsyncRecords": {
'fxsyncID_A': fxsync_payload_A,
'fxsyncID_B': fxsync_payload_B,
'fxsyncID_C': fxsync_payload_C
} // payload from BC
}
[2] Add/Update Records from Bookmark Collection (BC): {
"id": "zMgfGkRinh92",
"sortindex": 2000,
"last_modified": 1442247272150,
"payload": {
"id": "zMgfGkRinh92",
"type": "bookmark",
"title": "Mozilla",
"parentName": "mobile",
"bmkUri": "http://mozilla.org/",
"tags": [],
"keyword": null,
"description": null,
"loadInSidebar": false,
"parentid": "mobile"
},
"_status": "synced"
}
[3] Delete Records from Bookmark Collection (BC): {
"id": "_Avscjx5srFy",
"sortindex": 100,
"last_modified": 1441985077970,
"payload": {
"id": "_Avscjx5srFy",
"deleted": true
},
"_status": "synced"
}
[4] The schema table for 6 different types from [5]:
+---------------+---------------+---------------+
| bookmark | microsummary**| query |
+---------------+---------------+---------------+
| | *generatorUri | *folderName |
| | *staticTitle | *queryId |
| title | title | title |
| bmkUri | bmkUri | bmkUri |
| description | description | description |
| loadInSidebar | loadInSidebar | loadInSidebar |
| tags | tags | tags |
| keyword | keyword | keyword |
| parentid | parentid | parentid |
| parentName | parentName | parentName |
| predecessorid | predecessorid | predecessorid |
| type | type | type |
+---------------+---------------+---------------+
+---------------+---------------+---------------+
| folder | livemark | separator |
+---------------+---------------+---------------+
| | *siteUri | *pos |
| | *feedUri | |
| title | title | |
| parentid | parentid | parentid |
| parentName | parentName | parentName |
| predecessorid | predecessorid | predecessorid |
| type | type | type |
+---------------+---------------+---------------+
[*] Special property for its type.
[**] microsummary is OBSOLETED. Please see here:
https://wiki.mozilla.org/Microsummaries
[5] https://docs.services.mozilla.com/sync/objectformats.html#bookmarks
**/
_updateBookmark(payload, last_modified, userid) {
if (payload.type === 'microsummary') {
console.warn('microsummary is OBSOLETED ', payload);
return Promise.resolve();
}
if (payload.deleted) {
return BookmarksHelper.deleteBookmark(payload.id, userid);
} else if (['query', 'bookmark', 'folder', 'livemark', 'separator']
.every(value => value !== payload.type)) {
console.error('Unknown type? ', payload);
return Promise.resolve();
}
var typeWithUri = ['query', 'bookmark']
.some(value => value === payload.type);
if (typeWithUri && !payload.bmkUri) {
console.warn('Incorrect payload? ', payload);
return Promise.resolve();
}
var fxsyncRecords = {};
fxsyncRecords[payload.id] = payload;
fxsyncRecords[payload.id].timestamp = last_modified;
return BookmarksHelper.addBookmark({
// URL is the ID for bookmark records in bookmarks_store, but there are
// some types without a valid URL except bookmark type. URL is used as
// its ID to compatible bookmarks_store for bookmark type record.
// The combination of type and fxsyncID is used as its ID for the types
// except bookmark.
id: payload.type === 'bookmark' ? payload.bmkUri :
(payload.type + '|' + payload.id),
url: payload.bmkUri,
name: payload.title,
type: payload.type === 'bookmark' ? 'url' : 'others',
iconable: false,
icon: '',
fxsyncRecords: fxsyncRecords,
fxsyncId: payload.id
}, userid);
},
_next(remoteRecords, lastModifiedTime, userid, cursor) {
if (cursor === remoteRecords.length) {
return Promise.resolve();
}
if (!Number.isInteger(remoteRecords[cursor].last_modified)) {
console.warn('Incorrect last_modified?', remoteRecords[cursor]);
return this._next(remoteRecords, lastModifiedTime, userid, cursor + 1);
}
if (remoteRecords[cursor].last_modified <= lastModifiedTime) {
return Promise.resolve();
}
return this._updateBookmark(remoteRecords[cursor].payload,
remoteRecords[cursor].last_modified, userid).then(() => {
return this._next(remoteRecords, lastModifiedTime, userid, cursor + 1);
});
},
update(remoteBookmarks, options = { readonly: true }) {
if (!options.readonly) {
console.warn('Two-way sync not implemented yet for bookmarks.');
}
var mtime;
return LazyLoader.load(['shared/js/async_storage.js']).then(() => {
// We iterate over the records in the Kinto collection until we find a
// record whose last modified time is older than the time of the last
// successful sync run. However, if the DataStore has been cleared, or
// records have been removed from the DataStore since the last sync run,
// we cannot be sure that all older records are still there. So in both
// those cases we remove the SyncedCollectionMtime from AsyncStorage, so
// that this sync run will iterate over all the records in the Kinto
// collection, and not only over the ones that were recently modified.
return BookmarksHelper.handleClear(options.userid);
}).then(() => {
return BookmarksHelper.getSyncedCollectionMtime(options.userid);
}).then(_mtime => {
mtime = _mtime;
return remoteBookmarks.list();
}).then(list => {
return this._next(list.data, mtime, options.userid, 0).then(() => {
if (list.data.length === 0) {
return Promise.resolve();
}
var latestMtime = list.data[0].last_modified;
return BookmarksHelper.setSyncedCollectionMtime(latestMtime,
options.userid);
});
}).then(() => {
// Always return false for a read-only operation.
return Promise.resolve(false /* no writes done into kinto */);
}).catch(err => {
console.error('Bookmarks DataAdapter update error', err.message);
throw err;
});
},
handleConflict(conflict) {
// Because Bookmark adapter has not implemented record push yet,
// handleConflict will always use remote records.
return Promise.resolve(conflict.remote);
},
reset(options) {
return LazyLoader.load(['shared/js/async_storage.js']).then(() => {
return BookmarksHelper.reset(options.userid);
});
}
};

View file

@ -0,0 +1,479 @@
/* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */
/**
History Data Adapter
The feature of History adapter is to retrieve History records from FxSync
Service and merge to Places DataStore which has the same functionality, and
the adapter is based on SyncEngine's design to handle 'update' and
'handleConflict' request. History Data Adapter provides Read-Only version
currently, and the basic ideas are included:
- Traverse all records from History Collection (HC).
- Add a new or existed place to Places DataStore (PDS).
- Convert the records from HC to PDS format.
- Merge a new record and the existed one then write to PDS.
- Remove an existed place when receiving a "deleted: true" record.
**/
'use strict';
/* global
asyncStorage,
DataAdapters,
ERROR_SYNC_APP_RACE_CONDITION,
LazyLoader
*/
const HISTORY_COLLECTION_MTIME = '::collections::history::mtime';
const HISTORY_LAST_REVISIONID = '::collections::history::revisionid';
const HISTORY_SYNCTOID_PREFIX = '::synctoid::history::';
var HistoryHelper = (() => {
var _store;
function _ensureStore() {
if (_store) {
return Promise.resolve(_store);
}
return navigator.getDataStores('places').then(stores => {
_store = stores[0];
return _store;
});
}
/* SyncedCollectionMTime is the time of the last successful sync run.
* Subsequent sync runs will not check any records from the Kinto collection
* that have not been modified since then. This value is stored separately for
* each user (userid uniquely defines the FxSync account we're syncing with).
*/
function setSyncedCollectionMtime(mtime, userid) {
return new Promise(resolve => {
asyncStorage.setItem(userid + HISTORY_COLLECTION_MTIME, mtime, resolve);
});
}
function removeSyncedCollectionMtime(userid) {
return new Promise(resolve => {
asyncStorage.removeItem(userid + HISTORY_COLLECTION_MTIME, resolve);
});
}
function getSyncedCollectionMtime(userid) {
return new Promise(resolve => {
asyncStorage.getItem(userid + HISTORY_COLLECTION_MTIME, resolve);
});
}
/* LastRevisionId is the revisionId the DataStore had at the beginning of the
* last sync run. Even though there is only one DataStore, it is stored once
* for each userid, because a sync run only syncs with the FxSync account of
* the currently logged in user.
*/
function getLastRevisionId(userid) {
return new Promise(resolve => {
asyncStorage.getItem(userid + HISTORY_LAST_REVISIONID, resolve);
});
}
function setLastRevisionId(lastRevisionId, userid) {
return new Promise(resolve => {
asyncStorage.setItem(userid + HISTORY_LAST_REVISIONID, lastRevisionId,
resolve);
});
}
function removeLastRevisionId(userid) {
return new Promise(resolve => {
asyncStorage.removeItem(userid + HISTORY_LAST_REVISIONID, resolve);
});
}
/*
* setDataStoreId and getDataStoreId are used to create a table for caching
* SynctoId to DataStoreId matching. When a `deleted: true` record comes from
* FxSync, getDataStoreId can help to get DataStoreId easily. So a new record
* comes, the adapter has to use setDataStoreId to store the ID matching.
* Since both the synctoId and the dataStoreId for a given URL are unique to
* the currently logged in user, we store these values prefixed per `userid`
* (`xClientState` of the currently logged in user).
*/
function setDataStoreId(synctoId, dataStoreId, userid) {
return new Promise(resolve => {
asyncStorage.setItem(userid + HISTORY_SYNCTOID_PREFIX + synctoId,
dataStoreId,
resolve);
});
}
function getDataStoreId(synctoId, userid) {
return new Promise(resolve => {
asyncStorage.getItem(userid + HISTORY_SYNCTOID_PREFIX + synctoId,
resolve);
});
}
function mergeRecordsToDataStore(localRecord, remoteRecord) {
if (!localRecord || !remoteRecord ||
localRecord.url !== remoteRecord.url) {
// The local record has different url(id) with the new one.
console.error('Inconsistent records on url', localRecord, remoteRecord);
throw new Error('Inconsistent records on url');
}
if (!localRecord.fxsyncId && typeof remoteRecord.fxsyncId === 'string') {
/* When a localRecord is existed without fxsyncId, assign fxsyncId to it
from a remoteRecord. This case always happens at first synchronization
or merging two records with the same URL. */
localRecord.fxsyncId = remoteRecord.fxsyncId;
} else if (localRecord.fxsyncId !== remoteRecord.fxsyncId) {
// Two records have different fxsyncId but have the same url(id).
console.log('Inconsistent records on FxSync ID',
localRecord, remoteRecord);
throw new Error('Inconsistent records on FxSync ID',
localRecord, remoteRecord);
}
// We remember if a record had already been created locally before we got
// remote data for that URL, so that we know not to remove it even when the
// remote data is deleted. This applies only to readonly sync, and will be
// removed when sync becomes read-write.
if (localRecord.createdLocally === undefined) {
localRecord.createdLocally = true;
}
localRecord.visits = localRecord.visits || [];
// If a localRecord is without any visit records or with older visit
// than remoteRecord, its title will be replaced by remoteRecord's.
if ((localRecord.visits.length === 0 && remoteRecord.title) ||
(remoteRecord.visits[0] >= localRecord.visits[0])) {
localRecord.title = remoteRecord.title;
}
remoteRecord.visits.forEach(item => {
if (localRecord.visits.indexOf(item) === -1) {
localRecord.visits.push(item);
}
});
localRecord.visits.sort((a, b) => {
// sort in descending order
return b - a;
});
return localRecord;
}
function addPlace(place, userid) {
// 1. Get place by url(id of DataStore)
// 2.A Merge the existing one and new one if it's an existing one,
// and update the places.
// 2.B Add a new record with RevisionId.
// 3. Add the DataStore record ID into LocalID <-> RemoteID matching table.
var id = place.url;
var revisionId;
return _ensureStore().then(placesStore => {
revisionId = placesStore.revisionId;
return placesStore.get(id).then(existedPlace => {
// Bug 1208352 - PlacesDS accessing code should be extracted to a shared
// code to prevent drifting out of sync from different piece codes.
if (existedPlace) {
var newPlace = mergeRecordsToDataStore(existedPlace, place);
return placesStore.put(newPlace, id, revisionId);
}
// Setting createdLocally to false will cause the record to be deleted
// again if it's deleted remotely. This applies only to readonly sync,
// and will be removed when sync becomes read-write.
place.createdLocally = false;
return placesStore.add(place, id, revisionId);
}).then(() => {
return setDataStoreId(place.fxsyncId, id, userid);
});
}).catch(e => {
if (e.name === 'ConstraintError' &&
e.message === 'RevisionId is not up-to-date') {
return LazyLoader.load(['shared/js/sync/errors.js']).then(() => {
throw new Error(ERROR_SYNC_APP_RACE_CONDITION);
});
}
console.error(e);
});
}
function deleteByDataStoreId(id) {
return _ensureStore().then(store => {
var revisionId = store.revisionId;
return store.get(id).then(record => {
// Do not delete records that were originally created locally, even if
// they are deleted remotely. This applies only for readonly sync, and
// will be removed in the future when we switch to two-way sync.
if (record.createdLocally) {
return Promise.resolve();
}
return store.remove(id, revisionId);
});
});
}
function deletePlace(fxsyncId, userid) {
return getDataStoreId(fxsyncId, userid).then(id => {
if (!id) {
console.warn('Ignoring incoming tombstone for unknown FxSyncID',
fxsyncId);
return Promise.resolve();
}
return deleteByDataStoreId(id);
});
}
function checkIfClearedSince(lastRevisionId, userid) {
return _ensureStore().then(store => {
if (lastRevisionId === null) {
var cursor = store.sync();
// Skip first task which is always { id: null, operation: 'clear' }
cursor.next().then(() => {
return cursor;
});
}
return store.sync(lastRevisionId);
}).then(cursor => {
var wasCleared = false;
return new Promise(resolve => {
function runNextTask(cursor) {
cursor.next().then(task => {
if (task.operation === 'done') {
resolve({
newRevisionId: task.revisionId,
wasCleared
});
} else {
// In readonly mode, if the DataStore was cleared, or some records
// were removed, it's possible that previously imported data was
// lost. Therefore, we return wasCleared: true after playing the
// DataStore history to its current revisionId, so that
// removeSyncedCollectionMtime will be called, and a full
// re-import is triggered.
// If only one record was removed then it would not be necessary
// to re-import the whole Kinto collection, but right now we have
// no efficient way to retrieve just one record from the Kinto
// collection based on URL, because we don't have a mapping from
// URL to fxsyncId. Since readonly sync is idempotent, there is
// not much harm in this, but it could possibly be made more
// efficient, see
// https://bugzilla.mozilla.org/show_bug.cgi?id=1223418.
if (['clear', 'remove'].indexOf(task.operation) !== -1) {
wasCleared = true;
}
// Avoid stack overflow:
setTimeout(() => {
// Will eventually get to a 'done' task:
runNextTask(cursor);
});
}
});
}
runNextTask(cursor);
});
});
}
/*
* handleClear - trigger re-import if DataStore was cleared
*
* In the future, the bookmarks and history DataAdapters will support
* two-way sync, so they will not only import data from the kinto.js
* collection into the DataStore, but also check what changes have
* been made recently in the DataStore, and reflect these in the kinto.js
* collection. Until then, the only thing we check from the DataStore is
* whether it has been cleared. If a `clear` operation was executed on the
* DataStore since the last time we checked (`lastRevisionId`), then the
* lastModifiedTime of the kinto.js collection is removed from asyncStorage,
* triggering a full import.
*/
function handleClear(userid) {
var newRevisionId;
return getLastRevisionId(userid).then(lastRevisionId => {
return checkIfClearedSince(lastRevisionId, userid);
}).then(result => {
newRevisionId = result.newRevisionId;
if(result.wasCleared) {
// Removing this from asyncStorage will trigger a full re-import.
return removeSyncedCollectionMtime(userid);
}
return Promise.resolve();
}).then(() => {
return setLastRevisionId(newRevisionId, userid);
});
}
function reset(userid) {
return Promise.all([
removeSyncedCollectionMtime(userid),
removeLastRevisionId(userid)
]);
}
return {
mergeRecordsToDataStore,
setSyncedCollectionMtime,
getSyncedCollectionMtime,
deletePlace,
deleteByDataStoreId,
addPlace,
handleClear,
reset
};
})();
DataAdapters.history = {
/**
KintoCollection.list() provides a list containing all the remotely retrieved
Firefox Sync records sorted by "last_modified" property in descending order.
After each sync request we save the "last_modified" property of the last
processed record so we avoid going through the same records on following
operations.
History records are stored locally in a DataStore with format [1] while
history records coming from Firefox Sync (via Kinto collection) have
format[2]. We need to convert from [1] to [2] and viceversa. Also, we need to
add the remote record ID[1.1] from the Kinto collection to its corresponding
match in the local DataStore, so we can remove the local record afterwards
when any deleting record requests with the format [3] are coming from FxSync.
[1] Records stored in Places DataStore (PDS): {
"url": "http://mozilla.org/", // KEY in PDS
"title": "Mozilla",
"icons": {
"http://mozilla.org/favicon.ico": {
"sizes": []
}
},
"frecency": 1,
"visits": [
// NOTICE: date/time without ms
1442247252490, 1442247250001
],
"screenshot": {},
"fxsyncId" "REMOTE_ID", // [1.1]
"visited": 1442247252490
}
[2] Add/Update Records from History Collection (HC): {
"id": "zMgfGkRinh92",
"sortindex": 2000,
"last_modified": 1442247272150,
"payload": {
"id": "zMgfGkRinh92",
"histUri": "http://mozilla.org/",
"title": "Mozilla",
"visits": [
// NOTICE: date/time with ms
{ "date": 1442247252490018, "type": 2 },
{ "date": 1442247250001234, "type": 2 }
]
},
"_status": "synced"
}
[3] Delete Records from History Collection (HC): {
"id": "_Avscjx5srFy",
"sortindex": 100,
"last_modified": 1441985077970,
"payload": {
"id": "_Avscjx5srFy",
"deleted": true
},
"_status": "synced"
}
**/
_updatePlace(payload, userid) {
if (payload.deleted) {
return HistoryHelper.deletePlace(payload.id, userid);
}
if (!payload.histUri || !payload.visits) {
console.warn('Incorrect payload?', payload);
return Promise.resolve();
}
if (payload.histUri && Array.isArray(payload.visits) &&
payload.visits.length === 0) {
return HistoryHelper.deleteByDataStoreId(payload.histUri);
}
return HistoryHelper.addPlace({
url: payload.histUri,
title: payload.title,
visits: payload.visits.map(elem => Math.floor(elem.date / 1000)),
fxsyncId: payload.id
}, userid);
},
_next(remoteRecords, lastModifiedTime, userid, cursor) {
if (cursor === remoteRecords.length) {
return Promise.resolve();
}
if (!Number.isInteger(remoteRecords[cursor].last_modified)) {
console.warn('Incorrect last_modified?', remoteRecords[cursor]);
return this._next(remoteRecords, lastModifiedTime, userid, cursor + 1);
}
if (remoteRecords[cursor].last_modified <= lastModifiedTime) {
return Promise.resolve();
}
return this._updatePlace(remoteRecords[cursor].payload, userid)
.then(() => {
return this._next(remoteRecords, lastModifiedTime, userid, cursor + 1);
});
},
update(remoteHistory, options = { readonly: true }) {
if (!options.readonly) {
console.warn('Two-way sync not implemented yet for history.');
}
var mtime;
return LazyLoader.load(['shared/js/async_storage.js']).then(() => {
// We iterate over the records in the Kinto collection until we find a
// record whose last modified time is older than the time of the last
// successful sync run. However, if the DataStore has been cleared, or
// records have been removed from the DataStore since the last sync run,
// we cannot be sure that all older records are still there. So in both
// those cases we remove the SyncedCollectionMtime from AsyncStorage, so
// that this sync run will iterate over all the records in the Kinto
// collection, and not only over the ones that were recently modified.
return HistoryHelper.handleClear(options.userid);
}).then(() => {
return HistoryHelper.getSyncedCollectionMtime(options.userid);
}).then(_mtime => {
mtime = _mtime;
return remoteHistory.list();
}).then(list => {
return this._next(list.data, mtime, options.userid, 0).then(() => {
if (list.data.length === 0) {
return Promise.resolve();
}
var latestMtime = list.data[0].last_modified;
return HistoryHelper.setSyncedCollectionMtime(latestMtime,
options.userid);
}).then(() => {
// Always return false for a read-only operation.
return Promise.resolve(false);
});
}).catch(err => {
console.error('History DataAdapter update error', err.message);
throw err;
});
},
handleConflict(conflict) {
// Because History adapter has not implemented record push yet,
// handleConflict will always use remote records.
return Promise.resolve(conflict.remote);
},
reset(options) {
return LazyLoader.load(['shared/js/async_storage.js']).then(() => {
return HistoryHelper.reset(options.userid);
});
}
};

18
gaia/apps/sync/jsdoc.json Executable file
View file

@ -0,0 +1,18 @@
{
"name": "sync",
"tags": {
"dictionaries": ["jsdoc"]
},
"source": {
"include": ["!apps/sync/js/ext/*.js", "apps/sync/js/**/*.js"]
},
"plugins": [
"plugins/markdown",
"plugins/summarize"
],
"opts": {
"private": true,
"recurse": true,
"destination": "docs/sync"
}
}

38
gaia/apps/sync/manifest.webapp Executable file
View file

@ -0,0 +1,38 @@
{
"name": "Sync",
"description": "Syncs data with Firefox Sync",
"type": "certified",
"role": "system",
"launch_path": "/index.html",
"developer": {
"name": "The Gaia Team",
"url": "https://github.com/mozilla-b2g/gaia"
},
"permissions": {
"settings": { "access": "readonly" }
},
"datastores-owned": {
"sync_bookmarks_store": {
"access": "readwrite",
"description": "View and modify the bookmarks from Firefox Sync."
}
},
"datastores-access": {
"places": {
"readonly": false,
"description": "Stores data about browsing history."
},
"bookmarks_store": {
"readonly": false,
"description": "Stores data about browsing history."
}
},
"connections": {
"gaia::sync::request": {
"description": "Expose a simple API to be woken up on sync requests",
"rules": {
"minimumAccessLevel": "certified"
}
}
}
}

View file

@ -0,0 +1,18 @@
{
"extends": "../../../../.jshintrc",
"predef": [
"assert",
"expect",
"suite",
"test",
"setup",
"teardown",
"suiteSetup",
"suiteTeardown",
"require",
"requireApp",
"sinon",
"mocha",
"loadBodyHTML"
]
}

View file

@ -0,0 +1,933 @@
/* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */
'use strict';
/* global
asyncStorage,
BOOKMARKS_COLLECTION_MTIME,
BOOKMARKS_LAST_REVISIONID,
BOOKMARKS_SYNCTOID_PREFIX,
BookmarksHelper,
DataAdapters,
ERROR_SYNC_APP_RACE_CONDITION,
MockasyncStorage,
MockDatastore,
MockLazyLoader,
MockNavigatorDatastore
*/
require('/shared/js/sync/errors.js');
require('/shared/test/unit/mocks/mock_lazy_loader.js');
require('/shared/test/unit/mocks/mock_navigator_datastore.js');
require('/apps/system/test/unit/mock_asyncStorage.js');
requireApp('sync/js/adapters/bookmarks.js');
window.DataAdapters = {};
suite('sync/adapters/bookmarks >', function() {
var realDatastore, realLazyLoader, realAsyncStorage, testCollectionData;
var kintoCollection = {
list() {
return Promise.resolve({
data: testCollectionData
});
}
};
function getBookmarksStore() {
return navigator.getDataStores().then(stores => {
return Promise.resolve(stores[0]);
});
}
function verifyBookmarks(collectionItem, bookmarksItem) {
var payload = collectionItem.payload;
var keys = [
'id',
'type',
'iconable',
'icon',
'createdLocally',
'fxsyncRecords'
];
if (payload.type !== 'separator') {
keys.push('name');
}
if (['bookmark', 'query'].indexOf(payload.type) !== -1) {
keys.push('url');
}
assert.equal(Object.keys(bookmarksItem).length, keys.length);
keys.forEach(key => assert.equal(bookmarksItem.hasOwnProperty(key), true));
switch (payload.type) {
case 'bookmark':
assert.equal(payload.bmkUri, bookmarksItem.id);
assert.equal(payload.bmkUri, bookmarksItem.url);
assert.equal(payload.title, bookmarksItem.name);
assert.deepEqual(payload, bookmarksItem.fxsyncRecords[payload.id]);
assert.equal('url', bookmarksItem.type);
break;
case 'query':
assert.equal(payload.type + '|' + payload.id, bookmarksItem.id);
assert.equal(payload.bmkUri, bookmarksItem.url);
assert.equal(payload.title, bookmarksItem.name);
assert.deepEqual(payload, bookmarksItem.fxsyncRecords[payload.id]);
assert.equal('others', bookmarksItem.type);
break;
case 'folder':
assert.equal(payload.type + '|' + payload.id, bookmarksItem.id);
assert.equal(payload.title, bookmarksItem.name);
assert.deepEqual(payload, bookmarksItem.fxsyncRecords[payload.id]);
assert.equal('others', bookmarksItem.type);
break;
case 'livemark':
assert.equal(payload.type + '|' + payload.id, bookmarksItem.id);
assert.equal(payload.title, bookmarksItem.name);
assert.deepEqual(payload, bookmarksItem.fxsyncRecords[payload.id]);
assert.equal('others', bookmarksItem.type);
break;
case 'separator':
assert.equal(payload.type + '|' + payload.id, bookmarksItem.id);
assert.deepEqual(payload, bookmarksItem.fxsyncRecords[payload.id]);
assert.equal('others', bookmarksItem.type);
break;
case 'microsummary':
console.warn('microsummary is OBSOLETED ', payload);
break;
default:
assert.ok(false, 'Unknown type');
}
}
function testDataGenerator(initIndex, initDate, count) {
var list = [];
for (var i = initIndex; i < initIndex + count; i++) {
var visits = [];
var startData = initDate + i * 100;
for (var j = 0; j < 3; j++) {
visits.push({
date: (startData + j * 10) * 1000, type: 3
});
}
list.unshift({
id: 'UNIQUE_ID_' + i,
last_modified: initDate + i * 10,
payload: {
id: 'UNIQUE_ID_' + i,
bmkUri: 'http://example' + i + '.com/',
title: 'Example ' + i + ' Title',
type: 'bookmark'
}
});
}
return list;
}
suiteSetup(() => {
realDatastore = navigator.getDataStores;
realAsyncStorage = window.asyncStorage;
window.asyncStorage = MockasyncStorage;
realLazyLoader = window.LazyLoader;
window.LazyLoader = MockLazyLoader;
});
suiteTeardown(() => {
navigator.getDataStores = realDatastore;
window.LazyLoader = realLazyLoader;
window.asyncStorage = realAsyncStorage;
});
setup(() => {
navigator.getDataStores = MockNavigatorDatastore.getDataStores;
testCollectionData = [];
MockDatastore._tasks[1].revisionId = 'latest-not-cleared';
});
teardown(() => {
MockDatastore._inError = false;
MockDatastore._records = Object.create(null);
window.asyncStorage.mTeardown();
});
test('update - empty records', function(done) {
var bookmarksAdapter = DataAdapters.bookmarks;
bookmarksAdapter.update(kintoCollection, { readonly: true, userid: 'foo' })
.then(result => {
assert.equal(result, false);
assert.equal(asyncStorage.mItems['foo' + BOOKMARKS_COLLECTION_MTIME],
null);
assert.equal(asyncStorage.mItems[BOOKMARKS_LAST_REVISIONID], null);
}).then(done, reason => {
done(reason || new Error('Rejected by undefined reason.'));
});
});
['clear', 'remove'].forEach(task => {
suite(`after a DataStore ${task}`, function() {
suiteSetup(function() {
MockDatastore._taskCounter = 0;
MockDatastore._tasks = [
{
operation: 'clear',
id: 0,
data: {}
},
{
operation: 'add',
id: 0,
data: {}
},
{
operation: task,
id: 0,
data: {}
},
{
operation: 'done',
id: 0,
revisionId: `latest-after-${task}`,
data: null
}
];
});
suiteTeardown(function() {
MockDatastore._taskCounter = 0;
MockDatastore._tasks = [
{
operation: 'update',
id: 0,
data: {}
},
{
operation: 'done',
id: 0,
data: null,
revisionId: 'latest-not-cleared'
}
];
});
test('update - refills the DataStore', function(done) {
var bookmarksAdapter = DataAdapters.bookmarks;
testCollectionData = testDataGenerator(1, 1440000000, 5);
asyncStorage.mItems['foo' + BOOKMARKS_COLLECTION_MTIME] =
testCollectionData[0].last_modified;
bookmarksAdapter.update(kintoCollection,
{ readonly: true, userid: 'foo' })
.then(() => {
assert.equal(asyncStorage.mItems['foo' + BOOKMARKS_COLLECTION_MTIME],
testCollectionData[0].last_modified);
getBookmarksStore().then(bookmarksStore => {
var ids = testCollectionData.map(item => {
return item.payload.bmkUri;
});
bookmarksStore.get.apply(bookmarksStore, ids).then(list => {
for (var i = 0; i < ids.length; i++) {
verifyBookmarks(testCollectionData[i], list[i]);
assert.equal(
asyncStorage.mItems['foo' + BOOKMARKS_SYNCTOID_PREFIX +
testCollectionData[i].id],
testCollectionData[i].payload.bmkUri);
}
assert.equal(asyncStorage.mItems[
'foo' + BOOKMARKS_LAST_REVISIONID], `latest-after-${task}`);
done();
});
});
});
});
});
});
test('update - does not refill the DataStore if nothing removed locally',
function(done) {
var bookmarksAdapter = DataAdapters.bookmarks;
testCollectionData = testDataGenerator(1, 1440000000, 5);
asyncStorage.mItems['foo' + BOOKMARKS_COLLECTION_MTIME] =
testCollectionData[0].last_modified;
bookmarksAdapter.update(kintoCollection, { readonly: true, userid: 'foo' })
.then(() => {
assert.equal(asyncStorage.mItems['foo' + BOOKMARKS_COLLECTION_MTIME],
testCollectionData[0].last_modified);
getBookmarksStore().then(bookmarksStore => {
var ids = testCollectionData.map(item => {
return item.payload.bmkUri;
});
bookmarksStore.get.apply(bookmarksStore, ids).then(list => {
for (var i = 0; i < ids.length; i++) {
assert.equal(list[i], null);
}
assert.equal(asyncStorage.mItems[
'foo' + BOOKMARKS_LAST_REVISIONID], 'latest-not-cleared');
done();
});
});
});
});
suite('if a DataStore race condition occurs', function() {
setup(function() {
MockDatastore._raceCondition = true;
});
teardown(function() {
delete MockDatastore._raceCondition;
});
test('update - rejects its promise', function(done) {
var bookmarksAdapter = DataAdapters.bookmarks;
var lazyLoaderSpy = this.sinon.spy(MockLazyLoader, 'load');
testCollectionData = testDataGenerator(1, 1440000000, 5);
bookmarksAdapter.update(kintoCollection,
{ readonly: true, userid: 'foo' }).catch(error => {
assert.equal(lazyLoaderSpy.calledWith(['shared/js/sync/errors.js']),
true);
assert.equal(error.message, ERROR_SYNC_APP_RACE_CONDITION);
assert.equal(asyncStorage.mItems['foo' + BOOKMARKS_COLLECTION_MTIME],
null);
done();
});
});
});
test('update - 1 sync request with 5 new records', function(done) {
var bookmarksAdapter = DataAdapters.bookmarks;
var lazyLoaderSpy = this.sinon.spy(MockLazyLoader, 'load');
testCollectionData = testDataGenerator(1, 1440000000, 5);
bookmarksAdapter.update(kintoCollection, { readonly: true, userid: 'foo' })
.then(result => {
var mTime = testCollectionData[0].last_modified;
assert.equal(result, false);
assert.equal(lazyLoaderSpy.calledWith(['shared/js/async_storage.js']),
true);
assert.equal(asyncStorage.mItems['foo' + BOOKMARKS_COLLECTION_MTIME],
mTime);
assert.equal(asyncStorage.mItems['foo' + BOOKMARKS_LAST_REVISIONID],
'latest-not-cleared');
return Promise.resolve();
}).then(getBookmarksStore).then(bookmarksStore => {
var ids = testCollectionData.map(item => {
return item.payload.bmkUri;
});
return bookmarksStore.get.apply(bookmarksStore, ids).then(list => {
for (var i = 0; i < ids.length; i++) {
verifyBookmarks(testCollectionData[i], list[i]);
assert.equal(
asyncStorage.mItems['foo' + BOOKMARKS_SYNCTOID_PREFIX +
testCollectionData[i].id],
testCollectionData[i].payload.bmkUri);
}
});
}).then(done, reason => {
done(reason || new Error('Rejected by undefined reason.'));
});
});
test('update - 2 sync requests', function(done) {
var bookmarksAdapter = DataAdapters.bookmarks;
Promise.resolve().then(() => {
testCollectionData = testDataGenerator(1, 100, 5)
.concat(testCollectionData);
return bookmarksAdapter.update(kintoCollection,
{ readonly: true, userid: 'foo' });
}).then(result => {
assert.equal(result, false);
var mTime = testCollectionData[0].last_modified;
assert.equal(asyncStorage.mItems['foo' + BOOKMARKS_COLLECTION_MTIME],
mTime);
assert.equal(asyncStorage.mItems['foo' + BOOKMARKS_LAST_REVISIONID],
'latest-not-cleared');
return Promise.resolve();
}).then(() => {
testCollectionData = testDataGenerator(6, 500, 5)
.concat(testCollectionData);
return bookmarksAdapter.update(kintoCollection,
{ readonly: true, userid: 'foo' });
}).then(result => {
assert.equal(result, false);
var mTime = testCollectionData[0].last_modified;
assert.equal(asyncStorage.mItems['foo' + BOOKMARKS_COLLECTION_MTIME],
mTime);
assert.equal(asyncStorage.mItems['foo' + BOOKMARKS_LAST_REVISIONID],
'latest-not-cleared');
return Promise.resolve();
}).then(getBookmarksStore).then(bookmarksStore => {
var ids = testCollectionData.map(item => {
return item.payload.bmkUri;
});
return bookmarksStore.get.apply(bookmarksStore, ids).then(list => {
for (var i = 0; i < ids.length; i++) {
verifyBookmarks(testCollectionData[i], list[i]);
assert.equal(
asyncStorage.mItems['foo' + BOOKMARKS_SYNCTOID_PREFIX +
testCollectionData[i].id],
testCollectionData[i].payload.bmkUri);
}
});
}).then(done, reason => {
done(reason || new Error('Rejected by undefined reason.'));
});
});
test('update - 2 sync requests with 2 deleted: true records', function(done) {
var bookmarksAdapter = DataAdapters.bookmarks, store;
var deletedQueue = ['UNIQUE_ID_1', 'UNIQUE_ID_4'];
Promise.resolve().then(() => {
testCollectionData = testDataGenerator(1, 100, 5)
.concat(testCollectionData);
return bookmarksAdapter.update(kintoCollection,
{ readonly: true, userid: 'foo' });
}).then(result => {
assert.equal(result, false);
var mTime = testCollectionData[0].last_modified;
assert.equal(asyncStorage.mItems['foo' + BOOKMARKS_COLLECTION_MTIME],
mTime);
assert.equal(asyncStorage.mItems['foo' + BOOKMARKS_LAST_REVISIONID],
'latest-not-cleared');
return Promise.resolve();
}).then(() => {
testCollectionData = testDataGenerator(6, 500, 5)
.concat(testCollectionData);
var latestModified = testCollectionData[0].last_modified + 10000;
var deletedRecords = [];
deletedQueue.forEach((synctoId, i) => {
deletedRecords.push({
id: synctoId,
last_modified: latestModified + 10000 * i,
payload: {
deleted: true,
id: synctoId
}
});
});
testCollectionData = deletedRecords.concat(testCollectionData);
return bookmarksAdapter.update(kintoCollection,
{ readonly: true, userid: 'foo' });
}).then(result => {
assert.equal(result, false);
var mTime = testCollectionData[0].last_modified;
assert.equal(asyncStorage.mItems['foo' + BOOKMARKS_COLLECTION_MTIME],
mTime);
assert.equal(asyncStorage.mItems['foo' + BOOKMARKS_LAST_REVISIONID],
'latest-not-cleared');
return Promise.resolve();
}).then(getBookmarksStore).then(bookmarksStore => {
store = bookmarksStore;
var ids = testCollectionData.map(item => {
return item.payload.bmkUri;
});
return store.get.apply(store, ids).then(list => {
for (var i = 0; i < list.length; i++) {
if (list[i]) {
verifyBookmarks(testCollectionData[i], list[i]);
assert.equal(
asyncStorage.mItems['foo' + BOOKMARKS_SYNCTOID_PREFIX +
testCollectionData[i].id],
testCollectionData[i].payload.bmkUri);
} else {
assert.notEqual(deletedQueue.indexOf(testCollectionData[i].id), -1);
}
}
});
}).then(done, reason => {
done(reason || new Error('Rejected by undefined reason.'));
});
});
test('update - Add three records with the same URL and delete one',
function(done) {
var bookmarksAdapter = DataAdapters.bookmarks, store;
Promise.resolve().then(() => {
for (var i = 1; i <= 3; i++) {
testCollectionData.unshift({
id: 'UNIQUE_ID_' + i,
last_modified: 100 + i * 10,
payload: {
id: 'UNIQUE_ID_' + i,
type: 'bookmark',
bmkUri: 'http://example.com/',
title: 'Example ' + i + ' Title'
}
});
}
return bookmarksAdapter.update(kintoCollection,
{ readonly: true, userid: 'foo' });
}).then(result => {
assert.equal(result, false);
var mTime = testCollectionData[0].last_modified;
assert.equal(asyncStorage.mItems['foo' + BOOKMARKS_COLLECTION_MTIME],
mTime);
assert.equal(asyncStorage.mItems['foo' + BOOKMARKS_LAST_REVISIONID],
'latest-not-cleared');
return Promise.resolve();
}).then(() => {
testCollectionData = [
{
id: 'UNIQUE_ID_2',
last_modified: testCollectionData[0].last_modified + 10000,
payload: {
deleted: true,
id: 'UNIQUE_ID_2'
}
},
{
id: 'UNIQUE_ID_3',
last_modified: 130,
payload: {
id: 'UNIQUE_ID_3',
type: 'bookmark',
bmkUri: 'http://example.com/',
title: 'Example 3 Title'
}
},
{
id: 'UNIQUE_ID_1',
last_modified: 110,
payload: {
id: 'UNIQUE_ID_1',
type: 'bookmark',
bmkUri: 'http://example.com/',
title: 'Example 1 Title'
}
}
];
return bookmarksAdapter.update(kintoCollection,
{ readonly: true, userid: 'foo' });
}).then(result => {
assert.equal(result, false);
var mTime = testCollectionData[0].last_modified;
assert.equal(asyncStorage.mItems['foo' + BOOKMARKS_COLLECTION_MTIME],
mTime);
assert.equal(asyncStorage.mItems['foo' + BOOKMARKS_LAST_REVISIONID],
'latest-not-cleared');
return Promise.resolve();
}).then(getBookmarksStore).then(bookmarksStore => {
store = bookmarksStore;
return store.get.apply(store, ['http://example.com/']).then(item => {
var expectedBookmark = {
id: 'http://example.com/',
url: 'http://example.com/',
name: 'Example 1 Title',
type: 'url',
iconable: false,
icon: '',
createdLocally: false,
fxsyncRecords: {
UNIQUE_ID_1: {
id: 'UNIQUE_ID_1',
type: 'bookmark',
bmkUri: 'http://example.com/',
title: 'Example 1 Title',
timestamp: 110
},
UNIQUE_ID_2: {
id: 'UNIQUE_ID_2',
deleted: true
},
UNIQUE_ID_3: {
id: 'UNIQUE_ID_3',
type: 'bookmark',
bmkUri: 'http://example.com/',
title: 'Example 3 Title',
timestamp: 130
}
}
};
for(var i=1; i <= 3; i++) {
assert.equal(
asyncStorage.mItems[
'foo' + BOOKMARKS_SYNCTOID_PREFIX + 'UNIQUE_ID_' + i],
item.url);
}
assert.deepEqual(item, expectedBookmark);
});
}).then(done, reason => {
done(reason || new Error('Rejected by undefined reason.'));
});
});
test('update - Add two records and add one, all with the same URL',
function(done) {
var bookmarksAdapter = DataAdapters.bookmarks, store;
Promise.resolve().then(() => {
for (var i = 1; i <= 2; i++) {
testCollectionData.unshift({
id: 'UNIQUE_ID_' + i,
last_modified: 100 + i * 10,
payload: {
id: 'UNIQUE_ID_' + i,
type: 'bookmark',
bmkUri: 'http://example.com/',
title: 'Example ' + i + ' Title'
}
});
}
return bookmarksAdapter.update(kintoCollection,
{ readonly: true, userid: 'foo' });
}).then(result => {
assert.equal(result, false);
var mTime = testCollectionData[0].last_modified;
assert.equal(asyncStorage.mItems['foo' + BOOKMARKS_COLLECTION_MTIME],
mTime);
assert.equal(asyncStorage.mItems['foo' + BOOKMARKS_LAST_REVISIONID],
'latest-not-cleared');
return Promise.resolve();
}).then(() => {
testCollectionData.unshift({
id: 'UNIQUE_ID_3',
last_modified: 130,
payload: {
id: 'UNIQUE_ID_3',
type: 'bookmark',
bmkUri: 'http://example.com/',
title: 'Example 3 Title'
}
});
return bookmarksAdapter.update(kintoCollection,
{ readonly: true, userid: 'foo' });
}).then(result => {
assert.equal(result, false);
var mTime = testCollectionData[0].last_modified;
assert.equal(asyncStorage.mItems['foo' + BOOKMARKS_COLLECTION_MTIME],
mTime);
assert.equal(asyncStorage.mItems['foo' + BOOKMARKS_LAST_REVISIONID],
'latest-not-cleared');
return Promise.resolve();
}).then(getBookmarksStore).then(bookmarksStore => {
store = bookmarksStore;
return store.get.apply(store, ['http://example.com/']).then(item => {
var expectedBookmark = {
id: 'http://example.com/',
url: 'http://example.com/',
name: 'Example 3 Title',
type: 'url',
iconable: false,
icon: '',
createdLocally: false,
fxsyncRecords: {
UNIQUE_ID_1: {
id: 'UNIQUE_ID_1',
type: 'bookmark',
bmkUri: 'http://example.com/',
title: 'Example 1 Title',
timestamp: 110
},
UNIQUE_ID_2: {
id: 'UNIQUE_ID_2',
type: 'bookmark',
bmkUri: 'http://example.com/',
title: 'Example 2 Title',
timestamp: 120
},
UNIQUE_ID_3: {
id: 'UNIQUE_ID_3',
type: 'bookmark',
bmkUri: 'http://example.com/',
title: 'Example 3 Title',
timestamp: 130
}
}
};
for(var i=1; i <= 3; i++) {
assert.equal(
asyncStorage.mItems[
'foo' + BOOKMARKS_SYNCTOID_PREFIX + 'UNIQUE_ID_' + i],
item.url);
}
assert.deepEqual(item, expectedBookmark);
});
}).then(done, reason => {
done(reason || new Error('Rejected by undefined reason.'));
});
});
test('update - query, folder, livemark, and separator record',
function(done) {
var bookmarksAdapter = DataAdapters.bookmarks, store;
var i = 1;
testCollectionData.unshift({
id: 'UNIQUE_ID_' + i,
last_modified: 100 + i * 10,
payload: {
id: 'UNIQUE_ID_' + i,
type: 'query',
bmkUri: 'place:type=3&sort=4',
title: 'Example ' + i + ' Title'
}
});
i++;
testCollectionData.unshift({
id: 'UNIQUE_ID_' + i,
last_modified: 100 + i * 10,
payload: {
id: 'UNIQUE_ID_' + i,
type: 'folder',
title: 'Example ' + i + ' Title'
}
});
i++;
testCollectionData.unshift({
id: 'UNIQUE_ID_' + i,
last_modified: 100 + i * 10,
payload: {
id: 'UNIQUE_ID_' + i,
type: 'livemark',
title: 'Example ' + i + ' Title'
}
});
i++;
testCollectionData.unshift({
id: 'UNIQUE_ID_' + i,
last_modified: 100 + i * 10,
payload: {
id: 'UNIQUE_ID_' + i,
type: 'separator'
}
});
bookmarksAdapter.update(kintoCollection, { readonly: true, userid: 'foo' })
.then(result => {
assert.equal(result, false);
var mTime = testCollectionData[0].last_modified;
assert.equal(asyncStorage.mItems['foo' + BOOKMARKS_COLLECTION_MTIME],
mTime);
assert.equal(asyncStorage.mItems['foo' + BOOKMARKS_LAST_REVISIONID],
'latest-not-cleared');
return Promise.resolve();
}).then(getBookmarksStore).then(bookmarksStore => {
store = bookmarksStore;
var ids = testCollectionData.map(item => {
return item.payload.type + '|' + item.payload.id;
});
return store.get.apply(store, ids).then(list => {
for (var i = 0; i < list.length; i++) {
if (list[i]) {
verifyBookmarks(testCollectionData[i], list[i]);
for (var fxsyncId in testCollectionData[i].fxsyncRecords) {
assert.equal(asyncStorage.mItems[
'foo' + BOOKMARKS_SYNCTOID_PREFIX + fxsyncId],list[i].id);
}
} else {
assert.ok(false, 'Empty Record!');
}
}
});
}).then(done, reason => {
done(reason || new Error('Rejected by undefined reason.'));
});
});
test('update - empty bookmarks-uri record', function(done) {
var bookmarksAdapter = DataAdapters.bookmarks;
var i = 1;
testCollectionData.unshift({
id: 'UNIQUE_ID_' + i,
last_modified: 100 + i * 10,
payload: {
id: 'UNIQUE_ID_' + i,
type: 'bookmark',
bmkUri: '',
title: 'Example ' + i + ' Title'
}
});
bookmarksAdapter.update(kintoCollection, { readonly: true, userid: 'foo' })
.then(result => {
assert.equal(result, false);
assert.equal(asyncStorage.mItems['foo' + BOOKMARKS_COLLECTION_MTIME],
110);
assert.equal(asyncStorage.mItems['foo' + BOOKMARKS_LAST_REVISIONID],
'latest-not-cleared');
return Promise.resolve();
}).then(done, reason => {
done(reason || new Error('Rejected by undefined reason.'));
});
});
test('update - empty query-uri record', function(done) {
var bookmarksAdapter = DataAdapters.bookmarks;
var i = 1;
testCollectionData.unshift({
id: 'UNIQUE_ID_' + i,
last_modified: 100 + i * 10,
payload: {
id: 'UNIQUE_ID_' + i,
type: 'query',
bmkUri: '',
title: 'Example ' + i + ' Title'
}
});
bookmarksAdapter.update(kintoCollection, { readonly: true, userid: 'foo' })
.then(result => {
assert.equal(result, false);
assert.equal(asyncStorage.mItems['foo' + BOOKMARKS_COLLECTION_MTIME],
110);
assert.equal(asyncStorage.mItems['foo' + BOOKMARKS_LAST_REVISIONID],
'latest-not-cleared');
return Promise.resolve();
}).then(done, reason => {
done(reason || new Error('Rejected by undefined reason.'));
});
});
test('update - empty last_modified record', function(done) {
var bookmarksAdapter = DataAdapters.bookmarks;
var i = 1;
testCollectionData.unshift({
id: 'UNIQUE_ID_' + i,
last_modified: null,
payload: {
id: 'UNIQUE_ID_' + i,
type: 'bookmark',
bmkUri: '',
title: 'Example ' + i + ' Title'
}
});
bookmarksAdapter.update(kintoCollection, { readonly: true, userid: 'foo' })
.then(result => {
assert.equal(result, false);
assert.equal(asyncStorage.mItems['foo' + BOOKMARKS_COLLECTION_MTIME],
null);
assert.equal(asyncStorage.mItems['foo' + BOOKMARKS_LAST_REVISIONID],
'latest-not-cleared');
return Promise.resolve();
}).then(done, reason => {
done(reason || new Error('Rejected by undefined reason.'));
});
});
test('update - unknown type record', function(done) {
var bookmarksAdapter = DataAdapters.bookmarks;
var i = 1;
testCollectionData.unshift({
id: 'UNIQUE_ID_' + i,
last_modified: 1000,
payload: {
id: 'UNIQUE_ID_' + i,
type: 'unknown',
bmkUri: null,
title: 'Example ' + i + ' Title'
}
});
bookmarksAdapter.update(kintoCollection, { readonly: true, userid: 'foo' })
.then(result => {
assert.equal(result, false);
assert.equal(asyncStorage.mItems['foo' + BOOKMARKS_COLLECTION_MTIME],
1000);
assert.equal(asyncStorage.mItems['foo' + BOOKMARKS_LAST_REVISIONID],
'latest-not-cleared');
return Promise.resolve();
}).then(done, reason => {
done(reason || new Error('Rejected by undefined reason.'));
});
});
test('BookmarksHelper - merge remote record into local record',
function(done) {
var bookmark1 = {
url: 'http://www.mozilla.org/en-US/',
name: '',
type: 'url'
};
var bookmark2 = {
url: 'http://www.mozilla.org/en-US/',
name: 'Mozilla',
type: 'url',
fxsyncRecords: {
'XXXXX_ID_XXXXX': {}
}
};
var result = BookmarksHelper.mergeRecordsToDataStore(bookmark1, bookmark2,
'XXXXX_ID_XXXXX');
var expectedBookmark = {
url: 'http://www.mozilla.org/en-US/',
name: 'Mozilla',
type: 'url',
createdLocally: true,
fxsyncRecords: {
'XXXXX_ID_XXXXX': {}
}
};
assert.equal(result.name, expectedBookmark.name);
assert.equal(result.url, expectedBookmark.url);
assert.deepEqual(result, expectedBookmark);
done();
});
test('BookmarksHelper - merge two records with incorrect URL',
function(done) {
var bookmark1 = {
url: 'dummy',
name: '',
type: 'url'
};
var bookmark2 = {
url: 'http://www.mozilla.org/en-US/',
name: 'Mozilla',
type: 'url'
};
assert.throws(() => {
BookmarksHelper.mergeRecordsToDataStore(bookmark1, bookmark2,
'XXXXX_ID_XXXXX');
});
done();
});
test('BookmarksHelper - merge two records with fxsyncRecords',
function(done) {
var bookmark1 = {
url: 'http://www.mozilla.org/en-US/',
name: '',
type: 'url',
createdLocally: false,
fxsyncRecords: {
'XXXXX_ID_XXXXX_A': {
id: 'XXXXX_ID_XXXXX_A'
}
}
};
var bookmark2 = {
url: 'http://www.mozilla.org/en-US/',
name: 'Mozilla',
type: 'url',
fxsyncRecords: {
'XXXXX_ID_XXXXX_B': {
id: 'XXXXX_ID_XXXXX_B'
}
}
};
var result = BookmarksHelper.mergeRecordsToDataStore(bookmark1, bookmark2,
'XXXXX_ID_XXXXX_B');
var expectedBookmark = {
url: 'http://www.mozilla.org/en-US/',
name: 'Mozilla',
type: 'url',
createdLocally: false,
fxsyncRecords: {
'XXXXX_ID_XXXXX_A': {
id: 'XXXXX_ID_XXXXX_A'
},
'XXXXX_ID_XXXXX_B': {
id: 'XXXXX_ID_XXXXX_B'
}
}
};
assert.equal(result.name, expectedBookmark.name);
assert.equal(result.url, expectedBookmark.url);
assert.deepEqual(result, expectedBookmark);
done();
});
});

View file

@ -0,0 +1,671 @@
/* Any copyright is dedicated to the Public Domain.
* http://creativecommons.org/publicdomain/zero/1.0/ */
'use strict';
/* global
asyncStorage,
DataAdapters,
ERROR_SYNC_APP_RACE_CONDITION,
HISTORY_COLLECTION_MTIME,
HISTORY_LAST_REVISIONID,
HISTORY_SYNCTOID_PREFIX,
HistoryHelper,
MockasyncStorage,
MockDatastore,
MockLazyLoader,
MockNavigatorDatastore
*/
require('/shared/js/sync/errors.js');
require('/shared/test/unit/mocks/mock_lazy_loader.js');
require('/shared/test/unit/mocks/mock_navigator_datastore.js');
require('/apps/system/test/unit/mock_asyncStorage.js');
requireApp('sync/js/adapters/history.js');
window.DataAdapters = {};
suite('sync/adapters/history >', function() {
var realDatastore, realLazyLoader, realAsyncStorage, testCollectionData;
var dataStoreRemoveSpy;
var kintoCollection = {
list() {
return Promise.resolve({
data: testCollectionData
});
}
};
function getPlacesStore() {
return navigator.getDataStores().then(stores => {
return Promise.resolve(stores[0]);
});
}
function verifyPlaces(collectionItem, placesItem) {
var payload = collectionItem.payload;
assert.equal(payload.histUri, placesItem.url);
assert.equal(payload.title, placesItem.title);
for (var i = 0; i < payload.visits.length; i++) {
assert.equal(payload.visits[i].date, placesItem.visits[i] * 1000);
}
assert.equal(payload.id, placesItem.fxsyncId);
}
function testDataGenerator(initIndex, initDate, count) {
var list = [];
for (var i = initIndex; i < initIndex + count; i++) {
var visits = [];
var startData = initDate + i * 100;
for (var j = 0; j < 3; j++) {
// Visits from FxSync will be in anti-chronological order
visits.unshift({
date: (startData + j * 10) * 1000, type: 3
});
}
list.unshift({
id: 'UNIQUE_ID_' + i,
last_modified: initDate + i * 10,
payload: {
id: 'UNIQUE_ID_' + i,
histUri: 'http://example' + i + '.com/',
title: 'Example ' + i + ' Title',
visits: visits
}
});
}
return list;
}
suiteSetup(() => {
realDatastore = navigator.getDataStores;
realAsyncStorage = window.asyncStorage;
window.asyncStorage = MockasyncStorage;
realLazyLoader = window.LazyLoader;
window.LazyLoader = MockLazyLoader;
});
suiteTeardown(() => {
navigator.getDataStores = realDatastore;
window.LazyLoader = realLazyLoader;
window.asyncStorage = realAsyncStorage;
});
setup(() => {
navigator.getDataStores = MockNavigatorDatastore.getDataStores;
dataStoreRemoveSpy = sinon.spy(MockDatastore, 'remove');
testCollectionData = [];
MockDatastore._tasks[1].revisionId = 'latest-not-cleared';
});
teardown(() => {
dataStoreRemoveSpy.restore();
MockDatastore._inError = false;
MockDatastore._records = Object.create(null);
window.asyncStorage.mTeardown();
});
test('update - empty records', function(done) {
var historyAdapter = DataAdapters.history;
historyAdapter.update(kintoCollection, { readonly: true, userid: 'foo' })
.then((result) => {
assert.equal(result, false);
assert.equal(asyncStorage.mItems['foo' + HISTORY_LAST_REVISIONID],
'latest-not-cleared');
}).then(done, reason => {
assert.ok(false, reason);
});
});
['clear', 'remove'].forEach(task => {
suite(`after a DataStore ${task}`, function() {
suiteSetup(function() {
MockDatastore._taskCounter = 0;
MockDatastore._tasks = [
{
operation: 'clear',
id: 0,
data: {}
},
{
operation: 'add',
id: 0,
data: {}
},
{
operation: task,
id: 0,
data: {}
},
{
operation: 'done',
id: 0,
revisionId: `latest-after-${task}`,
data: null
}
];
});
suiteTeardown(function() {
MockDatastore._taskCounter = 0;
MockDatastore._tasks = [
{
operation: 'update',
id: 0,
data: {}
},
{
operation: 'done',
id: 0,
data: null,
revisionId: 'latest-not-cleared'
}
];
});
test('update - refills the DataStore', function(done) {
var historyAdapter = DataAdapters.history;
testCollectionData = testDataGenerator(1, 1440000000, 5);
asyncStorage.mItems['foo' + HISTORY_COLLECTION_MTIME] =
testCollectionData[0].last_modified;
historyAdapter.update(kintoCollection,
{ readonly: true, userid: 'foo' })
.then(() => {
assert.equal(asyncStorage.mItems['foo' + HISTORY_COLLECTION_MTIME],
testCollectionData[0].last_modified);
getPlacesStore().then(placesStore => {
var ids = testCollectionData.map(item => {
return item.payload.histUri;
});
placesStore.get.apply(placesStore, ids).then(list => {
for (var i = 0; i < ids.length; i++) {
verifyPlaces(testCollectionData[i], list[i]);
assert.equal(
asyncStorage.mItems[
'foo' + HISTORY_SYNCTOID_PREFIX + list[i].fxsyncId],
list[i].url);
}
assert.equal(asyncStorage.mItems[
'foo' + HISTORY_LAST_REVISIONID], `latest-after-${task}`);
done();
});
});
});
});
});
});
test('update - does not refill the DataStore if nothing removed locally',
function(done) {
var historyAdapter = DataAdapters.history;
testCollectionData = testDataGenerator(1, 1440000000, 5);
asyncStorage.mItems['foo' + HISTORY_COLLECTION_MTIME] =
testCollectionData[0].last_modified;
historyAdapter.update(kintoCollection, { readonly: true, userid: 'foo' })
.then(() => {
assert.equal(asyncStorage.mItems['foo' + HISTORY_COLLECTION_MTIME],
testCollectionData[0].last_modified);
getPlacesStore().then(placesStore => {
var ids = testCollectionData.map(item => {
return item.payload.histUri;
});
placesStore.get.apply(placesStore, ids).then(list => {
for (var i = 0; i < ids.length; i++) {
assert.equal(list[i], null);
}
assert.equal(asyncStorage.mItems[
'foo' + HISTORY_LAST_REVISIONID], 'latest-not-cleared');
done();
});
});
});
});
test('update - 1 sync request with 5 new records', function(done) {
var historyAdapter = DataAdapters.history;
var lazyLoaderSpy = this.sinon.spy(MockLazyLoader, 'load');
testCollectionData = testDataGenerator(1, 1440000000, 5);
historyAdapter.update(kintoCollection, { readonly: true, userid: 'foo' })
.then((result) => {
var mTime = testCollectionData[0].last_modified;
assert.equal(lazyLoaderSpy.calledWith(['shared/js/async_storage.js']),
true);
assert.equal(result, false);
assert.equal(asyncStorage.mItems['foo' + HISTORY_COLLECTION_MTIME],
mTime);
return Promise.resolve();
}).then(getPlacesStore).then(placesStore => {
var ids = testCollectionData.map(item => {
return item.payload.histUri;
});
return placesStore.get.apply(placesStore, ids).then(list => {
for (var i = 0; i < ids.length; i++) {
verifyPlaces(testCollectionData[i], list[i]);
assert.equal(
asyncStorage.mItems[
'foo' + HISTORY_SYNCTOID_PREFIX + list[i].fxsyncId],
list[i].url);
}
});
}).then(done, reason => {
assert.ok(false, reason);
});
});
suite('if a DataStore race condition occurs', function() {
setup(function() {
MockDatastore._raceCondition = true;
});
teardown(function() {
delete MockDatastore._raceCondition;
});
test('update - rejects its promise', function(done) {
var historyAdapter = DataAdapters.history;
var lazyLoaderSpy = this.sinon.spy(MockLazyLoader, 'load');
testCollectionData = testDataGenerator(1, 1440000000, 5);
historyAdapter.update(kintoCollection, { readonly: true, userid: 'foo' })
.catch(error => {
assert.equal(lazyLoaderSpy.calledWith(['shared/js/sync/errors.js']),
true);
assert.equal(error.message, ERROR_SYNC_APP_RACE_CONDITION);
assert.equal(asyncStorage.mItems['foo' + HISTORY_COLLECTION_MTIME],
null);
done();
});
});
});
test('update - 1 sync request with 5 pre-existing records', function(done) {
var historyAdapter = DataAdapters.history;
getPlacesStore().then(store => {
for (var i=1; i<=5; i++) {
store._records['http://example' + i + '.com/'] = {
url: 'http://example' + i + '.com/',
title: 'old',
visits: []
};
}
}).then(() => {
testCollectionData = testDataGenerator(1, 1440000000, 5);
return historyAdapter.update(kintoCollection,
{ readonly: true, userid: 'foo' });
}).then((result) => {
assert.equal(result, false);
var mTime = testCollectionData[0].last_modified;
assert.equal(asyncStorage.mItems['foo' + HISTORY_COLLECTION_MTIME],
mTime);
return Promise.resolve();
}).then(getPlacesStore).then(placesStore => {
var ids = testCollectionData.map(item => {
return item.payload.histUri;
});
return placesStore.get.apply(placesStore, ids).then(list => {
for (var i = 0; i < ids.length; i++) {
verifyPlaces(testCollectionData[i], list[i]);
assert.equal(
asyncStorage.mItems[
'foo' + HISTORY_SYNCTOID_PREFIX + list[i].fxsyncId],
list[i].url);
}
});
}).then(done, reason => {
assert.ok(false, reason);
});
});
test('update - 2 sync requests', function(done) {
var historyAdapter = DataAdapters.history;
Promise.resolve().then(() => {
testCollectionData = testDataGenerator(1, 100, 5)
.concat(testCollectionData);
return historyAdapter.update(kintoCollection,
{ readonly: true, userid: 'foo' });
}).then((result) => {
assert.equal(result, false);
var mTime = testCollectionData[0].last_modified;
assert.equal(asyncStorage.mItems['foo' + HISTORY_COLLECTION_MTIME],
mTime);
return Promise.resolve();
}).then(() => {
testCollectionData = testDataGenerator(6, 500, 5)
.concat(testCollectionData);
return historyAdapter.update(kintoCollection,
{ readonly: true, userid: 'foo' });
}).then((result) => {
assert.equal(result, false);
var mTime = testCollectionData[0].last_modified;
assert.equal(asyncStorage.mItems['foo' + HISTORY_COLLECTION_MTIME],
mTime);
return Promise.resolve();
}).then(getPlacesStore).then(placesStore => {
var ids = testCollectionData.map(item => {
return item.payload.histUri;
});
return placesStore.get.apply(placesStore, ids).then(list => {
for (var i = 0; i < ids.length; i++) {
verifyPlaces(testCollectionData[i], list[i]);
assert.equal(
asyncStorage.mItems[
'foo' + HISTORY_SYNCTOID_PREFIX + list[i].fxsyncId],
list[i].url);
}
});
}).then(done, reason => {
assert.ok(false, reason);
});
});
test('update - 2 sync requests with 2 deleted: true records', function(done) {
var historyAdapter = DataAdapters.history, store;
var deletedQueue = ['UNIQUE_ID_1', 'UNIQUE_ID_4'];
Promise.resolve().then(() => {
testCollectionData = testDataGenerator(1, 100, 5)
.concat(testCollectionData);
return historyAdapter.update(kintoCollection,
{ readonly: true, userid: 'foo' });
}).then((result) => {
assert.equal(result, false);
var mTime = testCollectionData[0].last_modified;
assert.equal(asyncStorage.mItems['foo' + HISTORY_COLLECTION_MTIME],
mTime);
return Promise.resolve();
}).then(() => {
testCollectionData = testDataGenerator(6, 500, 5)
.concat(testCollectionData);
var latestModified = testCollectionData[0].last_modified + 10000;
var deletedRecords = [];
deletedQueue.forEach((synctoId, i) => {
deletedRecords.push({
id: synctoId,
last_modified: latestModified + 10000 * i,
payload: {
deleted: true,
id: synctoId
}
});
});
testCollectionData = deletedRecords.concat(testCollectionData);
return historyAdapter.update(kintoCollection,
{ readonly: true, userid: 'foo' });
}).then((result) => {
assert.equal(result, false);
var mTime = testCollectionData[0].last_modified;
assert.equal(asyncStorage.mItems['foo' + HISTORY_COLLECTION_MTIME],
mTime);
assert.equal(asyncStorage.mItems['foo' + HISTORY_LAST_REVISIONID],
'latest-not-cleared');
return Promise.resolve();
}).then(getPlacesStore).then(placesStore => {
store = placesStore;
var ids = testCollectionData.map(item => {
return item.payload.histUri;
});
return store.get.apply(store, ids).then(list => {
for (var i = 0; i < list.length; i++) {
if (list[i]) {
verifyPlaces(testCollectionData[i], list[i]);
assert.equal(
asyncStorage.mItems[
'foo' + HISTORY_SYNCTOID_PREFIX + list[i].fxsyncId],
list[i].url);
} else {
assert.notEqual(deletedQueue.indexOf(testCollectionData[i].id), -1);
}
}
});
}).then(done, reason => {
assert.ok(false, reason);
});
});
test('update - non-array visits record', function(done) {
var historyAdapter = DataAdapters.history;
var i = 1;
testCollectionData.unshift({
id: 'UNIQUE_ID_' + i,
last_modified: 100 + i * 10,
payload: {
id: 'UNIQUE_ID_' + i,
histUri: 'http://example' + i + '.com/',
title: 'Example ' + i + ' Title',
visits: null
}
});
historyAdapter.update(kintoCollection,
{ readonly: true, userid: 'foo' }).then((result) => {
assert.equal(result, false);
assert.equal(asyncStorage.mItems['foo' + HISTORY_COLLECTION_MTIME], 110);
assert.equal(asyncStorage.mItems['foo' + HISTORY_LAST_REVISIONID],
'latest-not-cleared');
return Promise.resolve();
}).then(done, reason => {
assert.ok(false, reason);
});
});
test('update - empty visits array record (not created locally)',
function(done) {
var historyAdapter = DataAdapters.history;
var i = 1;
testCollectionData.unshift({
id: 'UNIQUE_ID_' + i,
last_modified: 100 + i * 10,
payload: {
id: 'UNIQUE_ID_' + i,
histUri: 'http://example' + i + '.com/',
title: 'Example ' + i + ' Title',
visits: []
}
});
MockDatastore._records = {
'http://example1.com/': {}
};
historyAdapter.update(kintoCollection,
{ readonly: true, userid: 'foo' }).then((result) => {
assert.equal(result, false);
assert.equal(asyncStorage.mItems['foo' + HISTORY_COLLECTION_MTIME], 110);
assert.equal(dataStoreRemoveSpy.callCount, 1);
assert.equal(dataStoreRemoveSpy.args[0][0], 'http://example1.com/');
return Promise.resolve();
}).then(done, reason => {
assert.ok(false, reason);
});
});
test('update - empty visits array record (created locally)', function(done) {
var historyAdapter = DataAdapters.history;
var i = 1;
testCollectionData.unshift({
id: 'UNIQUE_ID_' + i,
last_modified: 100 + i * 10,
payload: {
id: 'UNIQUE_ID_' + i,
histUri: 'http://example' + i + '.com/',
title: 'Example ' + i + ' Title',
visits: []
}
});
MockDatastore._records = {
'http://example1.com/': {
createdLocally: true
}
};
historyAdapter.update(kintoCollection,
{ readonly: true, userid: 'foo' }).then((result) => {
assert.equal(result, false);
assert.equal(asyncStorage.mItems['foo' + HISTORY_COLLECTION_MTIME], 110);
assert.equal(dataStoreRemoveSpy.callCount, 0);
return Promise.resolve();
}).then(done, reason => {
assert.ok(false, reason);
});
});
test('update - empty history-uri record', function(done) {
var historyAdapter = DataAdapters.history;
var i = 1;
testCollectionData.unshift({
id: 'UNIQUE_ID_' + i,
last_modified: 100 + i * 10,
payload: {
id: 'UNIQUE_ID_' + i,
histUri: '',
title: 'Example ' + i + ' Title',
visits: [10000, 20000]
}
});
historyAdapter.update(kintoCollection,
{ readonly: true, userid: 'foo' }).then((result) => {
assert.equal(result, false);
assert.equal(asyncStorage.mItems['foo' + HISTORY_COLLECTION_MTIME], 110);
assert.equal(asyncStorage.mItems['foo' + HISTORY_LAST_REVISIONID],
'latest-not-cleared');
return Promise.resolve();
}).then(done, reason => {
assert.ok(false, reason);
});
});
test('update - empty last_modified record', function(done) {
var historyAdapter = DataAdapters.history;
var i = 1;
testCollectionData.unshift({
id: 'UNIQUE_ID_' + i,
last_modified: null,
payload: {
id: 'UNIQUE_ID_' + i,
histUri: 'http://example' + i + '.com/',
title: 'Example ' + i + ' Title',
visits: [10000, 20000]
}
});
historyAdapter.update(kintoCollection,
{ readonly: true, userid: 'foo' }).then((result) => {
assert.equal(result, false);
assert.equal(asyncStorage.mItems['foo' + HISTORY_COLLECTION_MTIME], null);
assert.equal(asyncStorage.mItems['foo' + HISTORY_LAST_REVISIONID],
'latest-not-cleared');
return Promise.resolve();
}).then(done, reason => {
assert.ok(false, reason);
});
});
test('HistoryHelper - merge two remote records', function(done) {
var place1 = {
url: 'http://www.mozilla.org/en-US/',
title: '',
fxsyncId: '',
createdLocally: false,
visits: [ 1501000000000, 1502000000000 ]
};
var place2 = {
url: 'http://www.mozilla.org/en-US/',
title: 'Mozilla',
fxsyncId: 'XXXXX_ID_XXXXX',
visits: [ 1502000000000, 1503000000000 ]
};
var result = HistoryHelper.mergeRecordsToDataStore(place1, place2);
var expectedPlace = {
url: 'http://www.mozilla.org/en-US/',
title: 'Mozilla',
fxsyncId: 'XXXXX_ID_XXXXX',
createdLocally: false,
visits: [1503000000000, 1502000000000, 1501000000000]
};
assert.equal(result.title, expectedPlace.title);
assert.equal(result.url, expectedPlace.url);
assert.equal(result.visits.length, expectedPlace.visits.length);
for(var i = 0; i < result.visits.length; i++){
assert.equal(result.visits[i], expectedPlace.visits[i]);
}
done();
});
test('HistoryHelper - merge remote record into local record', function(done) {
var place1 = {
url: 'http://www.mozilla.org/en-US/',
title: '',
fxsyncId: '',
visits: [ 1501000000000, 1502000000000 ]
};
var place2 = {
url: 'http://www.mozilla.org/en-US/',
title: 'Mozilla',
fxsyncId: 'XXXXX_ID_XXXXX',
visits: [ 1502000000000, 1503000000000 ]
};
var result = HistoryHelper.mergeRecordsToDataStore(place1, place2);
var expectedPlace = {
url: 'http://www.mozilla.org/en-US/',
title: 'Mozilla',
fxsyncId: 'XXXXX_ID_XXXXX',
createdLocally: true,
visits: [1503000000000, 1502000000000, 1501000000000]
};
assert.equal(result.title, expectedPlace.title);
assert.equal(result.url, expectedPlace.url);
assert.equal(result.visits.length, expectedPlace.visits.length);
for(var i = 0; i < result.visits.length; i++){
assert.equal(result.visits[i], expectedPlace.visits[i]);
}
done();
});
test('HistoryHelper - merge two records with incorrect URL', function(done) {
var place1 = {
url: 'dummy',
title: '',
fxsyncId: '',
visits: [ 1501000000000, 1502000000000 ]
};
var place2 = {
url: 'http://www.mozilla.org/en-US/',
title: 'Mozilla',
fxsyncId: 'XXXXX_ID_XXXXX',
visits: [ 1502000000000, 1503000000000 ]
};
assert.throws(() => {
HistoryHelper.mergeRecordsToDataStore(place1, place2);
});
done();
});
test('HistoryHelper - merge two records with incorrect fxsyncId',
function(done) {
var place1 = {
url: 'http://www.mozilla.org/en-US/',
title: '',
fxsyncId: 'dummy',
visits: [ 1501000000000, 1502000000000 ]
};
var place2 = {
url: 'http://www.mozilla.org/en-US/',
title: 'Mozilla',
fxsyncId: 'XXXXX_ID_XXXXX',
visits: [ 1502000000000, 1503000000000 ]
};
assert.throws(() => {
HistoryHelper.mergeRecordsToDataStore(place1, place2);
});
done();
});
});