diff --git a/lib/sdk/addon/events.js b/lib/sdk/addon/events.js index 5b028c880..298d33272 100644 --- a/lib/sdk/addon/events.js +++ b/lib/sdk/addon/events.js @@ -22,7 +22,7 @@ function receive ({data, id, error}) { let request = requests.get(id); if (request) { if (error) request.reject(error); - else request.resolve(serialize(data)); + else request.resolve(clone(data)); requests.delete(id); } } @@ -38,11 +38,15 @@ function send (eventName, data) { requests.set(id, deferred); emit(hostReq, 'data', { id: id, - data: serialize(data), + data: clone(data), event: eventName }); return deferred.promise; } exports.send = send; -function serialize (obj) JSON.parse(JSON.stringify(obj)) +/* + * Implement internal structured cloning algorithm in the future? + * http://www.whatwg.org/specs/web-apps/current-work/multipage/common-dom-interfaces.html#internal-structured-cloning-algorithm + */ +function clone (obj) JSON.parse(JSON.stringify(obj || {})) diff --git a/lib/sdk/places/bookmarks.js b/lib/sdk/places/bookmarks.js new file mode 100644 index 000000000..da1e728e8 --- /dev/null +++ b/lib/sdk/places/bookmarks.js @@ -0,0 +1,387 @@ +/* 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"; + +module.metadata = { + "stability": "unstable", + "engines": { + "Firefox": "*" + } +}; + +/* + * Requiring hosts so they can subscribe to client messages + */ +require('./host/host-bookmarks'); +require('./host/host-tags'); +require('./host/host-query'); + +const { Cc, Ci } = require('chrome'); +const { Class } = require('../core/heritage'); +const { send } = require('../addon/events'); +const { defer, reject, all, resolve, promised } = require('../core/promise'); +const { EventTarget } = require('../event/target'); +const { emit } = require('../event/core'); +const { identity, defer:async } = require('../lang/functional'); +const { extend, merge } = require('../util/object'); +const { fromIterator } = require('../util/array'); +const { + constructTree, fetchItem, createQuery, + isRootGroup, createQueryOptions +} = require('./utils'); +const { + bookmarkContract, groupContract, separatorContract +} = require('./contract'); +const bmsrv = Cc["@mozilla.org/browser/nav-bookmarks-service;1"]. + getService(Ci.nsINavBookmarksService); + +/* + * Mapping of uncreated bookmarks with their created + * counterparts + */ +const itemMap = new WeakMap(); + +/* + * Constant used by nsIHistoryQuery; 1 is a bookmark query + * https://developer.mozilla.org/en-US/docs/XPCOM_Interface_Reference/nsINavHistoryQueryOptions + */ +const BOOKMARK_QUERY = 1; + +/* + * Bookmark Item classes + */ + +const Bookmark = Class({ + extends: [ + bookmarkContract.properties(identity) + ], + initialize: function initialize (options) { + merge(this, bookmarkContract(extend(defaults, options))); + }, + type: 'bookmark', + toString: function () '[object Bookmark]' +}); +exports.Bookmark = Bookmark; + +const Group = Class({ + extends: [ + groupContract.properties(identity) + ], + initialize: function initialize (options) { + // Don't validate if root group + if (isRootGroup(options)) + merge(this, options); + else + merge(this, groupContract(extend(defaults, options))); + }, + type: 'group', + toString: function () '[object Group]' +}); +exports.Group = Group; + +const Separator = Class({ + extends: [ + separatorContract.properties(identity) + ], + initialize: function initialize (options) { + merge(this, separatorContract(extend(defaults, options))); + }, + type: 'separator', + toString: function () '[object Separator]' +}); +exports.Separator = Separator; + +/* + * Functions + */ + +function save (items, options) { + items = [].concat(items); + options = options || {}; + let emitter = EventTarget(); + let results = []; + let errors = []; + let root = constructTree(items); + let cache = new Map(); + + let isExplicitSave = item => !!~items.indexOf(item); + // `walk` returns an aggregate promise indicating the completion + // of the `commitItem` on each node, not whether or not that + // commit was successful + + // Force this to be async, as if a ducktype fails validation, + // the promise implementation will fire an error event, which will + // not trigger the handler as it's not yet bound + // + // Can remove after `Promise.jsm` is implemented in Bug 881047, + // which will guarantee next tick execution + async(() => root.walk(preCommitItem).then(commitComplete))(); + + function preCommitItem ({value:item}) { + // Do nothing if tree root, default group (unsavable), + // or if it's a dependency and not explicitly saved (in the list + // of items to be saved), and not needed to be saved + if (item === null || // node is the tree root + isRootGroup(item) || + (getId(item) && !isExplicitSave(item))) + return; + + return promised(validate)(item) + .then(() => commitItem(item, options)) + .then(data => construct(data, cache)) + .then(savedItem => { + // If item was just created, make a map between + // the creation object and created object, + // so we can reference the item that doesn't have an id + if (!getId(item)) + saveId(item, savedItem.id); + + // Emit both the processed item, and original item + // so a mapping can be understood in handler + emit(emitter, 'data', savedItem, item); + + // Push to results iff item was explicitly saved + if (isExplicitSave(item)) + results[items.indexOf(item)] = savedItem; + }, reason => { + // Force reason to be a string for consistency + reason = reason + ''; + // Emit both the reason, and original item + // so a mapping can be understood in handler + emit(emitter, 'error', reason + '', item); + // Store unsaved item in results list + results[items.indexOf(item)] = item; + errors.push(reason); + }); + } + + // Called when traversal of the node tree is completed and all + // items have been committed + function commitComplete () { + emit(emitter, 'end', results); + } + + return emitter; +} +exports.save = save; + +function search (queries, options) { + queries = [].concat(queries); + let emitter = EventTarget(); + let cache = new Map(); + let queryObjs = queries.map(createQuery.bind(null, BOOKMARK_QUERY)); + let optionsObj = createQueryOptions(BOOKMARK_QUERY, options); + + send('sdk-places-query', { queries: queryObjs, options: optionsObj }) + .then(data => { + let deferreds = data.map(item => { + return construct(item, cache).then(bookmark => { + emit(emitter, 'data', bookmark); + return bookmark; + }, reason => { + emit(emitter, 'error', reason); + errors.push(reason); + }); + }); + + all(deferreds).then(data => { + emit(emitter, 'end', data); + }, () => emit(emitter, 'end', [])); + }); + return emitter; +} +exports.search = search; + +function remove (items) { + return [].concat(items).map(item => { + item.remove = true; + return item; + }); +} + +exports.remove = remove; + +/* + * Internal Utilities + */ + +function commitItem (item, options) { + // Get the item's ID, or getId it's saved version if it exists + let id = getId(item); + let data = normalize(item); + let promise; + + data.id = id; + + if (!id) { + promise = send('sdk-places-bookmarks-create', data); + } else if (item.remove) { + promise = send('sdk-places-bookmarks-remove', { id: id }); + } else { + promise = send('sdk-places-bookmarks-last-updated', { + id: id + }).then(function (updated) { + // If attempting to save an item that is not the + // latest snapshot of a bookmark item, execute + // the resolution function + if (updated !== item.updated && options.resolve) + return fetchItem(id) + .then(options.resolve.bind(null, data)); + else + return data; + }).then(send.bind(null, 'sdk-places-bookmarks-save')); + } + + return promise; +} + +/* + * Turns a bookmark item into a plain object, + * converts `tags` from Set to Array, group instance to an id + */ +function normalize (item) { + let data = merge({}, item); + // Circumvent prototype property of `type` + delete data.type; + data.type = item.type; + data.tags = []; + if (item.tags) { + data.tags = fromIterator(item.tags); + } + data.group = getId(data.group) || exports.UNSORTED.id; + + return data; +} + +/* + * Takes a data object and constructs a BookmarkItem instance + * of it, recursively generating parent instances as well. + * + * Pass in a `cache` Map to reuse instances of + * bookmark items to reduce overhead; + * The cache object is a map of id to a deferred with a + * promise that resolves to the bookmark item. + */ +function construct (object, cache, forced) { + let item = instantiate(object); + let deferred = defer(); + + // Item could not be instantiated + if (!item) + return resolve(null); + + // Return promise for item if found in the cache, + // and not `forced`. `forced` indicates that this is the construct + // call that should not read from cache, but should actually perform + // the construction, as it was set before several async calls + if (cache.has(item.id) && !forced) + return cache.get(item.id).promise; + else if (cache.has(item.id)) + deferred = cache.get(item.id); + else + cache.set(item.id, deferred); + + // When parent group is found in cache, use + // the same deferred value + if (item.group && cache.has(item.group)) { + cache.get(item.group).promise.then(group => { + item.group = group; + deferred.resolve(item); + }); + + // If not in the cache, and a root group, return + // the premade instance + } else if (rootGroups.get(item.group)) { + item.group = rootGroups.get(item.group); + deferred.resolve(item); + + // If not in the cache or a root group, fetch the parent + } else { + cache.set(item.group, defer()); + fetchItem(item.group).then(group => { + return construct(group, cache, true); + }).then(group => { + item.group = group; + deferred.resolve(item); + }, deferred.reject); + } + + return deferred.promise; +} + +function instantiate (object) { + if (object.type === 'bookmark') + return Bookmark(object); + if (object.type === 'group') + return Group(object); + if (object.type === 'separator') + return Separator(object); + return null; +} + +/** + * Validates a bookmark item; will throw an error if ininvalid, + * to be used with `promised`. As bookmark items check on their class, + * this only checks ducktypes + */ +function validate (object) { + if (!isDuckType(object)) return true; + let contract = object.type === 'bookmark' ? bookmarkContract : + object.type === 'group' ? groupContract : + object.type === 'separator' ? separatorContract : + null; + if (!contract) { + throw Error('No type specified'); + } + + // If object has a property set, and undefined, + // manually override with default as it'll fail otherwise + let withDefaults = Object.keys(defaults).reduce((obj, prop) => { + if (obj[prop] == null) obj[prop] = defaults[prop]; + return obj; + }, extend(object)); + + contract(withDefaults); +} + +function isDuckType (item) { + return !(item instanceof Bookmark) && + !(item instanceof Group) && + !(item instanceof Separator); +} + +function saveId (unsaved, id) { + itemMap.set(unsaved, id); +} + +// Fetches an item's ID from itself, or from the mapped items +function getId (item) { + return typeof item === 'number' ? item : + item ? item.id || itemMap.get(item) : + null; +} + +/* + * Set up the default, root groups + */ + +let defaultGroupMap = { + MENU: bmsrv.bookmarksMenuFolder, + TOOLBAR: bmsrv.toolbarFolder, + UNSORTED: bmsrv.unfiledBookmarksFolder +}; + +let rootGroups = new Map(); + +for (let i in defaultGroupMap) { + let group = Object.freeze(Group({ title: i, id: defaultGroupMap[i] })); + rootGroups.set(defaultGroupMap[i], group); + exports[i] = group; +} + +let defaults = { + group: exports.UNSORTED, + index: -1 +}; diff --git a/lib/sdk/places/contract.js b/lib/sdk/places/contract.js new file mode 100644 index 000000000..bcb8089e9 --- /dev/null +++ b/lib/sdk/places/contract.js @@ -0,0 +1,77 @@ +/* -*- Mode: Java; tab-width: 2; indent-tabs-mode: nil; c-basic-offset: 2 -*- */ +/* vim:set ts=2 sw=2 sts=2 et: */ +/* 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"; + +module.metadata = { + "stability": "unstable" +}; + +const { Cc, Ci } = require('chrome'); +const { EventEmitter } = require('../deprecated/events'); +const { isValidURI, URL } = require('../url'); +const { contract } = require('../util/contract'); +const { extend } = require('../util/object'); + +// map of property validations +const validItem = { + id: { + is: ['number', 'undefined', 'null'], + }, + group: { + is: ['object', 'number', 'undefined', 'null'], + ok: function (value) { + return value && + (value.toString && value.toString() === '[object Group]') || + typeof value === 'number' || + value.type === 'group'; + }, + msg: 'The `group` property must be a valid Group object' + }, + index: { + is: ['undefined', 'null', 'number'], + map: function (value) value == null ? -1 : value, + msg: 'The `index` property must be a number.' + }, + updated: { + is: ['number', 'undefined'] + } +}; + +const validTitle = { + title: { + is: ['string'], + msg: 'The `title` property must be defined.' + } +}; + +const validURL = { + url: { + is: ['string'], + ok: isValidURI, + msg: 'The `url` property must be a valid URL.' + } +}; + +const validTags = { + tags: { + is: ['object'], + ok: function (tags) tags instanceof Set, + map: function (tags) { + if (Array.isArray(tags)) + return new Set(tags); + if (tags == null) + return new Set(); + return tags; + }, + msg: 'The `tags` property must be a Set, or an array' + } +}; + +exports.bookmarkContract = contract( + extend(validItem, validTitle, validURL, validTags)); +exports.separatorContract = contract(validItem); +exports.groupContract = contract(extend(validItem, validTitle)); diff --git a/lib/sdk/places/history.js b/lib/sdk/places/history.js new file mode 100644 index 000000000..bf2aa5dc8 --- /dev/null +++ b/lib/sdk/places/history.js @@ -0,0 +1,59 @@ +/* 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"; + +module.metadata = { + "stability": "unstable", + "engines": { + "Firefox": "*" + } +}; + +/* + * Requiring hosts so they can subscribe to client messages + */ +require('./host/host-bookmarks'); +require('./host/host-tags'); +require('./host/host-query'); + +const { Cc, Ci } = require('chrome'); +const { Class } = require('../core/heritage'); +const { events, send } = require('../addon/events'); +const { defer, reject, all } = require('../core/promise'); +const { uuid } = require('../util/uuid'); +const { flatten } = require('../util/array'); +const { has, extend, merge, pick } = require('../util/object'); +const { emit } = require('../event/core'); +const { EventTarget } = require('../event/target'); +const { + urlQueryParser, createQuery, createQueryOptions +} = require('./utils'); + +/* + * Constant used by nsIHistoryQuery; 0 is a history query + * https://developer.mozilla.org/en-US/docs/XPCOM_Interface_Reference/nsINavHistoryQueryOptions + */ +const HISTORY_QUERY = 0; + +let search = function query (queries, options) { + queries = [].concat(queries); + let emitter = EventTarget(); + let queryObjs = queries.map(createQuery.bind(null, HISTORY_QUERY)); + let optionsObj = createQueryOptions(HISTORY_QUERY, options); + + send('sdk-places-query', { + query: queryObjs, + options: optionsObj + }).then(results => { + results.map(item => emit(emitter, 'data', item)); + emit(emitter, 'end', results); + }, reason => { + emit(emitter, 'error', reason); + emit(emitter, 'end', []); + }); + + return emitter; +}; +exports.search = search; diff --git a/lib/sdk/places/host/host-bookmarks.js b/lib/sdk/places/host/host-bookmarks.js new file mode 100644 index 000000000..f138bb22e --- /dev/null +++ b/lib/sdk/places/host/host-bookmarks.js @@ -0,0 +1,236 @@ +/* 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"; + +module.metadata = { + "stability": "experimental", + "engines": { + "Firefox": "*" + } +}; + +const { Cc, Ci } = require('chrome'); +const browserHistory = Cc["@mozilla.org/browser/nav-history-service;1"]. + getService(Ci.nsIBrowserHistory); +const asyncHistory = Cc["@mozilla.org/browser/history;1"]. + getService(Ci.mozIAsyncHistory); +const bmsrv = Cc["@mozilla.org/browser/nav-bookmarks-service;1"]. + getService(Ci.nsINavBookmarksService); +const taggingService = Cc["@mozilla.org/browser/tagging-service;1"]. + getService(Ci.nsITaggingService); +const ios = Cc['@mozilla.org/network/io-service;1']. + getService(Ci.nsIIOService); +const { query } = require('./host-query'); +const { + defer, all, resolve, promised, reject +} = require('../../core/promise'); +const { request, response } = require('../../addon/host'); +const { send } = require('../../addon/events'); +const { on, emit } = require('../../event/core'); +const { filter } = require('../../event/utils'); +const { URL, isValidURI } = require('../../url'); +const { newURI } = require('../../url/utils'); + +const DEFAULT_INDEX = bmsrv.DEFAULT_INDEX; +const UNSORTED_ID = bmsrv.unfiledBookmarksFolder; +const ROOT_FOLDERS = [ + bmsrv.unfiledBookmarksFolder, bmsrv.toolbarFolder, + bmsrv.tagsFolder, bmsrv.bookmarksMenuFolder +]; + +const EVENT_MAP = { + 'sdk-places-bookmarks-create': createBookmarkItem, + 'sdk-places-bookmarks-save': saveBookmarkItem, + 'sdk-places-bookmarks-last-updated': getBookmarkLastUpdated, + 'sdk-places-bookmarks-get': getBookmarkItem, + 'sdk-places-bookmarks-remove': removeBookmarkItem, + 'sdk-places-bookmarks-get-all': getAllBookmarks, + 'sdk-places-bookmarks-get-children': getChildren +}; + +function typeMap (type) { + if (typeof type === 'number') { + if (bmsrv.TYPE_BOOKMARK === type) return 'bookmark'; + if (bmsrv.TYPE_FOLDER === type) return 'group'; + if (bmsrv.TYPE_SEPARATOR === type) return 'separator'; + } else { + if ('bookmark' === type) return bmsrv.TYPE_BOOKMARK; + if ('group' === type) return bmsrv.TYPE_FOLDER; + if ('separator' === type) return bmsrv.TYPE_SEPARATOR; + } +} + +function getBookmarkLastUpdated ({id}) + resolve(bmsrv.getItemLastModified(id)) +exports.getBookmarkLastUpdated; + +function createBookmarkItem (data) { + let error; + + if (data.group == null) data.group = UNSORTED_ID; + if (data.index == null) data.index = DEFAULT_INDEX; + + if (data.type === 'group') + data.id = bmsrv.createFolder( + data.group, data.title, data.index + ); + else if (data.type === 'separator') + data.id = bmsrv.insertSeparator( + data.group, data.index + ); + else + data.id = bmsrv.insertBookmark( + data.group, newURI(data.url), data.index, data.title + ); + + // In the event where default or no index is provided (-1), + // query the actual index for the response + if (data.index === -1) + data.index = bmsrv.getItemIndex(data.id); + + data.updated = bmsrv.getItemLastModified(data.id); + + return tag(data, true).then(() => data); +} +exports.createBookmarkItem = createBookmarkItem; + +function saveBookmarkItem (data) { + let id = data.id; + if (!id) + reject('Item is missing id'); + + let group = bmsrv.getFolderIdForItem(id); + let index = bmsrv.getItemIndex(id); + let type = bmsrv.getItemType(id); + + if (data.url) { + bmsrv.changeBookmarkURI(id, newURI(data.url)); + } + else if (typeMap(type) === 'bookmark') + data.url = bmsrv.getBookmarkURI(id).spec; + + if (data.title) + bmsrv.setItemTitle(id, data.title); + else if (typeMap(type) !== 'separator') + data.title = bmsrv.getItemTitle(id); + + if (data.group && data.group !== group) + bmsrv.moveItem(id, data.group, data.index || -1); + else if (data.index != null && data.index !== index) { + // We use moveItem here instead of setItemIndex + // so we don't have to manage the indicies of the siblings + bmsrv.moveItem(id, group, data.index); + } else if (data.index == null) + data.index = bmsrv.getItemIndex(id); + + data.updated = bmsrv.getItemLastModified(data.id); + + return tag(data).then(() => data); +} +exports.saveBookmarkItem = saveBookmarkItem; + +function removeBookmarkItem (data) { + let id = data.id; + + if (!id) + reject('Item is missing id'); + + bmsrv.removeItem(id); + return resolve(null); +} +exports.removeBookmarkItem = removeBookmarkItem; + +function getBookmarkItem (data) { + let id = data.id; + + if (!id) + reject('Item is missing id'); + + let type = bmsrv.getItemType(id); + + data.type = typeMap(type); + + if (type === bmsrv.TYPE_BOOKMARK || type === bmsrv.TYPE_FOLDER) + data.title = bmsrv.getItemTitle(id); + + if (type === bmsrv.TYPE_BOOKMARK) { + data.url = bmsrv.getBookmarkURI(id).spec; + // Should be moved into host-tags as a method + data.tags = taggingService.getTagsForURI(newURI(data.url), {}); + } + + data.group = bmsrv.getFolderIdForItem(id); + data.index = bmsrv.getItemIndex(id); + data.updated = bmsrv.getItemLastModified(data.id); + + return resolve(data); +} +exports.getBookmarkItem = getBookmarkItem; + +function getAllBookmarks () { + return query({}, { queryType: 1 }).then(bookmarks => + all(bookmarks.map(getBookmarkItem))); +} +exports.getAllBookmarks = getAllBookmarks; + +function getChildren ({ id }) { + if (typeMap(bmsrv.getItemType(id)) !== 'group') return []; + let ids = []; + for (let i = 0; ids[ids.length - 1] !== -1; i++) + ids.push(bmsrv.getIdForItemAt(id, i)); + ids.pop(); + return all(ids.map(id => getBookmarkItem({ id: id }))); +} +exports.getChildren = getChildren; + +/* + * Hook into host + */ + +let reqStream = filter(request, function (data) /sdk-places-bookmarks/.test(data.event)); +on(reqStream, 'data', function ({event, id, data}) { + if (!EVENT_MAP[event]) return; + + let resData = { + id: id, + event: event + }; + + promised(EVENT_MAP[event])(data).then(res => { + resData.data = res; + respond(resData); + }, reason => { + resData.error = reason; + respond(resData); + }); +}); + +function respond (data) { + emit(response, 'data', data); +} + +function tag (data, isNew) { + // If a new item, we can skip checking what other tags + // are on the item + if (data.type !== 'bookmark') { + return resolve(); + } else if (!isNew) { + return send('sdk-places-tags-get-tags-by-url', { url: data.url }) + .then(tags => { + return send('sdk-places-tags-untag', { + tags: tags.filter(tag => !~data.tags.indexOf(tag)), + url: data.url + }); + }).then(() => send('sdk-places-tags-tag', { + url: data.url, tags: data.tags + })); + } + else if (data.tags && data.tags.length) { + return send('sdk-places-tags-tag', { url: data.url, tags: data.tags }); + } + else + return resolve(); +} + diff --git a/lib/sdk/places/host/host-query.js b/lib/sdk/places/host/host-query.js new file mode 100644 index 000000000..8bbb71d7b --- /dev/null +++ b/lib/sdk/places/host/host-query.js @@ -0,0 +1,171 @@ +/* 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"; + +module.metadata = { + "stability": "experimental", + "engines": { + "Firefox": "*" + } +}; + +const { Cc, Ci } = require('chrome'); +const { defer, all, resolve } = require('../../core/promise'); +const { safeMerge, omit } = require('../../util/object'); +const historyService = Cc['@mozilla.org/browser/nav-history-service;1'] + .getService(Ci.nsINavHistoryService); +const bookmarksService = Cc['@mozilla.org/browser/nav-bookmarks-service;1'] + .getService(Ci.nsINavBookmarksService); +const { request, response } = require('../../addon/host'); +const { newURI } = require('../../url/utils'); +const { send } = require('../../addon/events'); +const { on, emit } = require('../../event/core'); +const { filter } = require('../../event/utils'); + +const ROOT_FOLDERS = [ + bookmarksService.unfiledBookmarksFolder, bookmarksService.toolbarFolder, + bookmarksService.bookmarksMenuFolder +]; + +const EVENT_MAP = { + 'sdk-places-query': queryReceiver +}; + +// Properties that need to be manually +// copied into a nsINavHistoryQuery object +const MANUAL_QUERY_PROPERTIES = [ + 'uri', 'folder', 'tags', 'url', 'folder' +]; + +const PLACES_PROPERTIES = [ + 'uri', 'title', 'accessCount', 'time' +]; + +function execute (queries, options) { + let deferred = defer(); + let root = historyService + .executeQueries(queries, queries.length, options).root; + + let items = collect([], root); + deferred.resolve(items); + return deferred.promise; +} + +function collect (acc, node) { + node.containerOpen = true; + for (let i = 0; i < node.childCount; i++) { + let child = node.getChild(i); + acc.push(child); + if (child.type === child.RESULT_TYPE_FOLDER) { + let container = child.QueryInterface(Ci.nsINavHistoryContainerResultNode); + collect(acc, container); + } + } + node.containerOpen = false; + return acc; +} + +function query (queries, options) { + queries = queries || []; + options = options || {}; + let deferred = defer(); + let optionsObj, queryObjs; + + try { + optionsObj = historyService.getNewQueryOptions(); + queryObjs = [].concat(queries).map(createQuery); + if (!queryObjs.length) { + queryObjs = [historyService.getNewQuery()]; + } + safeMerge(optionsObj, options); + } catch (e) { + deferred.reject(e); + return deferred.promise; + } + + /* + * Currently `places:` queries are not supported + */ + optionsObj.excludeQueries = true; + + execute(queryObjs, optionsObj).then(function (results) { + if (optionsObj.queryType === 0) { + return results.map(normalize); + } else if (optionsObj.queryType === 1) { + // Formats query results into more standard + // data structures for returning + return all(results.map(({itemId}) => + send('sdk-places-bookmarks-get', { id: itemId }))); + } + }).then(deferred.resolve, deferred.reject); + + return deferred.promise; +} +exports.query = query; + +function createQuery (query) { + query = query || {}; + let queryObj = historyService.getNewQuery(); + + safeMerge(queryObj, omit(query, MANUAL_QUERY_PROPERTIES)); + + if (query.tags && Array.isArray(query.tags)) + queryObj.tags = query.tags; + if (query.uri || query.url) + queryObj.uri = newURI(query.uri || query.url); + if (query.folder) + queryObj.setFolders([query.folder], 1); + return queryObj; +} + +function queryReceiver (message) { + let queries = message.data.queries || message.data.query; + let options = message.data.options; + let resData = { + id: message.id, + event: message.event + }; + + query(queries, options).then(results => { + resData.data = results; + respond(resData); + }, reason => { + resData.error = reason; + respond(resData); + }); +} + +/* + * Converts a nsINavHistoryResultNode into a plain object + * + * https://developer.mozilla.org/en-US/docs/XPCOM_Interface_Reference/nsINavHistoryResultNode + */ +function normalize (historyObj) { + return PLACES_PROPERTIES.reduce((obj, prop) => { + if (prop === 'uri') + obj.url = historyObj.uri; + else if (prop === 'time') { + // Cast from microseconds to milliseconds + obj.time = Math.floor(historyObj.time / 1000) + } else if (prop === 'accessCount') + obj.visitCount = historyObj[prop]; + else + obj[prop] = historyObj[prop]; + return obj; + }, {}); +} + +/* + * Hook into host + */ + +let reqStream = filter(request, function (data) /sdk-places-query/.test(data.event)); +on(reqStream, 'data', function (e) { + if (EVENT_MAP[e.event]) EVENT_MAP[e.event](e); +}); + +function respond (data) { + emit(response, 'data', data); +} diff --git a/lib/sdk/places/host/host-tags.js b/lib/sdk/places/host/host-tags.js new file mode 100644 index 000000000..d3e22e33d --- /dev/null +++ b/lib/sdk/places/host/host-tags.js @@ -0,0 +1,91 @@ +/* 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"; + +module.metadata = { + "stability": "experimental", + "engines": { + "Firefox": "*" + } +}; + +const { Cc, Ci } = require('chrome'); +const taggingService = Cc["@mozilla.org/browser/tagging-service;1"]. + getService(Ci.nsITaggingService); +const ios = Cc['@mozilla.org/network/io-service;1']. + getService(Ci.nsIIOService); +const { URL } = require('../../url'); +const { newURI } = require('../../url/utils'); +const { request, response } = require('../../addon/host'); +const { on, emit } = require('../../event/core'); +const { filter } = require('../../event/utils'); + +const EVENT_MAP = { + 'sdk-places-tags-tag': tag, + 'sdk-places-tags-untag': untag, + 'sdk-places-tags-get-tags-by-url': getTagsByURL, + 'sdk-places-tags-get-urls-by-tag': getURLsByTag +}; + +function tag (message) { + let data = message.data; + let resData = { + id: message.id, + event: message.event + }; + + resData.data = taggingService.tagURI(newURI(data.url), data.tags); + respond(resData); +} + +function untag (message) { + let data = message.data; + let resData = { + id: message.id, + event: message.event + }; + + resData.data = taggingService.untagURI(newURI(data.url), data.tags); + respond(resData); +} + +function getURLsByTag (message) { + let data = message.data; + let resData = { + id: message.id, + event: message.event + }; + + resData.data = taggingService + .getURIsForTag(data.tag).map(function (uri) uri.spec); + respond(resData); +} + +function getTagsByURL (message) { + let data = message.data; + let resData = { + id: message.id, + event: message.event + }; + + resData.data = taggingService.getTagsForURI(newURI(data.url), {}); + respond(resData); +} + +/* + * Hook into host + */ + +let reqStream = filter(request, function (data) { + return /sdk-places-tags/.test(data.event); +}); + +on(reqStream, 'data', function (e) { + if (EVENT_MAP[e.event]) EVENT_MAP[e.event](e); +}); + +function respond (data) { + emit(response, 'data', data); +} diff --git a/lib/sdk/places/utils.js b/lib/sdk/places/utils.js index 4a36afdcb..1ce87d717 100644 --- a/lib/sdk/places/utils.js +++ b/lib/sdk/places/utils.js @@ -5,12 +5,21 @@ 'use strict'; module.metadata = { - 'stability': 'unstable' + "stability": "experimental", + "engines": { + "Firefox": "*" + } }; +const { Cc, Ci } = require('chrome'); const { Class } = require('../core/heritage'); const { method } = require('../lang/functional'); const { defer, promised, all } = require('../core/promise'); +const { send } = require('../addon/events'); +const { EventTarget } = require('../event/target'); +const { merge } = require('../util/object'); +const bmsrv = Cc["@mozilla.org/browser/nav-bookmarks-service;1"]. + getService(Ci.nsINavBookmarksService); /* * TreeNodes are used to construct dependency trees @@ -62,3 +71,167 @@ function get (node, value) { } return null; } + +/* + * Constructs a tree of bookmark nodes + * returning the root (value: null); + */ + +function constructTree (items) { + let root = TreeNode(null); + items.forEach(treeify.bind(null, root)); + + function treeify (root, item) { + // If node already exists, skip + let node = root.get(item); + if (node) return node; + node = TreeNode(item); + + let parentNode = item.group ? treeify(root, item.group) : root; + parentNode.add(node); + + return node; + } + + return root; +} +exports.constructTree = constructTree; + +/* + * Shortcut for converting an id, or an object with an id, into + * an object with corresponding bookmark data + */ +function fetchItem (item) + send('sdk-places-bookmarks-get', { id: item.id || item }) +exports.fetchItem = fetchItem; + +/* + * Takes an ID or an object with ID and checks it against + * the root bookmark folders + */ +function isRootGroup (id) { + id = id && id.id; + return ~[bmsrv.bookmarksMenuFolder, bmsrv.toolbarFolder, + bmsrv.unfiledBookmarksFolder + ].indexOf(id); +} +exports.isRootGroup = isRootGroup; + +/* + * Merges appropriate options into query based off of url + * 4 scenarios: + * + * 'moz.com' // domain: moz.com, domainIsHost: true + * --> 'http://moz.com', 'http://moz.com/thunderbird' + * '*.moz.com' // domain: moz.com, domainIsHost: false + * --> 'http://moz.com', 'http://moz.com/index', 'http://ff.moz.com/test' + * 'http://moz.com' // url: http://moz.com/, urlIsPrefix: false + * --> 'http://moz.com/' + * 'http://moz.com/*' // url: http://moz.com/, urlIsPrefix: true + * --> 'http://moz.com/', 'http://moz.com/thunderbird' + */ + +function urlQueryParser (query, url) { + if (!url) return; + if (/^https?:\/\//.test(url)) { + query.uri = url.charAt(url.length - 1) === '/' ? url : url + '/'; + if (/\*$/.test(url)) { + query.uri = url.replace(/\*$/, ''); + query.uriIsPrefix = true; + } + } else { + if (/^\*/.test(url)) { + query.domain = url.replace(/^\*\./, ''); + query.domainIsHost = false; + } else { + query.domain = url; + query.domainIsHost = true; + } + } +} +exports.urlQueryParser = urlQueryParser; + +/* + * Takes an EventEmitter and returns a promise that + * aggregates results and handles a bulk resolve and reject + */ + +function promisedEmitter (emitter) { + let { promise, resolve, reject } = defer(); + let errors = []; + emitter.on('error', error => errors.push(error)); + emitter.on('end', (items) => { + if (errors.length) reject(errors[0]); + else resolve(items); + }); + return promise; +} +exports.promisedEmitter = promisedEmitter; + + +// https://developer.mozilla.org/en-US/docs/XPCOM_Interface_Reference/nsINavHistoryQueryOptions +function createQuery (type, query) { + query = query || {}; + let qObj = { + searchTerms: query.query + }; + + urlQueryParser(qObj, query.url); + + // 0 === history + if (type === 0) { + // PRTime used by query is in microseconds, not milliseconds + qObj.beginTime = (query.from || 0) * 1000; + qObj.endTime = (query.to || new Date()) * 1000; + + // Set reference time to Epoch + qObj.beginTimeReference = 0; + qObj.endTimeReference = 0; + } + // 1 === bookmarks + else if (type === 1) { + qObj.tags = query.tags; + qObj.folder = query.group && query.group.id; + } + // 2 === unified (not implemented on platform) + else if (type === 2) { + + } + + return qObj; +} +exports.createQuery = createQuery; + +// https://developer.mozilla.org/en-US/docs/XPCOM_Interface_Reference/nsINavHistoryQueryOptions + +const SORT_MAP = { + title: 1, + date: 3, // sort by visit date + url: 5, + visitCount: 7, + // keywords currently unsupported + // keyword: 9, + dateAdded: 11, // bookmarks only + lastModified: 13 // bookmarks only +}; + +function createQueryOptions (type, options) { + options = options || {}; + let oObj = {}; + oObj.sortingMode = SORT_MAP[options.sort] || 0; + if (options.descending && options.sort) + oObj.sortingMode++; + + // Resolve to default sort if ineligible based on query type + if (type === 0 && // history + (options.sort === 'dateAdded' || options.sort === 'lastModified')) + oObj.sortingMode = 0; + + oObj.maxResults = typeof options.count === 'number' ? options.count : 0; + + oObj.queryType = type; + + return oObj; +} +exports.createQueryOptions = createQueryOptions; + diff --git a/lib/sdk/util/object.js b/lib/sdk/util/object.js index 2f096df78..50cb0cab4 100644 --- a/lib/sdk/util/object.js +++ b/lib/sdk/util/object.js @@ -8,6 +8,8 @@ module.metadata = { "stability": "unstable" }; +const { flatten } = require('./array'); + /** * Merges all the properties of all arguments into first argument. If two or * more argument objects have own properties with the same name, the property @@ -29,6 +31,7 @@ module.metadata = { */ function merge(source) { let descriptor = {}; + // `Boolean` converts the first parameter to a boolean value. Any object is // converted to `true` where `null` and `undefined` becames `false`. Therefore // the `filter` method will keep only objects that are defined and not null. @@ -61,3 +64,29 @@ function each(obj, fn) { for (let key in obj) has(obj, key) && fn(obj[key], key, obj); } exports.each = each; + +/** + * Like `merge`, except no property descriptors are manipulated, for use + * with platform objects. Identical to underscore's `extend`. Useful for + * merging XPCOM objects + */ +function safeMerge(source) { + Array.slice(arguments, 1).forEach(function onEach (obj) { + for (let prop in obj) source[prop] = obj[prop]; + }); + return source; +} +exports.safeMerge = safeMerge; + +/* + * Returns a copy of the object without blacklisted properties + */ +function omit(source, ...values) { + let copy = {}; + let keys = flatten(values); + for (let prop in source) + if (!~keys.indexOf(prop)) + copy[prop] = source[prop]; + return copy; +} +exports.omit = omit; diff --git a/python-lib/cuddlefish/tests/test_xpi.py b/python-lib/cuddlefish/tests/test_xpi.py index 2dce03848..b6df6845a 100644 --- a/python-lib/cuddlefish/tests/test_xpi.py +++ b/python-lib/cuddlefish/tests/test_xpi.py @@ -223,7 +223,8 @@ def absify(*parts): os.path.join("sdk", "self.js"), os.path.join("sdk", "core", "promise.js"), os.path.join("sdk", "net", "url.js"), - os.path.join("sdk", "util", "object.js") + os.path.join("sdk", "util", "object.js"), + os.path.join("sdk", "util", "array.js") ]]) missing = set(expected) - set(used_files) @@ -265,6 +266,7 @@ def absify(*parts): "resources/addon-sdk/lib/sdk/net/", "resources/addon-sdk/lib/sdk/core/promise.js", "resources/addon-sdk/lib/sdk/util/object.js", + "resources/addon-sdk/lib/sdk/util/array.js", "resources/addon-sdk/lib/sdk/net/url.js", "resources/three/", "resources/three/lib/", diff --git a/test/places-helper.js b/test/places-helper.js new file mode 100644 index 000000000..fa9494b21 --- /dev/null +++ b/test/places-helper.js @@ -0,0 +1,220 @@ +/* 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' + +const { Cc, Ci } = require('chrome'); +const bmsrv = Cc['@mozilla.org/browser/nav-bookmarks-service;1']. + getService(Ci.nsINavBookmarksService); +const hsrv = Cc['@mozilla.org/browser/nav-history-service;1']. + getService(Ci.nsINavHistoryService); +const brsrv = Cc["@mozilla.org/browser/nav-history-service;1"] + .getService(Ci.nsIBrowserHistory); +const tagsrv = Cc['@mozilla.org/browser/tagging-service;1']. + getService(Ci.nsITaggingService); +const asyncHistory = Cc['@mozilla.org/browser/history;1']. + getService(Ci.mozIAsyncHistory); +const { send } = require('sdk/addon/events'); +const { setTimeout } = require('sdk/timers'); +const { newURI } = require('sdk/url/utils'); +const { defer, all } = require('sdk/core/promise'); +const { + Bookmark, Group, Separator, + save, search, + MENU, TOOLBAR, UNSORTED +} = require('sdk/places/bookmarks'); + +function invalidResolve (assert) { + return function (e) { + assert.fail('Resolve state should not be called: ' + e); + }; +} +exports.invalidResolve = invalidResolve; + +function invalidReject (assert) { + return function (e) { + assert.fail('Reject state should not be called: ' + e); + }; +} +exports.invalidReject = invalidReject; + +// Removes all children of group +function clearBookmarks (group) { + group + ? bmsrv.removeFolderChildren(group.id) + : clearAllBookmarks(); +} +exports.clearBookmarks = clearBookmarks; + +function clearAllBookmarks () { + [MENU, TOOLBAR, UNSORTED].forEach(clearBookmarks); +} +exports.clearAllBookmarks = clearAllBookmarks; + +function compareWithHost (assert, item) { + let id = item.id; + let type = item.type === 'group' ? bmsrv.TYPE_FOLDER : bmsrv['TYPE_' + item.type.toUpperCase()]; + let url = item.url && !item.url.endsWith('/') ? item.url + '/' : item.url; + + if (type === bmsrv.TYPE_BOOKMARK) { + assert.equal(url, bmsrv.getBookmarkURI(id).spec.toString(), 'Matches host url'); + let tags = tagsrv.getTagsForURI(newURI(item.url)); + for (let tag of tags) { + // Handle both array for raw data and set for instances + if (Array.isArray(item.tags)) + assert.ok(~item.tags.indexOf(tag), 'has correct tag'); + else + assert.ok(item.tags.has(tag), 'has correct tag'); + } + assert.equal(tags.length, + Array.isArray(item.tags) ? item.tags.length : item.tags.size, + 'matches tag count'); + } + if (type !== bmsrv.TYPE_SEPARATOR) { + assert.equal(item.title, bmsrv.getItemTitle(id), 'Matches host title'); + } + assert.equal(item.index, bmsrv.getItemIndex(id), 'Matches host index'); + assert.equal(item.group.id || item.group, bmsrv.getFolderIdForItem(id), 'Matches host group id'); + assert.equal(type, bmsrv.getItemType(id), 'Matches host type'); +} +exports.compareWithHost = compareWithHost; + +function addVisits (urls) { + var deferred = defer(); + asyncHistory.updatePlaces([].concat(urls).map(createVisit), { + handleResult: function () {}, + handleError: deferred.reject, + handleCompletion: deferred.resolve + }); + + return deferred.promise; +} +exports.addVisits = addVisits; + +// Creates a mozIVisitInfo object +function createVisit (url) { + let place = {} + place.uri = newURI(url); + place.title = "Test visit for " + place.uri.spec; + place.visits = [{ + transitionType: hsrv.TRANSITION_LINK, + visitDate: +(new Date()) * 1000, + referredURI: undefined + }]; + return place; +} + +function clearHistory () { + hsrv.removeAllPages(); +} +exports.clearHistory = clearHistory; + +function createBookmark (data) { + data = data || {}; + let item = { + title: data.title || 'Moz', + url: data.url || (!data.type || data.type === 'bookmark' ? + 'http://moz.com/' : + undefined), + tags: data.tags || (!data.type || data.type === 'bookmark' ? + ['firefox'] : + undefined), + type: data.type || 'bookmark', + group: data.group + }; + return send('sdk-places-bookmarks-create', item); +} +exports.createBookmark = createBookmark; + +function createBookmarkItem (data) { + let deferred = defer(); + data = data || {}; + save({ + title: data.title || 'Moz', + url: data.url || 'http://moz.com/', + tags: data.tags || (!data.type || data.type === 'bookmark' ? + ['firefox'] : + undefined), + type: data.type || 'bookmark', + group: data.group + }).on('end', function (bookmark) { + deferred.resolve(bookmark[0]); + }); + return deferred.promise; +} +exports.createBookmarkItem = createBookmarkItem; + +function createBookmarkTree () { + let agg = []; + return createBookmarkItem({ type: 'group', title: 'mozgroup' }) + .then(group => { + agg.push(group); + return all([createBookmarkItem({ + title: 'mozilla.com', + url: 'http://mozilla.com/', + group: group, + tags: ['mozilla', 'firefox', 'thunderbird', 'rust'] + }), createBookmarkItem({ + title: 'mozilla.org', + url: 'http://mozilla.org/', + group: group, + tags: ['mozilla', 'firefox', 'thunderbird', 'rust'] + }), createBookmarkItem({ + title: 'firefox', + url: 'http://firefox.com/', + group: group, + tags: ['mozilla', 'firefox', 'browser'] + }), createBookmarkItem({ + title: 'thunderbird', + url: 'http://mozilla.org/thunderbird/', + group: group, + tags: ['mozilla', 'thunderbird', 'email'] + }), createBookmarkItem({ + title: 'moz subfolder', + group: group, + type: 'group' + }) + ]); + }) + .then(results => { + agg = agg.concat(results); + let subfolder = results.filter(item => item.type === 'group')[0]; + return createBookmarkItem({ + title: 'dark javascript secrets', + url: 'http://w3schools.com', + group: subfolder, + tags: [] + }); + }).then(item => { + agg.push(item); + return createBookmarkItem( + { type: 'group', group: MENU, title: 'other stuff' } + ); + }).then(newGroup => { + agg.push(newGroup); + return all([ + createBookmarkItem({ + title: 'mdn', + url: 'http://developer.mozilla.org/en-US/', + group: newGroup, + tags: ['javascript'] + }), + createBookmarkItem({ + title: 'web audio', + url: 'http://webaud.io', + group: newGroup, + tags: ['javascript', 'web audio'] + }), + createBookmarkItem({ + title: 'web audio components', + url: 'http://component.fm', + group: newGroup, + tags: ['javascript', 'web audio', 'components'] + }) + ]); + }).then(results => { + agg = agg.concat(results); + return agg; + }); +} +exports.createBookmarkTree = createBookmarkTree; diff --git a/test/test-places-bookmarks.js b/test/test-places-bookmarks.js new file mode 100644 index 000000000..1ddf93cbe --- /dev/null +++ b/test/test-places-bookmarks.js @@ -0,0 +1,1014 @@ +/* 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'; + +const { Cc, Ci } = require('chrome'); +const { request } = require('sdk/addon/host'); +const { filter } = require('sdk/event/utils'); +const { on, off } = require('sdk/event/core'); +const { setTimeout } = require('sdk/timers'); +const { newURI } = require('sdk/url/utils'); +const { defer, all } = require('sdk/core/promise'); + +// Test for unsupported platforms +try { +const { + Bookmark, Group, Separator, + save, search, remove, + MENU, TOOLBAR, UNSORTED +} = require('sdk/places/bookmarks'); +const { + invalidResolve, invalidReject, clearBookmarks, createTree, + compareWithHost, clearAllBookmarks, createBookmark, createBookmarkItem, + createBookmarkTree, clearHistory, addVisits +} = require('./places-helper'); +const { promisedEmitter } = require('sdk/places/utils'); +const bmsrv = Cc['@mozilla.org/browser/nav-bookmarks-service;1']. + getService(Ci.nsINavBookmarksService); +const tagsrv = Cc['@mozilla.org/browser/tagging-service;1']. + getService(Ci.nsITaggingService); + clear(); +} catch (e) { unsupported(e); } + +exports.testDefaultFolders = function (assert) { + var ids = [ + bmsrv.bookmarksMenuFolder, + bmsrv.toolbarFolder, + bmsrv.unfiledBookmarksFolder + ]; + [MENU, TOOLBAR, UNSORTED].forEach(function (g, i) { + assert.ok(g.id === ids[i], ' default group matches id'); + }); +}; + +exports.testValidation = function (assert) { + assert.throws(() => { + Bookmark({ title: 'a title' }); + }, /The `url` property must be a valid URL/, 'throws empty URL error'); + + assert.throws(() => { + Bookmark({ title: 'a title', url: 'not.a.url' }); + }, /The `url` property must be a valid URL/, 'throws invalid URL error'); + + assert.throws(() => { + Bookmark({ url: 'http://foo.com' }); + }, /The `title` property must be defined/, 'throws title error'); + + assert.throws(() => { + Bookmark(); + }, /./, 'throws any error'); + + assert.throws(() => { + Group(); + }, /The `title` property must be defined/, 'throws title error for group'); + + assert.throws(() => { + Bookmark({ url: 'http://foo.com', title: 'my title', tags: 'a tag' }); + }, /The `tags` property must be a Set, or an array/, 'throws error for non set/array tag'); +}; + +exports.testCreateBookmarks = function (assert, done) { + var bm = Bookmark({ + title: 'moz', + url: 'http://mozilla.org', + tags: ['moz1', 'moz2', 'moz3'] + }); + + save(bm).on('data', (bookmark, input) => { + assert.equal(input, bm, 'input is original input item'); + assert.ok(bookmark.id, 'Bookmark has ID'); + assert.equal(bookmark.title, 'moz'); + assert.equal(bookmark.url, 'http://mozilla.org'); + assert.equal(bookmark.group, UNSORTED, 'Unsorted folder is default parent'); + assert.ok(bookmark !== bm, 'bookmark should be a new instance'); + compareWithHost(assert, bookmark); + }).on('end', bookmarks => { + assert.equal(bookmarks.length, 1, 'returned bookmarks in end'); + assert.equal(bookmarks[0].url, 'http://mozilla.org'); + assert.equal(bookmarks[0].tags.has('moz1'), true, 'has first tag'); + assert.equal(bookmarks[0].tags.has('moz2'), true, 'has second tag'); + assert.equal(bookmarks[0].tags.has('moz3'), true, 'has third tag'); + assert.pass('end event is called'); + clearAllBookmarks(); + done(); + }); +}; + +exports.testCreateGroup = function (assert, done) { + save(Group({ title: 'mygroup', group: MENU })).on('data', g => { + assert.ok(g.id, 'Bookmark has ID'); + assert.equal(g.title, 'mygroup', 'matches title'); + assert.equal(g.group, MENU, 'Menu folder matches'); + compareWithHost(assert, g); + }).on('end', results => { + assert.equal(results.length, 1); + assert.pass('end event is called'); + clearAllBookmarks(); + done(); + }); +}; + +exports.testCreateSeparator = function (assert, done) { + save(Separator({ group: MENU })).on('data', function (s) { + assert.ok(s.id, 'Separator has id'); + assert.equal(s.group, MENU, 'Parent group matches'); + compareWithHost(assert, s); + }).on('end', function (results) { + assert.equal(results.length, 1); + assert.pass('end event is called'); + clearAllBookmarks(); + done(); + }); +}; + +exports.testCreateError = function (assert, done) { + let bookmarks = [ + { title: 'moz1', url: 'http://moz1.com', type: 'bookmark'}, + { title: 'moz2', url: 'invalidurl', type: 'bookmark'}, + { title: 'moz3', url: 'http://moz3.com', type: 'bookmark'} + ]; + + let dataCount = 0, errorCount = 0; + save(bookmarks).on('data', bookmark => { + assert.ok(/moz[1|3]/.test(bookmark.title), 'valid bookmarks complete'); + dataCount++; + }).on('error', (reason, item) => { + assert.ok( + /The `url` property must be a valid URL/.test(reason), + 'Error event called with correct reason'); + assert.equal(item, bookmarks[1], 'returns input that failed in event'); + errorCount++; + }).on('end', items => { + assert.equal(dataCount, 2, 'data event called twice'); + assert.equal(errorCount, 1, 'error event called once'); + assert.equal(items.length, bookmarks.length, 'all items should be in result'); + assert.equal(items[0].toString(), '[object Bookmark]', + 'should be a saved instance'); + assert.equal(items[2].toString(), '[object Bookmark]', + 'should be a saved instance'); + assert.equal(items[1], bookmarks[1], 'should be original, unsaved object'); + + search({ query: 'moz' }).on('end', items => { + assert.equal(items.length, 2, 'only two items were successfully saved'); + bookmarks[1].url = 'http://moz2.com/'; + dataCount = errorCount = 0; + save(bookmarks).on('data', bookmark => { + dataCount++; + }).on('error', reason => errorCount++) + .on('end', items => { + assert.equal(items.length, 3, 'all 3 items saved'); + assert.equal(dataCount, 3, '3 data events called'); + assert.equal(errorCount, 0, 'no error events called'); + search({ query: 'moz' }).on('end', items => { + assert.equal(items.length, 3, 'only 3 items saved'); + items.map(item => + assert.ok(/moz\d\.com/.test(item.url), 'correct item')) + clearAllBookmarks(); + done(); + }); + }); + }); + }); +}; + +exports.testSaveDucktypes = function (assert, done) { + save({ + title: 'moz', + url: 'http://mozilla.org', + type: 'bookmark' + }).on('data', (bookmark) => { + compareWithHost(assert, bookmark); + clearAllBookmarks(); + done(); + }); +}; + +exports.testSaveDucktypesParent = function (assert, done) { + let folder = { title: 'myfolder', type: 'group' }; + let bookmark = { title: 'mozzie', url: 'http://moz.com', group: folder, type: 'bookmark' }; + let sep = { type: 'separator', group: folder }; + save([sep, bookmark]).on('end', (res) => { + compareWithHost(assert, res[0]); + compareWithHost(assert, res[1]); + assert.equal(res[0].group.title, 'myfolder', 'parent is ducktyped group'); + assert.equal(res[1].group.title, 'myfolder', 'parent is ducktyped group'); + clearAllBookmarks(); + done(); + }); +}; + +/* + * Tests the scenario where the original bookmark item is resaved + * and does not have an ID or an updated date, but should still be + * mapped to the item it created previously + */ +exports.testResaveOriginalItemMapping = function (assert, done) { + let bookmark = Bookmark({ title: 'moz', url: 'http://moz.org' }); + save(bookmark).on('data', newBookmark => { + bookmark.title = 'new moz'; + save(bookmark).on('data', newNewBookmark => { + assert.equal(newBookmark.id, newNewBookmark.id, 'should be the same bookmark item'); + assert.equal(bmsrv.getItemTitle(newBookmark.id), 'new moz', 'should have updated title'); + clearAllBookmarks(); + done(); + }); + }); +}; + +exports.testCreateMultipleBookmarks = function (assert, done) { + let data = [ + Bookmark({title: 'bm1', url: 'http://bm1.com'}), + Bookmark({title: 'bm2', url: 'http://bm2.com'}), + Bookmark({title: 'bm3', url: 'http://bm3.com'}), + ]; + save(data).on('data', function (bookmark, input) { + let stored = data.filter(({title}) => title === bookmark.title)[0]; + assert.equal(input, stored, 'input is original input item'); + assert.equal(bookmark.title, stored.title, 'titles match'); + assert.equal(bookmark.url, stored.url, 'urls match'); + compareWithHost(assert, bookmark); + }).on('end', function (bookmarks) { + assert.equal(bookmarks.length, 3, 'all bookmarks returned'); + clearAllBookmarks(); + done(); + }); +}; + +exports.testCreateImplicitParent = function (assert, done) { + let folder = Group({ title: 'my parent' }); + let bookmarks = [ + Bookmark({ title: 'moz1', url: 'http://moz1.com', group: folder }), + Bookmark({ title: 'moz2', url: 'http://moz2.com', group: folder }), + Bookmark({ title: 'moz3', url: 'http://moz3.com', group: folder }) + ]; + save(bookmarks).on('data', function (bookmark) { + if (bookmark.type === 'bookmark') { + assert.equal(bookmark.group.title, folder.title, 'parent is linked'); + compareWithHost(assert, bookmark); + } else if (bookmark.type === 'group') { + assert.equal(bookmark.group.id, UNSORTED.id, 'parent ID of group is correct'); + compareWithHost(assert, bookmark); + } + }).on('end', function (results) { + assert.equal(results.length, 3, 'results should only hold explicit saves'); + clearAllBookmarks(); + done(); + }); +}; + +exports.testCreateExplicitParent = function (assert, done) { + let folder = Group({ title: 'my parent' }); + let bookmarks = [ + Bookmark({ title: 'moz1', url: 'http://moz1.com', group: folder }), + Bookmark({ title: 'moz2', url: 'http://moz2.com', group: folder }), + Bookmark({ title: 'moz3', url: 'http://moz3.com', group: folder }) + ]; + save(bookmarks.concat(folder)).on('data', function (bookmark) { + if (bookmark.type === 'bookmark') { + assert.equal(bookmark.group.title, folder.title, 'parent is linked'); + compareWithHost(assert, bookmark); + } else if (bookmark.type === 'group') { + assert.equal(bookmark.group.id, UNSORTED.id, 'parent ID of group is correct'); + compareWithHost(assert, bookmark); + } + }).on('end', function () { + clearAllBookmarks(); + done(); + }); +}; + +exports.testCreateNested = function (assert, done) { + let topFolder = Group({ title: 'top', group: MENU }); + let midFolder = Group({ title: 'middle', group: topFolder }); + let bookmarks = [ + Bookmark({ title: 'moz1', url: 'http://moz1.com', group: midFolder }), + Bookmark({ title: 'moz2', url: 'http://moz2.com', group: midFolder }), + Bookmark({ title: 'moz3', url: 'http://moz3.com', group: midFolder }) + ]; + let dataEventCount = 0; + save(bookmarks).on('data', function (bookmark) { + if (bookmark.type === 'bookmark') { + assert.equal(bookmark.group.title, midFolder.title, 'parent is linked'); + } else if (bookmark.title === 'top') { + assert.equal(bookmark.group.id, MENU.id, 'parent ID of top group is correct'); + } else { + assert.equal(bookmark.group.title, topFolder.title, 'parent title of middle group is correct'); + } + dataEventCount++; + compareWithHost(assert, bookmark); + }).on('end', () => { + assert.equal(dataEventCount, 5, 'data events for all saves have occurred'); + assert.ok('end event called'); + clearAllBookmarks(); + done(); + }); +}; + +/* + * Was a scenario when implicitly saving a bookmark that was already created, + * it was not being properly fetched and attempted to recreate + */ +exports.testAddingToExistingParent = function (assert, done) { + let group = { type: 'group', title: 'mozgroup' }; + let bookmarks = [ + { title: 'moz1', url: 'http://moz1.com', type: 'bookmark', group: group }, + { title: 'moz2', url: 'http://moz2.com', type: 'bookmark', group: group }, + { title: 'moz3', url: 'http://moz3.com', type: 'bookmark', group: group } + ], + firstBatch, secondBatch; + + saveP(bookmarks).then(data => { + firstBatch = data; + return saveP([ + { title: 'moz4', url: 'http://moz4.com', type: 'bookmark', group: group }, + { title: 'moz5', url: 'http://moz5.com', type: 'bookmark', group: group } + ]); + }, console.error).then(data => { + secondBatch = data; + assert.equal(firstBatch[0].group.id, secondBatch[0].group.id, + 'successfully saved to the same parent'); + clearAllBookmarks(); + done(); + }, console.error); +}; + +exports.testUpdateParent = function (assert, done) { + let group = { type: 'group', title: 'mozgroup' }; + saveP(group).then(item => { + item[0].title = 'mozgroup-resave'; + return saveP(item[0]); + }).then(item => { + assert.equal(item[0].title, 'mozgroup-resave', 'group saved successfully'); + clearAllBookmarks(); + done(); + }); +}; + +exports.testUpdateSeparator = function (assert, done) { + let sep = [Separator(), Separator(), Separator()]; + saveP(sep).then(item => { + item[0].index = 2; + return saveP(item[0]); + }).then(item => { + assert.equal(item[0].index, 2, 'updated index of separator'); + clearAllBookmarks(); + done(); + }); +}; + +exports.testPromisedSave = function (assert, done) { + let topFolder = Group({ title: 'top', group: MENU }); + let midFolder = Group({ title: 'middle', group: topFolder }); + let bookmarks = [ + Bookmark({ title: 'moz1', url: 'http://moz1.com', group: midFolder}), + Bookmark({ title: 'moz2', url: 'http://moz2.com', group: midFolder}), + Bookmark({ title: 'moz3', url: 'http://moz3.com', group: midFolder}) + ]; + let first, second, third; + saveP(bookmarks).then(bms => { + first = bms.filter(b => b.title === 'moz1')[0]; + second = bms.filter(b => b.title === 'moz2')[0]; + third = bms.filter(b => b.title === 'moz3')[0]; + assert.equal(first.index, 0); + assert.equal(second.index, 1); + assert.equal(third.index, 2); + first.index = 3; + return saveP(first); + }).then(() => { + assert.equal(bmsrv.getItemIndex(first.id), 2, 'properly moved bookmark'); + assert.equal(bmsrv.getItemIndex(second.id), 0, 'other bookmarks adjusted'); + assert.equal(bmsrv.getItemIndex(third.id), 1, 'other bookmarks adjusted'); + clearAllBookmarks(); + done(); + }); +}; + +exports.testPromisedErrorSave = function (assert, done) { + let bookmarks = [ + { title: 'moz1', url: 'http://moz1.com', type: 'bookmark'}, + { title: 'moz2', url: 'invalidurl', type: 'bookmark'}, + { title: 'moz3', url: 'http://moz3.com', type: 'bookmark'} + ]; + saveP(bookmarks).then(invalidResolve, reason => { + assert.ok( + /The `url` property must be a valid URL/.test(reason), + 'Error event called with correct reason'); + + bookmarks[1].url = 'http://moz2.com'; + return saveP(bookmarks); + }).then(res => { + return searchP({ query: 'moz' }); + }).then(res => { + assert.equal(res.length, 3, 'all 3 should be saved upon retry'); + res.map(item => assert.ok(/moz\d\.com/.test(item.url), 'correct item')); + clearAllBookmarks(); + done(); + }, invalidReject); +}; + +exports.testMovingChildren = function (assert, done) { + let topFolder = Group({ title: 'top', group: MENU }); + let midFolder = Group({ title: 'middle', group: topFolder }); + let bookmarks = [ + Bookmark({ title: 'moz1', url: 'http://moz1.com', group: midFolder}), + Bookmark({ title: 'moz2', url: 'http://moz2.com', group: midFolder}), + Bookmark({ title: 'moz3', url: 'http://moz3.com', group: midFolder}) + ]; + save(bookmarks).on('end', bms => { + let first = bms.filter(b => b.title === 'moz1')[0]; + let second = bms.filter(b => b.title === 'moz2')[0]; + let third = bms.filter(b => b.title === 'moz3')[0]; + assert.equal(first.index, 0); + assert.equal(second.index, 1); + assert.equal(third.index, 2); + /* When moving down in the same container we take + * into account the removal of the original item. If you want + * to move from index X to index Y > X you must use + * moveItem(id, folder, Y + 1) + */ + first.index = 3; + save(first).on('end', () => { + assert.equal(bmsrv.getItemIndex(first.id), 2, 'properly moved bookmark'); + assert.equal(bmsrv.getItemIndex(second.id), 0, 'other bookmarks adjusted'); + assert.equal(bmsrv.getItemIndex(third.id), 1, 'other bookmarks adjusted'); + clearAllBookmarks(); + done(); + }); + }); +}; + +exports.testMovingChildrenNewFolder = function (assert, done) { + let topFolder = Group({ title: 'top', group: MENU }); + let midFolder = Group({ title: 'middle', group: topFolder }); + let newFolder = Group({ title: 'new', group: MENU }); + let bookmarks = [ + Bookmark({ title: 'moz1', url: 'http://moz1.com', group: midFolder}), + Bookmark({ title: 'moz2', url: 'http://moz2.com', group: midFolder}), + Bookmark({ title: 'moz3', url: 'http://moz3.com', group: midFolder}) + ]; + save(bookmarks).on('end', bms => { + let first = bms.filter(b => b.title === 'moz1')[0]; + let second = bms.filter(b => b.title === 'moz2')[0]; + let third = bms.filter(b => b.title === 'moz3')[0]; + let definedMidFolder = first.group; + let definedNewFolder; + first.group = newFolder; + assert.equal(first.index, 0); + assert.equal(second.index, 1); + assert.equal(third.index, 2); + save(first).on('data', (data) => { + if (data.type === 'group') definedNewFolder = data; + }).on('end', (moved) => { + assert.equal(bmsrv.getItemIndex(second.id), 0, 'other bookmarks adjusted'); + assert.equal(bmsrv.getItemIndex(third.id), 1, 'other bookmarks adjusted'); + assert.equal(bmsrv.getItemIndex(first.id), 0, 'properly moved bookmark'); + assert.equal(bmsrv.getFolderIdForItem(first.id), definedNewFolder.id, + 'bookmark has new parent'); + assert.equal(bmsrv.getFolderIdForItem(second.id), definedMidFolder.id, + 'sibling bookmarks did not move'); + assert.equal(bmsrv.getFolderIdForItem(third.id), definedMidFolder.id, + 'sibling bookmarks did not move'); + clearAllBookmarks(); + done(); + }); + }); +}; + +exports.testRemoveFunction = function (assert) { + let topFolder = Group({ title: 'new', group: MENU }); + let midFolder = Group({ title: 'middle', group: topFolder }); + let bookmarks = [ + Bookmark({ title: 'moz1', url: 'http://moz1.com', group: midFolder}), + Bookmark({ title: 'moz2', url: 'http://moz2.com', group: midFolder}), + Bookmark({ title: 'moz3', url: 'http://moz3.com', group: midFolder}) + ]; + remove([midFolder, topFolder].concat(bookmarks)).map(item => { + assert.equal(item.remove, true, 'remove toggled `remove` property to true'); + }); +}; + +exports.testRemove = function (assert, done) { + let id; + createBookmarkItem().then(data => { + id = data.id; + compareWithHost(assert, data); // ensure bookmark exists + save(remove(data)).on('data', (res) => { + assert.pass('data event should be called'); + assert.ok(!res, 'response should be empty'); + }).on('end', () => { + assert.throws(function () { + bmsrv.getItemTitle(id); + }, 'item should no longer exist'); + clearAllBookmarks(); + done(); + }); + }); +}; + +/* + * Tests recursively removing children when removing a group + */ +exports.testRemoveAllChildren = function (assert, done) { + let topFolder = Group({ title: 'new', group: MENU }); + let midFolder = Group({ title: 'middle', group: topFolder }); + let bookmarks = [ + Bookmark({ title: 'moz1', url: 'http://moz1.com', group: midFolder}), + Bookmark({ title: 'moz2', url: 'http://moz2.com', group: midFolder}), + Bookmark({ title: 'moz3', url: 'http://moz3.com', group: midFolder}) + ]; + + let saved = []; + save(bookmarks).on('data', (data) => saved.push(data)).on('end', () => { + save(remove(topFolder)).on('end', () => { + assert.equal(saved.length, 5, 'all items should have been saved'); + saved.map((item) => { + assert.throws(function () { + bmsrv.getItemTitle(item.id); + }, 'item should no longer exist'); + }); + clearAllBookmarks(); + done(); + }); + }); +}; + +exports.testResolution = function (assert, done) { + let firstSave, secondSave; + createBookmarkItem().then((item) => { + firstSave = item; + assert.ok(item.updated, 'bookmark has updated time'); + item.title = 'my title'; + save(item).on('data', (item) => { + secondSave = item; + assert.ok(firstSave.updated < secondSave.updated, 'snapshots have different update times'); + firstSave.title = 'updated title'; + save(firstSave, { resolve: (mine, theirs) => { + assert.equal(mine.title, 'updated title', 'correct data for my object'); + assert.equal(theirs.title, 'my title', 'correct data for their object'); + assert.equal(mine.url, theirs.url, 'other data is equal'); + assert.equal(mine.group, theirs.group, 'other data is equal'); + assert.ok(mine !== firstSave, 'instance is not passed in'); + assert.ok(theirs !== secondSave, 'instance is not passed in'); + assert.equal(mine.toString(), '[object Object]', 'serialized objects'); + assert.equal(theirs.toString(), '[object Object]', 'serialized objects'); + mine.title = 'a new title'; + return mine; + }}).on('end', (results) => { + let result = results[0]; + assert.equal(result.title, 'a new title', 'resolve handles results'); + clearAllBookmarks(); + done(); + }); + }); + }); +}; + +/* + * Same as the resolution test, but with the 'unsaved' snapshot + */ +exports.testResolutionMapping = function (assert, done) { + let bookmark = Bookmark({ title: 'moz', url: 'http://bookmarks4life.com/' }); + save(bookmark).on('end', (saved) => { + saved = saved[0]; + saved.title = 'updated title'; + save(saved).on('end', () => { + bookmark.title = 'conflicting title'; + save(bookmark, { resolve: (mine, theirs) => { + assert.equal(mine.title, 'conflicting title', 'correct data for my object'); + assert.equal(theirs.title, 'updated title', 'correct data for their object'); + assert.equal(mine.url, theirs.url, 'other data is equal'); + assert.equal(mine.group, theirs.group, 'other data is equal'); + assert.ok(mine !== bookmark, 'instance is not passed in'); + assert.ok(theirs !== saved, 'instance is not passed in'); + assert.equal(mine.toString(), '[object Object]', 'serialized objects'); + assert.equal(theirs.toString(), '[object Object]', 'serialized objects'); + mine.title = 'a new title'; + return mine; + }}).on('end', (results) => { + let result = results[0]; + assert.equal(result.title, 'a new title', 'resolve handles results'); + clearAllBookmarks(); + done(); + }); + }); + }); +}; + +exports.testUpdateTags = function (assert, done) { + createBookmarkItem({ tags: ['spidermonkey'] }).then(bookmark => { + bookmark.tags.add('jagermonkey'); + bookmark.tags.add('ionmonkey'); + bookmark.tags.delete('spidermonkey'); + save(bookmark).on('data', saved => { + assert.equal(saved.tags.size, 2, 'should have 2 tags'); + assert.ok(saved.tags.has('jagermonkey'), 'should have added tag'); + assert.ok(saved.tags.has('ionmonkey'), 'should have added tag'); + assert.ok(!saved.tags.has('spidermonkey'), 'should not have removed tag'); + clearAllBookmarks(); + done(); + }); + }); +}; + +/* + * View `createBookmarkTree` in `./places-helper.js` to see + * expected tree construction + */ + +exports.testSearchByGroupSimple = function (assert, done) { + createBookmarkTree().then(() => { + // In initial release of Places API, groups can only be queried + // via a 'simple query', which is one folder set, and no other + // parameters + return searchP({ group: UNSORTED }); + }).then(results => { + let groups = results.filter(({type}) => type === 'group'); + assert.equal(groups.length, 2, 'returns folders'); + assert.equal(results.length, 7, + 'should return all bookmarks and folders under UNSORTED'); + assert.equal(groups[0].toString(), '[object Group]', 'returns instance'); + return searchP({ + group: groups.filter(({title}) => title === 'mozgroup')[0] + }); + }).then(results => { + let groups = results.filter(({type}) => type === 'group'); + assert.equal(groups.length, 1, 'returns one subfolder'); + assert.equal(results.length, 6, + 'returns all children bookmarks/folders'); + assert.ok(results.filter(({url}) => url === 'http://w3schools.com/'), + 'returns nested children'); + clearAllBookmarks(); + done(); + }).then(null, console.error); +}; + +exports.testSearchByGroupComplex = function (assert, done) { + let mozgroup; + createBookmarkTree().then(results => { + mozgroup = results.filter(({title}) => title === 'mozgroup')[0]; + return searchP({ group: mozgroup, query: 'javascript' }); + }).then(results => { + assert.equal(results.length, 1, 'only one javascript result under mozgroup'); + assert.equal(results[0].url, 'http://w3schools.com/', 'correct result'); + return searchP({ group: mozgroup, url: '*.mozilla.org' }); + }).then(results => { + assert.equal(results.length, 2, 'expected results'); + assert.ok( + !results.filter(({url}) => /developer.mozilla/.test(url)).length, + 'does not find results from other folders'); + clearAllBookmarks(); + done(); + }, console.error); +}; + +exports.testSearchEmitters = function (assert, done) { + createBookmarkTree().then(() => { + let count = 0; + search({ tags: ['mozilla', 'firefox'] }).on('data', data => { + assert.ok(/mozilla|firefox/.test(data.title), 'one of the correct items'); + assert.ok(data.tags.has('firefox'), 'has firefox tag'); + assert.ok(data.tags.has('mozilla'), 'has mozilla tag'); + assert.equal(data + '', '[object Bookmark]', 'returns bookmark'); + count++; + }).on('end', data => { + assert.equal(count, 3, 'data event was called for each item'); + assert.equal(data.length, 3, + 'should return two bookmarks that have both mozilla AND firefox'); + assert.equal(data[0].title, 'mozilla.com', 'returns correct bookmark'); + assert.equal(data[1].title, 'mozilla.org', 'returns correct bookmark'); + assert.equal(data[2].title, 'firefox', 'returns correct bookmark'); + assert.equal(data[0] + '', '[object Bookmark]', 'returns bookmarks'); + clearAllBookmarks(); + done(); + }); + }); +}; + +exports.testSearchTags = function (assert, done) { + createBookmarkTree().then(() => { + // AND tags + return searchP({ tags: ['mozilla', 'firefox'] }); + }).then(data => { + assert.equal(data.length, 3, + 'should return two bookmarks that have both mozilla AND firefox'); + assert.equal(data[0].title, 'mozilla.com', 'returns correct bookmark'); + assert.equal(data[1].title, 'mozilla.org', 'returns correct bookmark'); + assert.equal(data[2].title, 'firefox', 'returns correct bookmark'); + assert.equal(data[0] + '', '[object Bookmark]', 'returns bookmarks'); + return searchP([{tags: ['firefox']}, {tags: ['javascript']}]); + }).then(data => { + // OR tags + assert.equal(data.length, 6, + 'should return all bookmarks with firefox OR javascript tag'); + clearAllBookmarks(); + done(); + }); +}; + +/* + * Tests 4 scenarios + * '*.mozilla.com' + * 'mozilla.com' + * 'http://mozilla.com/' + * 'http://mozilla.com/*' + */ +exports.testSearchURL = function (assert, done) { + createBookmarkTree().then(() => { + return searchP({ url: 'mozilla.org' }); + }).then(data => { + assert.equal(data.length, 2, 'only URLs with host domain'); + assert.equal(data[0].url, 'http://mozilla.org/'); + assert.equal(data[1].url, 'http://mozilla.org/thunderbird/'); + return searchP({ url: '*.mozilla.org' }); + }).then(data => { + assert.equal(data.length, 3, 'returns domain and when host is other than domain'); + assert.equal(data[0].url, 'http://mozilla.org/'); + assert.equal(data[1].url, 'http://mozilla.org/thunderbird/'); + assert.equal(data[2].url, 'http://developer.mozilla.org/en-US/'); + return searchP({ url: 'http://mozilla.org' }); + }).then(data => { + assert.equal(data.length, 1, 'only exact URL match'); + assert.equal(data[0].url, 'http://mozilla.org/'); + return searchP({ url: 'http://mozilla.org/*' }); + }).then(data => { + assert.equal(data.length, 2, 'only URLs that begin with query'); + assert.equal(data[0].url, 'http://mozilla.org/'); + assert.equal(data[1].url, 'http://mozilla.org/thunderbird/'); + return searchP([{ url: 'mozilla.org' }, { url: 'component.fm' }]); + }).then(data => { + assert.equal(data.length, 3, 'returns URLs that match EITHER query'); + assert.equal(data[0].url, 'http://mozilla.org/'); + assert.equal(data[1].url, 'http://mozilla.org/thunderbird/'); + assert.equal(data[2].url, 'http://component.fm/'); + }).then(() => { + clearAllBookmarks(); + done(); + }); +}; + +/* + * Searches url, title, tags + */ +exports.testSearchQuery = function (assert, done) { + createBookmarkTree().then(() => { + return searchP({ query: 'thunder' }); + }).then(data => { + assert.equal(data.length, 3); + assert.equal(data[0].title, 'mozilla.com', 'query matches tag, url, or title'); + assert.equal(data[1].title, 'mozilla.org', 'query matches tag, url, or title'); + assert.equal(data[2].title, 'thunderbird', 'query matches tag, url, or title'); + return searchP([{ query: 'rust' }, { query: 'component' }]); + }).then(data => { + // rust OR component + assert.equal(data.length, 3); + assert.equal(data[0].title, 'mozilla.com', 'query matches tag, url, or title'); + assert.equal(data[1].title, 'mozilla.org', 'query matches tag, url, or title'); + assert.equal(data[2].title, 'web audio components', 'query matches tag, url, or title'); + return searchP([{ query: 'moz', tags: ['javascript']}]); + }).then(data => { + assert.equal(data.length, 1); + assert.equal(data[0].title, 'mdn', + 'only one item matches moz query AND has a javascript tag'); + }).then(() => { + clearAllBookmarks(); + done(); + }); +}; + +/* + * Test caching on bulk calls. + * Each construction of a bookmark item snapshot results in + * the recursive lookup of parent groups up to the root groups -- + * ensure that the appropriate instances equal each other, and no duplicate + * fetches are called + * + * Implementation-dependent, this checks the host event `sdk-places-bookmarks-get`, + * and if implementation changes, this could increase or decrease + */ + +exports.testCaching = function (assert, done) { + let count = 0; + let stream = filter(request, ({event}) => + /sdk-places-bookmarks-get/.test(event)); + on(stream, 'data', handle); + + let group = { type: 'group', title: 'mozgroup' }; + let bookmarks = [ + { title: 'moz1', url: 'http://moz1.com', type: 'bookmark', group: group }, + { title: 'moz2', url: 'http://moz2.com', type: 'bookmark', group: group }, + { title: 'moz3', url: 'http://moz3.com', type: 'bookmark', group: group } + ]; + + /* + * Use timeout in tests since the platform calls are synchronous + * and the counting event shim may not have occurred yet + */ + + saveP(bookmarks).then(() => { + let deferred = defer(); + setTimeout(() => { + assert.equal(count, 0, 'all new items and root group, no fetches should occur'); + count = 0; + deferred.resolve(saveP([ + { title: 'moz4', url: 'http://moz4.com', type: 'bookmark', group: group }, + { title: 'moz5', url: 'http://moz5.com', type: 'bookmark', group: group } + ])); + }, 10); + return deferred.promise; + /* + * Test `save` look-up + */ + }).then(() => { + let deferred = defer(); + setTimeout(() => { + assert.equal(count, 1, 'should only look up parent once'); + count = 0; + deferred.resolve(search({ query: 'moz' })); + }, 10); + return deferred.promise; + }).then(results => { + setTimeout(() => { + /* + * Should query for each bookmark (5) from the query (id -> data), + * their parent during `construct` (1) and the root shouldn't + * require a lookup + */ + assert.equal(count, 6, 'lookup occurs once for each item and parent'); + off(stream, 'data', handle); + clearAllBookmarks(); + done(); + }, 10); + }); + + function handle ({data}) count++ +}; + +/* + * Search Query Options + */ + +exports.testSearchCount = function (assert, done) { + let max = 8; + createBookmarkTree() + .then(testCount(1)) + .then(testCount(2)) + .then(testCount(3)) + .then(testCount(5)) + .then(testCount(10)) + .then(() => { + clear(); + done(); + }); + + function testCount (n) { + return function () { + return searchP({}, { count: n }).then(results => { + if (n > max) n = max; + assert.equal(results.length, n, + 'count ' + n + ' returns ' + n + ' results'); + }); + }; + } +}; + +exports.testSearchSort = function (assert, done) { + let urls = [ + 'http://mozilla.com/', 'http://webaud.io/', 'http://mozilla.com/webfwd/', + 'http://developer.mozilla.com/', 'http://bandcamp.com/' + ]; + + saveP( + urls.map(url => + Bookmark({ url: url, title: url.replace(/http:\/\/|\//g,'')})) + ).then(() => { + return searchP({}, { sort: 'title' }); + }).then(results => { + checkOrder(results, [4,3,0,2,1]); + return searchP({}, { sort: 'title', descending: true }); + }).then(results => { + checkOrder(results, [1,2,0,3,4]); + return searchP({}, { sort: 'url' }); + }).then(results => { + checkOrder(results, [4,3,0,2,1]); + return searchP({}, { sort: 'url', descending: true }); + }).then(results => { + checkOrder(results, [1,2,0,3,4]); + return addVisits(['http://mozilla.com/', 'http://mozilla.com']); + }).then(() => + saveP(Bookmark({ url: 'http://github.com', title: 'github.com' })) + ).then(() => addVisits('http://bandcamp.com/')) + .then(() => searchP({ query: 'webfwd' })) + .then(results => { + results[0].title = 'new title for webfwd'; + return saveP(results[0]); + }) + .then(() => + searchP({}, { sort: 'visitCount' }) + ).then(results => { + assert.equal(results[5].url, 'http://mozilla.com/', + 'last entry is the highest visit count'); + return searchP({}, { sort: 'visitCount', descending: true }); + }).then(results => { + assert.equal(results[0].url, 'http://mozilla.com/', + 'first entry is the highest visit count'); + return searchP({}, { sort: 'date' }); + }).then(results => { + assert.equal(results[5].url, 'http://bandcamp.com/', + 'latest visited should be first'); + return searchP({}, { sort: 'date', descending: true }); + }).then(results => { + assert.equal(results[0].url, 'http://bandcamp.com/', + 'latest visited should be at the end'); + return searchP({}, { sort: 'dateAdded' }); + }).then(results => { + assert.equal(results[5].url, 'http://github.com/', + 'last added should be at the end'); + return searchP({}, { sort: 'dateAdded', descending: true }); + }).then(results => { + assert.equal(results[0].url, 'http://github.com/', + 'last added should be first'); + return searchP({}, { sort: 'lastModified' }); + }).then(results => { + assert.equal(results[5].url, 'http://mozilla.com/webfwd/', + 'last modified should be last'); + return searchP({}, { sort: 'lastModified', descending: true }); + }).then(results => { + assert.equal(results[0].url, 'http://mozilla.com/webfwd/', + 'last modified should be first'); + }).then(() => { + clear(); + done(); + }); + + function checkOrder (results, nums) { + assert.equal(results.length, nums.length, 'expected return count'); + for (let i = 0; i < nums.length; i++) { + assert.equal(results[i].url, urls[nums[i]], 'successful order'); + } + } +}; + +exports.testSearchComplexQueryWithOptions = function (assert, done) { + createBookmarkTree().then(() => { + return searchP([ + { tags: ['rust'], url: '*.mozilla.org' }, + { tags: ['javascript'], query: 'mozilla' } + ], { sort: 'title' }); + }).then(results => { + let expected = [ + 'http://developer.mozilla.org/en-US/', + 'http://mozilla.org/' + ]; + for (let i = 0; i < expected.length; i++) + assert.equal(results[i].url, expected[i], 'correct ordering and item'); + clearAllBookmarks(); + done(); + }); +}; + +exports.testCheckSaveOrder = function (assert, done) { + let group = Group({ title: 'mygroup' }); + let bookmarks = [ + Bookmark({ url: 'http://url1.com', title: 'url1', group: group }), + Bookmark({ url: 'http://url2.com', title: 'url2', group: group }), + Bookmark({ url: 'http://url3.com', title: 'url3', group: group }), + Bookmark({ url: 'http://url4.com', title: 'url4', group: group }), + Bookmark({ url: 'http://url5.com', title: 'url5', group: group }) + ]; + saveP(bookmarks).then(results => { + for (let i = 0; i < bookmarks.length; i++) + assert.equal(results[i].url, bookmarks[i].url, + 'correct ordering of bookmark results'); + clear(); + done(); + }); +}; + +function clear () { + clearAllBookmarks(); + clearHistory(); +} + +// If the module doesn't support the app we're being run in, require() will +// throw. In that case, remove all tests above from exports, and add one dummy +// test that passes. +function unsupported (err) { + if (!/^Unsupported Application/.test(err.message)) + throw err; + + module.exports = { + "test Unsupported Application": function Unsupported (assert) { + assert.pass(err.message); + } + }; +} + +function saveP () { + return promisedEmitter(save.apply(null, Array.slice(arguments))); +} + +function searchP () { + return promisedEmitter(search.apply(null, Array.slice(arguments))); +} +require('test').run(exports); diff --git a/test/test-places-favicon.js b/test/test-places-favicon.js index dfccd610e..d7421db33 100644 --- a/test/test-places-favicon.js +++ b/test/test-places-favicon.js @@ -11,6 +11,7 @@ const host = 'http://localhost:' + port; const { onFaviconChange, serve, binFavicon } = require('./favicon-helpers'); const { once } = require('sdk/system/events'); const { defer } = require('sdk/core/promise'); +const { clearHistory } = require('./places-helper'); const faviconService = Cc["@mozilla.org/browser/favicon-service;1"]. getService(Ci.nsIFaviconService); @@ -172,6 +173,7 @@ function waitAndExpire (url) { function complete(tab, srv, done) { tab.close(function () { + clearHistory(); srv.stop(done); }) } diff --git a/test/test-places-history.js b/test/test-places-history.js new file mode 100644 index 000000000..53e332824 --- /dev/null +++ b/test/test-places-history.js @@ -0,0 +1,273 @@ +/* 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' + +const { Cc, Ci } = require('chrome'); +const { defer, all } = require('sdk/core/promise'); +const { has } = require('sdk/util/array'); +const { setTimeout } = require('sdk/timers'); +try { +const { + search +} = require('sdk/places/history'); +const { + invalidResolve, invalidReject, clearBookmarks, createTree, + compareWithHost, clearAllBookmarks, addVisits, clearHistory +} = require('./places-helper'); +const { promisedEmitter } = require('sdk/places/utils'); +const hsrv = Cc['@mozilla.org/browser/nav-history-service;1']. + getService(Ci.nsINavHistoryService); +clear(); +} catch(e) { unsupported(e); } + +exports.testEmptyQuery = function (assert, done) { + let within = toBeWithin(); + addVisits([ + 'http://simplequery-1.com', 'http://simplequery-2.com' + ]).then(searchP).then(results => { + assert.equal(results.length, 2, 'Correct number of entries returned'); + assert.equal(results[0].url, 'http://simplequery-1.com/', + 'matches url'); + assert.equal(results[1].url, 'http://simplequery-2.com/', + 'matches url'); + assert.equal(results[0].title, 'Test visit for ' + results[0].url, + 'title matches'); + assert.equal(results[1].title, 'Test visit for ' + results[1].url, + 'title matches'); + assert.equal(results[0].visitCount, 1, 'matches access'); + assert.equal(results[1].visitCount, 1, 'matches access'); + assert.ok(within(results[0].time), 'accurate access time'); + assert.ok(within(results[1].time), 'accurate access time'); + assert.equal(Object.keys(results[0]).length, 4, + 'no addition exposed properties on history result'); + clear(); + done(); + }, invalidReject); +}; + +exports.testVisitCount = function (assert, done) { + addVisits([ + 'http://simplequery-1.com', 'http://simplequery-1.com', + 'http://simplequery-1.com', 'http://simplequery-1.com' + ]).then(searchP).then(results => { + assert.equal(results.length, 1, 'Correct number of entries returned'); + assert.equal(results[0].url, 'http://simplequery-1.com/', 'correct url'); + assert.equal(results[0].visitCount, 4, 'matches access count'); + clear(); + done(); + }, invalidReject); +}; + +/* + * Tests 4 scenarios + * '*.mozilla.org' + * 'mozilla.org' + * 'http://mozilla.org/' + * 'http://mozilla.org/*' + */ +exports.testSearchURL = function (assert, done) { + addVisits([ + 'http://developer.mozilla.org', 'http://mozilla.org', + 'http://mozilla.org/index', 'https://mozilla.org' + ]).then(() => searchP({ url: '*.mozilla.org' })) + .then(results => { + assert.equal(results.length, 4, 'returns all entries'); + return searchP({ url: 'mozilla.org' }); + }).then(results => { + assert.equal(results.length, 3, 'returns entries where mozilla.org is host'); + return searchP({ url: 'http://mozilla.org/' }); + }).then(results => { + assert.equal(results.length, 1, 'should just be an exact match'); + return searchP({ url: 'http://mozilla.org/*' }); + }).then(results => { + assert.equal(results.length, 2, 'should match anything starting with substring'); + clear(); + done(); + }); +}; + +exports.testSearchTimeRange = function (assert, done) { + let firstTime, secondTime; + addVisits([ + 'http://earlyvisit.org', 'http://earlyvisit.org/earlytown.html' + ]).then(searchP).then(results => { + firstTime = results[0].time; + var deferred = defer(); + setTimeout(function () deferred.resolve(), 1000); + return deferred.promise; + }).then(() => { + return addVisits(['http://newvisit.org', 'http://newvisit.org/whoawhoa.html']); + }).then(searchP).then(results => { + results.filter(({url, time}) => { + if (/newvisit/.test(url)) secondTime = time; + }); + return searchP({ from: firstTime - 1000 }); + }).then(results => { + assert.equal(results.length, 4, 'should return all entries'); + return searchP({ to: firstTime + 500 }); + }).then(results => { + assert.equal(results.length, 2, 'should return only first entries'); + results.map(item => { + assert.ok(/earlyvisit/.test(item.url), 'correct entry'); + }); + return searchP({ from: firstTime + 500 }); + }).then(results => { + assert.equal(results.length, 2, 'should return only last entries'); + results.map(item => { + assert.ok(/newvisit/.test(item.url), 'correct entry'); + }); + clear(); + done(); + }); +}; + +exports.testSearchQuery = function (assert, done) { + addVisits([ + 'http://mozilla.com', 'http://webaud.io', 'http://mozilla.com/webfwd' + ]).then(() => { + return searchP({ query: 'moz' }); + }).then(results => { + assert.equal(results.length, 2, 'should return urls that match substring'); + results.map(({url}) => { + assert.ok(/moz/.test(url), 'correct item'); + }); + return searchP([{ query: 'webfwd' }, { query: 'aud.io' }]); + }).then(results => { + assert.equal(results.length, 2, 'should OR separate queries'); + results.map(({url}) => { + assert.ok(/webfwd|aud\.io/.test(url), 'correct item'); + }); + clear(); + done(); + }); +}; + +/* + * Query Options + */ + +exports.testSearchCount = function (assert, done) { + addVisits([ + 'http://mozilla.com', 'http://webaud.io', 'http://mozilla.com/webfwd', + 'http://developer.mozilla.com', 'http://bandcamp.com' + ]).then(testCount(1)) + .then(testCount(2)) + .then(testCount(3)) + .then(testCount(5)) + .then(() => { + clear(); + done(); + }); + + function testCount (n) { + return function () { + return searchP({}, { count: n }).then(results => { + assert.equal(results.length, n, + 'count ' + n + ' returns ' + n + ' results'); + }); + }; + } +}; + +exports.testSearchSort = function (assert, done) { + let places = [ + 'http://mozilla.com/', 'http://webaud.io/', 'http://mozilla.com/webfwd/', + 'http://developer.mozilla.com/', 'http://bandcamp.com/' + ]; + addVisits(places).then(() => { + return searchP({}, { sort: 'title' }); + }).then(results => { + checkOrder(results, [4,3,0,2,1]); + return searchP({}, { sort: 'title', descending: true }); + }).then(results => { + checkOrder(results, [1,2,0,3,4]); + return searchP({}, { sort: 'url' }); + }).then(results => { + checkOrder(results, [4,3,0,2,1]); + return searchP({}, { sort: 'url', descending: true }); + }).then(results => { + checkOrder(results, [1,2,0,3,4]); + return addVisits('http://mozilla.com') // for visit conut + .then(() => addVisits('http://github.com')); // for checking date + }).then(() => { + return searchP({}, { sort: 'visitCount' }); + }).then(results => { + assert.equal(results[5].url, 'http://mozilla.com/', + 'last entry is the highest visit count'); + return searchP({}, { sort: 'visitCount', descending: true }); + }).then(results => { + assert.equal(results[0].url, 'http://mozilla.com/', + 'first entry is the highest visit count'); + return searchP({}, { sort: 'date' }); + }).then(results => { + assert.equal(results[5].url, 'http://github.com/', + 'latest visited should be first'); + return searchP({}, { sort: 'date', descending: true }); + }).then(results => { + assert.equal(results[0].url, 'http://github.com/', + 'latest visited should be at the end'); + }).then(() => { + clear(); + done(); + }); + + function checkOrder (results, nums) { + assert.equal(results.length, nums.length, 'expected return count'); + for (let i = 0; i < nums.length; i++) { + assert.equal(results[i].url, places[nums[i]], 'successful order'); + } + } +}; + +exports.testEmitters = function (assert, done) { + let urls = [ + 'http://mozilla.com/', 'http://webaud.io/', 'http://mozilla.com/webfwd/', + 'http://developer.mozilla.com/', 'http://bandcamp.com/' + ]; + addVisits(urls).then(() => { + let count = 0; + search().on('data', item => { + assert.ok(~urls.indexOf(item.url), 'data value found in url list'); + count++; + }).on('end', results => { + assert.equal(results.length, 5, 'correct count of items'); + assert.equal(count, 5, 'data event called 5 times'); + clear(); + done(); + }); + }); +}; + +function toBeWithin (range) { + range = range || 2000; + var current = new Date() * 1000; // convert to microseconds + return compared => { + return compared - current < range; + }; +} + +function clear () { + clearAllBookmarks(); + clearHistory(); +} + +// If the module doesn't support the app we're being run in, require() will +// throw. In that case, remove all tests above from exports, and add one dummy +// test that passes. +function unsupported (err) { + if (!/^Unsupported Application/.test(err.message)) + throw err; + + module.exports = { + "test Unsupported Application": function Unsupported (assert) { + assert.pass(err.message); + } + }; +} + +function searchP () { + return promisedEmitter(search.apply(null, Array.slice(arguments))); +} + +require('test').run(exports); diff --git a/test/test-places-host.js b/test/test-places-host.js new file mode 100644 index 000000000..c955f127c --- /dev/null +++ b/test/test-places-host.js @@ -0,0 +1,292 @@ +/* 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'; + +const { Cc, Ci } = require('chrome'); +const { defer, all } = require('sdk/core/promise'); +const { setTimeout } = require('sdk/timers'); +const { newURI } = require('sdk/url/utils'); +const { send } = require('sdk/addon/events'); +try { +require('sdk/places/host/host-bookmarks'); +require('sdk/places/host/host-tags'); +require('sdk/places/host/host-query'); +const { + invalidResolve, invalidReject, clearBookmarks, createTree, + compareWithHost, clearAllBookmarks, createBookmark, createBookmarkTree +} = require('./places-helper'); + +const bmsrv = Cc['@mozilla.org/browser/nav-bookmarks-service;1']. + getService(Ci.nsINavBookmarksService); +const hsrv = Cc['@mozilla.org/browser/nav-history-service;1']. + getService(Ci.nsINavHistoryService); +const tagsrv = Cc['@mozilla.org/browser/tagging-service;1']. + getService(Ci.nsITaggingService); +clearAllBookmarks(); +} catch(e) { unsupported(e); } + +exports.testBookmarksCreate = function (assert, done) { + let items = [{ + title: 'my title', + url: 'http://moz.com', + tags: ['some', 'tags', 'yeah'], + type: 'bookmark' + }, { + title: 'my folder', + type: 'group', + group: bmsrv.bookmarksMenuFolder + }, { + type: 'separator', + group: bmsrv.unfiledBookmarksFolder + }]; + + all(items.map(function (item) { + return send('sdk-places-bookmarks-create', item).then(function (data) { + compareWithHost(assert, data); + }, invalidReject(assert)); + })).then(function () { + clearAllBookmarks(); + done(); + }, invalidReject(assert)); +}; + +exports.testBookmarksCreateFail = function (assert, done) { + let items = [{ + title: 'my title', + url: 'not-a-url', + type: 'bookmark' + }, { + type: 'group', + group: bmsrv.bookmarksMenuFolder + }, { + group: bmsrv.unfiledBookmarksFolder + }]; + all(items.map(function (item) { + return send('sdk-places-bookmarks-create', item).then(null, function (reason) { + assert.ok(reason, 'bookmark create should fail'); + }); + })).then(function () { + clearAllBookmarks(); + done(); + }); +}; + +exports.testBookmarkLastUpdated = function (assert, done) { + let timestamp; + let item; + createBookmark().then(function (data) { + item = data; + timestamp = item.updated; + return send('sdk-places-bookmarks-last-updated', { id: item.id }); + }).then(function (updated) { + assert.equal(timestamp, updated, 'should return last updated time'); + item.title = 'updated mozilla'; + return send('sdk-places-bookmarks-save', item).then(function (data) { + let deferred = defer(); + setTimeout(function () deferred.resolve(data), 100); + return deferred.promise; + }); + }).then(function (data) { + assert.ok(data.updated > timestamp, 'time has elapsed and updated the updated property'); + clearAllBookmarks(); + done(); + }); +}; + +exports.testBookmarkRemove = function (assert, done) { + let id; + createBookmark().then(function (data) { + id = data.id; + compareWithHost(assert, data); // ensure bookmark exists + bmsrv.getItemTitle(id); // does not throw an error + return send('sdk-places-bookmarks-remove', data); + }).then(function () { + assert.throws(function () { + bmsrv.getItemTitle(id); + }, 'item should no longer exist'); + clearAllBookmarks(); + done(); + }, console.error); +}; + +exports.testBookmarkGet = function (assert, done) { + let bookmark; + createBookmark().then(function (data) { + bookmark = data; + return send('sdk-places-bookmarks-get', { id: data.id }); + }).then(function (data) { + 'title url index group updated type tags'.split(' ').map(function (prop) { + if (prop === 'tags') { + for (let tag of bookmark.tags) { + assert.ok(~data.tags.indexOf(tag), + 'correctly fetched tag ' + tag); + } + assert.equal(bookmark.tags.length, data.tags.length, + 'same amount of tags'); + } + else + assert.equal(bookmark[prop], data[prop], 'correctly fetched ' + prop); + }); + clearAllBookmarks(); + done(); + }); +}; + +exports.testTagsTag = function (assert, done) { + let url; + createBookmark().then(function (data) { + url = data.url; + return send('sdk-places-tags-tag', { + url: data.url, tags: ['mozzerella', 'foxfire'] + }); + }).then(function () { + let tags = tagsrv.getTagsForURI(newURI(url)); + assert.ok(~tags.indexOf('mozzerella'), 'first tag found'); + assert.ok(~tags.indexOf('foxfire'), 'second tag found'); + assert.ok(~tags.indexOf('firefox'), 'default tag found'); + assert.equal(tags.length, 3, 'no extra tags'); + clearAllBookmarks(); + done(); + }); +}; + +exports.testTagsUntag = function (assert, done) { + let item; + createBookmark({tags: ['tag1', 'tag2', 'tag3']}).then(function (data) { + item = data; + return send('sdk-places-tags-untag', { + url: item.url, + tags: ['tag2', 'firefox'] + }); + }).then(function () { + let tags = tagsrv.getTagsForURI(newURI(item.url)); + assert.ok(~tags.indexOf('tag1'), 'first tag persisted'); + assert.ok(~tags.indexOf('tag3'), 'second tag persisted'); + assert.ok(!~tags.indexOf('firefox'), 'first tag removed'); + assert.ok(!~tags.indexOf('tag2'), 'second tag removed'); + assert.equal(tags.length, 2, 'no extra tags'); + clearAllBookmarks(); + done(); + }); +}; + +exports.testTagsGetURLsByTag = function (assert, done) { + let item; + createBookmark().then(function (data) { + item = data; + return send('sdk-places-tags-get-urls-by-tag', { + tag: 'firefox' + }); + }).then(function(urls) { + assert.equal(item.url, urls[0], 'returned correct url'); + assert.equal(urls.length, 1, 'returned only one url'); + clearAllBookmarks(); + done(); + }); +}; + +exports.testTagsGetTagsByURL = function (assert, done) { + let item; + createBookmark({ tags: ['firefox', 'mozilla', 'metal']}).then(function (data) { + item = data; + return send('sdk-places-tags-get-tags-by-url', { + url: data.url, + }); + }).then(function(tags) { + assert.ok(~tags.indexOf('firefox'), 'returned first tag'); + assert.ok(~tags.indexOf('mozilla'), 'returned second tag'); + assert.ok(~tags.indexOf('metal'), 'returned third tag'); + assert.equal(tags.length, 3, 'returned all tags'); + clearAllBookmarks(); + done(); + }); +}; + +exports.testHostQuery = function (assert, done) { + all([ + createBookmark({ url: 'http://firefox.com', tags: ['firefox', 'mozilla'] }), + createBookmark({ url: 'http://mozilla.com', tags: ['mozilla'] }), + createBookmark({ url: 'http://thunderbird.com' }) + ]).then(data => { + return send('sdk-places-query', { + queries: { tags: ['mozilla'] }, + options: { sortingMode: 6, queryType: 1 } // sort by URI ascending, bookmarks only + }); + }).then(results => { + assert.equal(results.length, 2, 'should only return two'); + assert.equal(results[0].url, 'http://mozilla.com/', 'is sorted by URI asc'); + return send('sdk-places-query', { + queries: { tags: ['mozilla'] }, + options: { sortingMode: 5, queryType: 1 } // sort by URI descending, bookmarks only + }); + }).then(results => { + assert.equal(results.length, 2, 'should only return two'); + assert.equal(results[0].url, 'http://firefox.com/', 'is sorted by URI desc'); + clearAllBookmarks(); + done(); + }); +}; + +exports.testHostMultiQuery = function (assert, done) { + all([ + createBookmark({ url: 'http://firefox.com', tags: ['firefox', 'mozilla'] }), + createBookmark({ url: 'http://mozilla.com', tags: ['mozilla'] }), + createBookmark({ url: 'http://thunderbird.com' }) + ]).then(data => { + return send('sdk-places-query', { + queries: [{ tags: ['firefox'] }, { uri: 'http://thunderbird.com/' }], + options: { sortingMode: 5, queryType: 1 } // sort by URI descending, bookmarks only + }); + }).then(results => { + assert.equal(results.length, 2, 'should return 2 results ORing queries'); + assert.equal(results[0].url, 'http://firefox.com/', 'should match URL or tag'); + assert.equal(results[1].url, 'http://thunderbird.com/', 'should match URL or tag'); + return send('sdk-places-query', { + queries: [{ tags: ['firefox'], url: 'http://mozilla.com/' }], + options: { sortingMode: 5, queryType: 1 } // sort by URI descending, bookmarks only + }); + }).then(results => { + assert.equal(results.length, 0, 'query props should be AND\'d'); + clearAllBookmarks(); + done(); + }); +}; + +exports.testGetAllBookmarks = function (assert, done) { + createBookmarkTree().then(() => { + return send('sdk-places-bookmarks-get-all', {}); + }).then(res => { + assert.equal(res.length, 8, 'all bookmarks returned'); + clearAllBookmarks(); + done(); + }, console.error); +}; + +exports.testGetAllChildren = function (assert, done) { + createBookmarkTree().then(results => { + return send('sdk-places-bookmarks-get-children', { + id: results.filter(({title}) => title === 'mozgroup')[0].id + }); + }).then(results => { + assert.equal(results.length, 5, + 'should return all children and folders at a single depth'); + clearAllBookmarks(); + done(); + }); +}; + +// If the module doesn't support the app we're being run in, require() will +// throw. In that case, remove all tests above from exports, and add one dummy +// test that passes. +function unsupported (err) { + if (!/^Unsupported Application/.test(err.message)) + throw err; + + module.exports = { + "test Unsupported Application": function Unsupported (assert) { + assert.pass(err.message); + } + }; +} +require('test').run(exports); diff --git a/test/test-places-utils.js b/test/test-places-utils.js index 30201f828..0babcc12e 100644 --- a/test/test-places-utils.js +++ b/test/test-places-utils.js @@ -4,8 +4,10 @@ 'use strict'; const { defer, all } = require('sdk/core/promise'); -const { TreeNode } = require('sdk/places/utils'); const { setTimeout } = require('sdk/timers'); +try { +const { TreeNode } = require('sdk/places/utils'); +} catch(e) { unsupported(e); } exports['test construct tree'] = function (assert) { let tree = TreeNode(1); @@ -72,4 +74,18 @@ exports['test async walk'] = function (assert, done) { }); }; +// If the module doesn't support the app we're being run in, require() will +// throw. In that case, remove all tests above from exports, and add one dummy +// test that passes. +function unsupported (err) { + if (!/^Unsupported Application/.test(err.message)) + throw err; + + module.exports = { + "test Unsupported Application": function Unsupported (assert) { + assert.pass(err.message); + } + }; +} + require('test').run(exports);