From 77cec8a034a6bfac49b1aea59a1d29a8f7240d10 Mon Sep 17 00:00:00 2001 From: Bob Silverberg Date: Fri, 13 May 2016 11:09:06 -0400 Subject: [PATCH] Bug 1265836 - Part 3: Implement insert and insertMany in History.jsm. r=mak MozReview-Commit-ID: GmXVDPuULtq --HG-- extra : transplant_source : %02%AB%9DZ%8F%E8ER%AE%2A%7E%A3L%89%DC%11g7%DF%C4 --- toolkit/components/places/Bookmarks.jsm | 69 ++-- toolkit/components/places/History.jsm | 304 ++++++++++++++++-- toolkit/components/places/PlacesUtils.jsm | 23 ++ .../places/tests/history/test_insert.js | 264 +++++++++++++++ .../places/tests/history/xpcshell.ini | 1 + 5 files changed, 581 insertions(+), 80 deletions(-) create mode 100644 toolkit/components/places/tests/history/test_insert.js diff --git a/toolkit/components/places/Bookmarks.jsm b/toolkit/components/places/Bookmarks.jsm index f7807913439e..b4d997403e3d 100644 --- a/toolkit/components/places/Bookmarks.jsm +++ b/toolkit/components/places/Bookmarks.jsm @@ -176,11 +176,11 @@ var Bookmarks = Object.freeze({ let observers = PlacesUtils.bookmarks.getObservers(); // We need the itemId to notify, though once the switch to guids is // complete we may stop using it. - let uri = item.hasOwnProperty("url") ? toURI(item.url) : null; + let uri = item.hasOwnProperty("url") ? PlacesUtils.toURI(item.url) : null; let itemId = yield PlacesUtils.promiseItemId(item.guid); notify(observers, "onItemAdded", [ itemId, parent._id, item.index, item.type, uri, item.title || null, - toPRTime(item.dateAdded), item.guid, + PlacesUtils.toPRTime(item.dateAdded), item.guid, item.parentGuid ]); // If it's a tag, notify OnItemChanged to all bookmarks for this URL. @@ -188,7 +188,7 @@ var Bookmarks = Object.freeze({ if (isTagging) { for (let entry of (yield fetchBookmarksByURL(item))) { notify(observers, "onItemChanged", [ entry._id, "tags", false, "", - toPRTime(entry.lastModified), + PlacesUtils.toPRTime(entry.lastModified), entry.type, entry._parentId, entry.guid, entry.parentGuid, "" ]); @@ -323,8 +323,8 @@ var Bookmarks = Object.freeze({ item.lastModified != updatedItem.lastModified) { notify(observers, "onItemChanged", [ updatedItem._id, "lastModified", false, - `${toPRTime(updatedItem.lastModified)}`, - toPRTime(updatedItem.lastModified), + `${PlacesUtils.toPRTime(updatedItem.lastModified)}`, + PlacesUtils.toPRTime(updatedItem.lastModified), updatedItem.type, updatedItem._parentId, updatedItem.guid, @@ -333,7 +333,7 @@ var Bookmarks = Object.freeze({ if (updateInfo.hasOwnProperty("title")) { notify(observers, "onItemChanged", [ updatedItem._id, "title", false, updatedItem.title, - toPRTime(updatedItem.lastModified), + PlacesUtils.toPRTime(updatedItem.lastModified), updatedItem.type, updatedItem._parentId, updatedItem.guid, @@ -342,7 +342,7 @@ var Bookmarks = Object.freeze({ if (updateInfo.hasOwnProperty("url")) { notify(observers, "onItemChanged", [ updatedItem._id, "uri", false, updatedItem.url.href, - toPRTime(updatedItem.lastModified), + PlacesUtils.toPRTime(updatedItem.lastModified), updatedItem.type, updatedItem._parentId, updatedItem.guid, @@ -408,7 +408,7 @@ var Bookmarks = Object.freeze({ // Notify onItemRemoved to listeners. let observers = PlacesUtils.bookmarks.getObservers(); - let uri = item.hasOwnProperty("url") ? toURI(item.url) : null; + let uri = item.hasOwnProperty("url") ? PlacesUtils.toURI(item.url) : null; notify(observers, "onItemRemoved", [ item._id, item._parentId, item.index, item.type, uri, item.guid, item.parentGuid ]); @@ -417,7 +417,7 @@ var Bookmarks = Object.freeze({ if (isUntagging) { for (let entry of (yield fetchBookmarksByURL(item))) { notify(observers, "onItemChanged", [ entry._id, "tags", false, "", - toPRTime(entry.lastModified), + PlacesUtils.toPRTime(entry.lastModified), entry.type, entry._parentId, entry.guid, entry.parentGuid, "" ]); @@ -442,7 +442,7 @@ var Bookmarks = Object.freeze({ db => db.executeTransaction(function* () { const folderGuids = [this.toolbarGuid, this.menuGuid, this.unfiledGuid]; yield removeFoldersContents(db, folderGuids); - const time = toPRTime(new Date()); + const time = PlacesUtils.toPRTime(new Date()); for (let folderGuid of folderGuids) { yield db.executeCached( `UPDATE moz_bookmarks SET lastModified = :time @@ -769,7 +769,7 @@ function updateBookmark(info, item, newParent) { let tuples = new Map(); if (info.hasOwnProperty("lastModified")) - tuples.set("lastModified", { value: toPRTime(info.lastModified) }); + tuples.set("lastModified", { value: PlacesUtils.toPRTime(info.lastModified) }); if (info.hasOwnProperty("title")) tuples.set("title", { value: info.title }); @@ -886,8 +886,8 @@ function insertBookmark(item, parent) { :index, :title, :date_added, :last_modified, :guid) `, { url: item.hasOwnProperty("url") ? item.url.href : "nonexistent", type: item.type, parent: parent._id, index: item.index, - title: item.title, date_added: toPRTime(item.dateAdded), - last_modified: toPRTime(item.lastModified), guid: item.guid }); + title: item.title, date_added: PlacesUtils.toPRTime(item.dateAdded), + last_modified: PlacesUtils.toPRTime(item.lastModified), guid: item.guid }); yield setAncestorsLastModified(db, item.parentGuid, item.dateAdded); }); @@ -1239,39 +1239,6 @@ function removeSameValueProperties(dest, src) { } } -/** - * Converts an URL object to an nsIURI. - * - * @param url - * the URL object to convert. - * @return nsIURI for the given URL. - */ -function toURI(url) { - return NetUtil.newURI(url.href); -} - -/** - * Convert a Date object to a PRTime (microseconds). - * - * @param date - * the Date object to convert. - * @return microseconds from the epoch. - */ -function toPRTime(date) { - return date * 1000; -} - -/** - * Convert a PRTime to a Date object. - * - * @param time - * microseconds from the epoch. - * @return a Date object. - */ -function toDate(time) { - return new Date(parseInt(time / 1000)); -} - /** * Convert an array of mozIStorageRow objects to an array of bookmark objects. * @@ -1286,7 +1253,7 @@ function rowsToItemsArray(rows) { item[prop] = row.getResultByName(prop); } for (let prop of ["dateAdded", "lastModified"]) { - item[prop] = toDate(row.getResultByName(prop)); + item[prop] = PlacesUtils.toDate(row.getResultByName(prop)); } for (let prop of ["title", "parentGuid", "url" ]) { let val = row.getResultByName(prop); @@ -1332,7 +1299,7 @@ function simpleValidateFunc(boolValidateFn) { */ const VALIDATORS = Object.freeze({ guid: simpleValidateFunc(v => typeof(v) == "string" && - /^[a-zA-Z0-9\-_]{12}$/.test(v)), + PlacesUtils.isValidGuid(v)), parentGuid: simpleValidateFunc(v => typeof(v) == "string" && /^[a-zA-Z0-9\-_]{12}$/.test(v)), index: simpleValidateFunc(v => Number.isInteger(v) && @@ -1515,7 +1482,7 @@ var setAncestorsLastModified = Task.async(function* (db, folderGuid, time) { UPDATE moz_bookmarks SET lastModified = :time WHERE id IN ancestors `, { guid: folderGuid, type: Bookmarks.TYPE_FOLDER, - time: toPRTime(time) }); + time: PlacesUtils.toPRTime(time) }); }); /** @@ -1580,7 +1547,7 @@ Task.async(function* (db, folderGuids) { // Notify listeners in reverse order to serve children before parents. let observers = PlacesUtils.bookmarks.getObservers(); for (let item of itemsRemoved.reverse()) { - let uri = item.hasOwnProperty("url") ? toURI(item.url) : null; + let uri = item.hasOwnProperty("url") ? PlacesUtils.toURI(item.url) : null; notify(observers, "onItemRemoved", [ item._id, item._parentId, item.index, item.type, uri, item.guid, item.parentGuid ]); @@ -1589,7 +1556,7 @@ Task.async(function* (db, folderGuids) { if (isUntagging) { for (let entry of (yield fetchBookmarksByURL(item))) { notify(observers, "onItemChanged", [ entry._id, "tags", false, "", - toPRTime(entry.lastModified), + PlacesUtils.toPRTime(entry.lastModified), entry.type, entry._parentId, entry.guid, entry.parentGuid, "" ]); diff --git a/toolkit/components/places/History.jsm b/toolkit/components/places/History.jsm index 30cc6d0d6e15..26e38fc28212 100644 --- a/toolkit/components/places/History.jsm +++ b/toolkit/components/places/History.jsm @@ -132,38 +132,28 @@ this.History = Object.freeze({ }, /** - * Adds a set of visits for one or more page. + * Adds a number of visits for a single page. * * Any change may be observed through nsINavHistoryObserver * - * @note This function recomputes the frecency of the page automatically, - * regardless of the value of property `frecency` passed as argument. - * @note If there is no entry for the page, the entry is created. - * - * @param infos: (PageInfo) + * @param pageInfo: (PageInfo) * Information on a page. This `PageInfo` MUST contain - * - either a property `guid` or a property `url`, as specified - * by the definition of `PageInfo`; + * - a property `url`, as specified by the definition of `PageInfo`. * - a property `visits`, as specified by the definition of * `PageInfo`, which MUST contain at least one visit. * If a property `title` is provided, the title of the page * is updated. - * If the `visitDate` of a visit is not provided, it defaults + * If the `date` of a visit is not provided, it defaults * to now. - * or (Array) - * An array of the above, to batch requests. - * @param onResult: (function(PageInfo), [optional]) - * A callback invoked for each page, with the updated - * information on that page. Note that this `PageInfo` - * does NOT contain the visit data (i.e. `visits` is - * `undefined`). + * If the `transition` of a visit is not provided, it defaults to + * TRANSITION_LINK. * * @return (Promise) - * A promise resolved once the operation is complete, including - * all calls to `onResult`. - * @resolves (bool) - * `true` if at least one page entry was created, `false` otherwise - * (i.e. if page entries were updated but not created). + * A promise resolved once the operation is complete. + * @resolves (PageInfo) + * A PageInfo object populated with data after the insert is complete. + * @rejects (Error) + * Rejects if the insert was unsuccessful. * * @throws (Error) * If the `url` specified was for a protocol that should not be @@ -171,22 +161,97 @@ this.History = Object.freeze({ * "moz-anno:", "view-source:", "resource:", "data:", "wyciwyg:", * "javascript:", "blob:"). * @throws (Error) - * If `infos` has an unexpected type. + * If `pageInfo` has an unexpected type. * @throws (Error) - * If a `PageInfo` has neither `guid` nor `url`. + * If `pageInfo` does not have a `url`. * @throws (Error) - * If a `guid` property provided is not a valid GUID. + * If `pageInfo` does not have a `visits` property or if the + * value of `visits` is ill-typed or is an empty array. + * @throws (Error) + * If an element of `visits` has an invalid `date`. + * @throws (Error) + * If an element of `visits` has an invalid `transition`. + */ + insert: function (pageInfo) { + if (typeof pageInfo != "object" || !pageInfo) { + throw new TypeError("pageInfo must be an object"); + } + + let info = validatePageInfo(pageInfo); + + return PlacesUtils.withConnectionWrapper("History.jsm: insert", + db => insert(db, info)); + }, + + /** + * Adds a number of visits for a number of pages. + * + * Any change may be observed through nsINavHistoryObserver + * + * @param pageInfos: (Array) + * Information on a page. This `PageInfo` MUST contain + * - a property `url`, as specified by the definition of `PageInfo`. + * - a property `visits`, as specified by the definition of + * `PageInfo`, which MUST contain at least one visit. + * If a property `title` is provided, the title of the page + * is updated. + * If the `date` of a visit is not provided, it defaults + * to now. + * If the `transition` of a visit is not provided, it defaults to + * TRANSITION_LINK. + * @param onResult: (function(PageInfo)) + * A callback invoked for each page inserted. + * @param onError: (function(PageInfo)) + * A callback invoked for each page which generated an error + * when an insert was attempted. + * + * @return (Promise) + * A promise resolved once the operation is complete. + * @resolves (null) + * @rejects (Error) + * Rejects if all of the inserts were unsuccessful. + * + * @throws (Error) + * If the `url` specified was for a protocol that should not be + * stored (e.g. "chrome:", "mailbox:", "about:", "imap:", "news:", + * "moz-anno:", "view-source:", "resource:", "data:", "wyciwyg:", + * "javascript:", "blob:"). + * @throws (Error) + * If `pageInfos` has an unexpected type. + * @throws (Error) + * If a `pageInfo` does not have a `url`. * @throws (Error) * If a `PageInfo` does not have a `visits` property or if the * value of `visits` is ill-typed or is an empty array. * @throws (Error) * If an element of `visits` has an invalid `date`. * @throws (Error) - * If an element of `visits` is missing `transition` or if - * the value of `transition` is invalid. + * If an element of `visits` has an invalid `transition`. */ - update: function (infos, onResult) { - throw new Error("Method not implemented"); + insertMany: function (pageInfos, onResult, onError) { + let infos = []; + + if (!Array.isArray(pageInfos)) { + throw new TypeError("pageInfos must be an array"); + } + if (!pageInfos.length) { + throw new TypeError("pageInfos may not be an empty array"); + } + + if (onResult && typeof onResult != "function") { + throw new TypeError(`onResult: ${onResult} is not a valid function`); + } + if (onError && typeof onError != "function") { + throw new TypeError(`onError: ${onError} is not a valid function`); + } + + for (let pageInfo of pageInfos) { + let info = validatePageInfo(pageInfo); + infos.push(info); + } + + return PlacesUtils.withConnectionWrapper("History.jsm: insertMany", + db => insertMany(db, infos, onResult, onError)); }, /** @@ -390,6 +455,103 @@ this.History = Object.freeze({ TRANSITION_FRAMED_LINK: Ci.nsINavHistoryService.TRANSITION_FRAMED_LINK, }); +/** + * Validate an input PageInfo object, returning a valid PageInfo object. + * + * @param pageInfo: (PageInfo) + * @return (PageInfo) + */ +function validatePageInfo(pageInfo) { + let info = { + visits: [], + }; + + if (!pageInfo.url) { + throw new TypeError("PageInfo object must have a url property"); + } + + info.url = normalizeToURLOrGUID(pageInfo.url); + + if (typeof pageInfo.title === "string" && pageInfo.title.length) { + info.title = pageInfo.title; + } else if (pageInfo.title != null && pageInfo.title != undefined) { + throw new TypeError(`title property of PageInfo object: ${pageInfo.title} must be a string if provided`); + } + + if (!pageInfo.visits || !Array.isArray(pageInfo.visits) || !pageInfo.visits.length) { + throw new TypeError("PageInfo object must have an array of visits"); + } + for (let inVisit of pageInfo.visits) { + let visit = { + date: new Date(), + transition: inVisit.transition || History.TRANSITION_LINK, + }; + + if (!isValidTransitionType(visit.transition)) { + throw new TypeError(`transition: ${visit.transition} is not a valid transition type`); + } + + if (inVisit.date) { + ensureDate(inVisit.date); + if (inVisit.date > Date.now()) { + throw new TypeError(`date: ${inVisit.date} cannot be a future date`); + } + visit.date = inVisit.date; + } + + if (inVisit.referrer) { + visit.referrer = normalizeToURLOrGUID(inVisit.referrer); + } + info.visits.push(visit); + } + return info; +} + +/** + * Convert a PageInfo object into the format expected by updatePlaces. + * + * Note: this assumes that the PageInfo object has already been validated + * via validatePageInfo. + * + * @param pageInfo: (PageInfo) + * @return (info) + */ +function convertForUpdatePlaces(pageInfo) { + let info = { + uri: PlacesUtils.toURI(pageInfo.url), + title: pageInfo.title, + visits: [], + }; + + for (let inVisit of pageInfo.visits) { + let visit = { + visitDate: PlacesUtils.toPRTime(inVisit.date), + transitionType: inVisit.transition, + referrerURI: (inVisit.referrer) ? PlacesUtils.toURI(inVisit.referrer) : undefined, + }; + info.visits.push(visit); + } + return info; +} + +/** + * Is a value a valid transition type? + * + * @param transitionType: (String) + * @return (Boolean) + */ +function isValidTransitionType(transitionType) { + return [ + History.TRANSITION_LINK, + History.TRANSITION_TYPED, + History.TRANSITION_BOOKMARK, + History.TRANSITION_EMBED, + History.TRANSITION_REDIRECT_PERMANENT, + History.TRANSITION_REDIRECT_TEMPORARY, + History.TRANSITION_DOWNLOAD, + History.TRANSITION_FRAMED_LINK + ].includes(transitionType); +} /** * Normalize a key to either a string (if it is a valid GUID) or an @@ -402,7 +564,7 @@ this.History = Object.freeze({ function normalizeToURLOrGUID(key) { if (typeof key === "string") { // A string may be a URL or a guid - if (/^[a-zA-Z0-9\-_]{12}$/.test(key)) { + if (PlacesUtils.isValidGuid(key)) { return key; } return new URL(key); @@ -767,3 +929,87 @@ var remove = Task.async(function*(db, {guids, urls}, onResult = null) { return hasPagesToRemove; }); + +/** + * Merges an updateInfo object, as returned by asyncHistory.updatePlaces + * into a PageInfo object as defined in this file. + * + * @param updateInfo: (Object) + * An object that represents a page that is generated by + * asyncHistory.updatePlaces. + * @param pageInfo: (PageInfo) + * An PageInfo object into which to merge the data from updateInfo. + * Defaults to an empty object so that this method can be used + * to simply convert an updateInfo object into a PageInfo object. + * + * @return (PageInfo) + * A PageInfo object populated with data from updateInfo. + */ +function mergeUpdateInfoIntoPageInfo(updateInfo, pageInfo={}) { + pageInfo.guid = updateInfo.guid; + if (!pageInfo.url) { + pageInfo.url = new URL(updateInfo.uri.spec); + pageInfo.title = updateInfo.title; + pageInfo.visits = updateInfo.visits.map(visit => { + return { + date: PlacesUtils.toDate(visit.visitDate), + transition: visit.transitionType, + referrer: (visit.referrerURI) ? new URL(visit.referrerURI.spec) : null + } + }); + } + return pageInfo; +} + +// Inner implementation of History.insert. +var insert = Task.async(function*(db, pageInfo) { + let info = convertForUpdatePlaces(pageInfo); + + return new Promise((resolve, reject) => { + PlacesUtils.asyncHistory.updatePlaces(info, { + handleError: error => { + reject(error); + }, + handleResult: result => { + pageInfo = mergeUpdateInfoIntoPageInfo(result, pageInfo); + }, + handleCompletion: () => { + resolve(pageInfo); + } + }); + }); +}); + +// Inner implementation of History.insertMany. +var insertMany = Task.async(function*(db, pageInfos, onResult, onError) { + let infos = []; + let onResultData = []; + let onErrorData = []; + + for (let pageInfo of pageInfos) { + let info = convertForUpdatePlaces(pageInfo); + infos.push(info); + } + + return new Promise((resolve, reject) => { + PlacesUtils.asyncHistory.updatePlaces(infos, { + handleError: (resultCode, result) => { + let pageInfo = mergeUpdateInfoIntoPageInfo(result); + onErrorData.push(pageInfo); + }, + handleResult: result => { + let pageInfo = mergeUpdateInfoIntoPageInfo(result); + onResultData.push(pageInfo); + }, + handleCompletion: () => { + notifyOnResult(onResultData, onResult); + notifyOnResult(onErrorData, onError); + if (onResultData.length) { + resolve(); + } else { + reject({message: "No items were added to history."}) + } + } + }); + }); +}); diff --git a/toolkit/components/places/PlacesUtils.jsm b/toolkit/components/places/PlacesUtils.jsm index bd1edfc1d8e0..6d3c82298a42 100644 --- a/toolkit/components/places/PlacesUtils.jsm +++ b/toolkit/components/places/PlacesUtils.jsm @@ -260,6 +260,29 @@ this.PlacesUtils = { return NetUtil.newURI(aSpec); }, + /** + * Is a string a valid GUID? + * + * @param guid: (String) + * @return (Boolean) + */ + isValidGuid(guid) { + return (/^[a-zA-Z0-9\-_]{12}$/.test(guid)); + }, + + /** + * Converts a string or n URL object to an nsIURI. + * + * @param url (URL) or (String) + * the URL to convert. + * @return nsIURI for the given URL. + */ + toURI(url) { + url = (url instanceof URL) ? url.href : url; + + return NetUtil.newURI(url); + }, + /** * Convert a Date object to a PRTime (microseconds). * diff --git a/toolkit/components/places/tests/history/test_insert.js b/toolkit/components/places/tests/history/test_insert.js new file mode 100644 index 000000000000..185a6489c8ba --- /dev/null +++ b/toolkit/components/places/tests/history/test_insert.js @@ -0,0 +1,264 @@ +/* -*- indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim:set ts=2 sw=2 sts=2 et: */ + +// Tests for `History.insert` and `History.insertMany`, as implemented in History.jsm + +"use strict"; + +add_task(function* test_insert_error_cases() { + const TEST_URL = "http://mozilla.com"; + + let validPageInfo = { + url: TEST_URL, + visits: [ + {transition: TRANSITION_LINK} + ] + }; + + Assert.throws( + () => PlacesUtils.history.insert(), + /TypeError: pageInfo must be an object/, + "passing a null into History.insert should throw a TypeError" + ); + Assert.throws( + () => PlacesUtils.history.insert(1), + /TypeError: pageInfo must be an object/, + "passing a non object into History.insert should throw a TypeError" + ); + Assert.throws( + () => PlacesUtils.history.insert({}), + /TypeError: PageInfo object must have a url property/, + "passing an object without a url to History.insert should throw a TypeError" + ); + Assert.throws( + () => PlacesUtils.history.insert({url: 123}), + /TypeError: Invalid url or guid: 123/, + "passing an object with an invalid url to History.insert should throw a TypeError" + ); + Assert.throws( + () => PlacesUtils.history.insert({url: TEST_URL}), + /TypeError: PageInfo object must have an array of visits/, + "passing an object without a visits property to History.insert should throw a TypeError" + ); + Assert.throws( + () => PlacesUtils.history.insert({url: TEST_URL, visits: 1}), + /TypeError: PageInfo object must have an array of visits/, + "passing an object with a non-array visits property to History.insert should throw a TypeError" + ); + Assert.throws( + () => PlacesUtils.history.insert({url: TEST_URL, visits: []}), + /TypeError: PageInfo object must have an array of visits/, + "passing an object with an empty array as the visits property to History.insert should throw a TypeError" + ); + Assert.throws( + () => PlacesUtils.history.insert({ + url: TEST_URL, + visits: [ + { + transition: TRANSITION_LINK, + date: "a" + } + ]}), + /TypeError: Expected a Date, got a/, + "passing a visit object with an invalid date to History.insert should throw a TypeError" + ); + Assert.throws( + () => PlacesUtils.history.insert({ + url: TEST_URL, + visits: [ + { + transition: TRANSITION_LINK + }, + { + transition: TRANSITION_LINK, + date: "a" + } + ]}), + /TypeError: Expected a Date, got a/, + "passing a second visit object with an invalid date to History.insert should throw a TypeError" + ); + let futureDate = new Date(); + futureDate.setDate(futureDate.getDate() + 1); + Assert.throws( + () => PlacesUtils.history.insert({ + url: TEST_URL, + visits: [ + { + transition: TRANSITION_LINK, + date: futureDate, + } + ]}), + `TypeError: date: ${futureDate} is not a valid date`, + "passing a visit object with a future date to History.insert should throw a TypeError" + ); + Assert.throws( + () => PlacesUtils.history.insert({ + url: TEST_URL, + visits: [ + {transition: "a"} + ]}), + /TypeError: transition: a is not a valid transition type/, + "passing a visit object with an invalid transition to History.insert should throw a TypeError" + ); +}); + +add_task(function* test_history_insert() { + const TEST_URL = "http://mozilla.com/"; + + let inserter = Task.async(function*(name, filter, referrer, date, transition) { + do_print(name); + do_print(`filter: ${filter}, referrer: ${referrer}, date: ${date}, transition: ${transition}`); + + let uri = NetUtil.newURI(TEST_URL + Math.random()); + let title = "Visit " + Math.random(); + + let pageInfo = { + title, + visits: [ + {transition: transition, referrer: referrer, date: date,} + ] + }; + + pageInfo.url = yield filter(uri); + + let result = yield PlacesUtils.history.insert(pageInfo); + + Assert.ok(PlacesUtils.isValidGuid(result.guid), "guid for pageInfo object is valid"); + Assert.equal(uri.spec, result.url.href, "url is correct for pageInfo object"); + Assert.equal(title, result.title, "title is correct for pageInfo object"); + Assert.equal(TRANSITION_LINK, result.visits[0].transition, "transition is correct for pageInfo object"); + if (referrer) { + Assert.equal(referrer, result.visits[0].referrer.href, "url of referrer for visit is correct"); + } else { + Assert.equal(null, result.visits[0].referrer, "url of referrer for visit is correct"); + } + if (date) { + Assert.equal(Number(date), + Number(result.visits[0].date), + "date of visit is correct"); + } + + Assert.ok(yield PlacesTestUtils.isPageInDB(uri), "Page was added"); + Assert.ok(yield PlacesTestUtils.visitsInDB(uri), "Visit was added"); + }); + + try { + for (let referrer of [TEST_URL, null]) { + for (let date of [new Date(), null]) { + for (let transition of [TRANSITION_LINK, null]) { + yield inserter("Testing History.insert() with an nsIURI", x => x, referrer, date, transition); + yield inserter("Testing History.insert() with a string url", x => x.spec, referrer, date, transition); + yield inserter("Testing History.insert() with a URL object", x => new URL(x.spec), referrer, date, transition); + } + } + } + } finally { + yield PlacesTestUtils.clearHistory(); + } +}); + +add_task(function* test_insert_multiple_error_cases() { + let validPageInfo = { + url: "http://mozilla.com", + visits: [ + {transition: TRANSITION_LINK} + ] + }; + + Assert.throws( + () => PlacesUtils.history.insertMany(), + /TypeError: pageInfos must be an array/, + "passing a null into History.insertMany should throw a TypeError" + ); + Assert.throws( + () => PlacesUtils.history.insertMany([]), + /TypeError: pageInfos may not be an empty array/, + "passing an empty array into History.insertMany should throw a TypeError" + ); + Assert.throws( + () => PlacesUtils.history.insertMany([validPageInfo, {}]), + /TypeError: PageInfo object must have a url property/, + "passing a second invalid PageInfo object to History.insertMany should throw a TypeError" + ); +}); + +add_task(function* test_history_insertMany() { + const BAD_URLS = ["about:config", "chrome://browser/content/browser.xul"]; + const GOOD_URLS = [1, 2, 3].map(x => {return `http://mozilla.com/${x}`;}); + + let makePageInfos = Task.async(function*(urls, filter = x => x) { + let pageInfos = []; + for (let url of urls) { + let uri = NetUtil.newURI(url); + + let pageInfo = { + title: `Visit to ${url}`, + visits: [ + {transition: TRANSITION_LINK} + ] + }; + + pageInfo.url = yield filter(uri); + pageInfos.push(pageInfo); + } + return pageInfos; + }); + + let inserter = Task.async(function*(name, filter, useCallbacks) { + do_print(name); + do_print(`filter: ${filter}`); + do_print(`useCallbacks: ${useCallbacks}`); + yield PlacesTestUtils.clearHistory(); + + let result; + let allUrls = GOOD_URLS.concat(BAD_URLS); + let pageInfos = yield makePageInfos(allUrls, filter); + + if (useCallbacks) { + let onResultUrls = []; + let onErrorUrls = []; + result = yield PlacesUtils.history.insertMany(pageInfos, pageInfo => { + let url = pageInfo.url.href; + Assert.ok(GOOD_URLS.includes(url), "onResult callback called for correct url"); + onResultUrls.push(url); + Assert.equal(`Visit to ${url}`, pageInfo.title, "onResult callback provides the correct title"); + Assert.ok(PlacesUtils.isValidGuid(pageInfo.guid), "onResult callback provides a valid guid"); + }, pageInfo => { + let url = pageInfo.url.href; + Assert.ok(BAD_URLS.includes(url), "onError callback called for correct uri"); + onErrorUrls.push(url); + Assert.equal(undefined, pageInfo.title, "onError callback provides the correct title"); + Assert.equal(undefined, pageInfo.guid, "onError callback provides the expected guid"); + }); + Assert.equal(GOOD_URLS.sort().toString(), onResultUrls.sort().toString(), "onResult callback was called for each good url"); + Assert.equal(BAD_URLS.sort().toString(), onErrorUrls.sort().toString(), "onError callback was called for each bad url"); + } else { + result = yield PlacesUtils.history.insertMany(pageInfos); + } + + Assert.equal(undefined, result, "insertMany returned undefined"); + + for (let url of allUrls) { + let expected = GOOD_URLS.includes(url); + Assert.equal(expected, yield PlacesTestUtils.isPageInDB(url), `isPageInDB for ${url} is ${expected}`); + Assert.equal(expected, yield PlacesTestUtils.visitsInDB(url), `visitsInDB for ${url} is ${expected}`); + } + }); + + try { + for (let useCallbacks of [false, true]) { + yield inserter("Testing History.insertMany() with an nsIURI", x => x, useCallbacks); + yield inserter("Testing History.insertMany() with a string url", x => x.spec, useCallbacks); + yield inserter("Testing History.insertMany() with a URL object", x => new URL(x.spec), useCallbacks); + } + // Test rejection when no items added + let pageInfos = yield makePageInfos(BAD_URLS); + PlacesUtils.history.insertMany(pageInfos).then(() => { + Assert.ok(false, "History.insertMany rejected promise with all bad URLs"); + }, error => { + Assert.equal("No items were added to history.", error.message, "History.insertMany rejected promise with all bad URLs"); + }); + } finally { + yield PlacesTestUtils.clearHistory(); + } +}); diff --git a/toolkit/components/places/tests/history/xpcshell.ini b/toolkit/components/places/tests/history/xpcshell.ini index 9a949d529b2c..db92930f6e49 100644 --- a/toolkit/components/places/tests/history/xpcshell.ini +++ b/toolkit/components/places/tests/history/xpcshell.ini @@ -2,6 +2,7 @@ head = head_history.js tail = +[test_insert.js] [test_remove.js] [test_removeVisits.js] [test_removeVisitsByFilter.js]