+
+
diff --git a/toolkit/components/places/tests/browser/begin.html b/toolkit/components/places/tests/browser/begin.html
new file mode 100644
index 0000000000..da4c16dd25
--- /dev/null
+++ b/toolkit/components/places/tests/browser/begin.html
@@ -0,0 +1,10 @@
+
+
+
+
+ Redirect twice
+
+
diff --git a/toolkit/components/places/tests/browser/browser.toml b/toolkit/components/places/tests/browser/browser.toml
new file mode 100644
index 0000000000..022b929240
--- /dev/null
+++ b/toolkit/components/places/tests/browser/browser.toml
@@ -0,0 +1,115 @@
+[DEFAULT]
+support-files = ["head.js"]
+
+["browser_bug399606.js"]
+https_first_disabled = true
+support-files = [
+ "399606-history.go-0.html",
+ "399606-httprefresh.html",
+ "399606-location.reload.html",
+ "399606-location.replace.html",
+ "399606-window.location.html",
+ "399606-window.location.href.html",
+]
+
+["browser_bug461710.js"]
+https_first_disabled = true
+support-files = [
+ "461710_link_page-2.html",
+ "461710_link_page-3.html",
+ "461710_link_page.html",
+ "461710_visited_page.html",
+]
+
+["browser_bug646422.js"]
+https_first_disabled = true
+
+["browser_bug680727.js"]
+https_first_disabled = true
+skip-if = ["verify"]
+
+["browser_bug1601563.js"]
+https_first_disabled = true
+support-files = [
+ "1601563-1.html",
+ "1601563-2.html",
+]
+
+["browser_double_redirect.js"]
+https_first_disabled = true
+support-files = [
+ "begin.html",
+ "final.html",
+ "redirect_once.sjs",
+ "redirect_twice.sjs",
+]
+
+["browser_favicon_privatebrowsing_perwindowpb.js"]
+
+["browser_history_post.js"]
+https_first_disabled = true
+support-files = [
+ "history_post.html",
+ "history_post.sjs",
+]
+
+["browser_multi_redirect_frecency.js"]
+https_first_disabled = true
+support-files = [
+ "final.html",
+ "redirect_once.sjs",
+ "redirect_thrice.sjs",
+ "redirect_twice.sjs",
+ "redirect_twice_perma.sjs",
+]
+
+["browser_notfound.js"]
+
+["browser_onvisit_title_null_for_navigation.js"]
+https_first_disabled = true
+skip-if = ["verify"]
+support-files = ["empty_page.html"]
+
+["browser_redirect.js"]
+support-files = [
+ "redirect.sjs",
+ "redirect-target.html",
+]
+
+["browser_redirect_self.js"]
+support-files = ["redirect_self.sjs"]
+
+["browser_settitle.js"]
+https_first_disabled = true
+support-files = [
+ "title1.html",
+ "title2.html",
+]
+
+["browser_upgrade.js"]
+
+["browser_visited_notfound.js"]
+
+["browser_visituri.js"]
+https_first_disabled = true
+support-files = [
+ "begin.html",
+ "final.html",
+ "redirect_once.sjs",
+ "redirect_twice.sjs",
+]
+
+["browser_visituri_nohistory.js"]
+support-files = [
+ "begin.html",
+ "final.html",
+ "favicon-normal16.png",
+ "favicon-normal32.png",
+]
+
+["browser_visituri_privatebrowsing_perwindowpb.js"]
+support-files = [
+ "begin.html",
+ "favicon.html",
+ "final.html",
+]
diff --git a/toolkit/components/places/tests/browser/browser_bug1601563.js b/toolkit/components/places/tests/browser/browser_bug1601563.js
new file mode 100644
index 0000000000..41e278ee54
--- /dev/null
+++ b/toolkit/components/places/tests/browser/browser_bug1601563.js
@@ -0,0 +1,40 @@
+const PREFIX =
+ "http://example.com/tests/toolkit/components/places/tests/browser/1601563";
+
+function titleUpdate(pageUrl) {
+ let lastTitle = null;
+ return PlacesTestUtils.waitForNotification("page-title-changed", events => {
+ if (pageUrl != events[0].url) {
+ return false;
+ }
+ lastTitle = events[0].title;
+ return true;
+ }).then(() => {
+ return lastTitle;
+ });
+}
+
+add_task(async function () {
+ registerCleanupFunction(PlacesUtils.history.clear);
+ const FIRST_URL = PREFIX + "-1.html";
+ const SECOND_URL = PREFIX + "-2.html";
+ let firstTitlePromise = titleUpdate(FIRST_URL);
+ let secondTitlePromise = titleUpdate(SECOND_URL);
+
+ let tab = await BrowserTestUtils.openNewForegroundTab(gBrowser, FIRST_URL);
+
+ let firstTitle = await firstTitlePromise;
+ is(firstTitle, "First title", "First title should match the page");
+
+ let secondTitle = await secondTitlePromise;
+ is(secondTitle, "Second title", "Second title should match the page");
+
+ let entry = await PlacesUtils.history.fetch(FIRST_URL);
+ is(
+ entry.title,
+ firstTitle,
+ "Should not override first title with document.open()ed frame"
+ );
+
+ await BrowserTestUtils.removeTab(tab);
+});
diff --git a/toolkit/components/places/tests/browser/browser_bug399606.js b/toolkit/components/places/tests/browser/browser_bug399606.js
new file mode 100644
index 0000000000..ace1549c54
--- /dev/null
+++ b/toolkit/components/places/tests/browser/browser_bug399606.js
@@ -0,0 +1,50 @@
+/* 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/. */
+
+add_task(async function () {
+ registerCleanupFunction(PlacesUtils.history.clear);
+
+ const URIS = [
+ "http://example.com/tests/toolkit/components/places/tests/browser/399606-window.location.href.html",
+ "http://example.com/tests/toolkit/components/places/tests/browser/399606-history.go-0.html",
+ "http://example.com/tests/toolkit/components/places/tests/browser/399606-location.replace.html",
+ "http://example.com/tests/toolkit/components/places/tests/browser/399606-location.reload.html",
+ "http://example.com/tests/toolkit/components/places/tests/browser/399606-httprefresh.html",
+ "http://example.com/tests/toolkit/components/places/tests/browser/399606-window.location.html",
+ ];
+
+ // Create and add history observer.
+ let count = 0;
+ let expectedURI = null;
+ function onVisitsListener(aEvents) {
+ for (let event of aEvents) {
+ info("Received onVisits: " + event.url);
+ if (event.url == expectedURI) {
+ count++;
+ }
+ }
+ }
+
+ async function promiseLoadedThreeTimes(uri) {
+ count = 0;
+ expectedURI = uri;
+ let tab = await BrowserTestUtils.openNewForegroundTab(gBrowser);
+ PlacesObservers.addListener(["page-visited"], onVisitsListener);
+ BrowserTestUtils.startLoadingURIString(gBrowser, uri);
+ await BrowserTestUtils.browserLoaded(gBrowser.selectedBrowser, false, uri);
+ await BrowserTestUtils.browserLoaded(gBrowser.selectedBrowser, false, uri);
+ await BrowserTestUtils.browserLoaded(gBrowser.selectedBrowser, false, uri);
+ PlacesObservers.removeListener(["page-visited"], onVisitsListener);
+ BrowserTestUtils.removeTab(tab);
+ }
+
+ for (let uri of URIS) {
+ await promiseLoadedThreeTimes(uri);
+ is(
+ count,
+ 1,
+ "'page-visited' has been received right number of times for " + uri
+ );
+ }
+});
diff --git a/toolkit/components/places/tests/browser/browser_bug461710.js b/toolkit/components/places/tests/browser/browser_bug461710.js
new file mode 100644
index 0000000000..6815860929
--- /dev/null
+++ b/toolkit/components/places/tests/browser/browser_bug461710.js
@@ -0,0 +1,89 @@
+const kRed = "rgb(255, 0, 0)";
+const kBlue = "rgb(0, 0, 255)";
+
+const prefix =
+ "http://example.com/tests/toolkit/components/places/tests/browser/461710_";
+
+add_task(async function () {
+ registerCleanupFunction(PlacesUtils.history.clear);
+ let normalWindow = await BrowserTestUtils.openNewBrowserWindow();
+ let privateWindow = await BrowserTestUtils.openNewBrowserWindow({
+ private: true,
+ });
+ let tests = [
+ {
+ private: false,
+ topic: "uri-visit-saved",
+ subtest: "visited_page.html",
+ },
+ {
+ private: false,
+ subtest: "link_page.html",
+ color: kRed,
+ message: "Visited link coloring should work outside of private mode",
+ },
+ {
+ private: true,
+ subtest: "link_page-2.html",
+ color: kBlue,
+ message: "Visited link coloring should not work inside of private mode",
+ },
+ {
+ private: false,
+ subtest: "link_page-3.html",
+ color: kRed,
+ message: "Visited link coloring should work outside of private mode",
+ },
+ ];
+
+ let uri = Services.io.newURI(prefix + tests[0].subtest);
+ for (let test of tests) {
+ info(test.subtest);
+ let promise = null;
+ if (test.topic) {
+ promise = TestUtils.topicObserved(test.topic, subject =>
+ uri.equals(subject.QueryInterface(Ci.nsIURI))
+ );
+ }
+ await BrowserTestUtils.withNewTab(
+ {
+ gBrowser: test.private ? privateWindow.gBrowser : normalWindow.gBrowser,
+ url: prefix + test.subtest,
+ },
+ async function (browser) {
+ if (promise) {
+ await promise;
+ }
+
+ if (test.color) {
+ // In e10s waiting for visited-status-resolution is not enough to ensure links
+ // have been updated, because it only tells us that messages to update links
+ // have been dispatched. We must still wait for the actual links to update.
+ await TestUtils.waitForCondition(async function () {
+ let color = await SpecialPowers.spawn(
+ browser,
+ [],
+ async function () {
+ let elem = content.document.getElementById("link");
+ return content.windowUtils.getVisitedDependentComputedStyle(
+ elem,
+ "",
+ "color"
+ );
+ }
+ );
+ return color == test.color;
+ }, test.message);
+ // The harness will consider the test as failed overall if there were no
+ // passes or failures, so record it as a pass.
+ ok(true, test.message);
+ }
+ }
+ );
+ }
+
+ let promisePBExit = TestUtils.topicObserved("last-pb-context-exited");
+ await BrowserTestUtils.closeWindow(privateWindow);
+ await promisePBExit;
+ await BrowserTestUtils.closeWindow(normalWindow);
+});
diff --git a/toolkit/components/places/tests/browser/browser_bug646422.js b/toolkit/components/places/tests/browser/browser_bug646422.js
new file mode 100644
index 0000000000..cb6512ed4e
--- /dev/null
+++ b/toolkit/components/places/tests/browser/browser_bug646422.js
@@ -0,0 +1,44 @@
+/* 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/. */
+
+/**
+ * Test for Bug 646224. Make sure that after changing the URI via
+ * history.pushState, the history service has a title stored for the new URI.
+ **/
+
+add_task(async function () {
+ let tab = await BrowserTestUtils.openNewForegroundTab(
+ gBrowser,
+ "http://example.com"
+ );
+
+ const newTitlePromise = PlacesTestUtils.waitForNotification(
+ "page-title-changed",
+ events => /new_page$/.test(events[0].url)
+ );
+
+ await SpecialPowers.spawn(tab.linkedBrowser, [], async function () {
+ let title = content.document.title;
+ content.history.pushState("", "", "new_page");
+ Assert.ok(title, "Content window should initially have a title.");
+ });
+
+ const events = await newTitlePromise;
+ const newtitle = events[0].title;
+
+ await SpecialPowers.spawn(
+ tab.linkedBrowser,
+ [{ newtitle }],
+ async function (args) {
+ Assert.equal(
+ args.newtitle,
+ content.document.title,
+ "Title after pushstate."
+ );
+ }
+ );
+
+ await PlacesUtils.history.clear();
+ gBrowser.removeTab(tab);
+});
diff --git a/toolkit/components/places/tests/browser/browser_bug680727.js b/toolkit/components/places/tests/browser/browser_bug680727.js
new file mode 100644
index 0000000000..313adb3c75
--- /dev/null
+++ b/toolkit/components/places/tests/browser/browser_bug680727.js
@@ -0,0 +1,130 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+/* Ensure that clicking the button in the Offline mode neterror page updates
+ global history. See bug 680727. */
+/* TEST_PATH=toolkit/components/places/tests/browser/browser_bug680727.js make -C $(OBJDIR) mochitest-browser-chrome */
+
+const kUniqueURI = Services.io.newURI("http://mochi.test:8888/#bug_680727");
+var proxyPrefValue;
+var ourTab;
+
+function test() {
+ waitForExplicitFinish();
+
+ // Tests always connect to localhost, and per bug 87717, localhost is now
+ // reachable in offline mode. To avoid this, disable any proxy.
+ proxyPrefValue = Services.prefs.getIntPref("network.proxy.type");
+ Services.prefs.setIntPref("network.proxy.type", 0);
+
+ // Clear network cache.
+ Services.cache2.clear();
+
+ // Go offline, expecting the error page.
+ Services.io.offline = true;
+
+ BrowserTestUtils.openNewForegroundTab(gBrowser).then(tab => {
+ ourTab = tab;
+ BrowserTestUtils.browserLoaded(
+ ourTab.linkedBrowser,
+ false,
+ null,
+ true
+ ).then(errorListener);
+ BrowserTestUtils.startLoadingURIString(
+ ourTab.linkedBrowser,
+ kUniqueURI.spec
+ );
+ });
+}
+
+// ------------------------------------------------------------------------------
+// listen to loading the neterror page. (offline mode)
+function errorListener() {
+ ok(Services.io.offline, "Services.io.offline is true.");
+
+ // This is an error page.
+ SpecialPowers.spawn(ourTab.linkedBrowser, [kUniqueURI.spec], function (uri) {
+ Assert.equal(
+ content.document.documentURI.substring(0, 27),
+ "about:neterror?e=netOffline",
+ "Document URI is the error page."
+ );
+
+ // But location bar should show the original request.
+ Assert.equal(
+ content.location.href,
+ uri,
+ "Docshell URI is the original URI."
+ );
+ }).then(() => {
+ // Global history does not record URI of a failed request.
+ PlacesTestUtils.promiseAsyncUpdates().then(() => {
+ PlacesUtils.history.hasVisits(kUniqueURI).then(isVisited => {
+ errorAsyncListener(kUniqueURI, isVisited);
+ });
+ });
+ });
+}
+
+function errorAsyncListener(aURI, aIsVisited) {
+ ok(
+ kUniqueURI.equals(aURI) && !aIsVisited,
+ "The neterror page is not listed in global history."
+ );
+
+ Services.prefs.setIntPref("network.proxy.type", proxyPrefValue);
+
+ // Now press the "Try Again" button, with offline mode off.
+ Services.io.offline = false;
+
+ BrowserTestUtils.browserLoaded(ourTab.linkedBrowser, false, null, true).then(
+ reloadListener
+ );
+
+ SpecialPowers.spawn(ourTab.linkedBrowser, [], function () {
+ Assert.ok(
+ content.document.querySelector("#netErrorButtonContainer > .try-again"),
+ "The error page has got a .try-again element"
+ );
+ content.document
+ .querySelector("#netErrorButtonContainer > .try-again")
+ .click();
+ });
+}
+
+// ------------------------------------------------------------------------------
+// listen to reload of neterror.
+function reloadListener() {
+ // This listener catches "DOMContentLoaded" on being called
+ // nsIWPL::onLocationChange(...). That is right *AFTER*
+ // IHistory::VisitURI(...) is called.
+ ok(!Services.io.offline, "Services.io.offline is false.");
+
+ SpecialPowers.spawn(ourTab.linkedBrowser, [kUniqueURI.spec], function (uri) {
+ // This is not an error page.
+ Assert.equal(
+ content.document.documentURI,
+ uri,
+ "Document URI is not the offline-error page, but the original URI."
+ );
+ }).then(() => {
+ // Check if global history remembers the successfully-requested URI.
+ PlacesTestUtils.promiseAsyncUpdates().then(() => {
+ PlacesUtils.history.hasVisits(kUniqueURI).then(isVisited => {
+ reloadAsyncListener(kUniqueURI, isVisited);
+ });
+ });
+ });
+}
+
+function reloadAsyncListener(aURI, aIsVisited) {
+ ok(kUniqueURI.equals(aURI) && aIsVisited, "We have visited the URI.");
+ PlacesUtils.history.clear().then(finish);
+}
+
+registerCleanupFunction(async function () {
+ Services.prefs.setIntPref("network.proxy.type", proxyPrefValue);
+ Services.io.offline = false;
+ BrowserTestUtils.removeTab(ourTab);
+});
diff --git a/toolkit/components/places/tests/browser/browser_double_redirect.js b/toolkit/components/places/tests/browser/browser_double_redirect.js
new file mode 100644
index 0000000000..435bd86f19
--- /dev/null
+++ b/toolkit/components/places/tests/browser/browser_double_redirect.js
@@ -0,0 +1,83 @@
+// Test for bug 411966.
+// When a page redirects multiple times, from_visit should point to the
+// previous visit in the chain, not to the first visit in the chain.
+
+add_task(async function () {
+ await PlacesUtils.history.clear();
+
+ const BASE_URL =
+ "http://example.com/tests/toolkit/components/places/tests/browser/";
+ const TEST_URI = NetUtil.newURI(BASE_URL + "begin.html");
+ const FIRST_REDIRECTING_URI = NetUtil.newURI(BASE_URL + "redirect_twice.sjs");
+ const FINAL_URI = NetUtil.newURI(
+ "http://test1.example.com/tests/toolkit/components/places/tests/browser/final.html"
+ );
+
+ let promiseVisits = new Promise(resolve => {
+ let observer = {
+ _notified: [],
+ onVisit(uri, id, time, referrerId, transition) {
+ info("Received onVisit: " + uri);
+ this._notified.push(uri);
+
+ if (uri != FINAL_URI.spec) {
+ return;
+ }
+
+ is(this._notified.length, 4);
+ PlacesObservers.removeListener(["page-visited"], this.handleEvents);
+
+ (async function () {
+ // Get all pages visited from the original typed one
+ let db = await PlacesUtils.promiseDBConnection();
+ let rows = await db.execute(
+ `SELECT url FROM moz_historyvisits
+ JOIN moz_places h ON h.id = place_id
+ WHERE from_visit IN
+ (SELECT v.id FROM moz_historyvisits v
+ JOIN moz_places p ON p.id = v.place_id
+ WHERE p.url_hash = hash(:url) AND p.url = :url)
+ `,
+ { url: TEST_URI.spec }
+ );
+
+ is(rows.length, 1, "Found right number of visits");
+ let visitedUrl = rows[0].getResultByName("url");
+ // Check that redirect from_visit is not from the original typed one
+ is(
+ visitedUrl,
+ FIRST_REDIRECTING_URI.spec,
+ "Check referrer for " + visitedUrl
+ );
+
+ resolve();
+ })();
+ },
+ handleEvents(events) {
+ is(events.length, 1, "Right number of visits notified");
+ is(events[0].type, "page-visited");
+ let { url, visitId, visitTime, referringVisitId, transitionType } =
+ events[0];
+ this.onVisit(url, visitId, visitTime, referringVisitId, transitionType);
+ },
+ };
+ observer.handleEvents = observer.handleEvents.bind(observer);
+ PlacesObservers.addListener(["page-visited"], observer.handleEvents);
+ });
+
+ PlacesUtils.history.markPageAsTyped(TEST_URI);
+ await BrowserTestUtils.withNewTab(
+ {
+ gBrowser,
+ url: TEST_URI.spec,
+ },
+ async function (browser) {
+ // Load begin page, click link on page to record visits.
+ await BrowserTestUtils.synthesizeMouseAtCenter("#clickme", {}, browser);
+
+ await promiseVisits;
+ }
+ );
+
+ await PlacesUtils.history.clear();
+});
diff --git a/toolkit/components/places/tests/browser/browser_favicon_privatebrowsing_perwindowpb.js b/toolkit/components/places/tests/browser/browser_favicon_privatebrowsing_perwindowpb.js
new file mode 100644
index 0000000000..ab3e0a1ef1
--- /dev/null
+++ b/toolkit/components/places/tests/browser/browser_favicon_privatebrowsing_perwindowpb.js
@@ -0,0 +1,46 @@
+/* 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/. */
+
+function test() {
+ waitForExplicitFinish();
+
+ const pageURI =
+ "http://example.org/tests/toolkit/components/places/tests/browser/favicon.html";
+ let windowsToClose = [];
+
+ registerCleanupFunction(function () {
+ windowsToClose.forEach(function (aWin) {
+ aWin.close();
+ });
+ });
+
+ function testOnWindow(aIsPrivate, aCallback) {
+ whenNewWindowLoaded({ private: aIsPrivate }, function (aWin) {
+ windowsToClose.push(aWin);
+ executeSoon(() => aCallback(aWin));
+ });
+ }
+
+ function waitForTabLoad(aWin, aCallback) {
+ BrowserTestUtils.browserLoaded(aWin.gBrowser.selectedBrowser).then(
+ aCallback
+ );
+ BrowserTestUtils.startLoadingURIString(
+ aWin.gBrowser.selectedBrowser,
+ pageURI
+ );
+ }
+
+ testOnWindow(true, function (win) {
+ waitForTabLoad(win, function () {
+ PlacesUtils.favicons.getFaviconURLForPage(
+ NetUtil.newURI(pageURI),
+ function (uri, dataLen, data, mimeType) {
+ is(uri, null, "No result should be found");
+ finish();
+ }
+ );
+ });
+ });
+}
diff --git a/toolkit/components/places/tests/browser/browser_history_post.js b/toolkit/components/places/tests/browser/browser_history_post.js
new file mode 100644
index 0000000000..a62592516f
--- /dev/null
+++ b/toolkit/components/places/tests/browser/browser_history_post.js
@@ -0,0 +1,35 @@
+const PAGE_URI =
+ "http://example.com/tests/toolkit/components/places/tests/browser/history_post.html";
+const SJS_URI = NetUtil.newURI(
+ "http://example.com/tests/toolkit/components/places/tests/browser/history_post.sjs"
+);
+
+add_task(async function () {
+ await BrowserTestUtils.withNewTab(
+ { gBrowser, url: PAGE_URI },
+ async function (aBrowser) {
+ await SpecialPowers.spawn(aBrowser, [], async function () {
+ let doc = content.document;
+ let submit = doc.getElementById("submit");
+ let iframe = doc.getElementById("post_iframe");
+ let p = new Promise((resolve, reject) => {
+ iframe.addEventListener(
+ "load",
+ function () {
+ resolve();
+ },
+ { once: true }
+ );
+ });
+ submit.click();
+ await p;
+ });
+ let visited = await PlacesUtils.history.hasVisits(SJS_URI);
+ ok(!visited, "The POST page should not be added to history");
+ ok(
+ !(await PlacesTestUtils.isPageInDB(SJS_URI.spec)),
+ "The page should not be in the database"
+ );
+ }
+ );
+});
diff --git a/toolkit/components/places/tests/browser/browser_multi_redirect_frecency.js b/toolkit/components/places/tests/browser/browser_multi_redirect_frecency.js
new file mode 100644
index 0000000000..a406422a2f
--- /dev/null
+++ b/toolkit/components/places/tests/browser/browser_multi_redirect_frecency.js
@@ -0,0 +1,177 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/ */
+
+const ROOT_URI =
+ "http://mochi.test:8888/tests/toolkit/components/places/tests/browser/";
+const REDIRECT_URI = Services.io.newURI(ROOT_URI + "redirect_thrice.sjs");
+const INTERMEDIATE_URI_1 = Services.io.newURI(
+ ROOT_URI + "redirect_twice_perma.sjs"
+);
+const INTERMEDIATE_URI_2 = Services.io.newURI(ROOT_URI + "redirect_once.sjs");
+const TARGET_URI = Services.io.newURI(
+ "http://test1.example.com/tests/toolkit/components/places/tests/browser/final.html"
+);
+
+const REDIRECT_SOURCE_VISIT_BONUS = Services.prefs.getIntPref(
+ "places.frecency.redirectSourceVisitBonus"
+);
+const PERM_REDIRECT_VISIT_BONUS = Services.prefs.getIntPref(
+ "places.frecency.permRedirectVisitBonus"
+);
+const TYPED_VISIT_BONUS = Services.prefs.getIntPref(
+ "places.frecency.typedVisitBonus"
+);
+
+// Ensure that decay frecency doesn't kick in during tests (as a result
+// of idle-daily).
+Services.prefs.setCharPref("places.frecency.decayRate", "1.0");
+
+registerCleanupFunction(async function () {
+ Services.prefs.clearUserPref("places.frecency.decayRate");
+ await PlacesUtils.history.clear();
+});
+
+async function check_uri(uri, frecency, hidden) {
+ is(
+ await PlacesTestUtils.getDatabaseValue("moz_places", "frecency", {
+ url: uri,
+ }),
+ frecency,
+ "Frecency of the page is the expected one"
+ );
+ is(
+ await PlacesTestUtils.getDatabaseValue("moz_places", "hidden", {
+ url: uri,
+ }),
+ hidden,
+ "Hidden value of the page is the expected one"
+ );
+}
+
+async function waitVisitedNotifications() {
+ let redirectNotified = false;
+ await PlacesTestUtils.waitForNotification("page-visited", visits => {
+ is(visits.length, 1, "Was notified for the right number of visits.");
+ let { url } = visits[0];
+ info("Received 'page-visited': " + url);
+ if (url == REDIRECT_URI.spec) {
+ redirectNotified = true;
+ }
+ return url == TARGET_URI.spec;
+ });
+ return redirectNotified;
+}
+
+let firstRedirectBonus = 0;
+let nextRedirectBonus = 0;
+let targetBonus = 0;
+
+add_task(async function test_multiple_redirect() {
+ // The redirect source bonus overrides the link bonus.
+ let visitedPromise = waitVisitedNotifications();
+ await BrowserTestUtils.withNewTab(
+ {
+ gBrowser,
+ url: REDIRECT_URI.spec,
+ },
+ async function () {
+ info("Waiting for onVisits");
+ let redirectNotified = await visitedPromise;
+ ok(redirectNotified, "The redirect should have been notified");
+
+ firstRedirectBonus += REDIRECT_SOURCE_VISIT_BONUS;
+ await check_uri(REDIRECT_URI, firstRedirectBonus, 1);
+ nextRedirectBonus += REDIRECT_SOURCE_VISIT_BONUS;
+ await check_uri(INTERMEDIATE_URI_1, nextRedirectBonus, 1);
+ await check_uri(INTERMEDIATE_URI_2, nextRedirectBonus, 1);
+ // TODO Bug 487813 - This should be TYPED_VISIT_BONUS, however as we don't
+ // currently track redirects across multiple redirects, we fallback to the
+ // PERM_REDIRECT_VISIT_BONUS.
+ targetBonus += PERM_REDIRECT_VISIT_BONUS;
+ await check_uri(TARGET_URI, targetBonus, 0);
+ }
+ );
+});
+
+add_task(async function test_multiple_redirect_typed() {
+ // The typed bonus wins because the redirect is permanent.
+ PlacesUtils.history.markPageAsTyped(REDIRECT_URI);
+ let visitedPromise = waitVisitedNotifications();
+ await BrowserTestUtils.withNewTab(
+ {
+ gBrowser,
+ url: REDIRECT_URI.spec,
+ },
+ async function () {
+ info("Waiting for onVisits");
+ let redirectNotified = await visitedPromise;
+ ok(redirectNotified, "The redirect should have been notified");
+
+ firstRedirectBonus += TYPED_VISIT_BONUS;
+ await check_uri(REDIRECT_URI, firstRedirectBonus, 1);
+ nextRedirectBonus += REDIRECT_SOURCE_VISIT_BONUS;
+ await check_uri(INTERMEDIATE_URI_1, nextRedirectBonus, 1);
+ await check_uri(INTERMEDIATE_URI_2, nextRedirectBonus, 1);
+ // TODO Bug 487813 - This should be TYPED_VISIT_BONUS, however as we don't
+ // currently track redirects across multiple redirects, we fallback to the
+ // PERM_REDIRECT_VISIT_BONUS.
+ targetBonus += PERM_REDIRECT_VISIT_BONUS;
+ await check_uri(TARGET_URI, targetBonus, 0);
+ }
+ );
+});
+
+add_task(async function test_second_typed_visit() {
+ // The typed bonus wins because the redirect is permanent.
+ PlacesUtils.history.markPageAsTyped(REDIRECT_URI);
+ let visitedPromise = waitVisitedNotifications();
+ await BrowserTestUtils.withNewTab(
+ {
+ gBrowser,
+ url: REDIRECT_URI.spec,
+ },
+ async function () {
+ info("Waiting for onVisits");
+ let redirectNotified = await visitedPromise;
+ ok(redirectNotified, "The redirect should have been notified");
+
+ firstRedirectBonus += TYPED_VISIT_BONUS;
+ await check_uri(REDIRECT_URI, firstRedirectBonus, 1);
+ nextRedirectBonus += REDIRECT_SOURCE_VISIT_BONUS;
+ await check_uri(INTERMEDIATE_URI_1, nextRedirectBonus, 1);
+ await check_uri(INTERMEDIATE_URI_2, nextRedirectBonus, 1);
+ // TODO Bug 487813 - This should be TYPED_VISIT_BONUS, however as we don't
+ // currently track redirects across multiple redirects, we fallback to the
+ // PERM_REDIRECT_VISIT_BONUS.
+ targetBonus += PERM_REDIRECT_VISIT_BONUS;
+ await check_uri(TARGET_URI, targetBonus, 0);
+ }
+ );
+});
+
+add_task(async function test_subsequent_link_visit() {
+ // Another non typed visit.
+ let visitedPromise = waitVisitedNotifications();
+ await BrowserTestUtils.withNewTab(
+ {
+ gBrowser,
+ url: REDIRECT_URI.spec,
+ },
+ async function () {
+ info("Waiting for onVisits");
+ let redirectNotified = await visitedPromise;
+ ok(redirectNotified, "The redirect should have been notified");
+
+ firstRedirectBonus += REDIRECT_SOURCE_VISIT_BONUS;
+ await check_uri(REDIRECT_URI, firstRedirectBonus, 1);
+ nextRedirectBonus += REDIRECT_SOURCE_VISIT_BONUS;
+ await check_uri(INTERMEDIATE_URI_1, nextRedirectBonus, 1);
+ await check_uri(INTERMEDIATE_URI_2, nextRedirectBonus, 1);
+ // TODO Bug 487813 - This should be TYPED_VISIT_BONUS, however as we don't
+ // currently track redirects across multiple redirects, we fallback to the
+ // PERM_REDIRECT_VISIT_BONUS.
+ targetBonus += PERM_REDIRECT_VISIT_BONUS;
+ await check_uri(TARGET_URI, targetBonus, 0);
+ }
+ );
+});
diff --git a/toolkit/components/places/tests/browser/browser_notfound.js b/toolkit/components/places/tests/browser/browser_notfound.js
new file mode 100644
index 0000000000..22ac67de0a
--- /dev/null
+++ b/toolkit/components/places/tests/browser/browser_notfound.js
@@ -0,0 +1,76 @@
+/* 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/. */
+
+add_task(async function () {
+ const url = "http://mochi.test:8888/notFoundPage.html";
+
+ await registerCleanupFunction(PlacesUtils.history.clear);
+
+ // Used to verify errors are not marked as typed.
+ PlacesUtils.history.markPageAsTyped(NetUtil.newURI(url));
+
+ let promiseVisited = PlacesTestUtils.waitForNotification(
+ "page-visited",
+ events => {
+ console.log(JSON.stringify(events));
+ return events.length == 1 && events[0].url === url;
+ }
+ );
+
+ await BrowserTestUtils.withNewTab(
+ {
+ gBrowser,
+ url,
+ },
+ async browser => {
+ info("awaiting for the visit");
+ await promiseVisited;
+
+ Assert.equal(
+ await PlacesTestUtils.getDatabaseValue("moz_places", "frecency", {
+ url,
+ }),
+ 0,
+ "Frecency should be 0"
+ );
+ Assert.equal(
+ await PlacesTestUtils.getDatabaseValue("moz_places", "hidden", { url }),
+ 0,
+ "Page should not be hidden"
+ );
+ Assert.equal(
+ await PlacesTestUtils.getDatabaseValue("moz_places", "typed", { url }),
+ 0,
+ "page should not be marked as typed"
+ );
+ Assert.equal(
+ await PlacesTestUtils.getDatabaseValue(
+ "moz_places",
+ "recalc_frecency",
+ { url }
+ ),
+ 0,
+ "page should not be marked for frecency recalculation"
+ );
+ Assert.equal(
+ await PlacesTestUtils.getDatabaseValue(
+ "moz_places",
+ "recalc_alt_frecency",
+ { url }
+ ),
+ 0,
+ "page should not be marked for alt frecency recalculation"
+ );
+
+ info("Adding new valid visits should cause recalculation");
+ await PlacesTestUtils.addVisits([url, "https://othersite.org/"]);
+ let frecency = await PlacesTestUtils.getDatabaseValue(
+ "moz_places",
+ "frecency",
+ { url }
+ );
+ Assert.greater(frecency, 0, "Check frecency was updated");
+ }
+ );
+});
diff --git a/toolkit/components/places/tests/browser/browser_onvisit_title_null_for_navigation.js b/toolkit/components/places/tests/browser/browser_onvisit_title_null_for_navigation.js
new file mode 100644
index 0000000000..a7c583975a
--- /dev/null
+++ b/toolkit/components/places/tests/browser/browser_onvisit_title_null_for_navigation.js
@@ -0,0 +1,41 @@
+const TEST_PATH = getRootDirectory(gTestPath).replace(
+ "chrome://mochitests/content",
+ "http://example.com"
+);
+
+add_task(async function checkTitleNotificationForNavigation() {
+ const EXPECTED_URL = Services.io.newURI(TEST_PATH + "empty_page.html");
+
+ const promiseVisit = PlacesTestUtils.waitForNotification(
+ "page-visited",
+ events => events[0].url === EXPECTED_URL.spec
+ );
+
+ const promiseTitle = PlacesTestUtils.waitForNotification(
+ "page-title-changed",
+ events => events[0].url === EXPECTED_URL.spec
+ );
+
+ let tab = await BrowserTestUtils.openNewForegroundTab(
+ gBrowser,
+ EXPECTED_URL.spec
+ );
+
+ const visitEvents = await promiseVisit;
+ Assert.equal(visitEvents.length, 1, "Right number of visits notified");
+ Assert.equal(visitEvents[0].type, "page-visited");
+ info("'page-visited': " + visitEvents[0].url);
+ Assert.equal(visitEvents[0].lastKnownTitle, null, "Should not have a title");
+
+ const titleEvents = await promiseTitle;
+ Assert.equal(titleEvents.length, 1, "Right number of title changed notified");
+ Assert.equal(titleEvents[0].type, "page-title-changed");
+ info("'page-title-changed': " + titleEvents[0].url);
+ Assert.equal(
+ titleEvents[0].title,
+ "I am an empty page",
+ "Should have correct title in titlechanged notification"
+ );
+
+ BrowserTestUtils.removeTab(tab);
+});
diff --git a/toolkit/components/places/tests/browser/browser_redirect.js b/toolkit/components/places/tests/browser/browser_redirect.js
new file mode 100644
index 0000000000..912b817ad1
--- /dev/null
+++ b/toolkit/components/places/tests/browser/browser_redirect.js
@@ -0,0 +1,149 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/ */
+
+const ROOT_URI =
+ "http://mochi.test:8888/tests/toolkit/components/places/tests/browser/";
+const REDIRECT_URI = Services.io.newURI(ROOT_URI + "redirect.sjs");
+const TARGET_URI = Services.io.newURI(ROOT_URI + "redirect-target.html");
+
+const REDIRECT_SOURCE_VISIT_BONUS = Services.prefs.getIntPref(
+ "places.frecency.redirectSourceVisitBonus"
+);
+const LINK_VISIT_BONUS = Services.prefs.getIntPref(
+ "places.frecency.linkVisitBonus"
+);
+const TYPED_VISIT_BONUS = Services.prefs.getIntPref(
+ "places.frecency.typedVisitBonus"
+);
+
+// Ensure that decay frecency doesn't kick in during tests (as a result
+// of idle-daily).
+Services.prefs.setCharPref("places.frecency.decayRate", "1.0");
+
+registerCleanupFunction(async function () {
+ Services.prefs.clearUserPref("places.frecency.decayRate");
+ await PlacesUtils.history.clear();
+});
+
+let redirectSourceFrecency = 0;
+let redirectTargetFrecency = 0;
+
+async function check_uri(uri, frecency, hidden) {
+ is(
+ await PlacesTestUtils.getDatabaseValue("moz_places", "frecency", {
+ url: uri,
+ }),
+ frecency,
+ "Frecency of the page is the expected one"
+ );
+ is(
+ await PlacesTestUtils.getDatabaseValue("moz_places", "hidden", {
+ url: uri,
+ }),
+ hidden,
+ "Hidden value of the page is the expected one"
+ );
+}
+
+add_task(async function redirect_check_new_typed_visit() {
+ // Used to verify the redirect bonus overrides the typed bonus.
+ PlacesUtils.history.markPageAsTyped(REDIRECT_URI);
+
+ redirectSourceFrecency += REDIRECT_SOURCE_VISIT_BONUS;
+ redirectTargetFrecency += TYPED_VISIT_BONUS;
+ let redirectNotified = false;
+
+ let visitedPromise = PlacesTestUtils.waitForNotification(
+ "page-visited",
+ visits => {
+ is(visits.length, 1, "Was notified for the right number of visits.");
+ let { url } = visits[0];
+ info("Received 'page-visited': " + url);
+ if (url == REDIRECT_URI.spec) {
+ redirectNotified = true;
+ }
+ return url == TARGET_URI.spec;
+ }
+ );
+
+ let tab = await BrowserTestUtils.openNewForegroundTab(
+ gBrowser,
+ REDIRECT_URI.spec
+ );
+ info("Waiting for onVisits");
+ await visitedPromise;
+ ok(redirectNotified, "The redirect should have been notified");
+
+ await check_uri(REDIRECT_URI, redirectSourceFrecency, 1);
+ await check_uri(TARGET_URI, redirectTargetFrecency, 0);
+
+ BrowserTestUtils.removeTab(tab);
+});
+
+add_task(async function redirect_check_second_typed_visit() {
+ // A second visit with a typed url.
+ PlacesUtils.history.markPageAsTyped(REDIRECT_URI);
+
+ redirectSourceFrecency += REDIRECT_SOURCE_VISIT_BONUS;
+ redirectTargetFrecency += TYPED_VISIT_BONUS;
+ let redirectNotified = false;
+
+ let visitedPromise = PlacesTestUtils.waitForNotification(
+ "page-visited",
+ visits => {
+ is(visits.length, 1, "Was notified for the right number of visits.");
+ let { url } = visits[0];
+ info("Received 'page-visited': " + url);
+ if (url == REDIRECT_URI.spec) {
+ redirectNotified = true;
+ }
+ return url == TARGET_URI.spec;
+ }
+ );
+
+ let tab = await BrowserTestUtils.openNewForegroundTab(
+ gBrowser,
+ REDIRECT_URI.spec
+ );
+ info("Waiting for onVisits");
+ await visitedPromise;
+ ok(redirectNotified, "The redirect should have been notified");
+
+ await check_uri(REDIRECT_URI, redirectSourceFrecency, 1);
+ await check_uri(TARGET_URI, redirectTargetFrecency, 0);
+
+ BrowserTestUtils.removeTab(tab);
+});
+
+add_task(async function redirect_check_subsequent_link_visit() {
+ // Another visit, but this time as a visited url.
+ redirectSourceFrecency += REDIRECT_SOURCE_VISIT_BONUS;
+ redirectTargetFrecency += LINK_VISIT_BONUS;
+ let redirectNotified = false;
+
+ let visitedPromise = PlacesTestUtils.waitForNotification(
+ "page-visited",
+ visits => {
+ is(visits.length, 1, "Was notified for the right number of visits.");
+ let { url } = visits[0];
+ info("Received 'page-visited': " + url);
+ if (url == REDIRECT_URI.spec) {
+ redirectNotified = true;
+ }
+ return url == TARGET_URI.spec;
+ }
+ );
+
+ let tab = await BrowserTestUtils.openNewForegroundTab(
+ gBrowser,
+ REDIRECT_URI.spec
+ );
+ info("Waiting for onVisits");
+ await visitedPromise;
+ ok(redirectNotified, "The redirect should have been notified");
+
+ await check_uri(REDIRECT_URI, redirectSourceFrecency, 1);
+ await check_uri(TARGET_URI, redirectTargetFrecency, 0);
+
+ BrowserTestUtils.removeTab(tab);
+});
diff --git a/toolkit/components/places/tests/browser/browser_redirect_self.js b/toolkit/components/places/tests/browser/browser_redirect_self.js
new file mode 100644
index 0000000000..7ed7ee0af0
--- /dev/null
+++ b/toolkit/components/places/tests/browser/browser_redirect_self.js
@@ -0,0 +1,51 @@
+/* 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/. */
+
+/*
+ * Tests a page that redirects to itself. On the initial visit the page should
+ * be marked as hidden, but then the second visit should unhide it.
+ * This ensures that that the history anti-flooding system doesn't skip the
+ * second visit.
+ */
+
+add_task(async function () {
+ await PlacesUtils.history.clear();
+ Cc["@mozilla.org/browser/history;1"]
+ .getService(Ci.mozIAsyncHistory)
+ .clearCache();
+ const url =
+ "http://mochi.test:8888/tests/toolkit/components/places/tests/browser/redirect_self.sjs";
+ let visitCount = 0;
+ function onVisitsListener(events) {
+ visitCount++;
+ Assert.equal(events.length, 1, "Right number of visits notified");
+ Assert.equal(events[0].url, url, "Got a visit for the expected url");
+ if (visitCount == 1) {
+ Assert.ok(events[0].hidden, "The visit should be hidden");
+ } else {
+ Assert.ok(!events[0].hidden, "The visit should not be hidden");
+ }
+ }
+ PlacesObservers.addListener(["page-visited"], onVisitsListener);
+ registerCleanupFunction(async function () {
+ PlacesObservers.removeListener(["page-visited"], onVisitsListener);
+ await PlacesUtils.history.clear();
+ });
+ await BrowserTestUtils.withNewTab(
+ {
+ gBrowser,
+ url,
+ },
+ async browser => {
+ await TestUtils.waitForCondition(() => visitCount == 2);
+ // Check that the visit is not hidden in the database.
+ Assert.ok(
+ !(await PlacesTestUtils.getDatabaseValue("moz_places", "hidden", {
+ url,
+ })),
+ "The url should not be hidden in the database"
+ );
+ }
+ );
+});
diff --git a/toolkit/components/places/tests/browser/browser_settitle.js b/toolkit/components/places/tests/browser/browser_settitle.js
new file mode 100644
index 0000000000..3bd5e5f3e6
--- /dev/null
+++ b/toolkit/components/places/tests/browser/browser_settitle.js
@@ -0,0 +1,48 @@
+var conn = PlacesUtils.history.DBConnection;
+
+/**
+ * Gets a single column value from either the places or historyvisits table.
+ */
+function getColumn(table, column, url) {
+ var stmt = conn.createStatement(
+ `SELECT ${column} FROM ${table} WHERE url_hash = hash(:val) AND url = :val`
+ );
+ try {
+ stmt.params.val = url;
+ stmt.executeStep();
+ return stmt.row[column];
+ } finally {
+ stmt.finalize();
+ }
+}
+
+add_task(async function () {
+ // Make sure titles are correctly saved for a URI with the proper
+ // notifications.
+ const titleChangedPromise =
+ PlacesTestUtils.waitForNotification("page-title-changed");
+
+ const url1 =
+ "http://example.com/tests/toolkit/components/places/tests/browser/title1.html";
+ await BrowserTestUtils.openNewForegroundTab(gBrowser, url1);
+
+ const url2 =
+ "http://example.com/tests/toolkit/components/places/tests/browser/title2.html";
+ let loadPromise = BrowserTestUtils.browserLoaded(gBrowser.selectedBrowser);
+ BrowserTestUtils.startLoadingURIString(gBrowser.selectedBrowser, url2);
+ await loadPromise;
+
+ const events = await titleChangedPromise;
+ is(
+ events[0].url,
+ "http://example.com/tests/toolkit/components/places/tests/browser/title2.html"
+ );
+ is(events[0].title, "Some title");
+ is(events[0].pageGuid, getColumn("moz_places", "guid", events[0].url));
+
+ const title = getColumn("moz_places", "title", events[0].url);
+ is(title, events[0].title);
+
+ gBrowser.removeCurrentTab();
+ await PlacesUtils.history.clear();
+});
diff --git a/toolkit/components/places/tests/browser/browser_upgrade.js b/toolkit/components/places/tests/browser/browser_upgrade.js
new file mode 100644
index 0000000000..a5077a345b
--- /dev/null
+++ b/toolkit/components/places/tests/browser/browser_upgrade.js
@@ -0,0 +1,106 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+/* eslint-disable @microsoft/sdl/no-insecure-url */
+
+"use strict";
+
+// This test checks that when a upgrade is happening through HTTPS-Only,
+// only a history entry for the https url of the site is added visibly for
+// the user, while the http version gets added as a hidden entry.
+
+async function assertIsPlaceHidden(url, expectHidden) {
+ const hidden = await PlacesTestUtils.getDatabaseValue(
+ "moz_places",
+ "hidden",
+ { url }
+ );
+ Assert.ok(hidden !== undefined, `We should have saved a visit to ${url}`);
+ Assert.equal(
+ hidden,
+ expectHidden ? 1 : 0,
+ `Check if the visit to ${url} is hidden`
+ );
+}
+
+async function assertVisitFromAndType(
+ url,
+ expectedFromVisitURL,
+ expectedVisitType
+) {
+ const db = await PlacesUtils.promiseDBConnection();
+ const rows = await db.execute(
+ `SELECT v1.visit_type FROM moz_historyvisits v1
+ JOIN moz_places p1 ON p1.id = v1.place_id
+ WHERE p1.url = :url
+ AND from_visit IN
+ (SELECT v2.id FROM moz_historyvisits v2
+ JOIN moz_places p2 ON p2.id = v2.place_id
+ WHERE p2.url = :expectedFromVisitURL)
+ `,
+ { url, expectedFromVisitURL }
+ );
+ Assert.equal(
+ rows.length,
+ 1,
+ `There should be a single visit to ${url} with "from_visit" set to the visit id of ${expectedFromVisitURL}`
+ );
+ Assert.equal(
+ rows[0].getResultByName("visit_type"),
+ expectedVisitType,
+ `The visit to ${url} should have a visit type of ${expectedVisitType}`
+ );
+}
+
+function waitForVisitNotifications(urls) {
+ return Promise.all(
+ urls.map(url =>
+ PlacesTestUtils.waitForNotification("page-visited", events =>
+ events.some(e => e.url === url)
+ )
+ )
+ );
+}
+
+add_task(async function test_upgrade() {
+ await SpecialPowers.pushPrefEnv({
+ set: [["dom.security.https_only_mode", true]],
+ });
+
+ await PlacesUtils.history.clear();
+
+ registerCleanupFunction(async function () {
+ await PlacesUtils.history.clear();
+ });
+
+ const visitPromise = waitForVisitNotifications([
+ "http://example.com/",
+ "https://example.com/",
+ ]);
+
+ info("Opening http://example.com/");
+ await BrowserTestUtils.withNewTab(
+ {
+ gBrowser,
+ url: "http://example.com/",
+ waitForLoad: true,
+ },
+ async browser => {
+ Assert.equal(
+ browser.currentURI.scheme,
+ "https",
+ "We should have been upgraded"
+ );
+ info("Waiting for page visits to reach places database");
+ await visitPromise;
+ info("Checking places database");
+ await assertIsPlaceHidden("http://example.com/", true);
+ await assertIsPlaceHidden("https://example.com/", false);
+ await assertVisitFromAndType(
+ "https://example.com/",
+ "http://example.com/",
+ Ci.nsINavHistoryService.TRANSITION_REDIRECT_PERMANENT
+ );
+ }
+ );
+});
diff --git a/toolkit/components/places/tests/browser/browser_visited_notfound.js b/toolkit/components/places/tests/browser/browser_visited_notfound.js
new file mode 100644
index 0000000000..36d1764361
--- /dev/null
+++ b/toolkit/components/places/tests/browser/browser_visited_notfound.js
@@ -0,0 +1,76 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+add_task(async function test() {
+ const url = "http://mochi.test:8888/notFoundPage.html";
+
+ // Ensure that decay frecency doesn't kick in during tests (as a result
+ // of idle-daily).
+ await SpecialPowers.pushPrefEnv({
+ set: [["places.frecency.decayRate", "1.0"]],
+ });
+ await registerCleanupFunction(PlacesUtils.history.clear);
+
+ // First add a visit to the page, this will ensure that later we skip
+ // updating the frecency for a newly not-found page.
+ await PlacesTestUtils.addVisits(url);
+ let frecency = await PlacesTestUtils.getDatabaseValue(
+ "moz_places",
+ "frecency",
+ { url }
+ );
+ Assert.equal(frecency, 100, "Check initial frecency");
+
+ // Used to verify errors are not marked as typed.
+ PlacesUtils.history.markPageAsTyped(NetUtil.newURI(url));
+
+ await BrowserTestUtils.withNewTab(
+ {
+ gBrowser,
+ url,
+ },
+ async browser => {
+ info("awaiting for the visit");
+
+ Assert.equal(
+ await PlacesTestUtils.getDatabaseValue("moz_places", "frecency", {
+ url,
+ }),
+ frecency,
+ "Frecency should be unchanged"
+ );
+ Assert.equal(
+ await PlacesTestUtils.getDatabaseValue("moz_places", "hidden", {
+ url,
+ }),
+ 0,
+ "Page should not be hidden"
+ );
+ Assert.equal(
+ await PlacesTestUtils.getDatabaseValue("moz_places", "typed", {
+ url,
+ }),
+ 0,
+ "page should not be marked as typed"
+ );
+ Assert.equal(
+ await PlacesTestUtils.getDatabaseValue(
+ "moz_places",
+ "recalc_frecency",
+ { url }
+ ),
+ 0,
+ "page should not be marked for frecency recalculation"
+ );
+ Assert.equal(
+ await PlacesTestUtils.getDatabaseValue(
+ "moz_places",
+ "recalc_alt_frecency",
+ { url }
+ ),
+ 0,
+ "page should not be marked for alt frecency recalculation"
+ );
+ }
+ );
+});
diff --git a/toolkit/components/places/tests/browser/browser_visituri.js b/toolkit/components/places/tests/browser/browser_visituri.js
new file mode 100644
index 0000000000..6633ac188b
--- /dev/null
+++ b/toolkit/components/places/tests/browser/browser_visituri.js
@@ -0,0 +1,100 @@
+/**
+ * One-time observer callback.
+ */
+function promiseObserve(name, checkFn) {
+ return new Promise(resolve => {
+ Services.obs.addObserver(function observer(subject) {
+ if (checkFn(subject)) {
+ Services.obs.removeObserver(observer, name);
+ resolve();
+ }
+ }, name);
+ });
+}
+
+var conn = PlacesUtils.history.DBConnection;
+
+/**
+ * Gets a single column value from either the places or historyvisits table.
+ */
+function getColumn(table, column, fromColumnName, fromColumnValue) {
+ let sql = `SELECT ${column}
+ FROM ${table}
+ WHERE ${fromColumnName} = :val
+ ${fromColumnName == "url" ? "AND url_hash = hash(:val)" : ""}
+ LIMIT 1`;
+ let stmt = conn.createStatement(sql);
+ try {
+ stmt.params.val = fromColumnValue;
+ ok(stmt.executeStep(), "Expect to get a row");
+ return stmt.row[column];
+ } finally {
+ stmt.reset();
+ }
+}
+
+add_task(async function () {
+ // Make sure places visit chains are saved correctly with a redirect
+ // transitions.
+
+ // Part 1: observe history events that fire when a visit occurs.
+ // Make sure visits appear in order, and that the visit chain is correct.
+ var expectedUrls = [
+ "http://example.com/tests/toolkit/components/places/tests/browser/begin.html",
+ "http://example.com/tests/toolkit/components/places/tests/browser/redirect_twice.sjs",
+ "http://example.com/tests/toolkit/components/places/tests/browser/redirect_once.sjs",
+ "http://test1.example.com/tests/toolkit/components/places/tests/browser/final.html",
+ ];
+ var currentIndex = 0;
+
+ function checkObserver(subject) {
+ var uri = subject.QueryInterface(Ci.nsIURI);
+ var expected = expectedUrls[currentIndex];
+ is(uri.spec, expected, "Saved URL visit " + uri.spec);
+
+ var placeId = getColumn("moz_places", "id", "url", uri.spec);
+ var fromVisitId = getColumn(
+ "moz_historyvisits",
+ "from_visit",
+ "place_id",
+ placeId
+ );
+
+ if (currentIndex == 0) {
+ is(fromVisitId, 0, "First visit has no from visit");
+ } else {
+ var lastVisitId = getColumn(
+ "moz_historyvisits",
+ "place_id",
+ "id",
+ fromVisitId
+ );
+ var fromVisitUrl = getColumn("moz_places", "url", "id", lastVisitId);
+ is(
+ fromVisitUrl,
+ expectedUrls[currentIndex - 1],
+ "From visit was " + expectedUrls[currentIndex - 1]
+ );
+ }
+
+ currentIndex++;
+ return currentIndex >= expectedUrls.length;
+ }
+ let visitUriPromise = promiseObserve("uri-visit-saved", checkObserver);
+
+ const testUrl =
+ "http://example.com/tests/toolkit/components/places/tests/browser/begin.html";
+ await BrowserTestUtils.openNewForegroundTab(gBrowser, testUrl);
+
+ // Load begin page, click link on page to record visits.
+ await BrowserTestUtils.synthesizeMouseAtCenter(
+ "#clickme",
+ {},
+ gBrowser.selectedBrowser
+ );
+ await visitUriPromise;
+
+ await PlacesUtils.history.clear();
+
+ gBrowser.removeCurrentTab();
+});
diff --git a/toolkit/components/places/tests/browser/browser_visituri_nohistory.js b/toolkit/components/places/tests/browser/browser_visituri_nohistory.js
new file mode 100644
index 0000000000..9db436b7ac
--- /dev/null
+++ b/toolkit/components/places/tests/browser/browser_visituri_nohistory.js
@@ -0,0 +1,44 @@
+/**
+ * Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/
+ */
+
+const INITIAL_URL =
+ "http://example.com/tests/toolkit/components/places/tests/browser/begin.html";
+const FINAL_URL =
+ "http://test1.example.com/tests/toolkit/components/places/tests/browser/final.html";
+
+/**
+ * One-time observer callback.
+ */
+function promiseObserve(name) {
+ return new Promise(resolve => {
+ Services.obs.addObserver(function observer(subject) {
+ Services.obs.removeObserver(observer, name);
+ resolve(subject);
+ }, name);
+ });
+}
+
+add_task(async function () {
+ await SpecialPowers.pushPrefEnv({ set: [["places.history.enabled", false]] });
+
+ let visitUriPromise = promiseObserve("uri-visit-saved");
+
+ await BrowserTestUtils.openNewForegroundTab(gBrowser, INITIAL_URL);
+
+ await SpecialPowers.popPrefEnv();
+
+ let browserLoadedPromise = BrowserTestUtils.browserLoaded(
+ gBrowser.selectedBrowser
+ );
+ BrowserTestUtils.startLoadingURIString(gBrowser, FINAL_URL);
+ await browserLoadedPromise;
+
+ let subject = await visitUriPromise;
+ let uri = subject.QueryInterface(Ci.nsIURI);
+ is(uri.spec, FINAL_URL, "received expected visit");
+
+ await PlacesUtils.history.clear();
+ gBrowser.removeCurrentTab();
+});
diff --git a/toolkit/components/places/tests/browser/browser_visituri_privatebrowsing_perwindowpb.js b/toolkit/components/places/tests/browser/browser_visituri_privatebrowsing_perwindowpb.js
new file mode 100644
index 0000000000..746611d2ad
--- /dev/null
+++ b/toolkit/components/places/tests/browser/browser_visituri_privatebrowsing_perwindowpb.js
@@ -0,0 +1,63 @@
+/* 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/. */
+
+const initialURL =
+ "http://example.com/tests/toolkit/components/places/tests/browser/begin.html";
+const finalURL =
+ "http://test1.example.com/tests/toolkit/components/places/tests/browser/final.html";
+
+var observer;
+var visitSavedPromise;
+
+add_setup(async function () {
+ visitSavedPromise = new Promise(resolve => {
+ observer = {
+ observe(subject, topic, data) {
+ // The uri-visit-saved topic should only work when on normal mode.
+ if (topic == "uri-visit-saved") {
+ Services.obs.removeObserver(observer, "uri-visit-saved");
+
+ // The expected visit should be the finalURL because private mode
+ // should not register a visit with the initialURL.
+ let uri = subject.QueryInterface(Ci.nsIURI);
+ resolve(uri.spec);
+ }
+ },
+ };
+ });
+
+ Services.obs.addObserver(observer, "uri-visit-saved");
+
+ registerCleanupFunction(async function () {
+ await PlacesUtils.history.clear();
+ });
+});
+
+// Note: The private window test must be the first one to run, since we'll listen
+// to the first uri-visit-saved notification, and we expect this test to not
+// fire any, so we'll just find the non-private window test notification.
+add_task(async function test_private_browsing_window() {
+ await testLoadInWindow({ private: true }, initialURL);
+});
+
+add_task(async function test_normal_window() {
+ await testLoadInWindow({ private: false }, finalURL);
+
+ let url = await visitSavedPromise;
+ Assert.equal(url, finalURL, "Check received expected visit");
+});
+
+async function testLoadInWindow(options, url) {
+ let win = await BrowserTestUtils.openNewBrowserWindow(options);
+
+ registerCleanupFunction(async function () {
+ await BrowserTestUtils.closeWindow(win);
+ });
+
+ let loadedPromise = BrowserTestUtils.browserLoaded(
+ win.gBrowser.selectedBrowser
+ );
+ BrowserTestUtils.startLoadingURIString(win.gBrowser.selectedBrowser, url);
+ await loadedPromise;
+}
diff --git a/toolkit/components/places/tests/browser/empty_page.html b/toolkit/components/places/tests/browser/empty_page.html
new file mode 100644
index 0000000000..ac9d144cb4
--- /dev/null
+++ b/toolkit/components/places/tests/browser/empty_page.html
@@ -0,0 +1,8 @@
+
+
+
+
+ I am an empty page
+
+ Empty
+
diff --git a/toolkit/components/places/tests/browser/favicon-normal16.png b/toolkit/components/places/tests/browser/favicon-normal16.png
new file mode 100644
index 0000000000..62b69a3d03
Binary files /dev/null and b/toolkit/components/places/tests/browser/favicon-normal16.png differ
diff --git a/toolkit/components/places/tests/browser/favicon-normal32.png b/toolkit/components/places/tests/browser/favicon-normal32.png
new file mode 100644
index 0000000000..5535363c94
Binary files /dev/null and b/toolkit/components/places/tests/browser/favicon-normal32.png differ
diff --git a/toolkit/components/places/tests/browser/favicon.html b/toolkit/components/places/tests/browser/favicon.html
new file mode 100644
index 0000000000..a0f5ea9594
--- /dev/null
+++ b/toolkit/components/places/tests/browser/favicon.html
@@ -0,0 +1,13 @@
+
+
+
+
+
+
+
+ OK we're done!
+
+
diff --git a/toolkit/components/places/tests/browser/final.html b/toolkit/components/places/tests/browser/final.html
new file mode 100644
index 0000000000..ccd5819181
--- /dev/null
+++ b/toolkit/components/places/tests/browser/final.html
@@ -0,0 +1,10 @@
+
+
+
+
+ OK we're done!
+
+
diff --git a/toolkit/components/places/tests/browser/head.js b/toolkit/components/places/tests/browser/head.js
new file mode 100644
index 0000000000..b1b916ee29
--- /dev/null
+++ b/toolkit/components/places/tests/browser/head.js
@@ -0,0 +1,19 @@
+ChromeUtils.defineESModuleGetters(this, {
+ PlacesTestUtils: "resource://testing-common/PlacesTestUtils.sys.mjs",
+});
+
+const TRANSITION_LINK = PlacesUtils.history.TRANSITION_LINK;
+const TRANSITION_TYPED = PlacesUtils.history.TRANSITION_TYPED;
+const TRANSITION_BOOKMARK = PlacesUtils.history.TRANSITION_BOOKMARK;
+const TRANSITION_REDIRECT_PERMANENT =
+ PlacesUtils.history.TRANSITION_REDIRECT_PERMANENT;
+const TRANSITION_REDIRECT_TEMPORARY =
+ PlacesUtils.history.TRANSITION_REDIRECT_TEMPORARY;
+const TRANSITION_EMBED = PlacesUtils.history.TRANSITION_EMBED;
+const TRANSITION_FRAMED_LINK = PlacesUtils.history.TRANSITION_FRAMED_LINK;
+const TRANSITION_DOWNLOAD = PlacesUtils.history.TRANSITION_DOWNLOAD;
+
+function whenNewWindowLoaded(aOptions, aCallback) {
+ BrowserTestUtils.waitForNewWindow().then(aCallback);
+ OpenBrowserWindow(aOptions);
+}
diff --git a/toolkit/components/places/tests/browser/history_post.html b/toolkit/components/places/tests/browser/history_post.html
new file mode 100644
index 0000000000..a579a9b8ae
--- /dev/null
+++ b/toolkit/components/places/tests/browser/history_post.html
@@ -0,0 +1,12 @@
+
+
+
+ Test post pages are not added to history
+
+
+
+
+
+
diff --git a/toolkit/components/places/tests/browser/history_post.sjs b/toolkit/components/places/tests/browser/history_post.sjs
new file mode 100644
index 0000000000..08c1afe853
--- /dev/null
+++ b/toolkit/components/places/tests/browser/history_post.sjs
@@ -0,0 +1,5 @@
+function handleRequest(request, response) {
+ response.setStatusLine("1.0", 200, "OK");
+ response.setHeader("Content-Type", "text/plain; charset=utf-8", false);
+ response.write("Ciao");
+}
diff --git a/toolkit/components/places/tests/browser/previews/browser.toml b/toolkit/components/places/tests/browser/previews/browser.toml
new file mode 100644
index 0000000000..10758a4803
--- /dev/null
+++ b/toolkit/components/places/tests/browser/previews/browser.toml
@@ -0,0 +1,8 @@
+[DEFAULT]
+prefs = [
+ "browser.pagethumbnails.capturing_disabled=false",
+ "places.previews.enabled=true",
+ "places.previews.log=true",
+]
+
+["browser_thumbnails.js"]
diff --git a/toolkit/components/places/tests/browser/previews/browser_thumbnails.js b/toolkit/components/places/tests/browser/previews/browser_thumbnails.js
new file mode 100644
index 0000000000..27f4fa3745
--- /dev/null
+++ b/toolkit/components/places/tests/browser/previews/browser_thumbnails.js
@@ -0,0 +1,174 @@
+/* Any copyright is dedicated to the Public Domain.
+ * https://creativecommons.org/publicdomain/zero/1.0/ */
+
+/**
+ * Tests PlacesPreviews.jsm
+ */
+const { PlacesPreviews } = ChromeUtils.importESModule(
+ "resource://gre/modules/PlacesPreviews.sys.mjs"
+);
+const { PlacesTestUtils } = ChromeUtils.importESModule(
+ "resource://testing-common/PlacesTestUtils.sys.mjs"
+);
+
+const TEST_URL1 = "https://example.com/";
+const TEST_URL2 = "https://example.org/";
+
+/**
+ * Counts tombstone entries.
+ * @returns {integer} number of tombstone entries.
+ */
+async function countTombstones() {
+ await PlacesTestUtils.promiseAsyncUpdates();
+ let db = await PlacesUtils.promiseDBConnection();
+ return (
+ await db.execute("SELECT count(*) FROM moz_previews_tombstones")
+ )[0].getResultByIndex(0);
+}
+
+add_task(async function test_thumbnail() {
+ registerCleanupFunction(async () => {
+ await PlacesUtils.history.clear();
+ // Ensure tombstones table has been emptied.
+ await TestUtils.waitForCondition(async () => {
+ return (await countTombstones()) == 0;
+ });
+ PlacesPreviews.testSetDeletionTimeout(null);
+ });
+ // Sanity check initial state.
+ Assert.equal(await countTombstones(), 0, "There's no tombstone entries");
+
+ info("Test preview creation and storage.");
+ await BrowserTestUtils.withNewTab(TEST_URL1, async browser => {
+ await retryUpdatePreview(browser.currentURI.spec);
+ let filePath = PlacesPreviews.getPathForUrl(TEST_URL1);
+ Assert.ok(await IOUtils.exists(filePath), "The screenshot exists");
+ Assert.equal(
+ filePath.substring(filePath.lastIndexOf(".")),
+ PlacesPreviews.fileExtension,
+ "Check extension"
+ );
+ await testImageFile(filePath);
+ await testMozPageThumb(TEST_URL1);
+ });
+});
+
+add_task(async function test_page_removal() {
+ info("Store another preview and test page removal.");
+ await BrowserTestUtils.withNewTab(TEST_URL2, async browser => {
+ await retryUpdatePreview(browser.currentURI.spec);
+ let filePath = PlacesPreviews.getPathForUrl(TEST_URL2);
+ Assert.ok(await IOUtils.exists(filePath), "The screenshot exists");
+ });
+
+ // Set deletion time to a small value so it runs immediately.
+ PlacesPreviews.testSetDeletionTimeout(0);
+ info("Wait for deletion, check one preview is removed, not the other one.");
+ let promiseDeleted = new Promise(resolve => {
+ PlacesPreviews.once("places-preview-deleted", (topic, filePath) => {
+ resolve(filePath);
+ });
+ });
+ await PlacesUtils.history.remove(TEST_URL1);
+
+ let deletedFilePath = await promiseDeleted;
+ Assert.ok(
+ !(await IOUtils.exists(deletedFilePath)),
+ "Check deleted file has been removed"
+ );
+
+ info("Check tombstones table has been emptied.");
+ Assert.equal(await countTombstones(), 0, "There's no tombstone entries");
+
+ info("Check the other thumbnail has not been removed.");
+ let path = PlacesPreviews.getPathForUrl(TEST_URL2);
+ Assert.ok(await IOUtils.exists(path), "Check non-deleted url is still there");
+ await testImageFile(path);
+ await testMozPageThumb(TEST_URL2);
+});
+
+add_task(async function async_test_deleteOrphans() {
+ let path = PlacesPreviews.getPathForUrl(TEST_URL2);
+ Assert.ok(await IOUtils.exists(path), "Sanity check one preview exists");
+ // Create a file in the given path that doesn't have an entry in Places.
+ let fakePath = PathUtils.join(
+ PlacesPreviews.getPath(),
+ "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa." + PlacesPreviews.fileExtension
+ );
+ // File contents don't matter.
+ await IOUtils.writeJSON(fakePath, { test: true });
+ let promiseDeleted = new Promise(resolve => {
+ PlacesPreviews.once("places-preview-deleted", (topic, filePath) => {
+ resolve(filePath);
+ });
+ });
+
+ await PlacesPreviews.deleteOrphans();
+ let deletedFilePath = await promiseDeleted;
+ Assert.equal(deletedFilePath, fakePath, "Check orphan has been deleted");
+ Assert.equal(await countTombstones(), 0, "There's no tombstone entries left");
+ Assert.ok(
+ !(await IOUtils.exists(fakePath)),
+ "Ensure orphan has been deleted"
+ );
+
+ Assert.ok(await IOUtils.exists(path), "Ensure valid preview is still there");
+});
+
+async function testImageFile(path) {
+ info("Load the file and check its content type.");
+ const buffer = await IOUtils.read(path);
+ const fourcc = new TextDecoder("utf-8").decode(buffer.slice(8, 12));
+ Assert.equal(fourcc, "WEBP", "Check the stored preview is webp");
+}
+
+async function testMozPageThumb(url) {
+ info("Check moz-page-thumb protocol: " + PlacesPreviews.getPageThumbURL(url));
+ let { data, contentType } = await fetchImage(
+ PlacesPreviews.getPageThumbURL(url)
+ );
+ Assert.equal(
+ contentType,
+ PlacesPreviews.fileContentType,
+ "Check the content type"
+ );
+ const fourcc = data.slice(8, 12);
+ Assert.equal(fourcc, "WEBP", "Check the returned preview is webp");
+}
+
+function fetchImage(url) {
+ return new Promise((resolve, reject) => {
+ NetUtil.asyncFetch(
+ {
+ uri: NetUtil.newURI(url),
+ loadUsingSystemPrincipal: true,
+ contentPolicyType: Ci.nsIContentPolicy.TYPE_INTERNAL_IMAGE,
+ },
+ (input, status, request) => {
+ if (!Components.isSuccessCode(status)) {
+ reject(new Error("unable to load image"));
+ return;
+ }
+
+ try {
+ let data = NetUtil.readInputStreamToString(input, input.available());
+ let contentType = request.QueryInterface(Ci.nsIChannel).contentType;
+ input.close();
+ resolve({ data, contentType });
+ } catch (ex) {
+ reject(ex);
+ }
+ }
+ );
+ });
+}
+
+/**
+ * Sometimes on macOS fetching the preview fails for timeout/network reasons,
+ * this retries so the test doesn't intermittently fail over it.
+ * @param {string} url The url to store a preview for.
+ * @returns {Promise} resolved once a preview has been captured.
+ */
+function retryUpdatePreview(url) {
+ return TestUtils.waitForCondition(() => PlacesPreviews.update(url));
+}
diff --git a/toolkit/components/places/tests/browser/redirect-target.html b/toolkit/components/places/tests/browser/redirect-target.html
new file mode 100644
index 0000000000..3700263385
--- /dev/null
+++ b/toolkit/components/places/tests/browser/redirect-target.html
@@ -0,0 +1 @@
+
Ciao!
diff --git a/toolkit/components/places/tests/browser/redirect.sjs b/toolkit/components/places/tests/browser/redirect.sjs
new file mode 100644
index 0000000000..ab47335ffe
--- /dev/null
+++ b/toolkit/components/places/tests/browser/redirect.sjs
@@ -0,0 +1,13 @@
+/* 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/. */
+
+function handleRequest(request, response) {
+ let page = "
Redirecting...
";
+
+ response.setStatusLine(request.httpVersion, "301", "Moved Permanently");
+ response.setHeader("Content-Type", "text/html", false);
+ response.setHeader("Content-Length", page.length + "", false);
+ response.setHeader("Location", "redirect-target.html", false);
+ response.write(page);
+}
diff --git a/toolkit/components/places/tests/browser/redirect_once.sjs b/toolkit/components/places/tests/browser/redirect_once.sjs
new file mode 100644
index 0000000000..b9ccd0829a
--- /dev/null
+++ b/toolkit/components/places/tests/browser/redirect_once.sjs
@@ -0,0 +1,13 @@
+/**
+ * Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/
+ */
+
+function handleRequest(request, response) {
+ response.setStatusLine("1.1", 301, "Found");
+ response.setHeader(
+ "Location",
+ "http://test1.example.com/tests/toolkit/components/places/tests/browser/final.html",
+ false
+ );
+}
diff --git a/toolkit/components/places/tests/browser/redirect_self.sjs b/toolkit/components/places/tests/browser/redirect_self.sjs
new file mode 100644
index 0000000000..953afe5f26
--- /dev/null
+++ b/toolkit/components/places/tests/browser/redirect_self.sjs
@@ -0,0 +1,27 @@
+/* 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/. */
+
+// Script that redirects to itself once.
+
+function handleRequest(request, response) {
+ if (
+ request.hasHeader("Cookie") &&
+ request.getHeader("Cookie").includes("redirect-self")
+ ) {
+ response.setStatusLine("1.0", 200, "OK");
+ // Expire the cookie.
+ response.setHeader(
+ "Set-Cookie",
+ "redirect-self=true; expires=Thu, 01 Jan 1970 00:00:00 GMT",
+ true
+ );
+ response.setHeader("Content-Type", "text/plain; charset=utf-8", false);
+ response.write("OK");
+ } else {
+ response.setStatusLine(request.httpVersion, 302, "Moved Temporarily");
+ response.setHeader("Set-Cookie", "redirect-self=true", true);
+ response.setHeader("Location", "redirect_self.sjs");
+ response.write("Moved Temporarily");
+ }
+}
diff --git a/toolkit/components/places/tests/browser/redirect_thrice.sjs b/toolkit/components/places/tests/browser/redirect_thrice.sjs
new file mode 100644
index 0000000000..55154a736e
--- /dev/null
+++ b/toolkit/components/places/tests/browser/redirect_thrice.sjs
@@ -0,0 +1,9 @@
+/**
+ * Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/
+ */
+
+function handleRequest(request, response) {
+ response.setStatusLine("1.1", 302, "Found");
+ response.setHeader("Location", "redirect_twice_perma.sjs", false);
+}
diff --git a/toolkit/components/places/tests/browser/redirect_twice.sjs b/toolkit/components/places/tests/browser/redirect_twice.sjs
new file mode 100644
index 0000000000..099d20022e
--- /dev/null
+++ b/toolkit/components/places/tests/browser/redirect_twice.sjs
@@ -0,0 +1,9 @@
+/**
+ * Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/
+ */
+
+function handleRequest(request, response) {
+ response.setStatusLine("1.1", 302, "Found");
+ response.setHeader("Location", "redirect_once.sjs", false);
+}
diff --git a/toolkit/components/places/tests/browser/redirect_twice_perma.sjs b/toolkit/components/places/tests/browser/redirect_twice_perma.sjs
new file mode 100644
index 0000000000..a40abd4170
--- /dev/null
+++ b/toolkit/components/places/tests/browser/redirect_twice_perma.sjs
@@ -0,0 +1,9 @@
+/**
+ * Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/
+ */
+
+function handleRequest(request, response) {
+ response.setStatusLine("1.1", 301, "Found");
+ response.setHeader("Location", "redirect_once.sjs", false);
+}
diff --git a/toolkit/components/places/tests/browser/title1.html b/toolkit/components/places/tests/browser/title1.html
new file mode 100644
index 0000000000..3c98d693ec
--- /dev/null
+++ b/toolkit/components/places/tests/browser/title1.html
@@ -0,0 +1,12 @@
+
+
+
+
+
+
+ title1.html
+
+
diff --git a/toolkit/components/places/tests/browser/title2.html b/toolkit/components/places/tests/browser/title2.html
new file mode 100644
index 0000000000..8830328796
--- /dev/null
+++ b/toolkit/components/places/tests/browser/title2.html
@@ -0,0 +1,13 @@
+
+
+
+
+ Some title
+
+
+ title2.html
+
+
diff --git a/toolkit/components/places/tests/chrome/bad_links.atom b/toolkit/components/places/tests/chrome/bad_links.atom
new file mode 100644
index 0000000000..4469272524
--- /dev/null
+++ b/toolkit/components/places/tests/chrome/bad_links.atom
@@ -0,0 +1,74 @@
+
+
+
+ Example Feed
+
+ 2003-12-13T18:30:02Z
+
+
+ John Doe
+
+ urn:uuid:60a76c80-d399-11d9-b93C-0003939e0af6
+
+
+
+ First good item
+
+ urn:uuid:1225c695-cfb8-4ebb-aaaa-80da344efa6a
+ 2003-12-13T18:30:02Z
+
+ Some text.
+
+
+
+
+ data: link
+
+ urn:uuid:1225c695-cfb8-4ebb-aaaa-80da344efa6b
+ 2003-12-13T18:30:03Z
+
+ Some text.
+
+
+
+
+ javascript: link
+
+ urn:uuid:1225c695-cfb8-4ebb-aaaa-80da344efa6c
+ 2003-12-13T18:30:04Z
+
+ Some text.
+
+
+
+
+ file: link
+
+ urn:uuid:1225c695-cfb8-4ebb-aaaa-80da344efa6d
+ 2003-12-13T18:30:05Z
+
+ Some text.
+
+
+
+
+ chrome: link
+
+ urn:uuid:1225c695-cfb8-4ebb-aaaa-80da344efa6e
+ 2003-12-13T18:30:06Z
+
+ Some text.
+
+
+
+
+ Last good item
+
+ urn:uuid:1225c695-cfb8-4ebb-aaaa-80da344efa6b
+ 2003-12-13T18:30:07Z
+
+ Some text.
+
+
+
+
diff --git a/toolkit/components/places/tests/chrome/browser_disableglobalhistory.xhtml b/toolkit/components/places/tests/chrome/browser_disableglobalhistory.xhtml
new file mode 100644
index 0000000000..aa525e6153
--- /dev/null
+++ b/toolkit/components/places/tests/chrome/browser_disableglobalhistory.xhtml
@@ -0,0 +1,42 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/toolkit/components/places/tests/chrome/chrome.toml b/toolkit/components/places/tests/chrome/chrome.toml
new file mode 100644
index 0000000000..d83317341b
--- /dev/null
+++ b/toolkit/components/places/tests/chrome/chrome.toml
@@ -0,0 +1,9 @@
+[DEFAULT]
+support-files = ["head.js"]
+
+["test_371798.xhtml"]
+
+["test_browser_disableglobalhistory.xhtml"]
+support-files = ["browser_disableglobalhistory.xhtml"]
+
+["test_cached_favicon.xhtml"]
diff --git a/toolkit/components/places/tests/chrome/head.js b/toolkit/components/places/tests/chrome/head.js
new file mode 100644
index 0000000000..7c03e6f33d
--- /dev/null
+++ b/toolkit/components/places/tests/chrome/head.js
@@ -0,0 +1,8 @@
+var { XPCOMUtils } = ChromeUtils.importESModule(
+ "resource://gre/modules/XPCOMUtils.sys.mjs"
+);
+
+ChromeUtils.defineESModuleGetters(this, {
+ PlacesTestUtils: "resource://testing-common/PlacesTestUtils.sys.mjs",
+ PlacesUtils: "resource://gre/modules/PlacesUtils.sys.mjs",
+});
diff --git a/toolkit/components/places/tests/chrome/link-less-items-no-site-uri.rss b/toolkit/components/places/tests/chrome/link-less-items-no-site-uri.rss
new file mode 100644
index 0000000000..612b0a5c2e
--- /dev/null
+++ b/toolkit/components/places/tests/chrome/link-less-items-no-site-uri.rss
@@ -0,0 +1,18 @@
+
+
+
+ feed title
+ 180
+
+ linked feed item
+ http://feed-item-link.com
+
+
+ link-less feed item
+
+
+ linked feed item
+ http://feed-item-link.com
+
+
+
diff --git a/toolkit/components/places/tests/chrome/link-less-items.rss b/toolkit/components/places/tests/chrome/link-less-items.rss
new file mode 100644
index 0000000000..a30d4a3531
--- /dev/null
+++ b/toolkit/components/places/tests/chrome/link-less-items.rss
@@ -0,0 +1,19 @@
+
+
+
+ feed title
+ http://feed-link.com
+ 180
+
+ linked feed item
+ http://feed-item-link.com
+
+
+ link-less feed item
+
+
+ linked feed item
+ http://feed-item-link.com
+
+
+
diff --git a/toolkit/components/places/tests/chrome/rss_as_html.rss b/toolkit/components/places/tests/chrome/rss_as_html.rss
new file mode 100644
index 0000000000..e823050353
--- /dev/null
+++ b/toolkit/components/places/tests/chrome/rss_as_html.rss
@@ -0,0 +1,27 @@
+
+
+
+sadfasdfasdfasfasdf
+http://www.example.com
+asdfasdfasdf.example.com
+de
+asdfasdfasdfasdf
+Tue, 11 Mar 2008 18:52:52 +0100
+http://blogs.law.harvard.edu/tech/rss
+10
+
+The First Title
+http://www.example.com/index.html
+Tue, 11 Mar 2008 18:24:43 +0100
+
+
+askdlfjas;dfkjas;fkdj
+
+]]>
+
+aklsjdhfasdjfahasdfhj
+http://foo.example.com/asdfasdf
+
+
+
diff --git a/toolkit/components/places/tests/chrome/rss_as_html.rss^headers^ b/toolkit/components/places/tests/chrome/rss_as_html.rss^headers^
new file mode 100644
index 0000000000..04fbaa08fe
--- /dev/null
+++ b/toolkit/components/places/tests/chrome/rss_as_html.rss^headers^
@@ -0,0 +1,2 @@
+HTTP 200 OK
+Content-Type: text/html
diff --git a/toolkit/components/places/tests/chrome/sample_feed.atom b/toolkit/components/places/tests/chrome/sample_feed.atom
new file mode 100644
index 0000000000..add75efb4d
--- /dev/null
+++ b/toolkit/components/places/tests/chrome/sample_feed.atom
@@ -0,0 +1,23 @@
+
+
+
+ Example Feed
+
+ 2003-12-13T18:30:02Z
+
+
+ John Doe
+
+ urn:uuid:60a76c80-d399-11d9-b93C-0003939e0af6
+
+
+
+ Atom-Powered Robots Run Amok
+
+ urn:uuid:1225c695-cfb8-4ebb-aaaa-80da344efa6a
+ 2003-12-13T18:30:02Z
+
+ Some text.
+
+
+
diff --git a/toolkit/components/places/tests/chrome/test_371798.xhtml b/toolkit/components/places/tests/chrome/test_371798.xhtml
new file mode 100644
index 0000000000..33e866e51e
--- /dev/null
+++ b/toolkit/components/places/tests/chrome/test_371798.xhtml
@@ -0,0 +1,76 @@
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/toolkit/components/places/tests/chrome/test_browser_disableglobalhistory.xhtml b/toolkit/components/places/tests/chrome/test_browser_disableglobalhistory.xhtml
new file mode 100644
index 0000000000..6a7d32dabe
--- /dev/null
+++ b/toolkit/components/places/tests/chrome/test_browser_disableglobalhistory.xhtml
@@ -0,0 +1,25 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/toolkit/components/places/tests/chrome/test_cached_favicon.xhtml b/toolkit/components/places/tests/chrome/test_cached_favicon.xhtml
new file mode 100644
index 0000000000..f7e7f4f1d8
--- /dev/null
+++ b/toolkit/components/places/tests/chrome/test_cached_favicon.xhtml
@@ -0,0 +1,135 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/toolkit/components/places/tests/expiration/head_expiration.js b/toolkit/components/places/tests/expiration/head_expiration.js
new file mode 100644
index 0000000000..ce9fe48348
--- /dev/null
+++ b/toolkit/components/places/tests/expiration/head_expiration.js
@@ -0,0 +1,112 @@
+/* -*- indent-tabs-mode: nil; js-indent-level: 2 -*-
+ * vim: sw=2 ts=2 et lcs=trail\:.,tab\:>~ :
+ * 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/. */
+
+// Import common head.
+{
+ /* import-globals-from ../head_common.js */
+ let commonFile = do_get_file("../head_common.js", false);
+ let uri = Services.io.newFileURI(commonFile);
+ Services.scriptloader.loadSubScript(uri.spec, this);
+}
+
+// Put any other stuff relative to this test folder below.
+
+/**
+ * Causes expiration component to start, otherwise it would wait for the first
+ * history notification.
+ */
+function force_expiration_start() {
+ Cc["@mozilla.org/places/expiration;1"]
+ .getService(Ci.nsIObserver)
+ .observe(null, "testing-mode", null);
+}
+
+/**
+ * Forces an expiration run.
+ *
+ * @param [optional] aLimit
+ * Limit for the expiration. Pass -1 for unlimited.
+ * Any other non-positive value will just expire orphans.
+ *
+ * @return {Promise}
+ * @resolves When expiration finishes.
+ * @rejects Never.
+ */
+function promiseForceExpirationStep(aLimit) {
+ let promise = promiseTopicObserved(PlacesUtils.TOPIC_EXPIRATION_FINISHED);
+ let expire = Cc["@mozilla.org/places/expiration;1"].getService(
+ Ci.nsIObserver
+ );
+ expire.observe(null, "places-debug-start-expiration", aLimit);
+ return promise;
+}
+
+/**
+ * Expiration preferences helpers.
+ */
+
+function setInterval(aNewInterval) {
+ Services.prefs.setIntPref(
+ "places.history.expiration.interval_seconds",
+ aNewInterval
+ );
+}
+function getInterval() {
+ return Services.prefs.getIntPref(
+ "places.history.expiration.interval_seconds"
+ );
+}
+function clearInterval() {
+ try {
+ Services.prefs.clearUserPref("places.history.expiration.interval_seconds");
+ } catch (ex) {}
+}
+
+function setMaxPages(aNewMaxPages) {
+ Services.prefs.setIntPref(
+ "places.history.expiration.max_pages",
+ aNewMaxPages
+ );
+}
+function getMaxPages() {
+ return Services.prefs.getIntPref("places.history.expiration.max_pages");
+}
+function clearMaxPages() {
+ try {
+ Services.prefs.clearUserPref("places.history.expiration.max_pages");
+ } catch (ex) {}
+}
+
+function setHistoryEnabled(aHistoryEnabled) {
+ Services.prefs.setBoolPref("places.history.enabled", aHistoryEnabled);
+}
+function getHistoryEnabled() {
+ return Services.prefs.getBoolPref("places.history.enabled");
+}
+function clearHistoryEnabled() {
+ try {
+ Services.prefs.clearUserPref("places.history.enabled");
+ } catch (ex) {}
+}
+
+/**
+ * Returns a PRTime in the past usable to add expirable visits.
+ *
+ * param [optional] daysAgo
+ * Expiration ignores any visit added in the last 7 days, so by default
+ * this will be set to 7.
+ * @note to be safe against DST issues we go back one day more.
+ */
+function getExpirablePRTime(daysAgo = 7) {
+ let dateObj = new Date();
+ // Normalize to midnight
+ dateObj.setHours(0);
+ dateObj.setMinutes(0);
+ dateObj.setSeconds(0);
+ dateObj.setMilliseconds(0);
+ dateObj = new Date(dateObj.getTime() - (daysAgo + 1) * 86400000);
+ return dateObj.getTime() * 1000;
+}
diff --git a/toolkit/components/places/tests/expiration/test_annos_expire_never.js b/toolkit/components/places/tests/expiration/test_annos_expire_never.js
new file mode 100644
index 0000000000..39c55ecc04
--- /dev/null
+++ b/toolkit/components/places/tests/expiration/test_annos_expire_never.js
@@ -0,0 +1,72 @@
+/* -*- indent-tabs-mode: nil; js-indent-level: 2 -*-
+ * vim: sw=2 ts=2 et lcs=trail\:.,tab\:>~ :
+ * 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/. */
+
+/**
+ * What this is aimed to test:
+ *
+ * EXPIRE_NEVER annotations should be expired when a page is removed from the
+ * database.
+ * If the annotation is a page annotation this will happen when the page is
+ * expired, namely when the page has no visits and is not bookmarked.
+ */
+
+add_task(async function test_annos_expire_never() {
+ // Set interval to a large value so we don't expire on it.
+ setInterval(3600); // 1h
+
+ // Expire all expirable pages.
+ setMaxPages(0);
+
+ // Add some visited page and a couple expire never annotations for each.
+ let now = getExpirablePRTime();
+ for (let i = 0; i < 5; i++) {
+ let pageURI = uri("http://page_anno." + i + ".mozilla.org/");
+ await PlacesTestUtils.addVisits({ uri: pageURI, visitDate: now++ });
+ await PlacesUtils.history.update({
+ url: pageURI,
+ annotations: new Map([
+ ["page_expire1", "test"],
+ ["page_expire2", "test"],
+ ]),
+ });
+ }
+
+ let pages = await getPagesWithAnnotation("page_expire1");
+ Assert.equal(pages.length, 5);
+ pages = await getPagesWithAnnotation("page_expire2");
+ Assert.equal(pages.length, 5);
+
+ // Add other visited page and a couple expire never annotations for each.
+ // We won't expire these visits, so the annotations should survive.
+ for (let i = 0; i < 5; i++) {
+ let pageURI = uri("http://persist_page_anno." + i + ".mozilla.org/");
+ await PlacesTestUtils.addVisits({ uri: pageURI, visitDate: now++ });
+ await PlacesUtils.history.update({
+ url: pageURI,
+ annotations: new Map([
+ ["page_persist1", "test"],
+ ["page_persist2", "test"],
+ ]),
+ });
+ }
+
+ pages = await getPagesWithAnnotation("page_persist1");
+ Assert.equal(pages.length, 5);
+ pages = await getPagesWithAnnotation("page_persist2");
+ Assert.equal(pages.length, 5);
+
+ // Expire all visits for the first 5 pages and the bookmarks.
+ await promiseForceExpirationStep(5);
+
+ pages = await getPagesWithAnnotation("page_expire1");
+ Assert.equal(pages.length, 0);
+ pages = await getPagesWithAnnotation("page_expire2");
+ Assert.equal(pages.length, 0);
+ pages = await getPagesWithAnnotation("page_persist1");
+ Assert.equal(pages.length, 5);
+ pages = await getPagesWithAnnotation("page_persist2");
+ Assert.equal(pages.length, 5);
+});
diff --git a/toolkit/components/places/tests/expiration/test_clearHistory.js b/toolkit/components/places/tests/expiration/test_clearHistory.js
new file mode 100644
index 0000000000..a4684f0269
--- /dev/null
+++ b/toolkit/components/places/tests/expiration/test_clearHistory.js
@@ -0,0 +1,57 @@
+/* -*- indent-tabs-mode: nil; js-indent-level: 2 -*-
+ * vim: sw=2 ts=2 et lcs=trail\:.,tab\:>~ :
+ * 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/. */
+
+/**
+ * What this is aimed to test:
+ *
+ * History.clear() should expire everything but bookmarked pages and valid
+ * annos.
+ */
+
+add_task(async function test_historyClear() {
+ // Set interval to a large value so we don't expire on it.
+ setInterval(3600); // 1h
+
+ // Expire all expirable pages.
+ setMaxPages(0);
+
+ // Add some bookmarked page with visit and annotations.
+ for (let i = 0; i < 5; i++) {
+ let pageURI = uri("http://item_anno." + i + ".mozilla.org/");
+ // This visit will be expired.
+ await PlacesTestUtils.addVisits({ uri: pageURI });
+ await PlacesUtils.bookmarks.insert({
+ parentGuid: PlacesUtils.bookmarks.unfiledGuid,
+ url: pageURI,
+ title: null,
+ });
+ // Will persist because the page is bookmarked.
+ await PlacesUtils.history.update({
+ url: pageURI,
+ annotations: new Map([["persist", "test"]]),
+ });
+ }
+
+ // Add some visited page and annotations for each.
+ for (let i = 0; i < 5; i++) {
+ // All page annotations related to these expired pages are expected to
+ // expire as well.
+ let pageURI = uri("http://page_anno." + i + ".mozilla.org/");
+ await PlacesTestUtils.addVisits({ uri: pageURI });
+ await PlacesUtils.history.update({
+ url: pageURI,
+ annotations: new Map([["expire", "test"]]),
+ });
+ }
+
+ // Expire all visits for the bookmarks
+ await PlacesUtils.history.clear();
+
+ Assert.equal((await getPagesWithAnnotation("expire")).length, 0);
+
+ let pages = await getPagesWithAnnotation("persist");
+ Assert.equal(pages.length, 5);
+});
diff --git a/toolkit/components/places/tests/expiration/test_debug_expiration.js b/toolkit/components/places/tests/expiration/test_debug_expiration.js
new file mode 100644
index 0000000000..204295d46c
--- /dev/null
+++ b/toolkit/components/places/tests/expiration/test_debug_expiration.js
@@ -0,0 +1,469 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+/**
+ * What this is aimed to test:
+ *
+ * Expiration can be manually triggered through a debug topic, but that should
+ * only expire orphan entries, unless -1 is passed as limit.
+ */
+
+const EXPIRE_DAYS = 90;
+var gExpirableTime = getExpirablePRTime(EXPIRE_DAYS);
+var gNonExpirableTime = getExpirablePRTime(EXPIRE_DAYS - 2);
+
+add_task(async function test_expire_orphans() {
+ // Add visits to 2 pages and force a orphan expiration. Visits should survive.
+ await PlacesTestUtils.addVisits({
+ uri: uri("http://page1.mozilla.org/"),
+ visitDate: gExpirableTime++,
+ });
+ await PlacesTestUtils.addVisits({
+ uri: uri("http://page2.mozilla.org/"),
+ visitDate: gExpirableTime++,
+ });
+ // Create a orphan place.
+ let bm = await PlacesUtils.bookmarks.insert({
+ parentGuid: PlacesUtils.bookmarks.unfiledGuid,
+ url: "http://page3.mozilla.org/",
+ title: "",
+ });
+ await PlacesUtils.bookmarks.remove(bm);
+
+ // Expire now.
+ await promiseForceExpirationStep(0);
+
+ // Check that visits survived.
+ Assert.equal(visits_in_database("http://page1.mozilla.org/"), 1);
+ Assert.equal(visits_in_database("http://page2.mozilla.org/"), 1);
+ Assert.ok(!page_in_database("http://page3.mozilla.org/"));
+
+ // Clean up.
+ await PlacesUtils.history.clear();
+});
+
+add_task(async function test_expire_orphans_optionalarg() {
+ // Add visits to 2 pages and force a orphan expiration. Visits should survive.
+ await PlacesTestUtils.addVisits({
+ uri: uri("http://page1.mozilla.org/"),
+ visitDate: gExpirableTime++,
+ });
+ await PlacesTestUtils.addVisits({
+ uri: uri("http://page2.mozilla.org/"),
+ visitDate: gExpirableTime++,
+ });
+ // Create a orphan place.
+ let bm = await PlacesUtils.bookmarks.insert({
+ parentGuid: PlacesUtils.bookmarks.unfiledGuid,
+ url: "http://page3.mozilla.org/",
+ title: "",
+ });
+ await PlacesUtils.bookmarks.remove(bm);
+
+ // Expire now.
+ await promiseForceExpirationStep();
+
+ // Check that visits survived.
+ Assert.equal(visits_in_database("http://page1.mozilla.org/"), 1);
+ Assert.equal(visits_in_database("http://page2.mozilla.org/"), 1);
+ Assert.ok(!page_in_database("http://page3.mozilla.org/"));
+
+ // Clean up.
+ await PlacesUtils.history.clear();
+});
+
+add_task(async function test_expire_limited() {
+ await PlacesTestUtils.addVisits([
+ {
+ // Should be expired cause it's the oldest visit
+ uri: "http://old.mozilla.org/",
+ visitDate: gExpirableTime++,
+ },
+ {
+ // Should not be expired cause we limit 1
+ uri: "http://new.mozilla.org/",
+ visitDate: gExpirableTime++,
+ },
+ ]);
+
+ // Expire now.
+ await promiseForceExpirationStep(1);
+
+ // Check that newer visit survived.
+ Assert.equal(visits_in_database("http://new.mozilla.org/"), 1);
+ // Other visits should have been expired.
+ Assert.ok(!page_in_database("http://old.mozilla.org/"));
+
+ // Clean up.
+ await PlacesUtils.history.clear();
+});
+
+add_task(async function test_expire_visitcount_longurl() {
+ let longurl = "http://long.mozilla.org/" + "a".repeat(232);
+ let longurl2 = "http://long2.mozilla.org/" + "a".repeat(232);
+ await PlacesTestUtils.addVisits([
+ {
+ // Should be expired cause it's the oldest visit
+ uri: "http://old.mozilla.org/",
+ visitDate: gExpirableTime++,
+ },
+ {
+ // Should not be expired cause it has 2 visits.
+ uri: longurl,
+ visitDate: gExpirableTime++,
+ },
+ {
+ uri: longurl,
+ visitDate: gNonExpirableTime,
+ },
+ {
+ // Should be expired cause it has 1 old visit.
+ uri: longurl2,
+ visitDate: gExpirableTime++,
+ },
+ ]);
+
+ await promiseForceExpirationStep(1);
+
+ // Check that some visits survived.
+ Assert.equal(visits_in_database(longurl), 2);
+ // Check visit has been removed.
+ Assert.equal(visits_in_database(longurl2), 0);
+
+ // Other visits should have been expired.
+ Assert.ok(!page_in_database("http://old.mozilla.org/"));
+
+ // Clean up.
+ await PlacesUtils.history.clear();
+});
+
+add_task(async function test_expire_limited_exoticurl() {
+ await PlacesTestUtils.addVisits([
+ {
+ // Should be expired cause it's the oldest visit
+ uri: "http://old.mozilla.org/",
+ visitDate: gExpirableTime++,
+ },
+ {
+ // Should not be expired cause younger than EXPIRE_DAYS.
+ uri: "http://nonexpirable-download.mozilla.org",
+ visitDate: gNonExpirableTime,
+ transition: PlacesUtils.history.TRANSITIONS.DOWNLOAD,
+ },
+ {
+ // Should be expired cause it's a long url older than EXPIRE_DAYS.
+ uri: "http://download.mozilla.org",
+ visitDate: gExpirableTime++,
+ transition: 7,
+ },
+ ]);
+
+ await promiseForceExpirationStep(1);
+
+ // Check that some visits survived.
+ Assert.equal(
+ visits_in_database("http://nonexpirable-download.mozilla.org/"),
+ 1
+ );
+ // The visits are gone, the url is not yet, cause we limited the expiration
+ // to one entry, and we already removed http://old.mozilla.org/.
+ // The page normally would be expired by the next expiration run.
+ Assert.equal(visits_in_database("http://download.mozilla.org/"), 0);
+ // Other visits should have been expired.
+ Assert.ok(!page_in_database("http://old.mozilla.org/"));
+
+ // Clean up.
+ await PlacesUtils.history.clear();
+});
+
+add_task(async function test_expire_exotic_hidden() {
+ let visits = [
+ {
+ // Should be expired cause it's the oldest visit
+ uri: "http://old.mozilla.org/",
+ visitDate: gExpirableTime++,
+ expectedCount: 0,
+ },
+ {
+ // Expirable typed hidden url.
+ uri: "https://typedhidden.mozilla.org/",
+ visitDate: gExpirableTime++,
+ transition: PlacesUtils.history.TRANSITIONS.FRAMED_LINK,
+ expectedCount: 2,
+ },
+ {
+ // Mark as typed.
+ uri: "https://typedhidden.mozilla.org/",
+ visitDate: gExpirableTime++,
+ transition: PlacesUtils.history.TRANSITIONS.TYPED,
+ expectedCount: 2,
+ },
+ {
+ // Expirable non-typed hidden url.
+ uri: "https://hidden.mozilla.org/",
+ visitDate: gExpirableTime++,
+ transition: PlacesUtils.history.TRANSITIONS.FRAMED_LINK,
+ expectedCount: 0,
+ },
+ ];
+ await PlacesTestUtils.addVisits(visits);
+ for (let visit of visits) {
+ Assert.greater(visits_in_database(visit.uri), 0);
+ }
+
+ await promiseForceExpirationStep(1);
+
+ for (let visit of visits) {
+ Assert.equal(
+ visits_in_database(visit.uri),
+ visit.expectedCount,
+ `${visit.uri} should${
+ visit.expectedCount == 0 ? " " : " not "
+ }have been expired`
+ );
+ }
+ // Clean up.
+ await PlacesUtils.history.clear();
+});
+
+add_task(async function test_expire_unlimited() {
+ let longurl = "http://long.mozilla.org/" + "a".repeat(232);
+ await PlacesTestUtils.addVisits([
+ {
+ uri: "http://old.mozilla.org/",
+ visitDate: gExpirableTime++,
+ },
+ {
+ uri: "http://new.mozilla.org/",
+ visitDate: gExpirableTime++,
+ },
+ // Add expirable visits.
+ {
+ uri: "http://download.mozilla.org/",
+ visitDate: gExpirableTime++,
+ transition: PlacesUtils.history.TRANSITION_DOWNLOAD,
+ },
+ {
+ uri: longurl,
+ visitDate: gExpirableTime++,
+ },
+
+ // Add non-expirable visits
+ {
+ uri: "http://nonexpirable.mozilla.org/",
+ visitDate: getExpirablePRTime(5),
+ },
+ {
+ uri: "http://nonexpirable-download.mozilla.org/",
+ visitDate: getExpirablePRTime(5),
+ transition: PlacesUtils.history.TRANSITION_DOWNLOAD,
+ },
+ {
+ uri: longurl,
+ visitDate: getExpirablePRTime(5),
+ },
+ ]);
+
+ await promiseForceExpirationStep(-1);
+
+ // Check that some visits survived.
+ Assert.equal(visits_in_database("http://nonexpirable.mozilla.org/"), 1);
+ Assert.equal(
+ visits_in_database("http://nonexpirable-download.mozilla.org/"),
+ 1
+ );
+ Assert.equal(visits_in_database(longurl), 1);
+ // Other visits should have been expired.
+ Assert.ok(!page_in_database("http://old.mozilla.org/"));
+ Assert.ok(!page_in_database("http://download.mozilla.org/"));
+ Assert.ok(!page_in_database("http://new.mozilla.org/"));
+
+ // Clean up.
+ await PlacesUtils.history.clear();
+});
+
+add_task(async function test_expire_icons() {
+ const dataUrl =
+ "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAA" +
+ "AAAA6fptVAAAACklEQVQI12NgAAAAAgAB4iG8MwAAAABJRU5ErkJggg==";
+
+ const entries = [
+ {
+ desc: "Not expired because recent",
+ page: "https://recent.notexpired.org/",
+ icon: "https://recent.notexpired.org/test_icon.png",
+ root: "https://recent.notexpired.org/favicon.ico",
+ iconExpired: false,
+ removed: false,
+ },
+ {
+ desc: "Not expired because recent, no root",
+ page: "https://recentnoroot.notexpired.org/",
+ icon: "https://recentnoroot.notexpired.org/test_icon.png",
+ iconExpired: false,
+ removed: false,
+ },
+ {
+ desc: "Expired because old with root",
+ page: "https://oldroot.expired.org/",
+ icon: "https://oldroot.expired.org/test_icon.png",
+ root: "https://oldroot.expired.org/favicon.ico",
+ iconExpired: true,
+ removed: true,
+ },
+ {
+ desc: "Not expired because bookmarked, even if old with root",
+ page: "https://oldrootbm.notexpired.org/",
+ icon: "https://oldrootbm.notexpired.org/test_icon.png",
+ root: "https://oldrootbm.notexpired.org/favicon.ico",
+ bookmarked: true,
+ iconExpired: true,
+ removed: false,
+ },
+ {
+ desc: "Not Expired because old but has no root",
+ page: "https://old.notexpired.org/",
+ icon: "https://old.notexpired.org/test_icon.png",
+ iconExpired: true,
+ removed: false,
+ },
+ {
+ desc: "Expired because it's an orphan page",
+ page: "http://root.ref.org/#test",
+ icon: undefined,
+ iconExpired: false,
+ removed: true,
+ },
+ {
+ desc: "Expired because it's an orphan page",
+ page: "http://root.ref.org/#test",
+ icon: undefined,
+ skipHistory: true,
+ iconExpired: false,
+ removed: true,
+ },
+ ];
+
+ for (let entry of entries) {
+ if (!entry.skipHistory) {
+ await PlacesTestUtils.addVisits(entry.page);
+ }
+ if (entry.bookmarked) {
+ await PlacesUtils.bookmarks.insert({
+ url: entry.page,
+ parentGuid: PlacesUtils.bookmarks.unfiledGuid,
+ });
+ }
+
+ if (entry.icon) {
+ PlacesUtils.favicons.replaceFaviconDataFromDataURL(
+ Services.io.newURI(entry.icon),
+ dataUrl,
+ 0,
+ Services.scriptSecurityManager.getSystemPrincipal()
+ );
+ await PlacesTestUtils.addFavicons(new Map([[entry.page, entry.icon]]));
+ Assert.equal(
+ await getFaviconUrlForPage(entry.page),
+ entry.icon,
+ "Sanity check the icon exists"
+ );
+ } else {
+ // This is an orphan page entry.
+ await PlacesUtils.withConnectionWrapper("addOrphanPage", async db => {
+ await db.execute(
+ `INSERT INTO moz_pages_w_icons (page_url, page_url_hash)
+ VALUES (:url, hash(:url))`,
+ { url: entry.page }
+ );
+ });
+ }
+
+ if (entry.root) {
+ PlacesUtils.favicons.replaceFaviconDataFromDataURL(
+ Services.io.newURI(entry.root),
+ dataUrl,
+ 0,
+ Services.scriptSecurityManager.getSystemPrincipal()
+ );
+ await PlacesTestUtils.addFavicons(new Map([[entry.page, entry.root]]));
+ }
+
+ if (entry.iconExpired) {
+ // Set an expired time on the icon.
+ await PlacesUtils.withConnectionWrapper("expireFavicon", async db => {
+ await db.execute(
+ `UPDATE moz_icons_to_pages SET expire_ms = 1
+ WHERE icon_id = (SELECT id FROM moz_icons WHERE icon_url = :url)`,
+ { url: entry.icon }
+ );
+ if (entry.root) {
+ await db.execute(
+ `UPDATE moz_icons SET expire_ms = 1 WHERE icon_url = :url`,
+ { url: entry.root }
+ );
+ }
+ });
+ }
+ if (entry.icon) {
+ Assert.equal(
+ await getFaviconUrlForPage(entry.page),
+ entry.icon,
+ "Sanity check the initial icon value"
+ );
+ }
+ }
+
+ info("Run expiration");
+ await promiseForceExpirationStep(-1);
+
+ info("Check expiration");
+ for (let entry of entries) {
+ Assert.ok(page_in_database(entry.page));
+
+ if (!entry.removed) {
+ Assert.equal(
+ await getFaviconUrlForPage(entry.page),
+ entry.icon,
+ entry.desc
+ );
+ continue;
+ }
+
+ if (entry.root) {
+ Assert.equal(
+ await getFaviconUrlForPage(entry.page),
+ entry.root,
+ entry.desc
+ );
+ continue;
+ }
+
+ if (entry.icon) {
+ await Assert.rejects(
+ getFaviconUrlForPage(entry.page),
+ /Unable to find an icon/,
+ entry.desc
+ );
+ continue;
+ }
+
+ // This was an orphan page entry.
+ let db = await PlacesUtils.promiseDBConnection();
+ let rows = await db.execute(
+ `SELECT count(*) FROM moz_pages_w_icons WHERE page_url_hash = hash(:url)`,
+ { url: entry.page }
+ );
+ Assert.equal(rows[0].getResultByIndex(0), 0, "Orphan page was removed");
+ }
+
+ // Clean up.
+ await PlacesUtils.history.clear();
+});
+
+add_setup(async function () {
+ // Set interval to a large value so we don't expire on it.
+ setInterval(3600); // 1h
+ // Set maxPages to a low value, so it's easy to go over it.
+ setMaxPages(1);
+});
diff --git a/toolkit/components/places/tests/expiration/test_idle_daily.js b/toolkit/components/places/tests/expiration/test_idle_daily.js
new file mode 100644
index 0000000000..11547e37dc
--- /dev/null
+++ b/toolkit/components/places/tests/expiration/test_idle_daily.js
@@ -0,0 +1,22 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+// Test that expiration runs on idle-daily.
+
+add_task(async function test_expiration_on_idle_daily() {
+ // Set interval to a large value so we don't expire on it.
+ setInterval(3600); // 1h
+
+ let expirationPromise = TestUtils.topicObserved(
+ PlacesUtils.TOPIC_EXPIRATION_FINISHED
+ );
+
+ let expire = Cc["@mozilla.org/places/expiration;1"].getService(
+ Ci.nsIObserver
+ );
+ expire.observe(null, "idle-daily", null);
+
+ await expirationPromise;
+});
diff --git a/toolkit/components/places/tests/expiration/test_interactions_expiration.js b/toolkit/components/places/tests/expiration/test_interactions_expiration.js
new file mode 100644
index 0000000000..67b4b466c3
--- /dev/null
+++ b/toolkit/components/places/tests/expiration/test_interactions_expiration.js
@@ -0,0 +1,102 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+/**
+ * Tests expiration of Places interactions data.
+ */
+// Number of days in the past where interactions will be expired.
+const EXPIRE_DAYS = 60;
+// Should be more recent than EXPIRED_DAYS.
+const RECENT_DATE = new Date() - (EXPIRE_DAYS - 1) * 86400000;
+
+add_task(async function setup() {
+ Services.prefs.setBoolPref("browser.places.interactions.enabled", true);
+ Services.prefs.setIntPref(
+ "browser.places.interactions.expireDays",
+ EXPIRE_DAYS
+ );
+});
+
+add_task(async function test_expire_interactions() {
+ // Add visits and metadata to 2 pages and force expiration.
+ await PlacesTestUtils.addVisits([
+ "https://expired.mozilla.org/",
+ "https://interactions-expired.mozilla.org/",
+ "https://some-interaction-expired.mozilla.org/",
+ "https://not-expired.mozilla.org/",
+ ]);
+ // Insert dummy interactions for all the pages.
+ await addDummyInteractions("https://removed.mozilla.org/", [0]);
+ await addDummyInteractions("https://interactions-expired.mozilla.org/", [
+ EXPIRE_DAYS + 10,
+ ]);
+ await addDummyInteractions("https://some-interactions-expired.mozilla.org/", [
+ 0,
+ EXPIRE_DAYS + 10,
+ ]);
+ await addDummyInteractions("https://not-expired.mozilla.org/", [
+ 0,
+ EXPIRE_DAYS / 2,
+ ]);
+
+ info("Remove a page from history and check interactions are removed");
+ await PlacesUtils.history.remove("https://removed.mozilla.org/");
+ await checkDummyInteractions("https://removed.mozilla.org/", 0);
+
+ // Expire now.
+ await promiseForceExpirationStep(-1);
+
+ info("Test interactions expiration result");
+ await checkDummyInteractions("https://interactions-expired.mozilla.org/", 0);
+ await checkDummyInteractions(
+ "https://some-interactions-expired.mozilla.org/",
+ 1
+ );
+ await checkDummyInteractions("https://not-expired.mozilla.org/", 2);
+
+ // Clean up.
+ await PlacesUtils.history.clear();
+});
+
+async function addDummyInteractions(url, interactionDaysAgo) {
+ await PlacesTestUtils.addVisits(url);
+ await PlacesUtils.withConnectionWrapper(
+ "test_interactions_expiration.js: addDummyInteraction",
+ async db => {
+ await db.execute(
+ `INSERT INTO moz_places_metadata (place_id, created_at, updated_at) VALUES (
+ (SELECT id FROM moz_places WHERE url_hash = hash(:url)),
+ strftime('%s','now','localtime','-' || :days || ' day','start of day','utc') * 1000,
+ strftime('%s','now','localtime','-' || :days || ' day','start of day','utc') * 1000
+ )`,
+ interactionDaysAgo.map(days => ({ url, days }))
+ );
+ }
+ );
+}
+
+async function checkDummyInteractions(url, interactionsLen) {
+ info("Check interactions for " + url);
+ await PlacesUtils.withConnectionWrapper(
+ "test_interactions_expiration.js: addDummyInteraction",
+ async db => {
+ let rows = await db.execute(
+ `SELECT updated_at
+ FROM moz_places_metadata
+ WHERE place_id = (SELECT id FROM moz_places WHERE url_hash = hash(:url))
+ ORDER BY updated_at DESC`,
+ { url }
+ );
+ let dates = rows.map(r => new Date(r.getResultByName("updated_at")));
+ Assert.equal(
+ rows.length,
+ interactionsLen,
+ "Found expected number of interactions"
+ );
+ Assert.ok(
+ dates.every(d => d > RECENT_DATE),
+ "All interactions are recent"
+ );
+ }
+ );
+}
diff --git a/toolkit/components/places/tests/expiration/test_notifications.js b/toolkit/components/places/tests/expiration/test_notifications.js
new file mode 100644
index 0000000000..d52319a9c9
--- /dev/null
+++ b/toolkit/components/places/tests/expiration/test_notifications.js
@@ -0,0 +1,36 @@
+/* -*- indent-tabs-mode: nil; js-indent-level: 2 -*-
+ * vim: sw=2 ts=2 et lcs=trail\:.,tab\:>~ :
+ * 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";
+
+/**
+ * What this is aimed to test:
+ *
+ * Ensure that History (through category cache) notifies us just once.
+ */
+
+var gObserver = {
+ notifications: 0,
+ observe(aSubject, aTopic, aData) {
+ this.notifications++;
+ },
+};
+Services.obs.addObserver(gObserver, PlacesUtils.TOPIC_EXPIRATION_FINISHED);
+
+add_task(async function test_history_expirations_notify_just_once() {
+ // Set interval to a large value so we don't expire on it.
+ setInterval(3600); // 1h
+
+ promiseForceExpirationStep(1);
+
+ await new Promise(resolve => {
+ do_timeout(2000, resolve);
+ });
+
+ Assert.equal(gObserver.notifications, 1);
+
+ Services.obs.removeObserver(gObserver, PlacesUtils.TOPIC_EXPIRATION_FINISHED);
+});
diff --git a/toolkit/components/places/tests/expiration/test_notifications_pageRemoved_allVisits.js b/toolkit/components/places/tests/expiration/test_notifications_pageRemoved_allVisits.js
new file mode 100644
index 0000000000..172f29cf96
--- /dev/null
+++ b/toolkit/components/places/tests/expiration/test_notifications_pageRemoved_allVisits.js
@@ -0,0 +1,156 @@
+/* -*- indent-tabs-mode: nil; js-indent-level: 2 -*-
+ * vim: sw=2 ts=2 et lcs=trail\:.,tab\:>~ :
+ * 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/. */
+
+/**
+ * What this is aimed to test:
+ *
+ * Expiring only visits for a page, but not the full page, should fire an
+ * page-removed for all visits notification.
+ */
+
+var tests = [
+ {
+ desc: "Add 1 bookmarked page.",
+ addPages: 1,
+ visitsPerPage: 1,
+ addBookmarks: 1,
+ limitExpiration: -1,
+ expectedNotifications: 1, // Will expire visits for 1 page.
+ expectedIsPartialRemoval: true,
+ },
+
+ {
+ desc: "Add 2 pages, 1 bookmarked.",
+ addPages: 2,
+ visitsPerPage: 1,
+ addBookmarks: 1,
+ limitExpiration: -1,
+ expectedNotifications: 1, // Will expire visits for 1 page.
+ expectedIsPartialRemoval: true,
+ },
+
+ {
+ desc: "Add 10 pages, none bookmarked.",
+ addPages: 10,
+ visitsPerPage: 1,
+ addBookmarks: 0,
+ limitExpiration: -1,
+ expectedNotifications: 0, // Will expire only full pages.
+ expectedIsPartialRemoval: false,
+ },
+
+ {
+ desc: "Add 10 pages, all bookmarked.",
+ addPages: 10,
+ visitsPerPage: 1,
+ addBookmarks: 10,
+ limitExpiration: -1,
+ expectedNotifications: 10, // Will expire visits for all pages.
+ expectedIsPartialRemoval: true,
+ },
+
+ {
+ desc: "Add 10 pages with lot of visits, none bookmarked.",
+ addPages: 10,
+ visitsPerPage: 10,
+ addBookmarks: 0,
+ limitExpiration: 10,
+ expectedNotifications: 10, // Will expire 1 visit for each page, but won't
+ // expire pages since they still have visits.
+ expectedIsPartialRemoval: true,
+ },
+];
+
+add_task(async () => {
+ // Set interval to a large value so we don't expire on it.
+ setInterval(3600); // 1h
+
+ // Expire anything that is expirable.
+ setMaxPages(0);
+
+ for (let testIndex = 1; testIndex <= tests.length; testIndex++) {
+ let currentTest = tests[testIndex - 1];
+ info("TEST " + testIndex + ": " + currentTest.desc);
+ currentTest.receivedNotifications = 0;
+
+ // Setup visits.
+ let timeInMicroseconds = getExpirablePRTime(8);
+
+ function newTimeInMicroseconds() {
+ timeInMicroseconds = timeInMicroseconds + 1000;
+ return timeInMicroseconds;
+ }
+
+ for (let j = 0; j < currentTest.visitsPerPage; j++) {
+ for (let i = 0; i < currentTest.addPages; i++) {
+ let page = "http://" + testIndex + "." + i + ".mozilla.org/";
+ await PlacesTestUtils.addVisits({
+ uri: uri(page),
+ visitDate: newTimeInMicroseconds(),
+ });
+ }
+ }
+
+ // Setup bookmarks.
+ currentTest.bookmarks = [];
+ for (let i = 0; i < currentTest.addBookmarks; i++) {
+ let page = "http://" + testIndex + "." + i + ".mozilla.org/";
+ await PlacesUtils.bookmarks.insert({
+ parentGuid: PlacesUtils.bookmarks.unfiledGuid,
+ title: null,
+ url: page,
+ });
+ currentTest.bookmarks.push(page);
+ }
+
+ // Observe history.
+ let notificationsHandled = new Promise(resolve => {
+ const listener = async events => {
+ for (const event of events) {
+ Assert.equal(event.type, "page-removed");
+ Assert.equal(event.reason, PlacesVisitRemoved.REASON_EXPIRED);
+
+ if (event.isRemovedFromStore) {
+ // Check this uri was not bookmarked.
+ Assert.equal(currentTest.bookmarks.indexOf(event.url), -1);
+ do_check_valid_places_guid(event.pageGuid);
+ } else {
+ currentTest.receivedNotifications++;
+ await check_guid_for_uri(
+ Services.io.newURI(event.url),
+ event.pageGuid
+ );
+ Assert.equal(
+ event.isPartialVisistsRemoval,
+ currentTest.expectedIsPartialRemoval,
+ "Should have the correct flag setting for partial removal"
+ );
+ }
+ }
+ PlacesObservers.removeListener(["page-removed"], listener);
+ resolve();
+ };
+ PlacesObservers.addListener(["page-removed"], listener);
+ });
+
+ // Expire now.
+ await promiseForceExpirationStep(currentTest.limitExpiration);
+ await notificationsHandled;
+
+ Assert.equal(
+ currentTest.receivedNotifications,
+ currentTest.expectedNotifications
+ );
+
+ // Clean up.
+ await PlacesUtils.bookmarks.eraseEverything();
+ await PlacesUtils.history.clear();
+ }
+
+ clearMaxPages();
+ await PlacesUtils.bookmarks.eraseEverything();
+ await PlacesUtils.history.clear();
+});
diff --git a/toolkit/components/places/tests/expiration/test_notifications_pageRemoved_fromStore.js b/toolkit/components/places/tests/expiration/test_notifications_pageRemoved_fromStore.js
new file mode 100644
index 0000000000..c8f7cf4aa0
--- /dev/null
+++ b/toolkit/components/places/tests/expiration/test_notifications_pageRemoved_fromStore.js
@@ -0,0 +1,110 @@
+/* -*- indent-tabs-mode: nil; js-indent-level: 2 -*-
+ * vim: sw=2 ts=2 et lcs=trail\:.,tab\:>~ :
+ * 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/. */
+
+/**
+ * What this is aimed to test:
+ *
+ * Expiring a full page should fire an page-removed event notification.
+ */
+
+var tests = [
+ {
+ desc: "Add 1 bookmarked page.",
+ addPages: 1,
+ addBookmarks: 1,
+ expectedNotifications: 0, // No expirable pages.
+ },
+
+ {
+ desc: "Add 2 pages, 1 bookmarked.",
+ addPages: 2,
+ addBookmarks: 1,
+ expectedNotifications: 1, // Only one expirable page.
+ },
+
+ {
+ desc: "Add 10 pages, none bookmarked.",
+ addPages: 10,
+ addBookmarks: 0,
+ expectedNotifications: 10, // Will expire everything.
+ },
+
+ {
+ desc: "Add 10 pages, all bookmarked.",
+ addPages: 10,
+ addBookmarks: 10,
+ expectedNotifications: 0, // No expirable pages.
+ },
+];
+
+add_task(async () => {
+ // Set interval to a large value so we don't expire on it.
+ setInterval(3600); // 1h
+
+ // Expire anything that is expirable.
+ setMaxPages(0);
+
+ for (let testIndex = 1; testIndex <= tests.length; testIndex++) {
+ let currentTest = tests[testIndex - 1];
+ print("\nTEST " + testIndex + ": " + currentTest.desc);
+ currentTest.receivedNotifications = 0;
+
+ // Setup visits.
+ let now = getExpirablePRTime();
+ for (let i = 0; i < currentTest.addPages; i++) {
+ let page = "http://" + testIndex + "." + i + ".mozilla.org/";
+ await PlacesTestUtils.addVisits({ uri: uri(page), visitDate: now++ });
+ }
+
+ // Setup bookmarks.
+ currentTest.bookmarks = [];
+ for (let i = 0; i < currentTest.addBookmarks; i++) {
+ let page = "http://" + testIndex + "." + i + ".mozilla.org/";
+ await PlacesUtils.bookmarks.insert({
+ parentGuid: PlacesUtils.bookmarks.unfiledGuid,
+ title: null,
+ url: page,
+ });
+ currentTest.bookmarks.push(page);
+ }
+
+ // Observe history.
+ const listener = events => {
+ for (const event of events) {
+ Assert.equal(event.type, "page-removed");
+
+ if (!event.isRemovedFromStore) {
+ continue;
+ }
+
+ currentTest.receivedNotifications++;
+ // Check this uri was not bookmarked.
+ Assert.equal(currentTest.bookmarks.indexOf(event.url), -1);
+ do_check_valid_places_guid(event.pageGuid);
+ Assert.equal(event.reason, PlacesVisitRemoved.REASON_EXPIRED);
+ }
+ };
+ PlacesObservers.addListener(["page-removed"], listener);
+
+ // Expire now.
+ await promiseForceExpirationStep(-1);
+
+ PlacesObservers.removeListener(["page-removed"], listener);
+
+ Assert.equal(
+ currentTest.receivedNotifications,
+ currentTest.expectedNotifications
+ );
+
+ // Clean up.
+ await PlacesUtils.bookmarks.eraseEverything();
+ await PlacesUtils.history.clear();
+ }
+
+ clearMaxPages();
+ await PlacesUtils.bookmarks.eraseEverything();
+ await PlacesUtils.history.clear();
+});
diff --git a/toolkit/components/places/tests/expiration/test_pref_interval.js b/toolkit/components/places/tests/expiration/test_pref_interval.js
new file mode 100644
index 0000000000..5bf340e7c4
--- /dev/null
+++ b/toolkit/components/places/tests/expiration/test_pref_interval.js
@@ -0,0 +1,62 @@
+/**
+ * What this is aimed to test:
+ *
+ * Expiration relies on an interval, that is user-preffable setting
+ * "places.history.expiration.interval_seconds".
+ * On pref change it will stop current interval timer and fire a new one,
+ * that will obey the new value.
+ * If the pref is set to a number <= 0 we will use the default value.
+ */
+
+// Default timer value for expiration in seconds. Must have same value as
+// PREF_INTERVAL_SECONDS_NOTSET in nsPlacesExpiration.
+const DEFAULT_TIMER_DELAY_SECONDS = 3 * 60;
+
+// Sync this with the const value in the component.
+const EXPIRE_AGGRESSIVITY_MULTIPLIER = 3;
+
+var tests = [
+ {
+ desc: "Set interval to 1s.",
+ interval: 1,
+ expectedTimerDelay: 1 * EXPIRE_AGGRESSIVITY_MULTIPLIER,
+ },
+
+ {
+ desc: "Set interval to a negative value.",
+ interval: -1,
+ expectedTimerDelay:
+ DEFAULT_TIMER_DELAY_SECONDS * EXPIRE_AGGRESSIVITY_MULTIPLIER,
+ },
+
+ {
+ desc: "Set interval to 0.",
+ interval: 0,
+ expectedTimerDelay:
+ DEFAULT_TIMER_DELAY_SECONDS * EXPIRE_AGGRESSIVITY_MULTIPLIER,
+ },
+
+ {
+ desc: "Set interval to a large value.",
+ interval: 100,
+ expectedTimerDelay: 100 * EXPIRE_AGGRESSIVITY_MULTIPLIER,
+ },
+];
+
+add_task(async function test() {
+ // The pref should not exist by default.
+ Assert.throws(() => getInterval(), /NS_ERROR_UNEXPECTED/);
+
+ // Force the component, so it will start observing preferences.
+ force_expiration_start();
+
+ for (let currentTest of tests) {
+ currentTest = tests.shift();
+ print(currentTest.desc);
+ let promise = promiseTopicObserved("test-interval-changed");
+ setInterval(currentTest.interval);
+ let [, data] = await promise;
+ Assert.equal(data, currentTest.expectedTimerDelay);
+ }
+ clearInterval();
+});
diff --git a/toolkit/components/places/tests/expiration/test_pref_maxpages.js b/toolkit/components/places/tests/expiration/test_pref_maxpages.js
new file mode 100644
index 0000000000..e4583359f1
--- /dev/null
+++ b/toolkit/components/places/tests/expiration/test_pref_maxpages.js
@@ -0,0 +1,116 @@
+/* -*- indent-tabs-mode: nil; js-indent-level: 2 -*-
+ * vim: sw=2 ts=2 et lcs=trail\:.,tab\:>~ :
+ * 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/. */
+
+/**
+ * What this is aimed to test:
+ *
+ * Expiration will obey to hardware spec, but user can set a custom maximum
+ * number of pages to retain, to restrict history, through
+ * "places.history.expiration.max_pages".
+ * This limit is used at next expiration run.
+ * If the pref is set to a number < 0 we will use the default value.
+ */
+
+var tests = [
+ {
+ desc: "Set max_pages to a negative value, with 1 page.",
+ maxPages: -1,
+ addPages: 1,
+ expectedNotifications: 0, // Will ignore and won't expire anything.
+ },
+
+ {
+ desc: "Set max_pages to 0.",
+ maxPages: 0,
+ addPages: 1,
+ expectedNotifications: 1,
+ },
+
+ {
+ desc: "Set max_pages to 0, with 2 pages.",
+ maxPages: 0,
+ addPages: 2,
+ expectedNotifications: 2, // Will expire everything.
+ },
+
+ // Notice if we are over limit we do a full step of expiration. So we ensure
+ // that we will expire if we are over the limit, but we don't ensure that we
+ // will expire exactly up to the limit. Thus in this case we expire
+ // everything.
+ {
+ desc: "Set max_pages to 1 with 2 pages.",
+ maxPages: 1,
+ addPages: 2,
+ expectedNotifications: 2, // Will expire everything (in this case).
+ },
+
+ {
+ desc: "Set max_pages to 10, with 9 pages.",
+ maxPages: 10,
+ addPages: 9,
+ expectedNotifications: 0, // We are at the limit, won't expire anything.
+ },
+
+ {
+ desc: "Set max_pages to 10 with 10 pages.",
+ maxPages: 10,
+ addPages: 10,
+ expectedNotifications: 0, // We are below the limit, won't expire anything.
+ },
+];
+
+add_task(async function test_pref_maxpages() {
+ // The pref should not exist by default.
+ try {
+ getMaxPages();
+ do_throw("interval pref should not exist by default");
+ } catch (ex) {}
+
+ // Set interval to a large value so we don't expire on it.
+ setInterval(3600); // 1h
+
+ for (let testIndex = 1; testIndex <= tests.length; testIndex++) {
+ let currentTest = tests[testIndex - 1];
+ print("\nTEST " + testIndex + ": " + currentTest.desc);
+ currentTest.receivedNotifications = 0;
+
+ // Setup visits.
+ let now = getExpirablePRTime();
+ for (let i = 0; i < currentTest.addPages; i++) {
+ let page = "http://" + testIndex + "." + i + ".mozilla.org/";
+ await PlacesTestUtils.addVisits({ uri: uri(page), visitDate: now-- });
+ }
+
+ const listener = events => {
+ for (const event of events) {
+ print("page-removed " + event.url);
+ Assert.equal(event.type, "page-removed");
+ Assert.ok(event.isRemovedFromStore);
+ Assert.equal(event.reason, PlacesVisitRemoved.REASON_EXPIRED);
+ currentTest.receivedNotifications++;
+ }
+ };
+ PlacesObservers.addListener(["page-removed"], listener);
+
+ setMaxPages(currentTest.maxPages);
+
+ // Expire now.
+ await promiseForceExpirationStep(-1);
+
+ PlacesObservers.removeListener(["page-removed"], listener);
+
+ Assert.equal(
+ currentTest.receivedNotifications,
+ currentTest.expectedNotifications
+ );
+
+ // Clean up.
+ await PlacesUtils.history.clear();
+ }
+
+ clearMaxPages();
+ await PlacesUtils.history.clear();
+});
diff --git a/toolkit/components/places/tests/expiration/xpcshell.toml b/toolkit/components/places/tests/expiration/xpcshell.toml
new file mode 100644
index 0000000000..dc9dc7e619
--- /dev/null
+++ b/toolkit/components/places/tests/expiration/xpcshell.toml
@@ -0,0 +1,23 @@
+[DEFAULT]
+head = "head_expiration.js"
+skip-if = ["os == 'android'"]
+
+["test_annos_expire_never.js"]
+
+["test_clearHistory.js"]
+
+["test_debug_expiration.js"]
+
+["test_idle_daily.js"]
+
+["test_interactions_expiration.js"]
+
+["test_notifications.js"]
+
+["test_notifications_pageRemoved_allVisits.js"]
+
+["test_notifications_pageRemoved_fromStore.js"]
+
+["test_pref_interval.js"]
+
+["test_pref_maxpages.js"]
diff --git a/toolkit/components/places/tests/favicons/expected-favicon-animated16.png.png b/toolkit/components/places/tests/favicons/expected-favicon-animated16.png.png
new file mode 100644
index 0000000000..22f825c500
Binary files /dev/null and b/toolkit/components/places/tests/favicons/expected-favicon-animated16.png.png differ
diff --git a/toolkit/components/places/tests/favicons/expected-favicon-big16.ico.png b/toolkit/components/places/tests/favicons/expected-favicon-big16.ico.png
new file mode 100644
index 0000000000..fa61cc5046
Binary files /dev/null and b/toolkit/components/places/tests/favicons/expected-favicon-big16.ico.png differ
diff --git a/toolkit/components/places/tests/favicons/expected-favicon-big32.jpg.png b/toolkit/components/places/tests/favicons/expected-favicon-big32.jpg.png
new file mode 100644
index 0000000000..42640cbb53
Binary files /dev/null and b/toolkit/components/places/tests/favicons/expected-favicon-big32.jpg.png differ
diff --git a/toolkit/components/places/tests/favicons/expected-favicon-big4.jpg.png b/toolkit/components/places/tests/favicons/expected-favicon-big4.jpg.png
new file mode 100644
index 0000000000..81d1b8ae19
Binary files /dev/null and b/toolkit/components/places/tests/favicons/expected-favicon-big4.jpg.png differ
diff --git a/toolkit/components/places/tests/favicons/expected-favicon-big48.ico.png b/toolkit/components/places/tests/favicons/expected-favicon-big48.ico.png
new file mode 100644
index 0000000000..7983889098
Binary files /dev/null and b/toolkit/components/places/tests/favicons/expected-favicon-big48.ico.png differ
diff --git a/toolkit/components/places/tests/favicons/expected-favicon-big64.png.png b/toolkit/components/places/tests/favicons/expected-favicon-big64.png.png
new file mode 100644
index 0000000000..2756cf0cb3
Binary files /dev/null and b/toolkit/components/places/tests/favicons/expected-favicon-big64.png.png differ
diff --git a/toolkit/components/places/tests/favicons/expected-favicon-scale160x3.jpg.png b/toolkit/components/places/tests/favicons/expected-favicon-scale160x3.jpg.png
new file mode 100644
index 0000000000..fc464f8e99
Binary files /dev/null and b/toolkit/components/places/tests/favicons/expected-favicon-scale160x3.jpg.png differ
diff --git a/toolkit/components/places/tests/favicons/expected-favicon-scale3x160.jpg.png b/toolkit/components/places/tests/favicons/expected-favicon-scale3x160.jpg.png
new file mode 100644
index 0000000000..c1412038a3
Binary files /dev/null and b/toolkit/components/places/tests/favicons/expected-favicon-scale3x160.jpg.png differ
diff --git a/toolkit/components/places/tests/favicons/favicon-animated16.png b/toolkit/components/places/tests/favicons/favicon-animated16.png
new file mode 100644
index 0000000000..8913387fc9
Binary files /dev/null and b/toolkit/components/places/tests/favicons/favicon-animated16.png differ
diff --git a/toolkit/components/places/tests/favicons/favicon-big16.ico b/toolkit/components/places/tests/favicons/favicon-big16.ico
new file mode 100644
index 0000000000..d44438903b
Binary files /dev/null and b/toolkit/components/places/tests/favicons/favicon-big16.ico differ
diff --git a/toolkit/components/places/tests/favicons/favicon-big32.jpg b/toolkit/components/places/tests/favicons/favicon-big32.jpg
new file mode 100644
index 0000000000..b2131bf0c1
Binary files /dev/null and b/toolkit/components/places/tests/favicons/favicon-big32.jpg differ
diff --git a/toolkit/components/places/tests/favicons/favicon-big4.jpg b/toolkit/components/places/tests/favicons/favicon-big4.jpg
new file mode 100644
index 0000000000..b84fcd35a6
Binary files /dev/null and b/toolkit/components/places/tests/favicons/favicon-big4.jpg differ
diff --git a/toolkit/components/places/tests/favicons/favicon-big48.ico b/toolkit/components/places/tests/favicons/favicon-big48.ico
new file mode 100644
index 0000000000..f22522411d
Binary files /dev/null and b/toolkit/components/places/tests/favicons/favicon-big48.ico differ
diff --git a/toolkit/components/places/tests/favicons/favicon-big64.png b/toolkit/components/places/tests/favicons/favicon-big64.png
new file mode 100644
index 0000000000..2756cf0cb3
Binary files /dev/null and b/toolkit/components/places/tests/favicons/favicon-big64.png differ
diff --git a/toolkit/components/places/tests/favicons/favicon-multi-frame16.png b/toolkit/components/places/tests/favicons/favicon-multi-frame16.png
new file mode 100644
index 0000000000..519e08cc21
Binary files /dev/null and b/toolkit/components/places/tests/favicons/favicon-multi-frame16.png differ
diff --git a/toolkit/components/places/tests/favicons/favicon-multi-frame32.png b/toolkit/components/places/tests/favicons/favicon-multi-frame32.png
new file mode 100644
index 0000000000..5ae61de789
Binary files /dev/null and b/toolkit/components/places/tests/favicons/favicon-multi-frame32.png differ
diff --git a/toolkit/components/places/tests/favicons/favicon-multi-frame64.png b/toolkit/components/places/tests/favicons/favicon-multi-frame64.png
new file mode 100644
index 0000000000..57123f351b
Binary files /dev/null and b/toolkit/components/places/tests/favicons/favicon-multi-frame64.png differ
diff --git a/toolkit/components/places/tests/favicons/favicon-multi.ico b/toolkit/components/places/tests/favicons/favicon-multi.ico
new file mode 100644
index 0000000000..e98adcafeb
Binary files /dev/null and b/toolkit/components/places/tests/favicons/favicon-multi.ico differ
diff --git a/toolkit/components/places/tests/favicons/favicon-normal16.png b/toolkit/components/places/tests/favicons/favicon-normal16.png
new file mode 100644
index 0000000000..62b69a3d03
Binary files /dev/null and b/toolkit/components/places/tests/favicons/favicon-normal16.png differ
diff --git a/toolkit/components/places/tests/favicons/favicon-normal32.png b/toolkit/components/places/tests/favicons/favicon-normal32.png
new file mode 100644
index 0000000000..5535363c94
Binary files /dev/null and b/toolkit/components/places/tests/favicons/favicon-normal32.png differ
diff --git a/toolkit/components/places/tests/favicons/favicon-scale160x3.jpg b/toolkit/components/places/tests/favicons/favicon-scale160x3.jpg
new file mode 100644
index 0000000000..422ee7ea0b
Binary files /dev/null and b/toolkit/components/places/tests/favicons/favicon-scale160x3.jpg differ
diff --git a/toolkit/components/places/tests/favicons/favicon-scale3x160.jpg b/toolkit/components/places/tests/favicons/favicon-scale3x160.jpg
new file mode 100644
index 0000000000..e8514966a0
Binary files /dev/null and b/toolkit/components/places/tests/favicons/favicon-scale3x160.jpg differ
diff --git a/toolkit/components/places/tests/favicons/head_favicons.js b/toolkit/components/places/tests/favicons/head_favicons.js
new file mode 100644
index 0000000000..d8109c66e0
--- /dev/null
+++ b/toolkit/components/places/tests/favicons/head_favicons.js
@@ -0,0 +1,81 @@
+/* -*- indent-tabs-mode: nil; js-indent-level: 2 -*- */
+/* 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/. */
+
+// Import common head.
+{
+ /* import-globals-from ../head_common.js */
+ let commonFile = do_get_file("../head_common.js", false);
+ let uri = Services.io.newFileURI(commonFile);
+ Services.scriptloader.loadSubScript(uri.spec, this);
+}
+
+// Put any other stuff relative to this test folder below.
+
+const systemPrincipal = Services.scriptSecurityManager.getSystemPrincipal();
+
+/**
+ * Checks that the favicon for the given page matches the provided data.
+ *
+ * @param aPageURI
+ * nsIURI object for the page to check.
+ * @param aExpectedMimeType
+ * Expected MIME type of the icon, for example "image/png".
+ * @param aExpectedData
+ * Expected icon data, expressed as an array of byte values.
+ * @param aCallback
+ * This function is called after the check finished.
+ */
+function checkFaviconDataForPage(
+ aPageURI,
+ aExpectedMimeType,
+ aExpectedData,
+ aCallback
+) {
+ PlacesUtils.favicons.getFaviconDataForPage(
+ aPageURI,
+ async function (aURI, aDataLen, aData, aMimeType) {
+ Assert.equal(aExpectedMimeType, aMimeType);
+ Assert.ok(compareArrays(aExpectedData, aData));
+ await check_guid_for_uri(aPageURI);
+ aCallback();
+ }
+ );
+}
+
+/**
+ * Checks that the given page has no associated favicon.
+ *
+ * @param aPageURI
+ * nsIURI object for the page to check.
+ * @param aCallback
+ * This function is called after the check finished.
+ */
+function checkFaviconMissingForPage(aPageURI, aCallback) {
+ PlacesUtils.favicons.getFaviconURLForPage(
+ aPageURI,
+ function (aURI, aDataLen, aData, aMimeType) {
+ Assert.ok(aURI === null);
+ aCallback();
+ }
+ );
+}
+
+function promiseFaviconMissingForPage(aPageURI) {
+ return new Promise(resolve => checkFaviconMissingForPage(aPageURI, resolve));
+}
+
+function promiseFaviconChanged(aExpectedPageURI, aExpectedFaviconURI) {
+ return new Promise(resolve => {
+ PlacesTestUtils.waitForNotification("favicon-changed", async events => {
+ for (let e of events) {
+ if (e.url == aExpectedPageURI.spec) {
+ Assert.equal(e.faviconUrl, aExpectedFaviconURI.spec);
+ await check_guid_for_uri(aExpectedPageURI, e.pageGuid);
+ resolve();
+ }
+ }
+ });
+ });
+}
diff --git a/toolkit/components/places/tests/favicons/noise.png b/toolkit/components/places/tests/favicons/noise.png
new file mode 100644
index 0000000000..d6876295cd
Binary files /dev/null and b/toolkit/components/places/tests/favicons/noise.png differ
diff --git a/toolkit/components/places/tests/favicons/test_cached-favicon_mime_type.js b/toolkit/components/places/tests/favicons/test_cached-favicon_mime_type.js
new file mode 100644
index 0000000000..1c95d63f94
--- /dev/null
+++ b/toolkit/components/places/tests/favicons/test_cached-favicon_mime_type.js
@@ -0,0 +1,88 @@
+/* -*- indent-tabs-mode: nil; js-indent-level: 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/. */
+
+/**
+ * This test ensures that the mime type is set for cached-favicon channels of favicons
+ * properly. Added with work in bug 481227.
+ */
+
+const testFaviconData =
+ "data:image/png,%89PNG%0D%0A%1A%0A%00%00%00%0DIHDR%00%00%00%10%00%00%00%10%08%06%00%00%00%1F%F3%FFa%00%00%00%04gAMA%00%00%AF%C87%05%8A%E9%00%00%00%19tEXtSoftware%00Adobe%20ImageReadyq%C9e%3C%00%00%01%D6IDATx%DAb%FC%FF%FF%3F%03%25%00%20%80%98%909%EF%DF%BFg%EF%EC%EC%FC%AD%AC%AC%FC%DF%95%91%F1%BF%89%89%C9%7F%20%FF%D7%EA%D5%AB%B7%DF%BBwO%16%9B%01%00%01%C4%00r%01%08%9F9s%C6%CD%D8%D8%F8%BF%0B%03%C3%FF3%40%BC%0A%88%EF%02q%1A%10%BB%40%F1%AAU%ABv%C1%D4%C30%40%00%81%89%993g%3E%06%1A%F6%3F%14%AA%11D%97%03%F1%7Fc%08%0D%E2%2B))%FD%17%04%89%A1%19%00%10%40%0C%D00%F8%0F3%00%C8%F8%BF%1B%E4%0Ac%88a%E5%60%17%19%FF%0F%0D%0D%05%1B%02v%D9%DD%BB%0A0%03%00%02%08%AC%B9%A3%A3%E3%17%03%D4v%90%01%EF%18%106%C3%0Cz%07%C5%BB%A1%DE%82y%07%20%80%A0%A6%08B%FCn%0C1%60%26%D4%20d%C3VA%C3%06%26%BE%0A%EA-%80%00%82%B9%E0%F7L4%0D%EF%90%F8%C6%60%2F%0A%82%BD%01%13%07%0700%D0%01%02%88%11%E4%02P%B41%DC%BB%C7%D0%014%0D%E8l%06W%20%06%BA%88%A1%1C%1AS%15%40%7C%16%CA6.%2Fgx%BFg%0F%83%CB%D9%B3%0C%7B%80%7C%80%00%02%BB%00%E8%9F%ED%20%1B%3A%A0%A6%9F%81%DA%DC%01%C5%B0%80%ED%80%FA%BF%BC%BC%FC%3F%83%12%90%9D%96%F6%1F%20%80%18%DE%BD%7B%C7%0E%8E%05AD%20%FEGr%A6%A0%A0%E0%7F%25P%80%02%9D%0F%D28%13%18%23%C6%C0%B0%02E%3D%C8%F5%00%01%04%8F%05P%A8%BA%40my%87%E4%12c%A8%8D%20%8B%D0%D3%00%08%03%04%10%9C%01R%E4%82d%3B%C8%A0%99%C6%90%90%C6%A5%19%84%01%02%08%9E%17%80%C9x%F7%7B%A0%DBVC%F9%A0%C0%5C%7D%16%2C%CE%00%F4%C6O%5C%99%09%20%800L%04y%A5%03%1A%95%A0%80%05%05%14.%DBA%18%20%80%18)%CD%CE%00%01%06%00%0C'%94%C7%C0k%C9%2C%00%00%00%00IEND%AEB%60%82";
+const testIconURI = uri("http://mozilla.org/favicon.png");
+
+function streamListener(aExpectedContentType) {
+ this._expectedContentType = aExpectedContentType;
+ this.done = Promise.withResolvers();
+}
+streamListener.prototype = {
+ onStartRequest() {},
+ onStopRequest(aRequest, aContext, aStatusCode) {
+ let channel = aRequest.QueryInterface(Ci.nsIChannel);
+ Assert.equal(
+ channel.contentType,
+ this._expectedContentType,
+ "The channel content type is the expected one"
+ );
+ this.done.resolve();
+ },
+ onDataAvailable(aRequest, aInputStream, aOffset, aCount) {
+ aRequest.cancel(Cr.NS_ERROR_ABORT);
+ throw Components.Exception("", Cr.NS_ERROR_ABORT);
+ },
+};
+
+add_task(async function () {
+ info("Test that the default icon has the right content type.");
+ let channel = NetUtil.newChannel({
+ uri: PlacesUtils.favicons.defaultFavicon,
+ loadUsingSystemPrincipal: true,
+ contentPolicyType: Ci.nsIContentPolicy.TYPE_INTERNAL_IMAGE_FAVICON,
+ });
+ let listener = new streamListener(
+ PlacesUtils.favicons.defaultFaviconMimeType
+ );
+ channel.asyncOpen(listener);
+ await listener.done.promise;
+});
+
+add_task(async function () {
+ info(
+ "Test icon URI that we don't know anything about. Will serve the default icon."
+ );
+ let channel = NetUtil.newChannel({
+ uri: PlacesUtils.favicons.getFaviconLinkForIcon(testIconURI).spec,
+ loadUsingSystemPrincipal: true,
+ contentPolicyType: Ci.nsIContentPolicy.TYPE_INTERNAL_IMAGE_FAVICON,
+ });
+ let listener = new streamListener(
+ PlacesUtils.favicons.defaultFaviconMimeType
+ );
+ channel.asyncOpen(listener);
+ await listener.done.promise;
+});
+
+add_task(async function () {
+ info("Test that the content type of a favicon we add is correct.");
+ let testURI = uri("http://mozilla.org/");
+ // Add the data before opening
+ PlacesUtils.favicons.replaceFaviconDataFromDataURL(
+ testIconURI,
+ testFaviconData,
+ 0,
+ systemPrincipal
+ );
+ await PlacesTestUtils.addVisits(testURI);
+ await setFaviconForPage(testURI, testIconURI);
+ // Open the channel
+ let channel = NetUtil.newChannel({
+ uri: PlacesUtils.favicons.getFaviconLinkForIcon(testIconURI).spec,
+ loadUsingSystemPrincipal: true,
+ contentPolicyType: Ci.nsIContentPolicy.TYPE_INTERNAL_IMAGE_FAVICON,
+ });
+ let listener = new streamListener("image/png");
+ channel.asyncOpen(listener);
+ await listener.done.promise;
+});
diff --git a/toolkit/components/places/tests/favicons/test_copyFavicons.js b/toolkit/components/places/tests/favicons/test_copyFavicons.js
new file mode 100644
index 0000000000..687b799a4b
--- /dev/null
+++ b/toolkit/components/places/tests/favicons/test_copyFavicons.js
@@ -0,0 +1,166 @@
+const TEST_URI1 = Services.io.newURI("http://mozilla.com/");
+const TEST_URI2 = Services.io.newURI("http://places.com/");
+const TEST_URI3 = Services.io.newURI("http://bookmarked.com/");
+const LOAD_NON_PRIVATE = PlacesUtils.favicons.FAVICON_LOAD_NON_PRIVATE;
+const LOAD_PRIVATE = PlacesUtils.favicons.FAVICON_LOAD_PRIVATE;
+
+function copyFavicons(source, dest, inPrivate) {
+ return new Promise(resolve => {
+ PlacesUtils.favicons.copyFavicons(
+ source,
+ dest,
+ inPrivate ? LOAD_PRIVATE : LOAD_NON_PRIVATE,
+ resolve
+ );
+ });
+}
+
+function promisePageChanged(url) {
+ return PlacesTestUtils.waitForNotification("favicon-changed", events =>
+ events.some(e => e.url == url)
+ );
+}
+
+add_task(async function test_copyFavicons_inputcheck() {
+ Assert.throws(
+ () => PlacesUtils.favicons.copyFavicons(null, TEST_URI2, LOAD_PRIVATE),
+ /NS_ERROR_ILLEGAL_VALUE/
+ );
+ Assert.throws(
+ () => PlacesUtils.favicons.copyFavicons(TEST_URI1, null, LOAD_PRIVATE),
+ /NS_ERROR_ILLEGAL_VALUE/
+ );
+ Assert.throws(
+ () => PlacesUtils.favicons.copyFavicons(TEST_URI1, TEST_URI2, 3),
+ /NS_ERROR_ILLEGAL_VALUE/
+ );
+ Assert.throws(
+ () => PlacesUtils.favicons.copyFavicons(TEST_URI1, TEST_URI2, -1),
+ /NS_ERROR_ILLEGAL_VALUE/
+ );
+ Assert.throws(
+ () => PlacesUtils.favicons.copyFavicons(TEST_URI1, TEST_URI2, null),
+ /NS_ERROR_ILLEGAL_VALUE/
+ );
+});
+
+add_task(async function test_copyFavicons_noop() {
+ info("Unknown uris");
+ Assert.equal(
+ await copyFavicons(TEST_URI1, TEST_URI2, false),
+ null,
+ "Icon should not have been copied"
+ );
+
+ info("Unknown dest uri");
+ await PlacesTestUtils.addVisits(TEST_URI1);
+ Assert.equal(
+ await copyFavicons(TEST_URI1, TEST_URI2, false),
+ null,
+ "Icon should not have been copied"
+ );
+
+ info("Unknown dest uri");
+ await PlacesTestUtils.addVisits(TEST_URI1);
+ Assert.equal(
+ await copyFavicons(TEST_URI1, TEST_URI2, false),
+ null,
+ "Icon should not have been copied"
+ );
+
+ info("Unknown dest uri, source has icon");
+ await setFaviconForPage(TEST_URI1, SMALLPNG_DATA_URI);
+ Assert.equal(
+ await copyFavicons(TEST_URI1, TEST_URI2, false),
+ null,
+ "Icon should not have been copied"
+ );
+
+ info("Known uris, source has icon, private");
+ await PlacesTestUtils.addVisits(TEST_URI2);
+ Assert.equal(
+ await copyFavicons(TEST_URI1, TEST_URI2, true),
+ null,
+ "Icon should not have been copied"
+ );
+
+ PlacesUtils.favicons.expireAllFavicons();
+ await PlacesUtils.history.clear();
+});
+
+add_task(async function test_copyFavicons() {
+ info("Normal copy across 2 pages");
+ await PlacesTestUtils.addVisits(TEST_URI1);
+ await setFaviconForPage(TEST_URI1, SMALLPNG_DATA_URI);
+ await setFaviconForPage(TEST_URI1, SMALLSVG_DATA_URI);
+ await PlacesTestUtils.addVisits(TEST_URI2);
+ let promiseChange = promisePageChanged(TEST_URI2.spec);
+ Assert.equal(
+ (await copyFavicons(TEST_URI1, TEST_URI2, false)).spec,
+ SMALLSVG_DATA_URI.spec,
+ "Icon should have been copied"
+ );
+ await promiseChange;
+ Assert.equal(
+ await getFaviconUrlForPage(TEST_URI2, 1),
+ SMALLPNG_DATA_URI.spec,
+ "Small icon found"
+ );
+ Assert.equal(
+ await getFaviconUrlForPage(TEST_URI2),
+ SMALLSVG_DATA_URI.spec,
+ "Large icon found"
+ );
+
+ info("Private copy to a bookmarked page");
+ await PlacesUtils.bookmarks.insert({
+ url: TEST_URI3,
+ parentGuid: PlacesUtils.bookmarks.unfiledGuid,
+ });
+ promiseChange = promisePageChanged(TEST_URI3.spec);
+ Assert.equal(
+ (await copyFavicons(TEST_URI1, TEST_URI3, true)).spec,
+ SMALLSVG_DATA_URI.spec,
+ "Icon should have been copied"
+ );
+ await promiseChange;
+ Assert.equal(
+ await getFaviconUrlForPage(TEST_URI3, 1),
+ SMALLPNG_DATA_URI.spec,
+ "Small icon found"
+ );
+ Assert.equal(
+ await getFaviconUrlForPage(TEST_URI3),
+ SMALLSVG_DATA_URI.spec,
+ "Large icon found"
+ );
+
+ PlacesUtils.favicons.expireAllFavicons();
+ await PlacesUtils.history.clear();
+});
+
+add_task(async function test_copyFavicons_overlap() {
+ info("Copy to a page that has one of the favicons already");
+ await PlacesTestUtils.addVisits(TEST_URI1);
+ await setFaviconForPage(TEST_URI1, SMALLPNG_DATA_URI);
+ await setFaviconForPage(TEST_URI1, SMALLSVG_DATA_URI);
+ await PlacesTestUtils.addVisits(TEST_URI2);
+ await setFaviconForPage(TEST_URI2, SMALLPNG_DATA_URI);
+ let promiseChange = promisePageChanged(TEST_URI2.spec);
+ Assert.equal(
+ (await copyFavicons(TEST_URI1, TEST_URI2, false)).spec,
+ SMALLSVG_DATA_URI.spec,
+ "Icon should have been copied"
+ );
+ await promiseChange;
+ Assert.equal(
+ await getFaviconUrlForPage(TEST_URI2, 1),
+ SMALLPNG_DATA_URI.spec,
+ "Small icon found"
+ );
+ Assert.equal(
+ await getFaviconUrlForPage(TEST_URI2),
+ SMALLSVG_DATA_URI.spec,
+ "Large icon found"
+ );
+});
diff --git a/toolkit/components/places/tests/favicons/test_expireAllFavicons.js b/toolkit/components/places/tests/favicons/test_expireAllFavicons.js
new file mode 100644
index 0000000000..73c3ca6e4b
--- /dev/null
+++ b/toolkit/components/places/tests/favicons/test_expireAllFavicons.js
@@ -0,0 +1,38 @@
+/**
+ * This file tests that favicons are correctly expired by expireAllFavicons.
+ */
+
+"use strict";
+
+const TEST_PAGE_URI = NetUtil.newURI("http://example.com/");
+const BOOKMARKED_PAGE_URI = NetUtil.newURI("http://example.com/bookmarked");
+
+add_task(async function test_expireAllFavicons() {
+ // Add a visited page.
+ await PlacesTestUtils.addVisits({
+ uri: TEST_PAGE_URI,
+ transition: TRANSITION_TYPED,
+ });
+
+ // Set a favicon for our test page.
+ await setFaviconForPage(TEST_PAGE_URI, SMALLPNG_DATA_URI);
+
+ // Add a page with a bookmark.
+ await PlacesUtils.bookmarks.insert({
+ parentGuid: PlacesUtils.bookmarks.toolbarGuid,
+ url: BOOKMARKED_PAGE_URI,
+ title: "Test bookmark",
+ });
+
+ // Set a favicon for our bookmark.
+ await setFaviconForPage(BOOKMARKED_PAGE_URI, SMALLPNG_DATA_URI);
+
+ // Start expiration only after data has been saved in the database.
+ let promise = promiseTopicObserved(PlacesUtils.TOPIC_FAVICONS_EXPIRED);
+ PlacesUtils.favicons.expireAllFavicons();
+ await promise;
+
+ // Check that the favicons for the pages we added were removed.
+ await promiseFaviconMissingForPage(TEST_PAGE_URI);
+ await promiseFaviconMissingForPage(BOOKMARKED_PAGE_URI);
+});
diff --git a/toolkit/components/places/tests/favicons/test_expire_migrated_icons.js b/toolkit/components/places/tests/favicons/test_expire_migrated_icons.js
new file mode 100644
index 0000000000..00516a2a0b
--- /dev/null
+++ b/toolkit/components/places/tests/favicons/test_expire_migrated_icons.js
@@ -0,0 +1,30 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+/**
+ * This file tests that favicons migrated from a previous profile, having a 0
+ * expiration, will be properly expired when fetching new ones.
+ */
+
+add_task(async function test_storing_a_normal_16x16_icon() {
+ const PAGE_URL = "http://places.test";
+ await PlacesTestUtils.addVisits(PAGE_URL);
+ await setFaviconForPage(PAGE_URL, SMALLPNG_DATA_URI);
+
+ // Now set expiration to 0 and change the payload.
+ info("Set expiration to 0 and replace favicon data");
+ await PlacesUtils.withConnectionWrapper("Change favicons payload", db => {
+ return db.execute(`UPDATE moz_icons SET expire_ms = 0, data = "test"`);
+ });
+
+ let { data, mimeType } = await getFaviconDataForPage(PAGE_URL);
+ Assert.equal(mimeType, "image/png");
+ Assert.deepEqual(
+ data,
+ "test".split("").map(c => c.charCodeAt(0))
+ );
+
+ info("Refresh favicon");
+ await setFaviconForPage(PAGE_URL, SMALLPNG_DATA_URI, false);
+ await compareFavicons("page-icon:" + PAGE_URL, SMALLPNG_DATA_URI);
+});
diff --git a/toolkit/components/places/tests/favicons/test_expire_on_new_icons.js b/toolkit/components/places/tests/favicons/test_expire_on_new_icons.js
new file mode 100644
index 0000000000..d5a7c42ba3
--- /dev/null
+++ b/toolkit/components/places/tests/favicons/test_expire_on_new_icons.js
@@ -0,0 +1,151 @@
+/* Any copyright is dedicated to the Public Domain.
+ * https://creativecommons.org/publicdomain/zero/1.0/ */
+
+/*
+ * Tests that adding new icons for a page expired old ones.
+ */
+
+add_task(async function test_expire_associated() {
+ const TEST_URL = "http://mozilla.com/";
+ await PlacesTestUtils.addVisits(TEST_URL);
+ const TEST_URL2 = "http://test.mozilla.com/";
+ await PlacesTestUtils.addVisits(TEST_URL2);
+
+ let favicons = [
+ {
+ name: "favicon-normal16.png",
+ mimeType: "image/png",
+ expired: true,
+ },
+ {
+ name: "favicon-normal32.png",
+ mimeType: "image/png",
+ },
+ {
+ name: "favicon-big64.png",
+ mimeType: "image/png",
+ },
+ ];
+
+ for (let icon of favicons) {
+ let data = readFileData(do_get_file(icon.name));
+ PlacesUtils.favicons.replaceFaviconData(
+ NetUtil.newURI(TEST_URL + icon.name),
+ data,
+ icon.mimeType
+ );
+ await setFaviconForPage(TEST_URL, TEST_URL + icon.name);
+ if (icon.expired) {
+ await expireIconRelationsForPage(TEST_URL);
+ // Add the same icon to another page.
+ PlacesUtils.favicons.replaceFaviconData(
+ NetUtil.newURI(TEST_URL + icon.name),
+ data,
+ icon.mimeType,
+ icon.expire
+ );
+ await setFaviconForPage(TEST_URL2, TEST_URL + icon.name);
+ }
+ }
+
+ // Only the second and the third icons should have survived.
+ Assert.equal(
+ await getFaviconUrlForPage(TEST_URL, 16),
+ TEST_URL + favicons[1].name,
+ "Should retrieve the 32px icon, not the 16px one."
+ );
+ Assert.equal(
+ await getFaviconUrlForPage(TEST_URL, 64),
+ TEST_URL + favicons[2].name,
+ "Should retrieve the 64px icon"
+ );
+
+ // The expired icon for page 2 should have survived.
+ Assert.equal(
+ await getFaviconUrlForPage(TEST_URL2, 16),
+ TEST_URL + favicons[0].name,
+ "Should retrieve the expired 16px icon"
+ );
+});
+
+add_task(async function test_expire_root() {
+ async function countEntries(tablename) {
+ await PlacesTestUtils.promiseAsyncUpdates();
+ let db = await PlacesUtils.promiseDBConnection();
+ let rows = await db.execute("SELECT * FROM " + tablename);
+ return rows.length;
+ }
+
+ // Clear the database.
+ let promise = promiseTopicObserved(PlacesUtils.TOPIC_FAVICONS_EXPIRED);
+ PlacesUtils.favicons.expireAllFavicons();
+ await promise;
+
+ Assert.equal(await countEntries("moz_icons"), 0, "There should be no icons");
+
+ let pageURI = NetUtil.newURI("http://root.mozilla.com/");
+ await PlacesTestUtils.addVisits(pageURI);
+
+ // Insert an expired icon.
+ let iconURI = NetUtil.newURI(pageURI.spec + "favicon-normal16.png");
+ PlacesUtils.favicons.replaceFaviconDataFromDataURL(
+ iconURI,
+ SMALLPNG_DATA_URI.spec,
+ 0,
+ systemPrincipal
+ );
+ await setFaviconForPage(pageURI, iconURI);
+ Assert.equal(
+ await countEntries("moz_icons_to_pages"),
+ 1,
+ "There should be 1 association"
+ );
+ // Set an expired time on the icon-page relation.
+ await expireIconRelationsForPage(pageURI.spec);
+
+ // Now insert a new root icon.
+ let rootIconURI = NetUtil.newURI(pageURI.spec + "favicon.ico");
+ PlacesUtils.favicons.replaceFaviconDataFromDataURL(
+ rootIconURI,
+ SMALLPNG_DATA_URI.spec,
+ 0,
+ systemPrincipal
+ );
+ await setFaviconForPage(pageURI, rootIconURI);
+
+ // Only the root icon should have survived.
+ Assert.equal(
+ await getFaviconUrlForPage(pageURI, 16),
+ rootIconURI.spec,
+ "Should retrieve the root icon."
+ );
+ Assert.equal(
+ await countEntries("moz_icons_to_pages"),
+ 0,
+ "There should be no associations"
+ );
+});
+
+async function expireIconRelationsForPage(url) {
+ // Set an expired time on the icon-page relation.
+ await PlacesUtils.withConnectionWrapper("expireFavicon", async db => {
+ await db.execute(
+ `
+ UPDATE moz_icons_to_pages SET expire_ms = 0
+ WHERE page_id = (SELECT id FROM moz_pages_w_icons WHERE page_url = :url)
+ `,
+ { url }
+ );
+ // Also ensure the icon is not expired, here we should only replace entries
+ // based on their association expiration, not the icon expiration.
+ let count = (
+ await db.execute(
+ `
+ SELECT count(*) FROM moz_icons
+ WHERE expire_ms < strftime('%s','now','localtime','utc') * 1000
+ `
+ )
+ )[0].getResultByIndex(0);
+ Assert.equal(count, 0, "All the icons should have future expiration");
+ });
+}
diff --git a/toolkit/components/places/tests/favicons/test_favicons_conversions.js b/toolkit/components/places/tests/favicons/test_favicons_conversions.js
new file mode 100644
index 0000000000..28a0fffb7f
--- /dev/null
+++ b/toolkit/components/places/tests/favicons/test_favicons_conversions.js
@@ -0,0 +1,192 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+/**
+ * This file tests the image conversions done by the favicon service.
+ */
+
+// Globals
+
+// The pixel values we get on Windows are sometimes +/- 1 value compared to
+// other platforms, so we need to skip some image content tests.
+var isWindows = "@mozilla.org/windows-registry-key;1" in Cc;
+
+/**
+ * Checks the conversion of the given test image file.
+ *
+ * @param aFileName
+ * File that contains the favicon image, located in the test folder.
+ * @param aFileMimeType
+ * MIME type of the image contained in the file.
+ * @param aFileLength
+ * Expected length of the file.
+ * @param aExpectConversion
+ * If false, the icon should be stored as is. If true, the expected data
+ * is loaded from a file named "expected-" + aFileName + ".png".
+ * @param aVaryOnWindows
+ * Indicates that the content of the converted image can be different on
+ * Windows and should not be checked on that platform.
+ * @param aCallback
+ * This function is called after the check finished.
+ */
+async function checkFaviconDataConversion(
+ aFileName,
+ aFileMimeType,
+ aFileLength,
+ aExpectConversion,
+ aVaryOnWindows
+) {
+ let pageURI = NetUtil.newURI("http://places.test/page/" + aFileName);
+ await PlacesTestUtils.addVisits({
+ uri: pageURI,
+ transition: TRANSITION_TYPED,
+ });
+ let faviconURI = NetUtil.newURI("http://places.test/icon/" + aFileName);
+ let fileData = readFileOfLength(aFileName, aFileLength);
+
+ PlacesUtils.favicons.replaceFaviconData(faviconURI, fileData, aFileMimeType);
+ await new Promise(resolve => {
+ PlacesUtils.favicons.setAndFetchFaviconForPage(
+ pageURI,
+ faviconURI,
+ true,
+ PlacesUtils.favicons.FAVICON_LOAD_NON_PRIVATE,
+ (aURI, aDataLen, aData, aMimeType) => {
+ if (!aExpectConversion) {
+ Assert.ok(compareArrays(aData, fileData));
+ Assert.equal(aMimeType, aFileMimeType);
+ } else {
+ if (!aVaryOnWindows || !isWindows) {
+ let expectedFile = do_get_file("expected-" + aFileName + ".png");
+ Assert.ok(compareArrays(aData, readFileData(expectedFile)));
+ }
+ Assert.equal(aMimeType, "image/png");
+ }
+ resolve();
+ },
+ Services.scriptSecurityManager.getSystemPrincipal()
+ );
+ });
+}
+
+add_task(async function test_storing_a_normal_16x16_icon() {
+ // 16x16 png, 286 bytes.
+ // optimized: no
+ await checkFaviconDataConversion(
+ "favicon-normal16.png",
+ "image/png",
+ 286,
+ false,
+ false
+ );
+});
+
+add_task(async function test_storing_a_normal_32x32_icon() {
+ // 32x32 png, 344 bytes.
+ // optimized: no
+ await checkFaviconDataConversion(
+ "favicon-normal32.png",
+ "image/png",
+ 344,
+ false,
+ false
+ );
+});
+
+add_task(async function test_storing_a_big_16x16_icon() {
+ // in: 16x16 ico, 1406 bytes.
+ // optimized: yes
+ await checkFaviconDataConversion(
+ "favicon-big16.ico",
+ "image/x-icon",
+ 1406,
+ true,
+ false
+ );
+});
+
+add_task(async function test_storing_an_oversize_4x4_icon() {
+ // in: 4x4 jpg, 4751 bytes.
+ // optimized: yes
+ await checkFaviconDataConversion(
+ "favicon-big4.jpg",
+ "image/jpeg",
+ 4751,
+ true,
+ false
+ );
+});
+
+add_task(async function test_storing_an_oversize_32x32_icon() {
+ // in: 32x32 jpg, 3494 bytes.
+ // optimized: yes
+ await checkFaviconDataConversion(
+ "favicon-big32.jpg",
+ "image/jpeg",
+ 3494,
+ true,
+ true
+ );
+});
+
+add_task(async function test_storing_an_oversize_48x48_icon() {
+ // in: 48x48 ico, 56646 bytes.
+ // (howstuffworks.com icon, contains 13 icons with sizes from 16x16 to
+ // 48x48 in varying depths)
+ // optimized: yes
+ await checkFaviconDataConversion(
+ "favicon-big48.ico",
+ "image/x-icon",
+ 56646,
+ true,
+ false
+ );
+});
+
+add_task(async function test_storing_an_oversize_64x64_icon() {
+ // in: 64x64 png, 10698 bytes.
+ // optimized: yes
+ await checkFaviconDataConversion(
+ "favicon-big64.png",
+ "image/png",
+ 10698,
+ true,
+ false
+ );
+});
+
+add_task(async function test_scaling_an_oversize_160x3_icon() {
+ // in: 160x3 jpg, 5095 bytes.
+ // optimized: yes
+ await checkFaviconDataConversion(
+ "favicon-scale160x3.jpg",
+ "image/jpeg",
+ 5095,
+ true,
+ false
+ );
+});
+
+add_task(async function test_scaling_an_oversize_3x160_icon() {
+ // in: 3x160 jpg, 5059 bytes.
+ // optimized: yes
+ await checkFaviconDataConversion(
+ "favicon-scale3x160.jpg",
+ "image/jpeg",
+ 5059,
+ true,
+ false
+ );
+});
+
+add_task(async function test_animated_16x16_icon() {
+ // in: 16x16 apng, 1791 bytes.
+ // optimized: yes
+ await checkFaviconDataConversion(
+ "favicon-animated16.png",
+ "image/png",
+ 1791,
+ true,
+ false
+ );
+});
diff --git a/toolkit/components/places/tests/favicons/test_favicons_protocols_ref.js b/toolkit/components/places/tests/favicons/test_favicons_protocols_ref.js
new file mode 100644
index 0000000000..aa0241a3d2
--- /dev/null
+++ b/toolkit/components/places/tests/favicons/test_favicons_protocols_ref.js
@@ -0,0 +1,114 @@
+/**
+ * This file tests the size ref on the icons protocols.
+ */
+
+const PAGE_URL = "http://icon.mozilla.org/";
+const ICON16_URL = "http://places.test/favicon-normal16.png";
+const ICON32_URL = "http://places.test/favicon-normal32.png";
+
+add_task(async function () {
+ await PlacesTestUtils.addVisits(PAGE_URL);
+ // Add 2 differently sized favicons for this page.
+
+ let data = readFileData(do_get_file("favicon-normal16.png"));
+ PlacesUtils.favicons.replaceFaviconData(
+ Services.io.newURI(ICON16_URL),
+ data,
+ "image/png"
+ );
+ await setFaviconForPage(PAGE_URL, ICON16_URL);
+ data = readFileData(do_get_file("favicon-normal32.png"));
+ PlacesUtils.favicons.replaceFaviconData(
+ Services.io.newURI(ICON32_URL),
+ data,
+ "image/png"
+ );
+ await setFaviconForPage(PAGE_URL, ICON32_URL);
+
+ const PAGE_ICON_URL = "page-icon:" + PAGE_URL;
+
+ await compareFavicons(
+ PAGE_ICON_URL,
+ PlacesUtils.favicons.getFaviconLinkForIcon(Services.io.newURI(ICON32_URL)),
+ "Not specifying a ref should return the bigger icon"
+ );
+ // Fake window object.
+ let win = { devicePixelRatio: 1.0 };
+ await compareFavicons(
+ PlacesUtils.urlWithSizeRef(win, PAGE_ICON_URL, 16),
+ PlacesUtils.favicons.getFaviconLinkForIcon(Services.io.newURI(ICON16_URL)),
+ "Size=16 should return the 16px icon"
+ );
+ await compareFavicons(
+ PlacesUtils.urlWithSizeRef(win, PAGE_ICON_URL, 32),
+ PlacesUtils.favicons.getFaviconLinkForIcon(Services.io.newURI(ICON32_URL)),
+ "Size=32 should return the 32px icon"
+ );
+ await compareFavicons(
+ PlacesUtils.urlWithSizeRef(win, PAGE_ICON_URL, 33),
+ PlacesUtils.favicons.getFaviconLinkForIcon(Services.io.newURI(ICON32_URL)),
+ "Size=33 should return the 32px icon"
+ );
+ await compareFavicons(
+ PlacesUtils.urlWithSizeRef(win, PAGE_ICON_URL, 17),
+ PlacesUtils.favicons.getFaviconLinkForIcon(Services.io.newURI(ICON32_URL)),
+ "Size=17 should return the 32px icon"
+ );
+ await compareFavicons(
+ PlacesUtils.urlWithSizeRef(win, PAGE_ICON_URL, 1),
+ PlacesUtils.favicons.getFaviconLinkForIcon(Services.io.newURI(ICON16_URL)),
+ "Size=1 should return the 16px icon"
+ );
+ await compareFavicons(
+ PlacesUtils.urlWithSizeRef(win, PAGE_ICON_URL, 0),
+ PlacesUtils.favicons.getFaviconLinkForIcon(Services.io.newURI(ICON32_URL)),
+ "Size=0 should return the bigger icon"
+ );
+ await compareFavicons(
+ PlacesUtils.urlWithSizeRef(win, PAGE_ICON_URL, -1),
+ PlacesUtils.favicons.getFaviconLinkForIcon(Services.io.newURI(ICON32_URL)),
+ "Invalid size should return the bigger icon"
+ );
+
+ // Add the icon also for the page with ref.
+ await PlacesTestUtils.addVisits(PAGE_URL + "#other§=12");
+ await setFaviconForPage(PAGE_URL + "#other§=12", ICON16_URL, false);
+ await setFaviconForPage(PAGE_URL + "#other§=12", ICON32_URL, false);
+ await compareFavicons(
+ PlacesUtils.urlWithSizeRef(win, PAGE_ICON_URL + "#other§=12", 16),
+ PlacesUtils.favicons.getFaviconLinkForIcon(Services.io.newURI(ICON16_URL)),
+ "Pre-existing refs should be retained"
+ );
+ await compareFavicons(
+ PlacesUtils.urlWithSizeRef(win, PAGE_ICON_URL + "#other§=12", 32),
+ PlacesUtils.favicons.getFaviconLinkForIcon(Services.io.newURI(ICON32_URL)),
+ "Pre-existing refs should be retained"
+ );
+
+ // If the ref-ed url is unknown, should still try to fetch icon for the unref-ed url.
+ await compareFavicons(
+ PlacesUtils.urlWithSizeRef(win, PAGE_ICON_URL + "#randomstuff", 32),
+ PlacesUtils.favicons.getFaviconLinkForIcon(Services.io.newURI(ICON32_URL)),
+ "Non-existing refs should be ignored"
+ );
+
+ win = { devicePixelRatio: 1.1 };
+ await compareFavicons(
+ PlacesUtils.urlWithSizeRef(win, PAGE_ICON_URL, 16),
+ PlacesUtils.favicons.getFaviconLinkForIcon(Services.io.newURI(ICON32_URL)),
+ "Size=16 with HIDPI should return the 32px icon"
+ );
+ await compareFavicons(
+ PlacesUtils.urlWithSizeRef(win, PAGE_ICON_URL, 32),
+ PlacesUtils.favicons.getFaviconLinkForIcon(Services.io.newURI(ICON32_URL)),
+ "Size=32 with HIDPI should return the 32px icon"
+ );
+
+ // Check setting a different default preferred size works.
+ PlacesUtils.favicons.setDefaultIconURIPreferredSize(16);
+ await compareFavicons(
+ PAGE_ICON_URL,
+ PlacesUtils.favicons.getFaviconLinkForIcon(Services.io.newURI(ICON16_URL)),
+ "Not specifying a ref should return the set default size icon"
+ );
+});
diff --git a/toolkit/components/places/tests/favicons/test_getFaviconDataForPage.js b/toolkit/components/places/tests/favicons/test_getFaviconDataForPage.js
new file mode 100644
index 0000000000..80f498f33f
--- /dev/null
+++ b/toolkit/components/places/tests/favicons/test_getFaviconDataForPage.js
@@ -0,0 +1,131 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+const FAVICON_URI = NetUtil.newURI(do_get_file("favicon-normal32.png"));
+const FAVICON_DATA = readFileData(do_get_file("favicon-normal32.png"));
+const FAVICON_MIMETYPE = "image/png";
+const ICON32_URL = "http://places.test/favicon-normal32.png";
+
+add_task(async function test_normal() {
+ Assert.equal(FAVICON_DATA.length, 344);
+ let pageURI = NetUtil.newURI("http://example.com/normal");
+
+ await PlacesTestUtils.addVisits(pageURI);
+ await new Promise(resolve => {
+ PlacesUtils.favicons.setAndFetchFaviconForPage(
+ pageURI,
+ FAVICON_URI,
+ true,
+ PlacesUtils.favicons.FAVICON_LOAD_NON_PRIVATE,
+ function () {
+ PlacesUtils.favicons.getFaviconDataForPage(
+ pageURI,
+ function (aURI, aDataLen, aData, aMimeType) {
+ Assert.ok(aURI.equals(FAVICON_URI));
+ Assert.equal(FAVICON_DATA.length, aDataLen);
+ Assert.ok(compareArrays(FAVICON_DATA, aData));
+ Assert.equal(FAVICON_MIMETYPE, aMimeType);
+ resolve();
+ }
+ );
+ },
+ Services.scriptSecurityManager.getSystemPrincipal()
+ );
+ });
+});
+
+add_task(async function test_missing() {
+ let pageURI = NetUtil.newURI("http://example.com/missing");
+
+ await new Promise(resolve => {
+ PlacesUtils.favicons.getFaviconDataForPage(
+ pageURI,
+ function (aURI, aDataLen, aData, aMimeType) {
+ // Check also the expected data types.
+ Assert.ok(aURI === null);
+ Assert.ok(aDataLen === 0);
+ Assert.ok(aData.length === 0);
+ Assert.ok(aMimeType === "");
+ resolve();
+ }
+ );
+ });
+});
+
+add_task(async function test_fallback() {
+ const ROOT_URL = "https://www.example.com/";
+ const ROOT_ICON_URL = ROOT_URL + "favicon.ico";
+ const SUBPAGE_URL = ROOT_URL + "/missing";
+
+ info("Set icon for the root");
+ await PlacesTestUtils.addVisits(ROOT_URL);
+ let data = readFileData(do_get_file("favicon-normal16.png"));
+ PlacesUtils.favicons.replaceFaviconData(
+ NetUtil.newURI(ROOT_ICON_URL),
+ data,
+ "image/png"
+ );
+ await setFaviconForPage(ROOT_URL, ROOT_ICON_URL);
+
+ info("check fallback icons");
+ await new Promise(resolve => {
+ PlacesUtils.favicons.getFaviconDataForPage(
+ NetUtil.newURI(ROOT_URL),
+ (aURI, aDataLen, aData, aMimeType) => {
+ Assert.equal(aURI.spec, ROOT_ICON_URL);
+ Assert.equal(aDataLen, data.length);
+ Assert.deepEqual(aData, data);
+ Assert.equal(aMimeType, "image/png");
+ resolve();
+ }
+ );
+ });
+ await new Promise(resolve => {
+ PlacesUtils.favicons.getFaviconDataForPage(
+ NetUtil.newURI(SUBPAGE_URL),
+ (aURI, aDataLen, aData, aMimeType) => {
+ Assert.equal(aURI.spec, ROOT_ICON_URL);
+ Assert.equal(aDataLen, data.length);
+ Assert.deepEqual(aData, data);
+ Assert.equal(aMimeType, "image/png");
+ resolve();
+ }
+ );
+ });
+
+ info("Now add a proper icon for the page");
+ await PlacesTestUtils.addVisits(SUBPAGE_URL);
+ let data32 = readFileData(do_get_file("favicon-normal32.png"));
+ PlacesUtils.favicons.replaceFaviconData(
+ NetUtil.newURI(ICON32_URL),
+ data32,
+ "image/png"
+ );
+ await setFaviconForPage(SUBPAGE_URL, ICON32_URL);
+
+ info("check no fallback icons");
+ await new Promise(resolve => {
+ PlacesUtils.favicons.getFaviconDataForPage(
+ NetUtil.newURI(ROOT_URL),
+ (aURI, aDataLen, aData, aMimeType) => {
+ Assert.equal(aURI.spec, ROOT_ICON_URL);
+ Assert.equal(aDataLen, data.length);
+ Assert.deepEqual(aData, data);
+ Assert.equal(aMimeType, "image/png");
+ resolve();
+ }
+ );
+ });
+ await new Promise(resolve => {
+ PlacesUtils.favicons.getFaviconDataForPage(
+ NetUtil.newURI(SUBPAGE_URL),
+ (aURI, aDataLen, aData, aMimeType) => {
+ Assert.equal(aURI.spec, ICON32_URL);
+ Assert.equal(aDataLen, data32.length);
+ Assert.deepEqual(aData, data32);
+ Assert.equal(aMimeType, "image/png");
+ resolve();
+ }
+ );
+ });
+});
diff --git a/toolkit/components/places/tests/favicons/test_getFaviconLinkForIcon.js b/toolkit/components/places/tests/favicons/test_getFaviconLinkForIcon.js
new file mode 100644
index 0000000000..e60a59bd80
--- /dev/null
+++ b/toolkit/components/places/tests/favicons/test_getFaviconLinkForIcon.js
@@ -0,0 +1,35 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+/**
+ * Test getFaviconLinkForIcon API.
+ */
+
+add_task(async function test_basic() {
+ // Check these protocols are pass-through.
+ for (let protocol of ["http://", "https://"]) {
+ let url = PlacesUtils.favicons.getFaviconLinkForIcon(
+ Services.io.newURI(protocol + "test/test.png")
+ ).spec;
+ Assert.equal(url, "cached-favicon:" + protocol + "test/test.png");
+ }
+});
+
+add_task(async function test_directRequestProtocols() {
+ // Check these protocols are pass-through.
+ for (let protocol of [
+ "about:",
+ "cached-favicon:",
+ "chrome://",
+ "data:",
+ "file:///",
+ "moz-page-thumb://",
+ "page-icon:",
+ "resource://",
+ ]) {
+ let url = PlacesUtils.favicons.getFaviconLinkForIcon(
+ Services.io.newURI(protocol + "test/test.png")
+ ).spec;
+ Assert.equal(url, protocol + "test/test.png");
+ }
+});
diff --git a/toolkit/components/places/tests/favicons/test_getFaviconURLForPage.js b/toolkit/components/places/tests/favicons/test_getFaviconURLForPage.js
new file mode 100644
index 0000000000..e8f459cb08
--- /dev/null
+++ b/toolkit/components/places/tests/favicons/test_getFaviconURLForPage.js
@@ -0,0 +1,101 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+const ICON32_URL = "http://places.test/favicon-normal32.png";
+
+add_task(async function test_normal() {
+ let pageURI = NetUtil.newURI("http://example.com/normal");
+
+ await PlacesTestUtils.addVisits(pageURI);
+ await new Promise(resolve => {
+ PlacesUtils.favicons.setAndFetchFaviconForPage(
+ pageURI,
+ SMALLPNG_DATA_URI,
+ true,
+ PlacesUtils.favicons.FAVICON_LOAD_NON_PRIVATE,
+ function () {
+ PlacesUtils.favicons.getFaviconURLForPage(
+ pageURI,
+ function (aURI, aDataLen, aData, aMimeType) {
+ Assert.ok(aURI.equals(SMALLPNG_DATA_URI));
+
+ // Check also the expected data types.
+ Assert.ok(aDataLen === 0);
+ Assert.ok(aData.length === 0);
+ Assert.ok(aMimeType === "");
+ resolve();
+ }
+ );
+ },
+ Services.scriptSecurityManager.getSystemPrincipal()
+ );
+ });
+});
+
+add_task(async function test_missing() {
+ let pageURI = NetUtil.newURI("http://example.com/missing");
+
+ await new Promise(resolve => {
+ PlacesUtils.favicons.getFaviconURLForPage(
+ pageURI,
+ function (aURI, aDataLen, aData, aMimeType) {
+ // Check also the expected data types.
+ Assert.ok(aURI === null);
+ Assert.ok(aDataLen === 0);
+ Assert.ok(aData.length === 0);
+ Assert.ok(aMimeType === "");
+ resolve();
+ }
+ );
+ });
+});
+
+add_task(async function test_fallback() {
+ const ROOT_URL = "https://www.example.com/";
+ const ROOT_ICON_URL = ROOT_URL + "favicon.ico";
+ const SUBPAGE_URL = ROOT_URL + "/missing";
+
+ info("Set icon for the root");
+ await PlacesTestUtils.addVisits(ROOT_URL);
+ let data = readFileData(do_get_file("favicon-normal16.png"));
+ PlacesUtils.favicons.replaceFaviconData(
+ NetUtil.newURI(ROOT_ICON_URL),
+ data,
+ "image/png"
+ );
+ await setFaviconForPage(ROOT_URL, ROOT_ICON_URL);
+
+ info("check fallback icons");
+ Assert.equal(
+ await getFaviconUrlForPage(ROOT_URL),
+ ROOT_ICON_URL,
+ "The root should have its favicon"
+ );
+ Assert.equal(
+ await getFaviconUrlForPage(SUBPAGE_URL),
+ ROOT_ICON_URL,
+ "The page should fallback to the root icon"
+ );
+
+ info("Now add a proper icon for the page");
+ await PlacesTestUtils.addVisits(SUBPAGE_URL);
+ let data32 = readFileData(do_get_file("favicon-normal32.png"));
+ PlacesUtils.favicons.replaceFaviconData(
+ NetUtil.newURI(ICON32_URL),
+ data32,
+ "image/png"
+ );
+ await setFaviconForPage(SUBPAGE_URL, ICON32_URL);
+
+ info("check no fallback icons");
+ Assert.equal(
+ await getFaviconUrlForPage(ROOT_URL),
+ ROOT_ICON_URL,
+ "The root should still have its favicon"
+ );
+ Assert.equal(
+ await getFaviconUrlForPage(SUBPAGE_URL),
+ ICON32_URL,
+ "The page should also have its icon"
+ );
+});
diff --git a/toolkit/components/places/tests/favicons/test_heavy_favicon.js b/toolkit/components/places/tests/favicons/test_heavy_favicon.js
new file mode 100644
index 0000000000..09adcaf6fa
--- /dev/null
+++ b/toolkit/components/places/tests/favicons/test_heavy_favicon.js
@@ -0,0 +1,36 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/ */
+
+/*
+ * Tests a png with a large file size that can't fit MAX_FAVICON_BUFFER_SIZE,
+ * it should be downsized until it can be stored, rather than thrown away.
+ */
+
+add_task(async function () {
+ let file = do_get_file("noise.png");
+ let icon = {
+ file,
+ uri: NetUtil.newURI(file),
+ data: readFileData(file),
+ mimetype: "image/png",
+ };
+
+ // If this should fail, it means MAX_FAVICON_BUFFER_SIZE has been made bigger
+ // than this icon. For this test to make sense the icon shoul always be
+ // bigger than MAX_FAVICON_BUFFER_SIZE. Please update the icon!
+ Assert.ok(
+ icon.data.length > Ci.nsIFaviconService.MAX_FAVICON_BUFFER_SIZE,
+ "The test icon file size must be larger than Ci.nsIFaviconService.MAX_FAVICON_BUFFER_SIZE"
+ );
+
+ let pageURI = uri("http://foo.bar/");
+ await PlacesTestUtils.addVisits(pageURI);
+
+ PlacesUtils.favicons.replaceFaviconData(icon.uri, icon.data, icon.mimetype);
+ await setFaviconForPage(pageURI, icon.uri);
+ Assert.equal(
+ await getFaviconUrlForPage(pageURI),
+ icon.uri.spec,
+ "A resampled version of the icon should be stored"
+ );
+});
diff --git a/toolkit/components/places/tests/favicons/test_incremental_vacuum.js b/toolkit/components/places/tests/favicons/test_incremental_vacuum.js
new file mode 100644
index 0000000000..ab93121d47
--- /dev/null
+++ b/toolkit/components/places/tests/favicons/test_incremental_vacuum.js
@@ -0,0 +1,48 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/ */
+
+// Tests incremental vacuum of the favicons database.
+
+const { PlacesDBUtils } = ChromeUtils.importESModule(
+ "resource://gre/modules/PlacesDBUtils.sys.mjs"
+);
+
+add_task(async function () {
+ let icon = {
+ file: do_get_file("noise.png"),
+ mimetype: "image/png",
+ };
+
+ let url = "http://foo.bar/";
+ await PlacesTestUtils.addVisits(url);
+ for (let i = 0; i < 10; ++i) {
+ let iconUri = NetUtil.newURI("http://mozilla.org/" + i);
+ let data = readFileData(icon.file);
+ PlacesUtils.favicons.replaceFaviconData(iconUri, data, icon.mimetype);
+ await setFaviconForPage(url, iconUri);
+ }
+
+ let promise = TestUtils.topicObserved("places-favicons-expired");
+ PlacesUtils.favicons.expireAllFavicons();
+ await promise;
+
+ let db = await PlacesUtils.promiseDBConnection();
+ let state = (
+ await db.execute("PRAGMA favicons.auto_vacuum")
+ )[0].getResultByIndex(0);
+ Assert.equal(state, 2, "auto_vacuum should be incremental");
+ let count = (
+ await db.execute("PRAGMA favicons.freelist_count")
+ )[0].getResultByIndex(0);
+ info(`Found ${count} freelist pages`);
+ let log = await PlacesDBUtils.incrementalVacuum();
+ info(log);
+ let newCount = (
+ await db.execute("PRAGMA favicons.freelist_count")
+ )[0].getResultByIndex(0);
+ info(`Found ${newCount} freelist pages`);
+ Assert.ok(
+ newCount < count,
+ "The number of freelist pages should have reduced"
+ );
+});
diff --git a/toolkit/components/places/tests/favicons/test_multiple_frames.js b/toolkit/components/places/tests/favicons/test_multiple_frames.js
new file mode 100644
index 0000000000..5c7f585715
--- /dev/null
+++ b/toolkit/components/places/tests/favicons/test_multiple_frames.js
@@ -0,0 +1,46 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+/**
+ * This file tests support for icons with multiple frames (like .ico files).
+ */
+
+add_task(async function () {
+ // in: 48x48 ico, 56646 bytes.
+ // (howstuffworks.com icon, contains 13 icons with sizes from 16x16 to
+ // 48x48 in varying depths)
+ let pageURI = NetUtil.newURI("http://places.test/page/");
+ await PlacesTestUtils.addVisits(pageURI);
+ let faviconURI = NetUtil.newURI("http://places.test/icon/favicon-multi.ico");
+ // Fake window.
+ let win = { devicePixelRatio: 1.0 };
+ let icoData = readFileData(do_get_file("favicon-multi.ico"));
+ PlacesUtils.favicons.replaceFaviconData(faviconURI, icoData, "image/x-icon");
+ await setFaviconForPage(pageURI, faviconURI);
+
+ for (let size of [16, 32, 64]) {
+ let file = do_get_file(`favicon-multi-frame${size}.png`);
+ let data = readFileData(file);
+
+ info("Check getFaviconDataForPage");
+ let icon = await getFaviconDataForPage(pageURI, size);
+ Assert.equal(icon.mimeType, "image/png");
+ Assert.deepEqual(icon.data, data);
+
+ info("Check cached-favicon protocol");
+ await compareFavicons(
+ Services.io.newFileURI(file),
+ PlacesUtils.urlWithSizeRef(
+ win,
+ PlacesUtils.favicons.getFaviconLinkForIcon(faviconURI).spec,
+ size
+ )
+ );
+
+ info("Check page-icon protocol");
+ await compareFavicons(
+ Services.io.newFileURI(file),
+ PlacesUtils.urlWithSizeRef(win, "page-icon:" + pageURI.spec, size)
+ );
+ }
+});
diff --git a/toolkit/components/places/tests/favicons/test_page-icon_protocol.js b/toolkit/components/places/tests/favicons/test_page-icon_protocol.js
new file mode 100644
index 0000000000..932040bafb
--- /dev/null
+++ b/toolkit/components/places/tests/favicons/test_page-icon_protocol.js
@@ -0,0 +1,243 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+const ICON_DATAURL =
+ "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAAAAAA6fptVAAAACklEQVQI12NgAAAAAgAB4iG8MwAAAABJRU5ErkJggg==";
+const TEST_URI = NetUtil.newURI("http://mozilla.org/");
+const ICON_URI = NetUtil.newURI("http://mozilla.org/favicon.ico");
+
+const { XPCShellContentUtils } = ChromeUtils.importESModule(
+ "resource://testing-common/XPCShellContentUtils.sys.mjs"
+);
+
+const PAGE_ICON_TEST_URLS = [
+ "page-icon:http://example.com/",
+ "page-icon:http://a-site-never-before-seen.test",
+ // For the following, the page-icon protocol is expected to successfully
+ // return the default favicon.
+ "page-icon:test",
+ "page-icon:",
+ "page-icon:chrome://something.html",
+ "page-icon:foo://bar/baz",
+];
+
+XPCShellContentUtils.init(this);
+
+const HTML = String.raw`
+
+
+
+
+
+ Hello from example.com!
+
+`;
+
+const server = XPCShellContentUtils.createHttpServer({
+ hosts: ["example.com"],
+});
+
+server.registerPathHandler("/", (request, response) => {
+ response.setHeader("Content-Type", "text/html");
+ response.write(HTML);
+});
+
+function fetchIconForSpec(spec) {
+ return new Promise((resolve, reject) => {
+ NetUtil.asyncFetch(
+ {
+ uri: NetUtil.newURI(spec),
+ loadUsingSystemPrincipal: true,
+ contentPolicyType: Ci.nsIContentPolicy.TYPE_INTERNAL_IMAGE_FAVICON,
+ },
+ (input, status, request) => {
+ if (!Components.isSuccessCode(status)) {
+ reject(new Error("unable to load icon"));
+ return;
+ }
+
+ try {
+ let data = NetUtil.readInputStreamToString(input, input.available());
+ let contentType = request.QueryInterface(Ci.nsIChannel).contentType;
+ input.close();
+ resolve({ data, contentType });
+ } catch (ex) {
+ reject(ex);
+ }
+ }
+ );
+ });
+}
+
+var gDefaultFavicon;
+var gFavicon;
+
+add_task(async function setup() {
+ await PlacesTestUtils.addVisits(TEST_URI);
+
+ PlacesUtils.favicons.replaceFaviconDataFromDataURL(
+ ICON_URI,
+ ICON_DATAURL,
+ (Date.now() + 8640000) * 1000,
+ Services.scriptSecurityManager.getSystemPrincipal()
+ );
+
+ await new Promise(resolve => {
+ PlacesUtils.favicons.setAndFetchFaviconForPage(
+ TEST_URI,
+ ICON_URI,
+ false,
+ PlacesUtils.favicons.FAVICON_LOAD_NON_PRIVATE,
+ resolve,
+ Services.scriptSecurityManager.getSystemPrincipal()
+ );
+ });
+
+ gDefaultFavicon = await fetchIconForSpec(
+ PlacesUtils.favicons.defaultFavicon.spec
+ );
+ gFavicon = await fetchIconForSpec(ICON_DATAURL);
+});
+
+add_task(async function known_url() {
+ let { data, contentType } = await fetchIconForSpec(
+ "page-icon:" + TEST_URI.spec
+ );
+ Assert.equal(contentType, gFavicon.contentType);
+ Assert.deepEqual(data, gFavicon.data, "Got the favicon data");
+});
+
+add_task(async function unknown_url() {
+ let { data, contentType } = await fetchIconForSpec(
+ "page-icon:http://www.moz.org/"
+ );
+ Assert.equal(contentType, gDefaultFavicon.contentType);
+ Assert.deepEqual(data, gDefaultFavicon.data, "Got the default favicon data");
+});
+
+add_task(async function invalid_url() {
+ let { data, contentType } = await fetchIconForSpec("page-icon:test");
+ Assert.equal(contentType, gDefaultFavicon.contentType);
+ Assert.ok(data == gDefaultFavicon.data, "Got the default favicon data");
+});
+
+add_task(async function subpage_url_fallback() {
+ let { data, contentType } = await fetchIconForSpec(
+ "page-icon:http://mozilla.org/missing"
+ );
+ Assert.equal(contentType, gFavicon.contentType);
+ Assert.deepEqual(data, gFavicon.data, "Got the root favicon data");
+});
+
+add_task(async function svg_icon() {
+ let faviconURI = NetUtil.newURI("http://places.test/favicon.svg");
+ PlacesUtils.favicons.replaceFaviconDataFromDataURL(
+ faviconURI,
+ SMALLSVG_DATA_URI.spec,
+ 0,
+ Services.scriptSecurityManager.getSystemPrincipal()
+ );
+ await setFaviconForPage(TEST_URI, faviconURI);
+ let svgIcon = await fetchIconForSpec(SMALLSVG_DATA_URI.spec);
+ info(svgIcon.contentType);
+ let pageIcon = await fetchIconForSpec("page-icon:" + TEST_URI.spec);
+ Assert.equal(svgIcon.contentType, pageIcon.contentType);
+ Assert.deepEqual(svgIcon.data, pageIcon.data, "Got the root favicon data");
+});
+
+add_task(async function page_with_ref() {
+ for (let url of [
+ "http://places.test.ref/#myref",
+ "http://places.test.ref/#!&b=16",
+ "http://places.test.ref/#",
+ ]) {
+ await PlacesTestUtils.addVisits(url);
+ await setFaviconForPage(url, ICON_URI, false);
+ let { data, contentType } = await fetchIconForSpec("page-icon:" + url);
+ Assert.equal(contentType, gFavicon.contentType);
+ Assert.deepEqual(data, gFavicon.data, "Got the favicon data");
+ await PlacesUtils.history.remove(url);
+ }
+});
+
+/**
+ * Tests that page-icon does not work in a normal content process.
+ */
+add_task(async function page_content_process() {
+ let contentPage = await XPCShellContentUtils.loadContentPage(
+ "http://example.com/",
+ {
+ remote: true,
+ }
+ );
+ Assert.notEqual(
+ contentPage.browsingContext.currentRemoteType,
+ "privilegedabout"
+ );
+
+ await contentPage.spawn([PAGE_ICON_TEST_URLS], async URLS => {
+ // We expect each of these URLs to produce an error event when
+ // we attempt to load them in this process type.
+ /* global content */
+ for (let url of URLS) {
+ let img = content.document.createElement("img");
+ img.src = url;
+ let imgPromise = new Promise((resolve, reject) => {
+ img.addEventListener("error", e => {
+ Assert.ok(true, "Got expected load error.");
+ resolve();
+ });
+ img.addEventListener("load", e => {
+ Assert.ok(false, "Did not expect a successful load.");
+ reject();
+ });
+ });
+ content.document.body.appendChild(img);
+ await imgPromise;
+ }
+ });
+
+ await contentPage.close();
+});
+
+/**
+ * Tests that page-icon does work for privileged about content process
+ */
+add_task(async function page_privileged_about_content_process() {
+ // about:certificate loads in the privileged about content process.
+ let contentPage = await XPCShellContentUtils.loadContentPage(
+ "about:certificate",
+ {
+ remote: true,
+ }
+ );
+ Assert.equal(
+ contentPage.browsingContext.currentRemoteType,
+ "privilegedabout"
+ );
+
+ await contentPage.spawn([PAGE_ICON_TEST_URLS], async URLS => {
+ // We expect each of these URLs to load correctly in this process
+ // type.
+ for (let url of URLS) {
+ let img = content.document.createElement("img");
+ img.src = url;
+ let imgPromise = new Promise((resolve, reject) => {
+ img.addEventListener("error", e => {
+ Assert.ok(false, "Did not expect an error. ");
+ reject();
+ });
+ img.addEventListener("load", e => {
+ Assert.ok(true, "Got expected load event.");
+ resolve();
+ });
+ });
+ content.document.body.appendChild(img);
+ await imgPromise;
+ }
+ });
+
+ await contentPage.close();
+});
diff --git a/toolkit/components/places/tests/favicons/test_query_result_favicon_changed_on_child.js b/toolkit/components/places/tests/favicons/test_query_result_favicon_changed_on_child.js
new file mode 100644
index 0000000000..4e5c55e50a
--- /dev/null
+++ b/toolkit/components/places/tests/favicons/test_query_result_favicon_changed_on_child.js
@@ -0,0 +1,153 @@
+/**
+ * Test for bug 451499 :
+ * Wrong folder icon appears on queries.
+ */
+
+"use strict";
+
+add_task(async function test_query_result_favicon_changed_on_child() {
+ // Bookmark our test page, so it will appear in the query resultset.
+ const PAGE_URI = Services.io.newURI("http://example.com/test_query_result");
+ await PlacesUtils.bookmarks.insert({
+ parentGuid: PlacesUtils.bookmarks.menuGuid,
+ title: "test_bookmark",
+ url: PAGE_URI,
+ });
+
+ // Get the last 10 bookmarks added to the menu or the toolbar.
+ let query = PlacesUtils.history.getNewQuery();
+ query.setParents([
+ PlacesUtils.bookmarks.menuGuid,
+ PlacesUtils.bookmarks.toolbarGuid,
+ ]);
+
+ let options = PlacesUtils.history.getNewQueryOptions();
+ options.queryType = Ci.nsINavHistoryQueryOptions.QUERY_TYPE_BOOKMARKS;
+ options.maxResults = 10;
+ options.excludeQueries = 1;
+ options.sortingMode = options.SORT_BY_DATE_DESCENDING;
+
+ let result = PlacesUtils.history.executeQuery(query, options);
+ let resultObserver = {
+ containerStateChanged(aContainerNode, aOldState, aNewState) {
+ if (aNewState == Ci.nsINavHistoryContainerResultNode.STATE_OPENED) {
+ // We set a favicon on PAGE_URI while the container is open. The
+ // favicon for the page must have data associated with it in order for
+ // the icon changed notifications to be sent, so we use a valid image
+ // data URI.
+ PlacesUtils.favicons.setAndFetchFaviconForPage(
+ PAGE_URI,
+ SMALLPNG_DATA_URI,
+ false,
+ PlacesUtils.favicons.FAVICON_LOAD_NON_PRIVATE,
+ null,
+ Services.scriptSecurityManager.getSystemPrincipal()
+ );
+ }
+ },
+ nodeIconChanged(aNode) {
+ if (PlacesUtils.nodeIsContainer(aNode)) {
+ do_throw(
+ "The icon should be set only for the page," +
+ " not for the containing query."
+ );
+ }
+ },
+ };
+ Object.setPrototypeOf(resultObserver, NavHistoryResultObserver.prototype);
+ result.addObserver(resultObserver);
+
+ // Open the container and wait for containerStateChanged. We should start
+ // observing before setting |containerOpen| as that's caused by the
+ // setAndFetchFaviconForPage() call caused by the containerStateChanged
+ // observer above.
+ let promise = promiseFaviconChanged(PAGE_URI, SMALLPNG_DATA_URI);
+ result.root.containerOpen = true;
+ await promise;
+
+ // We must wait for the asynchronous database thread to finish the
+ // operation, and then for the main thread to process any pending
+ // notifications that came from the asynchronous thread, before we can be
+ // sure that nodeIconChanged was not invoked in the meantime.
+ await PlacesTestUtils.promiseAsyncUpdates();
+ result.removeObserver(resultObserver);
+
+ // Free the resources immediately.
+ result.root.containerOpen = false;
+
+ await PlacesUtils.bookmarks.eraseEverything();
+ await PlacesUtils.history.clear();
+});
+
+add_task(
+ async function test_query_result_favicon_changed_not_affect_lastmodified() {
+ // Bookmark our test page, so it will appear in the query resultset.
+ const PAGE_URI2 = Services.io.newURI(
+ "http://example.com/test_query_result"
+ );
+ let bm = await PlacesUtils.bookmarks.insert({
+ parentGuid: PlacesUtils.bookmarks.menuGuid,
+ title: "test_bookmark",
+ url: PAGE_URI2,
+ });
+
+ let result = PlacesUtils.getFolderContents(PlacesUtils.bookmarks.menuGuid);
+
+ Assert.equal(
+ result.root.childCount,
+ 1,
+ "Should have only one item in the query"
+ );
+ Assert.equal(
+ result.root.getChild(0).uri,
+ PAGE_URI2.spec,
+ "Should have the correct child"
+ );
+ Assert.equal(
+ result.root.getChild(0).lastModified,
+ PlacesUtils.toPRTime(bm.lastModified),
+ "Should have the expected last modified date."
+ );
+
+ let promise = promiseFaviconChanged(PAGE_URI2, SMALLPNG_DATA_URI);
+ PlacesUtils.favicons.setAndFetchFaviconForPage(
+ PAGE_URI2,
+ SMALLPNG_DATA_URI,
+ false,
+ PlacesUtils.favicons.FAVICON_LOAD_NON_PRIVATE,
+ null,
+ Services.scriptSecurityManager.getSystemPrincipal()
+ );
+ await promise;
+
+ // Open the container and wait for containerStateChanged. We should start
+ // observing before setting |containerOpen| as that's caused by the
+ // setAndFetchFaviconForPage() call caused by the containerStateChanged
+ // observer above.
+
+ // We must wait for the asynchronous database thread to finish the
+ // operation, and then for the main thread to process any pending
+ // notifications that came from the asynchronous thread, before we can be
+ // sure that nodeIconChanged was not invoked in the meantime.
+ await PlacesTestUtils.promiseAsyncUpdates();
+
+ Assert.equal(
+ result.root.childCount,
+ 1,
+ "Should have only one item in the query"
+ );
+ Assert.equal(
+ result.root.getChild(0).uri,
+ PAGE_URI2.spec,
+ "Should have the correct child"
+ );
+ Assert.equal(
+ result.root.getChild(0).lastModified,
+ PlacesUtils.toPRTime(bm.lastModified),
+ "Should not have changed the last modified date."
+ );
+
+ // Free the resources immediately.
+ result.root.containerOpen = false;
+ }
+);
diff --git a/toolkit/components/places/tests/favicons/test_replaceFaviconData.js b/toolkit/components/places/tests/favicons/test_replaceFaviconData.js
new file mode 100644
index 0000000000..2e9835eaa9
--- /dev/null
+++ b/toolkit/components/places/tests/favicons/test_replaceFaviconData.js
@@ -0,0 +1,395 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/ */
+
+/*
+ * Tests for replaceFaviconData()
+ */
+
+var iconsvc = PlacesUtils.favicons;
+
+var originalFavicon = {
+ file: do_get_file("favicon-normal16.png"),
+ uri: uri(do_get_file("favicon-normal16.png")),
+ data: readFileData(do_get_file("favicon-normal16.png")),
+ mimetype: "image/png",
+};
+
+var uniqueFaviconId = 0;
+function createFavicon(fileName) {
+ let tempdir = Services.dirsvc.get("TmpD", Ci.nsIFile);
+
+ // remove any existing file at the path we're about to copy to
+ let outfile = tempdir.clone();
+ outfile.append(fileName);
+ try {
+ outfile.remove(false);
+ } catch (e) {}
+
+ originalFavicon.file.copyToFollowingLinks(tempdir, fileName);
+
+ let stream = Cc["@mozilla.org/network/file-output-stream;1"].createInstance(
+ Ci.nsIFileOutputStream
+ );
+ stream.init(outfile, 0x02 | 0x08 | 0x10, 0o600, 0);
+
+ // append some data that sniffers/encoders will ignore that will distinguish
+ // the different favicons we'll create
+ uniqueFaviconId++;
+ let uniqueStr = "uid:" + uniqueFaviconId;
+ stream.write(uniqueStr, uniqueStr.length);
+ stream.close();
+
+ Assert.equal(outfile.leafName.substr(0, fileName.length), fileName);
+
+ return {
+ file: outfile,
+ uri: uri(outfile),
+ data: readFileData(outfile),
+ mimetype: "image/png",
+ };
+}
+
+function checkCallbackSucceeded(
+ callbackMimetype,
+ callbackData,
+ sourceMimetype,
+ sourceData
+) {
+ Assert.equal(callbackMimetype, sourceMimetype);
+ Assert.ok(compareArrays(callbackData, sourceData));
+}
+
+function run_test() {
+ // check that the favicon loaded correctly
+ Assert.equal(originalFavicon.data.length, 286);
+ run_next_test();
+}
+
+add_task(async function test_replaceFaviconData_validHistoryURI() {
+ info("test replaceFaviconData for valid history uri");
+
+ let pageURI = uri("http://test1.bar/");
+ await PlacesTestUtils.addVisits(pageURI);
+
+ let favicon = createFavicon("favicon1.png");
+
+ iconsvc.replaceFaviconData(favicon.uri, favicon.data, favicon.mimetype);
+
+ await new Promise(resolve => {
+ iconsvc.setAndFetchFaviconForPage(
+ pageURI,
+ favicon.uri,
+ true,
+ PlacesUtils.favicons.FAVICON_LOAD_NON_PRIVATE,
+ function test_replaceFaviconData_validHistoryURI_check(
+ aURI,
+ aDataLen,
+ aData,
+ aMimeType
+ ) {
+ dump("GOT " + aMimeType + "\n");
+ checkCallbackSucceeded(
+ aMimeType,
+ aData,
+ favicon.mimetype,
+ favicon.data
+ );
+ checkFaviconDataForPage(
+ pageURI,
+ favicon.mimetype,
+ favicon.data,
+ function test_replaceFaviconData_validHistoryURI_callback() {
+ favicon.file.remove(false);
+ resolve();
+ }
+ );
+ },
+ systemPrincipal
+ );
+ });
+
+ await PlacesUtils.history.clear();
+});
+
+add_task(async function test_replaceFaviconData_overrideDefaultFavicon() {
+ info("test replaceFaviconData to override a later setAndFetchFaviconForPage");
+
+ let pageURI = uri("http://test2.bar/");
+ await PlacesTestUtils.addVisits(pageURI);
+
+ let firstFavicon = createFavicon("favicon2.png");
+ let secondFavicon = createFavicon("favicon3.png");
+
+ iconsvc.replaceFaviconData(
+ firstFavicon.uri,
+ secondFavicon.data,
+ secondFavicon.mimetype
+ );
+
+ await new Promise(resolve => {
+ iconsvc.setAndFetchFaviconForPage(
+ pageURI,
+ firstFavicon.uri,
+ true,
+ PlacesUtils.favicons.FAVICON_LOAD_NON_PRIVATE,
+ function test_replaceFaviconData_overrideDefaultFavicon_check(
+ aURI,
+ aDataLen,
+ aData,
+ aMimeType
+ ) {
+ checkCallbackSucceeded(
+ aMimeType,
+ aData,
+ secondFavicon.mimetype,
+ secondFavicon.data
+ );
+ checkFaviconDataForPage(
+ pageURI,
+ secondFavicon.mimetype,
+ secondFavicon.data,
+ function test_replaceFaviconData_overrideDefaultFavicon_callback() {
+ firstFavicon.file.remove(false);
+ secondFavicon.file.remove(false);
+ resolve();
+ }
+ );
+ },
+ systemPrincipal
+ );
+ });
+
+ await PlacesUtils.history.clear();
+});
+
+add_task(async function test_replaceFaviconData_replaceExisting() {
+ info(
+ "test replaceFaviconData to override a previous setAndFetchFaviconForPage"
+ );
+
+ let pageURI = uri("http://test3.bar");
+ await PlacesTestUtils.addVisits(pageURI);
+
+ let firstFavicon = createFavicon("favicon4.png");
+ let secondFavicon = createFavicon("favicon5.png");
+
+ await new Promise(resolve => {
+ iconsvc.setAndFetchFaviconForPage(
+ pageURI,
+ firstFavicon.uri,
+ true,
+ PlacesUtils.favicons.FAVICON_LOAD_NON_PRIVATE,
+ function test_replaceFaviconData_replaceExisting_firstSet_check(
+ aURI,
+ aDataLen,
+ aData,
+ aMimeType
+ ) {
+ checkCallbackSucceeded(
+ aMimeType,
+ aData,
+ firstFavicon.mimetype,
+ firstFavicon.data
+ );
+ checkFaviconDataForPage(
+ pageURI,
+ firstFavicon.mimetype,
+ firstFavicon.data,
+ function test_replaceFaviconData_overrideDefaultFavicon_firstCallback() {
+ iconsvc.replaceFaviconData(
+ firstFavicon.uri,
+ secondFavicon.data,
+ secondFavicon.mimetype
+ );
+ PlacesTestUtils.promiseAsyncUpdates().then(() => {
+ checkFaviconDataForPage(
+ pageURI,
+ secondFavicon.mimetype,
+ secondFavicon.data,
+ function test_replaceFaviconData_overrideDefaultFavicon_secondCallback() {
+ firstFavicon.file.remove(false);
+ secondFavicon.file.remove(false);
+ resolve();
+ },
+ systemPrincipal
+ );
+ });
+ }
+ );
+ },
+ systemPrincipal
+ );
+ });
+
+ await PlacesUtils.history.clear();
+});
+
+add_task(async function test_replaceFaviconData_unrelatedReplace() {
+ info("test replaceFaviconData to not make unrelated changes");
+
+ let pageURI = uri("http://test4.bar/");
+ await PlacesTestUtils.addVisits(pageURI);
+
+ let favicon = createFavicon("favicon6.png");
+ let unrelatedFavicon = createFavicon("favicon7.png");
+
+ iconsvc.replaceFaviconData(
+ unrelatedFavicon.uri,
+ unrelatedFavicon.data,
+ unrelatedFavicon.mimetype
+ );
+
+ await new Promise(resolve => {
+ iconsvc.setAndFetchFaviconForPage(
+ pageURI,
+ favicon.uri,
+ true,
+ PlacesUtils.favicons.FAVICON_LOAD_NON_PRIVATE,
+ function test_replaceFaviconData_unrelatedReplace_check(
+ aURI,
+ aDataLen,
+ aData,
+ aMimeType
+ ) {
+ checkCallbackSucceeded(
+ aMimeType,
+ aData,
+ favicon.mimetype,
+ favicon.data
+ );
+ checkFaviconDataForPage(
+ pageURI,
+ favicon.mimetype,
+ favicon.data,
+ function test_replaceFaviconData_unrelatedReplace_callback() {
+ favicon.file.remove(false);
+ unrelatedFavicon.file.remove(false);
+ resolve();
+ }
+ );
+ },
+ systemPrincipal
+ );
+ });
+
+ await PlacesUtils.history.clear();
+});
+
+add_task(async function test_replaceFaviconData_badInputs() {
+ info("test replaceFaviconData to throw on bad inputs");
+ let icon = createFavicon("favicon8.png");
+
+ Assert.throws(
+ () => iconsvc.replaceFaviconData(icon.uri, icon.data, ""),
+ /NS_ERROR_ILLEGAL_VALUE/
+ );
+ Assert.throws(
+ () => iconsvc.replaceFaviconData(icon.uri, icon.data, "not-an-image"),
+ /NS_ERROR_ILLEGAL_VALUE/
+ );
+ Assert.throws(
+ () => iconsvc.replaceFaviconData(null, icon.data, icon.mimetype),
+ /NS_ERROR_ILLEGAL_VALUE/
+ );
+ Assert.throws(
+ () => iconsvc.replaceFaviconData(icon.uri, [], icon.mimetype),
+ /NS_ERROR_ILLEGAL_VALUE/
+ );
+ Assert.throws(
+ () => iconsvc.replaceFaviconData(icon.uri, null, icon.mimetype),
+ /NS_ERROR_XPC_CANT_CONVERT_PRIMITIVE_TO_ARRAY/
+ );
+
+ icon.file.remove(false);
+ await PlacesUtils.history.clear();
+});
+
+add_task(async function test_replaceFaviconData_twiceReplace() {
+ info("test replaceFaviconData on multiple replacements");
+
+ let pageURI = uri("http://test5.bar/");
+ await PlacesTestUtils.addVisits(pageURI);
+
+ let firstFavicon = createFavicon("favicon9.png");
+ let secondFavicon = createFavicon("favicon10.png");
+
+ iconsvc.replaceFaviconData(
+ firstFavicon.uri,
+ firstFavicon.data,
+ firstFavicon.mimetype
+ );
+ iconsvc.replaceFaviconData(
+ firstFavicon.uri,
+ secondFavicon.data,
+ secondFavicon.mimetype
+ );
+
+ await new Promise(resolve => {
+ iconsvc.setAndFetchFaviconForPage(
+ pageURI,
+ firstFavicon.uri,
+ true,
+ PlacesUtils.favicons.FAVICON_LOAD_NON_PRIVATE,
+ function test_replaceFaviconData_twiceReplace_check(
+ aURI,
+ aDataLen,
+ aData,
+ aMimeType
+ ) {
+ checkCallbackSucceeded(
+ aMimeType,
+ aData,
+ secondFavicon.mimetype,
+ secondFavicon.data
+ );
+ checkFaviconDataForPage(
+ pageURI,
+ secondFavicon.mimetype,
+ secondFavicon.data,
+ function test_replaceFaviconData_twiceReplace_callback() {
+ firstFavicon.file.remove(false);
+ secondFavicon.file.remove(false);
+ resolve();
+ },
+ systemPrincipal
+ );
+ },
+ systemPrincipal
+ );
+ });
+
+ await PlacesUtils.history.clear();
+});
+
+add_task(async function test_replaceFaviconData_rootOverwrite() {
+ info("test replaceFaviconData doesn't overwrite root = 1");
+
+ async function getRootValue(url) {
+ let db = await PlacesUtils.promiseDBConnection();
+ let rows = await db.execute(
+ "SELECT root FROM moz_icons WHERE icon_url = :url",
+ { url }
+ );
+ return rows[0].getResultByName("root");
+ }
+
+ const PAGE_URL = "http://rootoverwrite.bar/";
+ let pageURI = Services.io.newURI(PAGE_URL);
+ const ICON_URL = "http://rootoverwrite.bar/favicon.ico";
+ let iconURI = Services.io.newURI(ICON_URL);
+
+ await PlacesTestUtils.addVisits(pageURI);
+
+ let icon = createFavicon("favicon9.png");
+ PlacesUtils.favicons.replaceFaviconData(iconURI, icon.data, icon.mimetype);
+ await PlacesTestUtils.addFavicons(new Map([[PAGE_URL, ICON_URL]]));
+
+ Assert.equal(await getRootValue(ICON_URL), 1, "Check root == 1");
+ let icon2 = createFavicon("favicon10.png");
+ PlacesUtils.favicons.replaceFaviconData(iconURI, icon2.data, icon2.mimetype);
+ // replaceFaviconData doesn't have a callback, but we must wait its updated.
+ await PlacesTestUtils.promiseAsyncUpdates();
+ Assert.equal(await getRootValue(ICON_URL), 1, "Check root did not change");
+
+ await PlacesUtils.history.clear();
+});
diff --git a/toolkit/components/places/tests/favicons/test_replaceFaviconDataFromDataURL.js b/toolkit/components/places/tests/favicons/test_replaceFaviconDataFromDataURL.js
new file mode 100644
index 0000000000..c1b83fc8a7
--- /dev/null
+++ b/toolkit/components/places/tests/favicons/test_replaceFaviconDataFromDataURL.js
@@ -0,0 +1,537 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/ */
+
+/*
+ * Tests for replaceFaviconData()
+ */
+
+var iconsvc = PlacesUtils.favicons;
+
+var originalFavicon = {
+ file: do_get_file("favicon-normal16.png"),
+ uri: uri(do_get_file("favicon-normal16.png")),
+ data: readFileData(do_get_file("favicon-normal16.png")),
+ mimetype: "image/png",
+};
+
+var uniqueFaviconId = 0;
+function createFavicon(fileName) {
+ let tempdir = Services.dirsvc.get("TmpD", Ci.nsIFile);
+
+ // remove any existing file at the path we're about to copy to
+ let outfile = tempdir.clone();
+ outfile.append(fileName);
+ try {
+ outfile.remove(false);
+ } catch (e) {}
+
+ originalFavicon.file.copyToFollowingLinks(tempdir, fileName);
+
+ let stream = Cc["@mozilla.org/network/file-output-stream;1"].createInstance(
+ Ci.nsIFileOutputStream
+ );
+ stream.init(outfile, 0x02 | 0x08 | 0x10, 0o600, 0);
+
+ // append some data that sniffers/encoders will ignore that will distinguish
+ // the different favicons we'll create
+ uniqueFaviconId++;
+ let uniqueStr = "uid:" + uniqueFaviconId;
+ stream.write(uniqueStr, uniqueStr.length);
+ stream.close();
+
+ Assert.equal(outfile.leafName.substr(0, fileName.length), fileName);
+
+ return {
+ file: outfile,
+ uri: uri(outfile),
+ data: readFileData(outfile),
+ mimetype: "image/png",
+ };
+}
+
+function createDataURLForFavicon(favicon) {
+ return "data:" + favicon.mimetype + ";base64," + toBase64(favicon.data);
+}
+
+function checkCallbackSucceeded(
+ callbackMimetype,
+ callbackData,
+ sourceMimetype,
+ sourceData
+) {
+ Assert.equal(callbackMimetype, sourceMimetype);
+ Assert.ok(compareArrays(callbackData, sourceData));
+}
+
+function run_test() {
+ // check that the favicon loaded correctly
+ Assert.equal(originalFavicon.data.length, 286);
+ run_next_test();
+}
+
+add_task(async function test_replaceFaviconDataFromDataURL_validHistoryURI() {
+ info("test replaceFaviconDataFromDataURL for valid history uri");
+
+ let pageURI = uri("http://test1.bar/");
+ await PlacesTestUtils.addVisits(pageURI);
+
+ let favicon = createFavicon("favicon1.png");
+ iconsvc.replaceFaviconDataFromDataURL(
+ favicon.uri,
+ createDataURLForFavicon(favicon),
+ 0,
+ Services.scriptSecurityManager.getSystemPrincipal()
+ );
+
+ await new Promise(resolve => {
+ iconsvc.setAndFetchFaviconForPage(
+ pageURI,
+ favicon.uri,
+ true,
+ PlacesUtils.favicons.FAVICON_LOAD_NON_PRIVATE,
+ function test_replaceFaviconDataFromDataURL_validHistoryURI_check(
+ aURI,
+ aDataLen,
+ aData,
+ aMimeType
+ ) {
+ checkCallbackSucceeded(
+ aMimeType,
+ aData,
+ favicon.mimetype,
+ favicon.data
+ );
+ checkFaviconDataForPage(
+ pageURI,
+ favicon.mimetype,
+ favicon.data,
+ function test_replaceFaviconDataFromDataURL_validHistoryURI_callback() {
+ favicon.file.remove(false);
+ resolve();
+ }
+ );
+ },
+ Services.scriptSecurityManager.getSystemPrincipal()
+ );
+ });
+
+ await PlacesUtils.history.clear();
+});
+
+add_task(
+ async function test_replaceFaviconDataFromDataURL_overrideDefaultFavicon() {
+ info(
+ "test replaceFaviconDataFromDataURL to override a later setAndFetchFaviconForPage"
+ );
+
+ let pageURI = uri("http://test2.bar/");
+ await PlacesTestUtils.addVisits(pageURI);
+
+ let firstFavicon = createFavicon("favicon2.png");
+ let secondFavicon = createFavicon("favicon3.png");
+
+ iconsvc.replaceFaviconDataFromDataURL(
+ firstFavicon.uri,
+ createDataURLForFavicon(secondFavicon),
+ 0,
+ Services.scriptSecurityManager.getSystemPrincipal()
+ );
+
+ await new Promise(resolve => {
+ iconsvc.setAndFetchFaviconForPage(
+ pageURI,
+ firstFavicon.uri,
+ true,
+ PlacesUtils.favicons.FAVICON_LOAD_NON_PRIVATE,
+ function test_replaceFaviconDataFromDataURL_overrideDefaultFavicon_check(
+ aURI,
+ aDataLen,
+ aData,
+ aMimeType
+ ) {
+ checkCallbackSucceeded(
+ aMimeType,
+ aData,
+ secondFavicon.mimetype,
+ secondFavicon.data
+ );
+ checkFaviconDataForPage(
+ pageURI,
+ secondFavicon.mimetype,
+ secondFavicon.data,
+ function test_replaceFaviconDataFromDataURL_overrideDefaultFavicon_callback() {
+ firstFavicon.file.remove(false);
+ secondFavicon.file.remove(false);
+ resolve();
+ }
+ );
+ },
+ Services.scriptSecurityManager.getSystemPrincipal()
+ );
+ });
+
+ await PlacesUtils.history.clear();
+ }
+);
+
+add_task(async function test_replaceFaviconDataFromDataURL_replaceExisting() {
+ info(
+ "test replaceFaviconDataFromDataURL to override a previous setAndFetchFaviconForPage"
+ );
+
+ let pageURI = uri("http://test3.bar");
+ await PlacesTestUtils.addVisits(pageURI);
+
+ let firstFavicon = createFavicon("favicon4.png");
+ let secondFavicon = createFavicon("favicon5.png");
+
+ await new Promise(resolve => {
+ iconsvc.setAndFetchFaviconForPage(
+ pageURI,
+ firstFavicon.uri,
+ true,
+ PlacesUtils.favicons.FAVICON_LOAD_NON_PRIVATE,
+ function test_replaceFaviconDataFromDataURL_replaceExisting_firstSet_check(
+ aURI,
+ aDataLen,
+ aData,
+ aMimeType
+ ) {
+ checkCallbackSucceeded(
+ aMimeType,
+ aData,
+ firstFavicon.mimetype,
+ firstFavicon.data
+ );
+ checkFaviconDataForPage(
+ pageURI,
+ firstFavicon.mimetype,
+ firstFavicon.data,
+ function test_replaceFaviconDataFromDataURL_replaceExisting_firstCallback() {
+ iconsvc.replaceFaviconDataFromDataURL(
+ firstFavicon.uri,
+ createDataURLForFavicon(secondFavicon),
+ 0,
+ Services.scriptSecurityManager.getSystemPrincipal()
+ );
+ checkFaviconDataForPage(
+ pageURI,
+ secondFavicon.mimetype,
+ secondFavicon.data,
+ function test_replaceFaviconDataFromDataURL_replaceExisting_secondCallback() {
+ firstFavicon.file.remove(false);
+ secondFavicon.file.remove(false);
+ resolve();
+ }
+ );
+ }
+ );
+ },
+ Services.scriptSecurityManager.getSystemPrincipal()
+ );
+ });
+
+ await PlacesUtils.history.clear();
+});
+
+add_task(async function test_replaceFaviconDataFromDataURL_unrelatedReplace() {
+ info("test replaceFaviconDataFromDataURL to not make unrelated changes");
+
+ let pageURI = uri("http://test4.bar/");
+ await PlacesTestUtils.addVisits(pageURI);
+
+ let favicon = createFavicon("favicon6.png");
+ let unrelatedFavicon = createFavicon("favicon7.png");
+
+ iconsvc.replaceFaviconDataFromDataURL(
+ unrelatedFavicon.uri,
+ createDataURLForFavicon(unrelatedFavicon),
+ 0,
+ Services.scriptSecurityManager.getSystemPrincipal()
+ );
+
+ await new Promise(resolve => {
+ iconsvc.setAndFetchFaviconForPage(
+ pageURI,
+ favicon.uri,
+ true,
+ PlacesUtils.favicons.FAVICON_LOAD_NON_PRIVATE,
+ function test_replaceFaviconDataFromDataURL_unrelatedReplace_check(
+ aURI,
+ aDataLen,
+ aData,
+ aMimeType
+ ) {
+ checkCallbackSucceeded(
+ aMimeType,
+ aData,
+ favicon.mimetype,
+ favicon.data
+ );
+ checkFaviconDataForPage(
+ pageURI,
+ favicon.mimetype,
+ favicon.data,
+ function test_replaceFaviconDataFromDataURL_unrelatedReplace_callback() {
+ favicon.file.remove(false);
+ unrelatedFavicon.file.remove(false);
+ resolve();
+ }
+ );
+ },
+ Services.scriptSecurityManager.getSystemPrincipal()
+ );
+ });
+
+ await PlacesUtils.history.clear();
+});
+
+add_task(async function test_replaceFaviconDataFromDataURL_badInputs() {
+ info("test replaceFaviconDataFromDataURL to throw on bad inputs");
+
+ let favicon = createFavicon("favicon8.png");
+
+ let ex = null;
+ try {
+ iconsvc.replaceFaviconDataFromDataURL(
+ favicon.uri,
+ "",
+ 0,
+ Services.scriptSecurityManager.getSystemPrincipal()
+ );
+ } catch (e) {
+ ex = e;
+ } finally {
+ Assert.ok(!!ex);
+ }
+
+ ex = null;
+ try {
+ iconsvc.replaceFaviconDataFromDataURL(
+ null,
+ createDataURLForFavicon(favicon),
+ 0,
+ Services.scriptSecurityManager.getSystemPrincipal()
+ );
+ } catch (e) {
+ ex = e;
+ } finally {
+ Assert.ok(!!ex);
+ }
+
+ favicon.file.remove(false);
+
+ await PlacesUtils.history.clear();
+});
+
+add_task(async function test_replaceFaviconDataFromDataURL_twiceReplace() {
+ info("test replaceFaviconDataFromDataURL on multiple replacements");
+
+ let pageURI = uri("http://test5.bar/");
+ await PlacesTestUtils.addVisits(pageURI);
+
+ let firstFavicon = createFavicon("favicon9.png");
+ let secondFavicon = createFavicon("favicon10.png");
+
+ iconsvc.replaceFaviconDataFromDataURL(
+ firstFavicon.uri,
+ createDataURLForFavicon(firstFavicon),
+ 0,
+ Services.scriptSecurityManager.getSystemPrincipal()
+ );
+ iconsvc.replaceFaviconDataFromDataURL(
+ firstFavicon.uri,
+ createDataURLForFavicon(secondFavicon),
+ 0,
+ Services.scriptSecurityManager.getSystemPrincipal()
+ );
+
+ await new Promise(resolve => {
+ iconsvc.setAndFetchFaviconForPage(
+ pageURI,
+ firstFavicon.uri,
+ true,
+ PlacesUtils.favicons.FAVICON_LOAD_NON_PRIVATE,
+ function test_replaceFaviconDataFromDataURL_twiceReplace_check(
+ aURI,
+ aDataLen,
+ aData,
+ aMimeType
+ ) {
+ checkCallbackSucceeded(
+ aMimeType,
+ aData,
+ secondFavicon.mimetype,
+ secondFavicon.data
+ );
+ checkFaviconDataForPage(
+ pageURI,
+ secondFavicon.mimetype,
+ secondFavicon.data,
+ function test_replaceFaviconDataFromDataURL_twiceReplace_callback() {
+ firstFavicon.file.remove(false);
+ secondFavicon.file.remove(false);
+ resolve();
+ }
+ );
+ },
+ Services.scriptSecurityManager.getSystemPrincipal()
+ );
+ });
+
+ await PlacesUtils.history.clear();
+});
+
+add_task(
+ async function test_replaceFaviconDataFromDataURL_afterRegularAssign() {
+ info("test replaceFaviconDataFromDataURL after replaceFaviconData");
+
+ let pageURI = uri("http://test6.bar/");
+ await PlacesTestUtils.addVisits(pageURI);
+
+ let firstFavicon = createFavicon("favicon11.png");
+ let secondFavicon = createFavicon("favicon12.png");
+
+ iconsvc.replaceFaviconData(
+ firstFavicon.uri,
+ firstFavicon.data,
+ firstFavicon.mimetype
+ );
+ iconsvc.replaceFaviconDataFromDataURL(
+ firstFavicon.uri,
+ createDataURLForFavicon(secondFavicon),
+ 0,
+ Services.scriptSecurityManager.getSystemPrincipal()
+ );
+
+ await new Promise(resolve => {
+ iconsvc.setAndFetchFaviconForPage(
+ pageURI,
+ firstFavicon.uri,
+ true,
+ PlacesUtils.favicons.FAVICON_LOAD_NON_PRIVATE,
+ function test_replaceFaviconDataFromDataURL_afterRegularAssign_check(
+ aURI,
+ aDataLen,
+ aData,
+ aMimeType
+ ) {
+ checkCallbackSucceeded(
+ aMimeType,
+ aData,
+ secondFavicon.mimetype,
+ secondFavicon.data
+ );
+ checkFaviconDataForPage(
+ pageURI,
+ secondFavicon.mimetype,
+ secondFavicon.data,
+ function test_replaceFaviconDataFromDataURL_afterRegularAssign_callback() {
+ firstFavicon.file.remove(false);
+ secondFavicon.file.remove(false);
+ resolve();
+ }
+ );
+ },
+ Services.scriptSecurityManager.getSystemPrincipal()
+ );
+ });
+
+ await PlacesUtils.history.clear();
+ }
+);
+
+add_task(
+ async function test_replaceFaviconDataFromDataURL_beforeRegularAssign() {
+ info("test replaceFaviconDataFromDataURL before replaceFaviconData");
+
+ let pageURI = uri("http://test7.bar/");
+ await PlacesTestUtils.addVisits(pageURI);
+
+ let firstFavicon = createFavicon("favicon13.png");
+ let secondFavicon = createFavicon("favicon14.png");
+
+ iconsvc.replaceFaviconDataFromDataURL(
+ firstFavicon.uri,
+ createDataURLForFavicon(firstFavicon),
+ 0,
+ Services.scriptSecurityManager.getSystemPrincipal()
+ );
+ iconsvc.replaceFaviconData(
+ firstFavicon.uri,
+ secondFavicon.data,
+ secondFavicon.mimetype
+ );
+
+ await new Promise(resolve => {
+ iconsvc.setAndFetchFaviconForPage(
+ pageURI,
+ firstFavicon.uri,
+ true,
+ PlacesUtils.favicons.FAVICON_LOAD_NON_PRIVATE,
+ function test_replaceFaviconDataFromDataURL_beforeRegularAssign_check(
+ aURI,
+ aDataLen,
+ aData,
+ aMimeType
+ ) {
+ checkCallbackSucceeded(
+ aMimeType,
+ aData,
+ secondFavicon.mimetype,
+ secondFavicon.data
+ );
+ checkFaviconDataForPage(
+ pageURI,
+ secondFavicon.mimetype,
+ secondFavicon.data,
+ function test_replaceFaviconDataFromDataURL_beforeRegularAssign_callback() {
+ firstFavicon.file.remove(false);
+ secondFavicon.file.remove(false);
+ resolve();
+ }
+ );
+ },
+ Services.scriptSecurityManager.getSystemPrincipal()
+ );
+ });
+
+ await PlacesUtils.history.clear();
+ }
+);
+
+/* toBase64 copied from image/test/unit/test_encoder_png.js */
+
+/* Convert data (an array of integers) to a Base64 string. */
+const toBase64Table =
+ // eslint-disable-next-line no-useless-concat
+ "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz" + "0123456789+/";
+const base64Pad = "=";
+function toBase64(data) {
+ let result = "";
+ let length = data.length;
+ let i;
+ // Convert every three bytes to 4 ascii characters.
+ for (i = 0; i < length - 2; i += 3) {
+ result += toBase64Table[data[i] >> 2];
+ result += toBase64Table[((data[i] & 0x03) << 4) + (data[i + 1] >> 4)];
+ result += toBase64Table[((data[i + 1] & 0x0f) << 2) + (data[i + 2] >> 6)];
+ result += toBase64Table[data[i + 2] & 0x3f];
+ }
+
+ // Convert the remaining 1 or 2 bytes, pad out to 4 characters.
+ if (length % 3) {
+ i = length - (length % 3);
+ result += toBase64Table[data[i] >> 2];
+ if (length % 3 == 2) {
+ result += toBase64Table[((data[i] & 0x03) << 4) + (data[i + 1] >> 4)];
+ result += toBase64Table[(data[i + 1] & 0x0f) << 2];
+ result += base64Pad;
+ } else {
+ result += toBase64Table[(data[i] & 0x03) << 4];
+ result += base64Pad + base64Pad;
+ }
+ }
+
+ return result;
+}
diff --git a/toolkit/components/places/tests/favicons/test_root_icons.js b/toolkit/components/places/tests/favicons/test_root_icons.js
new file mode 100644
index 0000000000..f0487cc162
--- /dev/null
+++ b/toolkit/components/places/tests/favicons/test_root_icons.js
@@ -0,0 +1,246 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+/**
+ * This file tests root icons associations and expiration
+ */
+
+add_task(async function () {
+ let pageURI = NetUtil.newURI("http://www.places.test/page/");
+ await PlacesTestUtils.addVisits(pageURI);
+ let faviconURI = NetUtil.newURI("http://www.places.test/favicon.ico");
+ PlacesUtils.favicons.replaceFaviconDataFromDataURL(
+ faviconURI,
+ SMALLPNG_DATA_URI.spec,
+ 0,
+ systemPrincipal
+ );
+ await setFaviconForPage(pageURI, faviconURI);
+
+ // Sanity checks.
+ Assert.equal(await getFaviconUrlForPage(pageURI), faviconURI.spec);
+ Assert.equal(
+ await getFaviconUrlForPage("https://places.test/somethingelse/"),
+ faviconURI.spec
+ );
+
+ // Check database entries.
+ await PlacesTestUtils.promiseAsyncUpdates();
+ let db = await PlacesUtils.promiseDBConnection();
+ let rows = await db.execute("SELECT * FROM moz_icons");
+ Assert.equal(rows.length, 1, "There should only be 1 icon entry");
+ Assert.equal(
+ rows[0].getResultByName("root"),
+ 1,
+ "It should be marked as a root icon"
+ );
+ rows = await db.execute("SELECT * FROM moz_pages_w_icons");
+ Assert.equal(rows.length, 0, "There should be no page entry");
+ rows = await db.execute("SELECT * FROM moz_icons_to_pages");
+ Assert.equal(rows.length, 0, "There should be no relation entry");
+
+ // Add another pages to the same host. The icon should not be removed.
+ await PlacesTestUtils.addVisits("http://places.test/page2/");
+ await PlacesUtils.history.remove(pageURI);
+
+ // Still works since the icon has not been removed.
+ Assert.equal(await getFaviconUrlForPage(pageURI), faviconURI.spec);
+
+ // Remove all the pages for the given domain.
+ await PlacesUtils.history.remove("http://places.test/page2/");
+ // The icon should be removed along with the domain.
+ rows = await db.execute("SELECT * FROM moz_icons");
+ Assert.equal(rows.length, 0, "The icon should have been removed");
+});
+
+add_task(async function test_removePagesByTimeframe() {
+ const BASE_URL = "http://www.places.test";
+ // Add a visit in the past with no directly associated icon.
+ let oldPageURI = NetUtil.newURI(`${BASE_URL}/old/`);
+ await PlacesTestUtils.addVisits({
+ uri: oldPageURI,
+ visitDate: new Date(Date.now() - 86400000),
+ });
+ // And another more recent visit.
+ let pageURI = NetUtil.newURI(`${BASE_URL}/page/`);
+ await PlacesTestUtils.addVisits({
+ uri: pageURI,
+ visitDate: new Date(Date.now() - 7200000),
+ });
+
+ // Add a normal icon to the most recent page.
+ let faviconURI = NetUtil.newURI(`${BASE_URL}/page/favicon.ico`);
+ PlacesUtils.favicons.replaceFaviconDataFromDataURL(
+ faviconURI,
+ SMALLSVG_DATA_URI.spec,
+ 0,
+ systemPrincipal
+ );
+ await setFaviconForPage(pageURI, faviconURI);
+ // Add a root icon to the most recent page.
+ let rootIconURI = NetUtil.newURI(`${BASE_URL}/favicon.ico`);
+ PlacesUtils.favicons.replaceFaviconDataFromDataURL(
+ rootIconURI,
+ SMALLPNG_DATA_URI.spec,
+ 0,
+ systemPrincipal
+ );
+ await setFaviconForPage(pageURI, rootIconURI);
+
+ // Sanity checks.
+ Assert.equal(
+ await getFaviconUrlForPage(pageURI),
+ faviconURI.spec,
+ "Should get the biggest icon"
+ );
+ Assert.equal(
+ await getFaviconUrlForPage(pageURI, 1),
+ rootIconURI.spec,
+ "Should get the smallest icon"
+ );
+ Assert.equal(
+ await getFaviconUrlForPage(oldPageURI),
+ rootIconURI.spec,
+ "Should get the root icon"
+ );
+
+ info("Removing the newer page, not the old one");
+ await PlacesUtils.history.removeByFilter({
+ beginDate: new Date(Date.now() - 14400000),
+ endDate: new Date(),
+ });
+ await PlacesTestUtils.promiseAsyncUpdates();
+
+ let db = await PlacesUtils.promiseDBConnection();
+ let rows = await db.execute("SELECT * FROM moz_icons");
+ Assert.equal(rows.length, 1, "There should only be 1 icon entry");
+ Assert.equal(
+ rows[0].getResultByName("root"),
+ 1,
+ "It should be marked as a root icon"
+ );
+ rows = await db.execute("SELECT * FROM moz_pages_w_icons");
+ Assert.equal(rows.length, 0, "There should be no page entry");
+ rows = await db.execute("SELECT * FROM moz_icons_to_pages");
+ Assert.equal(rows.length, 0, "There should be no relation entry");
+
+ await PlacesUtils.history.removeByFilter({
+ beginDate: new Date(0),
+ endDt: new Date(),
+ });
+ await PlacesTestUtils.promiseAsyncUpdates();
+ rows = await db.execute("SELECT * FROM moz_icons");
+ // Debug logging for possible intermittent failure (bug 1358368).
+ if (rows.length) {
+ dump_table("moz_icons");
+ }
+ Assert.equal(rows.length, 0, "There should be no icon entry");
+});
+
+add_task(async function test_different_host() {
+ let pageURI = NetUtil.newURI("http://places.test/page/");
+ await PlacesTestUtils.addVisits(pageURI);
+ let faviconURI = NetUtil.newURI("http://mozilla.test/favicon.ico");
+ PlacesUtils.favicons.replaceFaviconDataFromDataURL(
+ faviconURI,
+ SMALLPNG_DATA_URI.spec,
+ 0,
+ systemPrincipal
+ );
+ await setFaviconForPage(pageURI, faviconURI);
+
+ Assert.equal(
+ await getFaviconUrlForPage(pageURI),
+ faviconURI.spec,
+ "Should get the png icon"
+ );
+ // Check the icon is not marked as a root icon in the database.
+ let db = await PlacesUtils.promiseDBConnection();
+ let rows = await db.execute(
+ "SELECT root FROM moz_icons WHERE icon_url = :url",
+ { url: faviconURI.spec }
+ );
+ Assert.strictEqual(rows[0].getResultByName("root"), 0);
+});
+
+add_task(async function test_same_size() {
+ // Add two icons with the same size, one is a root icon. Check that the
+ // non-root icon is preferred when a smaller size is requested.
+ let data = readFileData(do_get_file("favicon-normal32.png"));
+ let pageURI = NetUtil.newURI("http://new_places.test/page/");
+ await PlacesTestUtils.addVisits(pageURI);
+
+ let faviconURI = NetUtil.newURI("http://new_places.test/favicon.ico");
+ PlacesUtils.favicons.replaceFaviconData(faviconURI, data, "image/png");
+ await setFaviconForPage(pageURI, faviconURI);
+ faviconURI = NetUtil.newURI("http://new_places.test/another_icon.ico");
+ PlacesUtils.favicons.replaceFaviconData(faviconURI, data, "image/png");
+ await setFaviconForPage(pageURI, faviconURI);
+
+ Assert.equal(
+ await getFaviconUrlForPage(pageURI, 20),
+ faviconURI.spec,
+ "Should get the non-root icon"
+ );
+});
+
+add_task(async function test_root_on_different_host() {
+ async function getRootValue(url) {
+ let db = await PlacesUtils.promiseDBConnection();
+ let rows = await db.execute(
+ "SELECT root FROM moz_icons WHERE icon_url = :url",
+ { url }
+ );
+ return rows[0].getResultByName("root");
+ }
+
+ // Check that a root icon associated to 2 domains is not removed when the
+ // root domain is removed.
+ const TEST_URL1 = "http://places1.test/page/";
+ let pageURI1 = NetUtil.newURI(TEST_URL1);
+ await PlacesTestUtils.addVisits(pageURI1);
+
+ const TEST_URL2 = "http://places2.test/page/";
+ let pageURI2 = NetUtil.newURI(TEST_URL2);
+ await PlacesTestUtils.addVisits(pageURI2);
+
+ // Root favicon for TEST_URL1.
+ const ICON_URL = "http://places1.test/favicon.ico";
+ let iconURI = NetUtil.newURI(ICON_URL);
+ PlacesUtils.favicons.replaceFaviconDataFromDataURL(
+ iconURI,
+ SMALLPNG_DATA_URI.spec,
+ 0,
+ systemPrincipal
+ );
+ await setFaviconForPage(pageURI1, iconURI);
+ Assert.equal(await getRootValue(ICON_URL), 1, "Check root == 1");
+ Assert.equal(
+ await getFaviconUrlForPage(pageURI1, 16),
+ ICON_URL,
+ "The icon should been found"
+ );
+
+ // Same favicon for TEST_URL2.
+ PlacesUtils.favicons.replaceFaviconDataFromDataURL(
+ iconURI,
+ SMALLPNG_DATA_URI.spec,
+ 0,
+ systemPrincipal
+ );
+ await setFaviconForPage(pageURI2, iconURI);
+ Assert.equal(await getRootValue(ICON_URL), 1, "Check root == 1");
+ Assert.equal(
+ await getFaviconUrlForPage(pageURI2, 16),
+ ICON_URL,
+ "The icon should be found"
+ );
+
+ await PlacesUtils.history.remove(pageURI1);
+
+ Assert.equal(
+ await getFaviconUrlForPage(pageURI2, 16),
+ ICON_URL,
+ "The icon should not have been removed"
+ );
+});
diff --git a/toolkit/components/places/tests/favicons/test_setAndFetchFaviconForPage.js b/toolkit/components/places/tests/favicons/test_setAndFetchFaviconForPage.js
new file mode 100644
index 0000000000..1b4ea87ec0
--- /dev/null
+++ b/toolkit/components/places/tests/favicons/test_setAndFetchFaviconForPage.js
@@ -0,0 +1,123 @@
+/* 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/. */
+
+// This file tests the normal operation of setAndFetchFaviconForPage.
+
+let gTests = [
+ {
+ desc: "Normal test",
+ href: "http://example.com/normal",
+ loadType: PlacesUtils.favicons.FAVICON_LOAD_NON_PRIVATE,
+ async setup() {
+ await PlacesTestUtils.addVisits({
+ uri: this.href,
+ transition: TRANSITION_TYPED,
+ });
+ },
+ },
+ {
+ desc: "Bookmarked about: uri",
+ href: "about:testAboutURI_bookmarked",
+ loadType: PlacesUtils.favicons.FAVICON_LOAD_NON_PRIVATE,
+ async setup() {
+ await PlacesUtils.bookmarks.insert({
+ parentGuid: PlacesUtils.bookmarks.unfiledGuid,
+ url: this.href,
+ });
+ },
+ },
+ {
+ desc: "Bookmarked in private window",
+ href: "http://example.com/privateBrowsing_bookmarked",
+ loadType: PlacesUtils.favicons.FAVICON_LOAD_PRIVATE,
+ async setup() {
+ await PlacesUtils.bookmarks.insert({
+ parentGuid: PlacesUtils.bookmarks.unfiledGuid,
+ url: this.href,
+ });
+ },
+ },
+ {
+ desc: "Bookmarked with disabled history",
+ href: "http://example.com/disabledHistory_bookmarked",
+ loadType: PlacesUtils.favicons.FAVICON_LOAD_NON_PRIVATE,
+ async setup() {
+ await PlacesUtils.bookmarks.insert({
+ parentGuid: PlacesUtils.bookmarks.unfiledGuid,
+ url: this.href,
+ });
+ Services.prefs.setBoolPref("places.history.enabled", false);
+ },
+ clean() {
+ Services.prefs.setBoolPref("places.history.enabled", true);
+ },
+ },
+];
+
+add_task(async function () {
+ let faviconURI = SMALLPNG_DATA_URI;
+ let faviconMimeType = "image/png";
+
+ registerCleanupFunction(async function () {
+ await PlacesUtils.bookmarks.eraseEverything();
+ await PlacesUtils.history.clear();
+ });
+
+ for (let test of gTests) {
+ info(test.desc);
+ let pageURI = PlacesUtils.toURI(test.href);
+
+ await test.setup();
+
+ let pageGuid;
+ let promise = PlacesTestUtils.waitForNotification(
+ "favicon-changed",
+ events =>
+ events.some(e => {
+ if (e.url == pageURI.spec && e.faviconUrl == faviconURI.spec) {
+ pageGuid = e.pageGuid;
+ return true;
+ }
+ return false;
+ })
+ );
+
+ PlacesUtils.favicons.setAndFetchFaviconForPage(
+ pageURI,
+ faviconURI,
+ true,
+ test.private,
+ null,
+ Services.scriptSecurityManager.getSystemPrincipal()
+ );
+
+ await promise;
+
+ Assert.equal(
+ pageGuid,
+ await PlacesTestUtils.getDatabaseValue("moz_places", "guid", {
+ url: pageURI,
+ }),
+ "Page guid is correct"
+ );
+ let { dataLen, data, mimeType } = await PlacesUtils.promiseFaviconData(
+ pageURI.spec
+ );
+ Assert.equal(faviconMimeType, mimeType, "Check expected MimeType");
+ Assert.equal(
+ SMALLPNG_DATA_LEN,
+ data.length,
+ "Check favicon data for the given page matches the provided data"
+ );
+ Assert.equal(
+ dataLen,
+ data.length,
+ "Check favicon dataLen for the given page matches the provided data"
+ );
+
+ if (test.clean) {
+ await test.clean();
+ }
+ }
+});
diff --git a/toolkit/components/places/tests/favicons/test_setAndFetchFaviconForPage_failures.js b/toolkit/components/places/tests/favicons/test_setAndFetchFaviconForPage_failures.js
new file mode 100644
index 0000000000..d5b16bd670
--- /dev/null
+++ b/toolkit/components/places/tests/favicons/test_setAndFetchFaviconForPage_failures.js
@@ -0,0 +1,156 @@
+/* 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/. */
+
+/**
+ * This file tests setAndFetchFaviconForPage when it is called with invalid
+ * arguments, and when no favicon is stored for the given arguments.
+ */
+
+let faviconURI = Services.io.newURI(
+ "http://example.org/tests/toolkit/components/places/tests/browser/favicon-normal16.png"
+);
+add_task(async function () {
+ registerCleanupFunction(async function () {
+ await PlacesUtils.bookmarks.eraseEverything();
+ await PlacesUtils.history.clear();
+ });
+
+ // We'll listen for favicon changes for the whole test, to ensure only the
+ // last one will send a notification. Due to thread serialization, at that
+ // point we can be sure previous calls didn't send a notification.
+ let lastPageURI = Services.io.newURI("http://example.com/verification");
+ let promiseIconChanged = PlacesTestUtils.waitForNotification(
+ "favicon-changed",
+ events =>
+ events.some(
+ e => e.url == lastPageURI.spec && e.faviconUrl == SMALLPNG_DATA_URI.spec
+ )
+ );
+
+ info("Test null page uri");
+ Assert.throws(
+ () => {
+ PlacesUtils.favicons.setAndFetchFaviconForPage(
+ null,
+ faviconURI,
+ true,
+ PlacesUtils.favicons.FAVICON_LOAD_NON_PRIVATE,
+ null,
+ Services.scriptSecurityManager.getSystemPrincipal()
+ );
+ },
+ /NS_ERROR_ILLEGAL_VALUE/,
+ "Exception expected because aPageURI is null"
+ );
+
+ info("Test null favicon uri");
+ Assert.throws(
+ () => {
+ PlacesUtils.favicons.setAndFetchFaviconForPage(
+ Services.io.newURI("http://example.com/null_faviconURI"),
+ null,
+ true,
+ PlacesUtils.favicons.FAVICON_LOAD_NON_PRIVATE,
+ null,
+ Services.scriptSecurityManager.getSystemPrincipal()
+ );
+ },
+ /NS_ERROR_ILLEGAL_VALUE/,
+ "Exception expected because aFaviconURI is null."
+ );
+
+ info("Test about uri");
+ PlacesUtils.favicons.setAndFetchFaviconForPage(
+ Services.io.newURI("about:testAboutURI"),
+ faviconURI,
+ true,
+ PlacesUtils.favicons.FAVICON_LOAD_NON_PRIVATE,
+ null,
+ Services.scriptSecurityManager.getSystemPrincipal()
+ );
+
+ info("Test private browsing non bookmarked uri");
+ let pageURI = Services.io.newURI("http://example.com/privateBrowsing");
+ await PlacesTestUtils.addVisits({
+ uri: pageURI,
+ transitionType: TRANSITION_TYPED,
+ });
+ PlacesUtils.favicons.setAndFetchFaviconForPage(
+ pageURI,
+ faviconURI,
+ true,
+ PlacesUtils.favicons.FAVICON_LOAD_PRIVATE,
+ null,
+ Services.scriptSecurityManager.getSystemPrincipal()
+ );
+
+ info("Test disabled history");
+ pageURI = Services.io.newURI("http://example.com/disabledHistory");
+ await PlacesTestUtils.addVisits({
+ uri: pageURI,
+ transition: TRANSITION_TYPED,
+ });
+ Services.prefs.setBoolPref("places.history.enabled", false);
+
+ PlacesUtils.favicons.setAndFetchFaviconForPage(
+ pageURI,
+ faviconURI,
+ true,
+ PlacesUtils.favicons.FAVICON_LOAD_NON_PRIVATE,
+ null,
+ Services.scriptSecurityManager.getSystemPrincipal()
+ );
+
+ // The setAndFetchFaviconForPage function calls CanAddURI synchronously, thus
+ // we can set the preference back to true immediately.
+ Services.prefs.setBoolPref("places.history.enabled", true);
+
+ info("Test error icon");
+ // This error icon must stay in sync with FAVICON_ERRORPAGE_URL in
+ // nsIFaviconService.idl and aboutNetError.html.
+ let faviconErrorPageURI = Services.io.newURI(
+ "chrome://global/skin/icons/info.svg"
+ );
+ pageURI = Services.io.newURI("http://example.com/errorIcon");
+ await PlacesTestUtils.addVisits({
+ uri: pageURI,
+ transition: TRANSITION_TYPED,
+ });
+ PlacesUtils.favicons.setAndFetchFaviconForPage(
+ pageURI,
+ faviconErrorPageURI,
+ true,
+ PlacesUtils.favicons.FAVICON_LOAD_NON_PRIVATE,
+ null,
+ Services.scriptSecurityManager.getSystemPrincipal()
+ );
+
+ info("Test nonexisting page");
+ PlacesUtils.favicons.setAndFetchFaviconForPage(
+ Services.io.newURI("http://example.com/nonexistingPage"),
+ faviconURI,
+ true,
+ PlacesUtils.favicons.FAVICON_LOAD_NON_PRIVATE,
+ null,
+ Services.scriptSecurityManager.getSystemPrincipal()
+ );
+
+ info("Final sanity check");
+ // This is the only test that should cause the waitForFaviconChanged
+ // callback to be invoked.
+ await PlacesTestUtils.addVisits({
+ uri: lastPageURI,
+ transition: TRANSITION_TYPED,
+ });
+ PlacesUtils.favicons.setAndFetchFaviconForPage(
+ lastPageURI,
+ SMALLPNG_DATA_URI,
+ true,
+ PlacesUtils.favicons.FAVICON_LOAD_NON_PRIVATE,
+ null,
+ Services.scriptSecurityManager.getSystemPrincipal()
+ );
+
+ await promiseIconChanged;
+});
diff --git a/toolkit/components/places/tests/favicons/test_setAndFetchFaviconForPage_redirects.js b/toolkit/components/places/tests/favicons/test_setAndFetchFaviconForPage_redirects.js
new file mode 100644
index 0000000000..feda238f97
--- /dev/null
+++ b/toolkit/components/places/tests/favicons/test_setAndFetchFaviconForPage_redirects.js
@@ -0,0 +1,89 @@
+/* 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/. */
+
+// This file tests setAndFetchFaviconForPage on bookmarked redirects.
+
+add_task(async function same_host_redirect() {
+ // Add a bookmarked page that redirects to another page, set a favicon on the
+ // latter and check the former gets it too, if they are in the same host.
+ let srcUrl = "http://bookmarked.com/";
+ let destUrl = "https://other.bookmarked.com/";
+ await PlacesTestUtils.addVisits([
+ { uri: srcUrl, transition: TRANSITION_LINK },
+ {
+ uri: destUrl,
+ transition: TRANSITION_REDIRECT_TEMPORARY,
+ referrer: srcUrl,
+ },
+ ]);
+ await PlacesUtils.bookmarks.insert({
+ parentGuid: PlacesUtils.bookmarks.unfiledGuid,
+ url: srcUrl,
+ });
+
+ registerCleanupFunction(async function () {
+ await PlacesUtils.bookmarks.eraseEverything();
+ await PlacesUtils.history.clear();
+ });
+
+ let promise = PlacesTestUtils.waitForNotification("favicon-changed", events =>
+ events.some(e => e.url == srcUrl && e.faviconUrl == SMALLPNG_DATA_URI.spec)
+ );
+
+ PlacesUtils.favicons.setAndFetchFaviconForPage(
+ Services.io.newURI(destUrl),
+ SMALLPNG_DATA_URI,
+ true,
+ PlacesUtils.favicons.FAVICON_LOAD_NON_PRIVATE,
+ null,
+ Services.scriptSecurityManager.getSystemPrincipal()
+ );
+
+ await promise;
+
+ // The favicon should be set also on the bookmarked url that redirected.
+ let { dataLen } = await PlacesUtils.promiseFaviconData(srcUrl);
+ Assert.equal(dataLen, SMALLPNG_DATA_LEN, "Check favicon dataLen");
+});
+
+add_task(async function other_host_redirect() {
+ // Add a bookmarked page that redirects to another page, set a favicon on the
+ // latter and check the former gets it too, if they are in the same host.
+ let srcUrl = "http://first.com/";
+ let destUrl = "https://notfirst.com/";
+ await PlacesTestUtils.addVisits([
+ { uri: srcUrl, transition: TRANSITION_LINK },
+ {
+ uri: destUrl,
+ transition: TRANSITION_REDIRECT_TEMPORARY,
+ referrer: srcUrl,
+ },
+ ]);
+ await PlacesUtils.bookmarks.insert({
+ parentGuid: PlacesUtils.bookmarks.unfiledGuid,
+ url: srcUrl,
+ });
+
+ let promise = Promise.race([
+ PlacesTestUtils.waitForNotification("favicon-changed", events =>
+ events.some(
+ e => e.url == srcUrl && e.faviconUrl == SMALLPNG_DATA_URI.spec
+ )
+ ),
+ new Promise((resolve, reject) =>
+ do_timeout(300, () => reject(new Error("timeout")))
+ ),
+ ]);
+
+ PlacesUtils.favicons.setAndFetchFaviconForPage(
+ Services.io.newURI(destUrl),
+ SMALLPNG_DATA_URI,
+ true,
+ PlacesUtils.favicons.FAVICON_LOAD_NON_PRIVATE,
+ null,
+ Services.scriptSecurityManager.getSystemPrincipal()
+ );
+
+ await Assert.rejects(promise, /timeout/);
+});
diff --git a/toolkit/components/places/tests/favicons/test_svg_favicon.js b/toolkit/components/places/tests/favicons/test_svg_favicon.js
new file mode 100644
index 0000000000..8d9f2edf11
--- /dev/null
+++ b/toolkit/components/places/tests/favicons/test_svg_favicon.js
@@ -0,0 +1,34 @@
+const PAGEURI = NetUtil.newURI("http://deliciousbacon.com/");
+
+add_task(async function () {
+ // First, add a history entry or else Places can't save a favicon.
+ await PlacesTestUtils.addVisits({
+ uri: PAGEURI,
+ transition: TRANSITION_LINK,
+ visitDate: Date.now() * 1000,
+ });
+
+ await new Promise(resolve => {
+ function onSetComplete(aURI, aDataLen, aData, aMimeType, aWidth) {
+ equal(aURI.spec, SMALLSVG_DATA_URI.spec, "setFavicon aURI check");
+ equal(aDataLen, 263, "setFavicon aDataLen check");
+ equal(aMimeType, "image/svg+xml", "setFavicon aMimeType check");
+ dump(aWidth);
+ resolve();
+ }
+
+ PlacesUtils.favicons.setAndFetchFaviconForPage(
+ PAGEURI,
+ SMALLSVG_DATA_URI,
+ false,
+ PlacesUtils.favicons.FAVICON_LOAD_NON_PRIVATE,
+ onSetComplete,
+ Services.scriptSecurityManager.getSystemPrincipal()
+ );
+ });
+
+ let data = await PlacesUtils.promiseFaviconData(PAGEURI.spec);
+ equal(data.uri.spec, SMALLSVG_DATA_URI.spec, "getFavicon aURI check");
+ equal(data.dataLen, 263, "getFavicon aDataLen check");
+ equal(data.mimeType, "image/svg+xml", "getFavicon aMimeType check");
+});
diff --git a/toolkit/components/places/tests/favicons/xpcshell.toml b/toolkit/components/places/tests/favicons/xpcshell.toml
new file mode 100644
index 0000000000..997aa48e0b
--- /dev/null
+++ b/toolkit/components/places/tests/favicons/xpcshell.toml
@@ -0,0 +1,72 @@
+[DEFAULT]
+head = "head_favicons.js"
+skip-if = ["os == 'android'"]
+support-files = [
+ "expected-favicon-animated16.png.png",
+ "expected-favicon-big32.jpg.png",
+ "expected-favicon-big4.jpg.png",
+ "expected-favicon-big16.ico.png",
+ "expected-favicon-big48.ico.png",
+ "expected-favicon-big64.png.png",
+ "expected-favicon-scale160x3.jpg.png",
+ "expected-favicon-scale3x160.jpg.png",
+ "favicon-animated16.png",
+ "favicon-big16.ico",
+ "favicon-big32.jpg",
+ "favicon-big4.jpg",
+ "favicon-big48.ico",
+ "favicon-big64.png",
+ "favicon-multi.ico",
+ "favicon-multi-frame16.png",
+ "favicon-multi-frame32.png",
+ "favicon-multi-frame64.png",
+ "favicon-normal16.png",
+ "favicon-normal32.png",
+ "favicon-scale160x3.jpg",
+ "favicon-scale3x160.jpg",
+ "noise.png",
+]
+
+["test_cached-favicon_mime_type.js"]
+
+["test_copyFavicons.js"]
+
+["test_expireAllFavicons.js"]
+
+["test_expire_migrated_icons.js"]
+
+["test_expire_on_new_icons.js"]
+
+["test_favicons_conversions.js"]
+
+["test_favicons_protocols_ref.js"]
+
+["test_getFaviconDataForPage.js"]
+
+["test_getFaviconLinkForIcon.js"]
+
+["test_getFaviconURLForPage.js"]
+
+["test_heavy_favicon.js"]
+
+["test_incremental_vacuum.js"]
+
+["test_multiple_frames.js"]
+
+["test_page-icon_protocol.js"]
+
+["test_query_result_favicon_changed_on_child.js"]
+
+["test_replaceFaviconData.js"]
+
+["test_replaceFaviconDataFromDataURL.js"]
+
+["test_root_icons.js"]
+
+["test_setAndFetchFaviconForPage.js"]
+
+["test_setAndFetchFaviconForPage_failures.js"]
+
+["test_setAndFetchFaviconForPage_redirects.js"]
+
+["test_svg_favicon.js"]
diff --git a/toolkit/components/places/tests/gtest/mock_Link.h b/toolkit/components/places/tests/gtest/mock_Link.h
new file mode 100644
index 0000000000..03d9381d52
--- /dev/null
+++ b/toolkit/components/places/tests/gtest/mock_Link.h
@@ -0,0 +1,72 @@
+/* -*- Mode: C++; tab-width: 2; indent-tabs-mode: nil; c-basic-offset: 2 -*-
+ * vim: sw=2 ts=2 et lcs=trail\:.,tab\:>~ :
+ * 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/. */
+
+/**
+ * This is a mock Link object which can be used in tests.
+ */
+
+#ifndef mock_Link_h__
+#define mock_Link_h__
+
+#include "mozilla/MemoryReporting.h"
+#include "mozilla/dom/Link.h"
+#include "mozilla/StaticPrefs_layout.h"
+
+class mock_Link : public mozilla::dom::Link {
+ public:
+ NS_DECL_ISUPPORTS
+
+ typedef void (*Handler)(State);
+
+ explicit mock_Link(Handler aHandlerFunction, bool aRunNextTest = true)
+ : mRunNextTest(aRunNextTest) {
+ AwaitNewNotification(aHandlerFunction);
+ }
+
+ void VisitedQueryFinished(bool aVisited) final {
+ // Notify our callback function.
+ mHandler(aVisited ? State::Visited : State::Unvisited);
+
+ // Break the cycle so the object can be destroyed.
+ mDeathGrip = nullptr;
+ }
+
+ size_t SizeOfExcludingThis(mozilla::SizeOfState& aState) const final {
+ return 0; // the value shouldn't matter
+ }
+
+ void NodeInfoChanged(mozilla::dom::Document* aOldDoc) final {}
+
+ bool GotNotified() const { return !mDeathGrip; }
+
+ void AwaitNewNotification(Handler aNewHandler) {
+ MOZ_ASSERT(!mDeathGrip, "Still waiting for a notification");
+ // Create a cyclic ownership, so that the link will be released only
+ // after its status has been updated. This will ensure that, when it should
+ // run the next test, it will happen at the end of the test function, if
+ // the link status has already been set before. Indeed the link status is
+ // updated on a separate connection, thus may happen at any time.
+ mDeathGrip = this;
+ mHandler = aNewHandler;
+ }
+
+ protected:
+ ~mock_Link() {
+ // Run the next test if we are supposed to.
+ if (mRunNextTest) {
+ run_next_test();
+ }
+ }
+
+ private:
+ Handler mHandler = nullptr;
+ bool mRunNextTest;
+ RefPtr mDeathGrip;
+};
+
+NS_IMPL_ISUPPORTS(mock_Link, mozilla::dom::Link)
+
+#endif // mock_Link_h__
diff --git a/toolkit/components/places/tests/gtest/moz.build b/toolkit/components/places/tests/gtest/moz.build
new file mode 100644
index 0000000000..eb7157efc5
--- /dev/null
+++ b/toolkit/components/places/tests/gtest/moz.build
@@ -0,0 +1,12 @@
+# -*- Mode: python; indent-tabs-mode: nil; tab-width: 40 -*-
+# vim: set filetype=python:
+# 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/.
+
+UNIFIED_SOURCES += [
+ "test_casing.cpp",
+ "test_IHistory.cpp",
+]
+
+FINAL_LIBRARY = "xul-gtest"
diff --git a/toolkit/components/places/tests/gtest/places_test_harness.h b/toolkit/components/places/tests/gtest/places_test_harness.h
new file mode 100644
index 0000000000..f1f1d388cd
--- /dev/null
+++ b/toolkit/components/places/tests/gtest/places_test_harness.h
@@ -0,0 +1,421 @@
+/* -*- Mode: C++; tab-width: 2; indent-tabs-mode: nil; c-basic-offset: 2 -*-
+ * vim: sw=2 ts=2 et lcs=trail\:.,tab\:>~ :
+ * 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/. */
+
+#include "gtest/gtest.h"
+#include "mozilla/dom/PlacesEventBinding.h"
+#include "nsIWeakReference.h"
+#include "nsThreadUtils.h"
+#include "nsDocShellCID.h"
+
+#include "nsToolkitCompsCID.h"
+#include "nsServiceManagerUtils.h"
+#include "nsINavHistoryService.h"
+#include "nsIObserverService.h"
+#include "nsIThread.h"
+#include "nsIURI.h"
+#include "mozilla/IHistory.h"
+#include "mozilla/SpinEventLoopUntil.h"
+#include "mozIStorageConnection.h"
+#include "mozIStorageStatement.h"
+#include "mozIStorageAsyncStatement.h"
+#include "mozIStorageStatementCallback.h"
+#include "mozIStoragePendingStatement.h"
+#include "nsIObserver.h"
+#include "prinrval.h"
+#include "prtime.h"
+#include "mozilla/Attributes.h"
+#include "mozilla/dom/PlacesEvent.h"
+#include "mozilla/dom/PlacesObservers.h"
+#include "mozilla/places/INativePlacesEventCallback.h"
+
+using mozilla::dom::PlacesEventType;
+using mozilla::dom::PlacesObservers;
+using mozilla::places::INativePlacesEventCallback;
+
+#define WAIT_TIMEOUT_USEC (5 * PR_USEC_PER_SEC)
+
+#define do_check_true(aCondition) EXPECT_TRUE(aCondition)
+
+#define do_check_false(aCondition) EXPECT_FALSE(aCondition)
+
+#define do_check_success(aResult) do_check_true(NS_SUCCEEDED(aResult))
+
+#define do_check_eq(aExpected, aActual) do_check_true((aExpected) == (aActual))
+
+struct Test {
+ void (*func)(void);
+ const char* const name;
+};
+#define PTEST(aName) \
+ { aName, #aName }
+
+/**
+ * Runs the next text.
+ */
+void run_next_test();
+
+/**
+ * To be used around asynchronous work.
+ */
+void do_test_pending();
+void do_test_finished();
+
+/**
+ * Spins current thread until a topic is received.
+ */
+class WaitForTopicSpinner final : public nsIObserver {
+ public:
+ NS_DECL_ISUPPORTS
+
+ explicit WaitForTopicSpinner(const char* const aTopic)
+ : mTopicReceived(false), mStartTime(PR_IntervalNow()) {
+ nsCOMPtr observerService =
+ do_GetService(NS_OBSERVERSERVICE_CONTRACTID);
+ do_check_true(observerService);
+ (void)observerService->AddObserver(this, aTopic, false);
+ }
+
+ void Spin() {
+ bool timedOut = false;
+ mozilla::SpinEventLoopUntil(
+ "places:WaitForTopicSpinner::Spin"_ns, [&]() -> bool {
+ if (mTopicReceived) {
+ return true;
+ }
+
+ if ((PR_IntervalNow() - mStartTime) > (WAIT_TIMEOUT_USEC)) {
+ timedOut = true;
+ return true;
+ }
+
+ return false;
+ });
+
+ if (timedOut) {
+ // Timed out waiting for the topic.
+ do_check_true(false);
+ }
+ }
+
+ NS_IMETHOD Observe(nsISupports* aSubject, const char* aTopic,
+ const char16_t* aData) override {
+ mTopicReceived = true;
+ nsCOMPtr observerService =
+ do_GetService(NS_OBSERVERSERVICE_CONTRACTID);
+ do_check_true(observerService);
+ (void)observerService->RemoveObserver(this, aTopic);
+ return NS_OK;
+ }
+
+ private:
+ ~WaitForTopicSpinner() = default;
+
+ bool mTopicReceived;
+ PRIntervalTime mStartTime;
+};
+NS_IMPL_ISUPPORTS(WaitForTopicSpinner, nsIObserver)
+
+/**
+ * Spins current thread until a Places notification is received.
+ */
+class WaitForNotificationSpinner final : public INativePlacesEventCallback {
+ public:
+ NS_INLINE_DECL_THREADSAFE_REFCOUNTING(WaitForNotificationSpinner, override)
+
+ explicit WaitForNotificationSpinner(const PlacesEventType aEventType)
+ : mEventType(aEventType), mStartTime(PR_IntervalNow()) {
+ AutoTArray events;
+ events.AppendElement(mEventType);
+ PlacesObservers::AddListener(events, this);
+ }
+
+ void SpinUntilCompleted() {
+ bool timedOut = false;
+ mozilla::SpinEventLoopUntil(
+ "places::WaitForNotificationSpinner::SpinUntilCompleted"_ns,
+ [&]() -> bool {
+ if (mEventReceived) {
+ return true;
+ }
+
+ if ((PR_IntervalNow() - mStartTime) > (WAIT_TIMEOUT_USEC)) {
+ timedOut = true;
+ return true;
+ }
+
+ return false;
+ });
+
+ if (timedOut) {
+ // Timed out waiting for the notification.
+ do_check_true(false);
+ }
+ }
+
+ void HandlePlacesEvent(const PlacesEventSequence& aEvents) override {
+ for (const auto& event : aEvents) {
+ if (event->Type() == mEventType) {
+ mEventReceived = true;
+ AutoTArray events;
+ events.AppendElement(mEventType);
+ PlacesObservers::RemoveListener(events, this);
+ return;
+ }
+ }
+ }
+
+ private:
+ ~WaitForNotificationSpinner() = default;
+
+ bool mEventReceived = false;
+ PlacesEventType mEventType;
+ PRIntervalTime mStartTime;
+};
+
+/**
+ * Spins current thread until an async statement is executed.
+ */
+class PlacesAsyncStatementSpinner final : public mozIStorageStatementCallback {
+ public:
+ NS_DECL_ISUPPORTS
+ NS_DECL_MOZISTORAGESTATEMENTCALLBACK
+
+ PlacesAsyncStatementSpinner();
+ void SpinUntilCompleted();
+ uint16_t completionReason;
+
+ protected:
+ ~PlacesAsyncStatementSpinner() = default;
+
+ volatile bool mCompleted;
+};
+
+NS_IMPL_ISUPPORTS(PlacesAsyncStatementSpinner, mozIStorageStatementCallback)
+
+PlacesAsyncStatementSpinner::PlacesAsyncStatementSpinner()
+ : completionReason(0), mCompleted(false) {}
+
+NS_IMETHODIMP
+PlacesAsyncStatementSpinner::HandleResult(mozIStorageResultSet* aResultSet) {
+ return NS_OK;
+}
+
+NS_IMETHODIMP
+PlacesAsyncStatementSpinner::HandleError(mozIStorageError* aError) {
+ return NS_OK;
+}
+
+NS_IMETHODIMP
+PlacesAsyncStatementSpinner::HandleCompletion(uint16_t aReason) {
+ completionReason = aReason;
+ mCompleted = true;
+ return NS_OK;
+}
+
+void PlacesAsyncStatementSpinner::SpinUntilCompleted() {
+ nsCOMPtr thread(::do_GetCurrentThread());
+ nsresult rv = NS_OK;
+ bool processed = true;
+ while (!mCompleted && NS_SUCCEEDED(rv)) {
+ rv = thread->ProcessNextEvent(true, &processed);
+ }
+}
+
+using PlaceRecord = struct PlaceRecord {
+ int64_t id = -1;
+ int32_t hidden = 0;
+ int32_t typed = 0;
+ int32_t visitCount = 0;
+ nsCString guid;
+ int64_t frecency = -1;
+};
+
+using VisitRecord = struct VisitRecord {
+ int64_t id = -1;
+ int64_t lastVisitId = -1;
+ int32_t transitionType = 0;
+};
+
+already_AddRefed do_get_IHistory() {
+ nsCOMPtr history = do_GetService(NS_IHISTORY_CONTRACTID);
+ do_check_true(history);
+ return history.forget();
+}
+
+already_AddRefed do_get_NavHistory() {
+ nsCOMPtr serv =
+ do_GetService(NS_NAVHISTORYSERVICE_CONTRACTID);
+ do_check_true(serv);
+ return serv.forget();
+}
+
+already_AddRefed do_get_db() {
+ nsCOMPtr history = do_get_NavHistory();
+ do_check_true(history);
+
+ nsCOMPtr dbConn;
+ nsresult rv = history->GetDBConnection(getter_AddRefs(dbConn));
+ do_check_success(rv);
+ return dbConn.forget();
+}
+
+/**
+ * Get the place record from the database.
+ *
+ * @param aURI The unique URI of the place we are looking up
+ * @param result Out parameter where the result is stored
+ */
+void do_get_place(nsIURI* aURI, PlaceRecord& result) {
+ nsCOMPtr dbConn = do_get_db();
+ nsCOMPtr stmt;
+
+ nsCString spec;
+ nsresult rv = aURI->GetSpec(spec);
+ do_check_success(rv);
+
+ rv = dbConn->CreateStatement(
+ nsLiteralCString("SELECT id, hidden, typed, visit_count, guid, frecency "
+ "FROM moz_places "
+ "WHERE url_hash = hash(?1) AND url = ?1"),
+ getter_AddRefs(stmt));
+ do_check_success(rv);
+
+ rv = stmt->BindUTF8StringByIndex(0, spec);
+ do_check_success(rv);
+
+ bool hasResults;
+ rv = stmt->ExecuteStep(&hasResults);
+ do_check_success(rv);
+ if (!hasResults) {
+ result.id = 0;
+ return;
+ }
+
+ rv = stmt->GetInt64(0, &result.id);
+ do_check_success(rv);
+ rv = stmt->GetInt32(1, &result.hidden);
+ do_check_success(rv);
+ rv = stmt->GetInt32(2, &result.typed);
+ do_check_success(rv);
+ rv = stmt->GetInt32(3, &result.visitCount);
+ do_check_success(rv);
+ rv = stmt->GetUTF8String(4, result.guid);
+ do_check_success(rv);
+ rv = stmt->GetInt64(5, &result.frecency);
+ do_check_success(rv);
+}
+
+/**
+ * Gets the most recent visit to a place.
+ *
+ * @param placeID ID from the moz_places table
+ * @param result Out parameter where visit is stored
+ */
+void do_get_lastVisit(int64_t placeId, VisitRecord& result) {
+ nsCOMPtr dbConn = do_get_db();
+ nsCOMPtr stmt;
+
+ nsresult rv = dbConn->CreateStatement(
+ nsLiteralCString(
+ "SELECT id, from_visit, visit_type FROM moz_historyvisits "
+ "WHERE place_id=?1 "
+ "LIMIT 1"),
+ getter_AddRefs(stmt));
+ do_check_success(rv);
+
+ rv = stmt->BindInt64ByIndex(0, placeId);
+ do_check_success(rv);
+
+ bool hasResults;
+ rv = stmt->ExecuteStep(&hasResults);
+ do_check_success(rv);
+
+ if (!hasResults) {
+ result.id = 0;
+ return;
+ }
+
+ rv = stmt->GetInt64(0, &result.id);
+ do_check_success(rv);
+ rv = stmt->GetInt64(1, &result.lastVisitId);
+ do_check_success(rv);
+ rv = stmt->GetInt32(2, &result.transitionType);
+ do_check_success(rv);
+}
+
+void do_wait_async_updates() {
+ nsCOMPtr db = do_get_db();
+ nsCOMPtr stmt;
+
+ db->CreateAsyncStatement("BEGIN EXCLUSIVE"_ns, getter_AddRefs(stmt));
+ nsCOMPtr pending;
+ (void)stmt->ExecuteAsync(nullptr, getter_AddRefs(pending));
+
+ db->CreateAsyncStatement("COMMIT"_ns, getter_AddRefs(stmt));
+ RefPtr spinner =
+ new PlacesAsyncStatementSpinner();
+ (void)stmt->ExecuteAsync(spinner, getter_AddRefs(pending));
+
+ spinner->SpinUntilCompleted();
+}
+
+/**
+ * Adds a URI to the database.
+ *
+ * @param aURI
+ * The URI to add to the database.
+ */
+void addURI(nsIURI* aURI) {
+ nsCOMPtr history = do_GetService(NS_IHISTORY_CONTRACTID);
+ do_check_true(history);
+ nsresult rv = history->VisitURI(nullptr, aURI, nullptr,
+ mozilla::IHistory::TOP_LEVEL, 0);
+ do_check_success(rv);
+
+ do_wait_async_updates();
+}
+
+static const char TOPIC_PROFILE_CHANGE_QM[] = "profile-before-change-qm";
+static const char TOPIC_PLACES_CONNECTION_CLOSED[] = "places-connection-closed";
+
+class WaitForConnectionClosed final : public nsIObserver {
+ RefPtr mSpinner;
+
+ ~WaitForConnectionClosed() = default;
+
+ public:
+ NS_DECL_ISUPPORTS
+
+ WaitForConnectionClosed() {
+ nsCOMPtr os =
+ do_GetService(NS_OBSERVERSERVICE_CONTRACTID);
+ MOZ_ASSERT(os);
+ if (os) {
+ // The places-connection-closed notification happens because of things
+ // that occur during profile-before-change, so we use the stage after that
+ // to wait for it.
+ MOZ_ALWAYS_SUCCEEDS(
+ os->AddObserver(this, TOPIC_PROFILE_CHANGE_QM, false));
+ }
+ mSpinner = new WaitForTopicSpinner(TOPIC_PLACES_CONNECTION_CLOSED);
+ }
+
+ NS_IMETHOD Observe(nsISupports* aSubject, const char* aTopic,
+ const char16_t* aData) override {
+ nsCOMPtr os =
+ do_GetService(NS_OBSERVERSERVICE_CONTRACTID);
+ MOZ_ASSERT(os);
+ if (os) {
+ MOZ_ALWAYS_SUCCEEDS(os->RemoveObserver(this, aTopic));
+ }
+
+ mSpinner->Spin();
+
+ return NS_OK;
+ }
+};
+
+NS_IMPL_ISUPPORTS(WaitForConnectionClosed, nsIObserver)
diff --git a/toolkit/components/places/tests/gtest/places_test_harness_tail.h b/toolkit/components/places/tests/gtest/places_test_harness_tail.h
new file mode 100644
index 0000000000..0464d14e0d
--- /dev/null
+++ b/toolkit/components/places/tests/gtest/places_test_harness_tail.h
@@ -0,0 +1,89 @@
+/* -*- Mode: C++; tab-width: 2; indent-tabs-mode: nil; c-basic-offset: 2 -*-
+ * vim: sw=2 ts=2 et lcs=trail\:.,tab\:>~ :
+ * 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/. */
+
+#include "nsWidgetsCID.h"
+#include "nsIUserIdleService.h"
+#include "mozilla/StackWalk.h"
+
+#ifndef TEST_NAME
+# error "Must #define TEST_NAME before including places_test_harness_tail.h"
+#endif
+
+int gTestsIndex = 0;
+
+#define TEST_INFO_STR "TEST-INFO | "
+
+class RunNextTest : public mozilla::Runnable {
+ public:
+ RunNextTest() : mozilla::Runnable("RunNextTest") {}
+ NS_IMETHOD Run() override {
+ NS_ASSERTION(NS_IsMainThread(), "Not running on the main thread?");
+ if (gTestsIndex < int(mozilla::ArrayLength(gTests))) {
+ do_test_pending();
+ Test& test = gTests[gTestsIndex++];
+ (void)fprintf(stderr, TEST_INFO_STR "Running %s.\n", test.name);
+ test.func();
+ }
+
+ do_test_finished();
+ return NS_OK;
+ }
+};
+
+static const bool kDebugRunNextTest = false;
+
+void run_next_test() {
+ if (kDebugRunNextTest) {
+ printf_stderr("run_next_test()\n");
+ MozWalkTheStack(stderr);
+ }
+ nsCOMPtr event = new RunNextTest();
+ do_check_success(NS_DispatchToCurrentThread(event));
+}
+
+int gPendingTests = 0;
+
+void do_test_pending() {
+ NS_ASSERTION(NS_IsMainThread(), "Not running on the main thread?");
+ if (kDebugRunNextTest) {
+ printf_stderr("do_test_pending()\n");
+ MozWalkTheStack(stderr);
+ }
+ gPendingTests++;
+}
+
+void do_test_finished() {
+ NS_ASSERTION(NS_IsMainThread(), "Not running on the main thread?");
+ NS_ASSERTION(gPendingTests > 0, "Invalid pending test count!");
+ gPendingTests--;
+}
+
+void disable_idle_service() {
+ (void)fprintf(stderr, TEST_INFO_STR "Disabling Idle Service.\n");
+
+ nsCOMPtr idle =
+ do_GetService("@mozilla.org/widget/useridleservice;1");
+ idle->SetDisabled(true);
+}
+
+TEST(IHistory, Test)
+{
+ RefPtr spinClose = new WaitForConnectionClosed();
+
+ // Tinderboxes are constantly on idle. Since idle tasks can interact with
+ // tests, causing random failures, disable the idle service.
+ disable_idle_service();
+
+ do_test_pending();
+ run_next_test();
+
+ // Spin the event loop until we've run out of tests to run.
+ mozilla::SpinEventLoopUntil("places:TEST(IHistory, Test)"_ns,
+ [&]() { return !gPendingTests; });
+
+ // And let any other events finish before we quit.
+ (void)NS_ProcessPendingEvents(nullptr);
+}
diff --git a/toolkit/components/places/tests/gtest/test_IHistory.cpp b/toolkit/components/places/tests/gtest/test_IHistory.cpp
new file mode 100644
index 0000000000..a047ca0e63
--- /dev/null
+++ b/toolkit/components/places/tests/gtest/test_IHistory.cpp
@@ -0,0 +1,519 @@
+/* -*- Mode: C++; tab-width: 2; indent-tabs-mode: nil; c-basic-offset: 2 -*-
+ * vim: sw=2 ts=2 et lcs=trail\:.,tab\:>~ :
+ * 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/. */
+
+#include "places_test_harness.h"
+#include "nsIPrefBranch.h"
+#include "nsIPrefService.h"
+#include "nsString.h"
+#include "mozilla/Attributes.h"
+#include "mozilla/SpinEventLoopUntil.h"
+#include "mozilla/StaticPrefs_layout.h"
+#include "nsNetUtil.h"
+
+#include "mock_Link.h"
+using namespace mozilla;
+using namespace mozilla::dom;
+
+/**
+ * This file tests the IHistory interface.
+ */
+
+////////////////////////////////////////////////////////////////////////////////
+//// Helper Methods
+
+void expect_visit(Link::State aState) {
+ do_check_true(aState == Link::State::Visited);
+}
+
+void expect_no_visit(Link::State aState) {
+ do_check_true(aState == Link::State::Unvisited);
+}
+
+already_AddRefed new_test_uri() {
+ // Create a unique spec.
+ static int32_t specNumber = 0;
+ nsCString spec = "http://mozilla.org/"_ns;
+ spec.AppendInt(specNumber++);
+
+ // Create the URI for the spec.
+ nsCOMPtr testURI;
+ nsresult rv = NS_NewURI(getter_AddRefs(testURI), spec);
+ do_check_success(rv);
+ return testURI.forget();
+}
+
+class VisitURIObserver final : public nsIObserver {
+ ~VisitURIObserver() = default;
+
+ public:
+ NS_DECL_ISUPPORTS
+
+ explicit VisitURIObserver(int aExpectedVisits = 1)
+ : mVisits(0), mExpectedVisits(aExpectedVisits) {
+ nsCOMPtr observerService =
+ do_GetService(NS_OBSERVERSERVICE_CONTRACTID);
+ do_check_true(observerService);
+ (void)observerService->AddObserver(this, "uri-visit-saved", false);
+ }
+
+ void WaitForNotification() {
+ SpinEventLoopUntil("places:VisitURIObserver::WaitForNotification"_ns,
+ [&]() { return mVisits >= mExpectedVisits; });
+ }
+
+ NS_IMETHOD Observe(nsISupports* aSubject, const char* aTopic,
+ const char16_t* aData) override {
+ mVisits++;
+
+ if (mVisits == mExpectedVisits) {
+ nsCOMPtr observerService =
+ do_GetService(NS_OBSERVERSERVICE_CONTRACTID);
+ (void)observerService->RemoveObserver(this, "uri-visit-saved");
+ }
+
+ return NS_OK;
+ }
+
+ private:
+ int mVisits;
+ int mExpectedVisits;
+};
+NS_IMPL_ISUPPORTS(VisitURIObserver, nsIObserver)
+
+////////////////////////////////////////////////////////////////////////////////
+//// Test Functions
+
+void test_set_places_enabled() {
+ // Ensure places is enabled for everyone.
+ nsresult rv;
+ nsCOMPtr prefBranch =
+ do_GetService(NS_PREFSERVICE_CONTRACTID, &rv);
+ do_check_success(rv);
+
+ rv = prefBranch->SetBoolPref("places.history.enabled", true);
+ do_check_success(rv);
+
+ // Run the next test.
+ run_next_test();
+}
+
+void test_wait_checkpoint() {
+ // This "fake" test is here to wait for the initial WAL checkpoint we force
+ // after creating the database schema, since that may happen at any time,
+ // and cause concurrent readers to access an older checkpoint.
+ nsCOMPtr db = do_get_db();
+ nsCOMPtr stmt;
+ db->CreateAsyncStatement("SELECT 1"_ns, getter_AddRefs(stmt));
+ RefPtr spinner =
+ new PlacesAsyncStatementSpinner();
+ nsCOMPtr pending;
+ (void)stmt->ExecuteAsync(spinner, getter_AddRefs(pending));
+ spinner->SpinUntilCompleted();
+
+ // Run the next test.
+ run_next_test();
+}
+
+// These variables are shared between part 1 and part 2 of the test. Part 2
+// sets the nsCOMPtr's to nullptr, freeing the reference.
+namespace test_unvisited_does_not_notify {
+nsCOMPtr testURI;
+RefPtr testLink;
+} // namespace test_unvisited_does_not_notify
+void test_unvisited_does_not_notify_part1() {
+ using namespace test_unvisited_does_not_notify;
+
+ // This test is done in two parts. The first part registers for a URI that
+ // should not be visited. We then run another test that will also do a
+ // lookup and will be notified. Since requests are answered in the order they
+ // are requested (at least as long as the same URI isn't asked for later), we
+ // will know that the Link was not notified.
+
+ // First, we need a test URI.
+ testURI = new_test_uri();
+
+ // Create our test Link.
+ testLink = new mock_Link(expect_no_visit);
+
+ // Now, register our Link to be notified.
+ nsCOMPtr history = do_get_IHistory();
+ history->RegisterVisitedCallback(testURI, testLink);
+
+ // Run the next test.
+ run_next_test();
+}
+
+void test_visited_notifies() {
+ // First, we add our test URI to history.
+ nsCOMPtr testURI = new_test_uri();
+ addURI(testURI);
+
+ // Create our test Link. The callback function will release the reference we
+ // have on the Link.
+ RefPtr link = new mock_Link(expect_visit);
+
+ // Now, register our Link to be notified.
+ nsCOMPtr history = do_get_IHistory();
+ history->RegisterVisitedCallback(testURI, link);
+
+ // Note: test will continue upon notification.
+}
+
+void test_unvisited_does_not_notify_part2() {
+ using namespace test_unvisited_does_not_notify;
+
+ SpinEventLoopUntil("places:test_unvisited_does_not_notify_part2"_ns,
+ [&]() { return testLink->GotNotified(); });
+
+ // We would have had a failure at this point had the content node been told it
+ // was visited. Therefore, now we change it so that it expects a visited
+ // notification, and unregisters itself after addURI.
+ testLink->AwaitNewNotification(expect_visit);
+ addURI(testURI);
+
+ // Clear the stored variables now.
+ testURI = nullptr;
+ testLink = nullptr;
+}
+
+void test_same_uri_notifies_both() {
+ // First, we add our test URI to history.
+ nsCOMPtr testURI = new_test_uri();
+ addURI(testURI);
+
+ // Create our two test Links. The callback function will release the
+ // reference we have on the Links. Only the second Link should run the next
+ // test!
+ RefPtr link1 = new mock_Link(expect_visit, false);
+ RefPtr link2 = new mock_Link(expect_visit);
+
+ // Now, register our Link to be notified.
+ nsCOMPtr history = do_get_IHistory();
+ history->RegisterVisitedCallback(testURI, link1);
+ history->RegisterVisitedCallback(testURI, link2);
+
+ // Note: test will continue upon notification.
+}
+
+void test_unregistered_visited_does_not_notify() {
+ // This test must have a test that has a successful notification after it.
+ // The Link would have been notified by now if we were buggy and notified
+ // unregistered Links (due to request serialization).
+
+ nsCOMPtr testURI = new_test_uri();
+ RefPtr link = new mock_Link(expect_no_visit, false);
+ nsCOMPtr history(do_get_IHistory());
+ history->RegisterVisitedCallback(testURI, link);
+
+ // Unregister the Link.
+ history->UnregisterVisitedCallback(testURI, link);
+
+ // And finally add a visit for the URI.
+ addURI(testURI);
+
+ // If history tries to notify us, we'll either crash because the Link will
+ // have been deleted (we are the only thing holding a reference to it), or our
+ // expect_no_visit call back will produce a failure. Either way, the test
+ // will be reported as a failure.
+
+ // Run the next test.
+ run_next_test();
+}
+
+void test_new_visit_notifies_waiting_Link() {
+ // Create our test Link. The callback function will release the reference we
+ // have on the link.
+ //
+ // Note that this will query the database and we'll get an _unvisited_
+ // notification, then (after we addURI) a _visited_ one.
+ RefPtr link = new mock_Link(expect_no_visit);
+
+ // Now, register our content node to be notified.
+ nsCOMPtr testURI = new_test_uri();
+ nsCOMPtr history = do_get_IHistory();
+ history->RegisterVisitedCallback(testURI, link);
+
+ SpinEventLoopUntil("places:test_new_visit_notifies_waiting_Link"_ns,
+ [&]() { return link->GotNotified(); });
+
+ link->AwaitNewNotification(expect_visit);
+
+ // Add ourselves to history.
+ addURI(testURI);
+
+ // Note: test will continue upon notification.
+}
+
+void test_RegisterVisitedCallback_returns_before_notifying() {
+ // Add a URI so that it's already in history.
+ nsCOMPtr testURI = new_test_uri();
+ addURI(testURI);
+
+ // Create our test Link.
+ RefPtr link = new mock_Link(expect_no_visit, false);
+
+ // Now, register our content node to be notified. It should not be notified.
+ nsCOMPtr history = do_get_IHistory();
+ history->RegisterVisitedCallback(testURI, link);
+
+ // Remove ourselves as an observer. We would have failed if we had been
+ // notified.
+ history->UnregisterVisitedCallback(testURI, link);
+
+ run_next_test();
+}
+
+void test_visituri_inserts() {
+ nsCOMPtr history = do_get_IHistory();
+ nsCOMPtr lastURI = new_test_uri();
+ nsCOMPtr visitedURI = new_test_uri();
+
+ history->VisitURI(nullptr, visitedURI, lastURI, mozilla::IHistory::TOP_LEVEL,
+ 0);
+
+ RefPtr finisher = new VisitURIObserver();
+ finisher->WaitForNotification();
+
+ PlaceRecord place;
+ do_get_place(visitedURI, place);
+
+ do_check_true(place.id > 0);
+ do_check_false(place.hidden);
+ do_check_false(place.typed);
+ do_check_eq(place.visitCount, 1);
+
+ run_next_test();
+}
+
+void test_visituri_updates() {
+ nsCOMPtr history = do_get_IHistory();
+ nsCOMPtr lastURI = new_test_uri();
+ nsCOMPtr visitedURI = new_test_uri();
+ RefPtr finisher;
+
+ history->VisitURI(nullptr, visitedURI, lastURI, mozilla::IHistory::TOP_LEVEL,
+ 0);
+ finisher = new VisitURIObserver();
+ finisher->WaitForNotification();
+
+ history->VisitURI(nullptr, visitedURI, lastURI, mozilla::IHistory::TOP_LEVEL,
+ 0);
+ finisher = new VisitURIObserver();
+ finisher->WaitForNotification();
+
+ PlaceRecord place;
+ do_get_place(visitedURI, place);
+
+ do_check_eq(place.visitCount, 2);
+
+ run_next_test();
+}
+
+void test_visituri_preserves_shown_and_typed() {
+ nsCOMPtr history = do_get_IHistory();
+ nsCOMPtr lastURI = new_test_uri();
+ nsCOMPtr visitedURI = new_test_uri();
+
+ history->VisitURI(nullptr, visitedURI, lastURI, mozilla::IHistory::TOP_LEVEL,
+ 0);
+ // this simulates the uri visit happening in a frame. Normally frame
+ // transitions would be hidden unless it was previously loaded top-level
+ history->VisitURI(nullptr, visitedURI, lastURI, 0, 0);
+
+ RefPtr finisher = new VisitURIObserver(2);
+ finisher->WaitForNotification();
+
+ PlaceRecord place;
+ do_get_place(visitedURI, place);
+ do_check_false(place.hidden);
+
+ run_next_test();
+}
+
+void test_visituri_creates_visit() {
+ nsCOMPtr history = do_get_IHistory();
+ nsCOMPtr lastURI = new_test_uri();
+ nsCOMPtr visitedURI = new_test_uri();
+
+ history->VisitURI(nullptr, visitedURI, lastURI, mozilla::IHistory::TOP_LEVEL,
+ 0);
+ RefPtr finisher = new VisitURIObserver();
+ finisher->WaitForNotification();
+
+ PlaceRecord place;
+ VisitRecord visit;
+ do_get_place(visitedURI, place);
+ do_get_lastVisit(place.id, visit);
+
+ do_check_true(visit.id > 0);
+ do_check_eq(visit.lastVisitId, 0);
+ do_check_eq(visit.transitionType, nsINavHistoryService::TRANSITION_LINK);
+
+ run_next_test();
+}
+
+void test_visituri_frecency() {
+ // Adding a visit calculates frecency immediately.
+ nsCOMPtr history = do_get_IHistory();
+ nsCOMPtr visitedURI = new_test_uri();
+ RefPtr spinner =
+ new WaitForNotificationSpinner(PlacesEventType::Pages_rank_changed);
+ history->VisitURI(nullptr, visitedURI, nullptr, mozilla::IHistory::TOP_LEVEL,
+ 0);
+ RefPtr finisher = new VisitURIObserver();
+ finisher->WaitForNotification();
+ spinner->SpinUntilCompleted();
+ PlaceRecord place;
+ do_get_place(visitedURI, place);
+ do_check_true(place.frecency > 0);
+ run_next_test();
+}
+
+void test_visituri_hidden() {
+ nsCOMPtr history = do_get_IHistory();
+ {
+ // Insert a framed link visit.
+ nsCOMPtr visitedURI = new_test_uri();
+ nsCOMPtr navHistory = do_get_NavHistory();
+ navHistory->MarkPageAsFollowedLink(visitedURI);
+ history->VisitURI(nullptr, visitedURI, nullptr, 0, 0);
+ RefPtr finisher = new VisitURIObserver();
+ finisher->WaitForNotification();
+ PlaceRecord place;
+ do_get_place(visitedURI, place);
+ do_check_true(place.hidden);
+ }
+
+ // Insert a redirect.
+ nsCOMPtr visitedURI = new_test_uri();
+ history->VisitURI(nullptr, visitedURI, nullptr,
+ mozilla::IHistory::TOP_LEVEL | IHistory::REDIRECT_SOURCE,
+ 0);
+ {
+ RefPtr finisher = new VisitURIObserver();
+ finisher->WaitForNotification();
+ PlaceRecord place;
+ do_get_place(visitedURI, place);
+ do_check_true(place.hidden);
+ }
+
+ // Now add a non-hidden visit to the hidden page, check it gets unhidden.
+ history->VisitURI(nullptr, visitedURI, nullptr, mozilla::IHistory::TOP_LEVEL,
+ 0);
+ {
+ RefPtr finisher = new VisitURIObserver();
+ finisher->WaitForNotification();
+ PlaceRecord place;
+ do_get_place(visitedURI, place);
+ do_check_false(place.hidden);
+ }
+
+ // Add another hidden visit, it should stay unhidden.
+ history->VisitURI(nullptr, visitedURI, nullptr,
+ mozilla::IHistory::TOP_LEVEL | IHistory::REDIRECT_SOURCE,
+ 0);
+ {
+ RefPtr finisher = new VisitURIObserver();
+ finisher->WaitForNotification();
+ PlaceRecord place;
+ do_get_place(visitedURI, place);
+ do_check_false(place.hidden);
+ }
+
+ run_next_test();
+}
+
+void test_visituri_transition_typed() {
+ nsCOMPtr navHistory = do_get_NavHistory();
+ nsCOMPtr history = do_get_IHistory();
+ nsCOMPtr lastURI = new_test_uri();
+ nsCOMPtr visitedURI = new_test_uri();
+
+ navHistory->MarkPageAsTyped(visitedURI);
+ history->VisitURI(nullptr, visitedURI, lastURI, mozilla::IHistory::TOP_LEVEL,
+ 0);
+ RefPtr finisher = new VisitURIObserver();
+ finisher->WaitForNotification();
+
+ PlaceRecord place;
+ VisitRecord visit;
+ do_get_place(visitedURI, place);
+ do_get_lastVisit(place.id, visit);
+
+ do_check_true(visit.transitionType == nsINavHistoryService::TRANSITION_TYPED);
+
+ run_next_test();
+}
+
+void test_visituri_transition_embed() {
+ nsCOMPtr history = do_get_IHistory();
+ nsCOMPtr lastURI = new_test_uri();
+ nsCOMPtr visitedURI = new_test_uri();
+
+ history->VisitURI(nullptr, visitedURI, lastURI, 0, 0);
+ RefPtr finisher = new VisitURIObserver();
+ finisher->WaitForNotification();
+
+ PlaceRecord place;
+ VisitRecord visit;
+ do_get_place(visitedURI, place);
+ do_get_lastVisit(place.id, visit);
+
+ do_check_eq(place.id, 0);
+ do_check_eq(visit.id, 0);
+
+ run_next_test();
+}
+
+void test_new_visit_adds_place_guid() {
+ // First, add a visit and wait. This will also add a place.
+ nsCOMPtr visitedURI = new_test_uri();
+ nsCOMPtr history = do_get_IHistory();
+ nsresult rv = history->VisitURI(nullptr, visitedURI, nullptr,
+ mozilla::IHistory::TOP_LEVEL, 0);
+ do_check_success(rv);
+ RefPtr finisher = new VisitURIObserver();
+ finisher->WaitForNotification();
+
+ // Check that we have a guid for our visit.
+ PlaceRecord place;
+ do_get_place(visitedURI, place);
+ do_check_eq(place.visitCount, 1);
+ do_check_eq(place.guid.Length(), 12u);
+
+ run_next_test();
+}
+
+////////////////////////////////////////////////////////////////////////////////
+//// Test Harness
+
+/**
+ * Note: for tests marked "Order Important!", please see the test for details.
+ */
+Test gTests[] = {
+ PTEST(test_set_places_enabled), // Must come first!
+ PTEST(test_wait_checkpoint), // Must come second!
+ PTEST(test_unvisited_does_not_notify_part1), // Order Important!
+ PTEST(test_visited_notifies),
+ PTEST(test_unvisited_does_not_notify_part2), // Order Important!
+ PTEST(test_same_uri_notifies_both),
+ PTEST(test_unregistered_visited_does_not_notify), // Order Important!
+ PTEST(test_new_visit_adds_place_guid),
+ PTEST(test_new_visit_notifies_waiting_Link),
+ PTEST(test_RegisterVisitedCallback_returns_before_notifying),
+ PTEST(test_visituri_inserts),
+ PTEST(test_visituri_updates),
+ PTEST(test_visituri_preserves_shown_and_typed),
+ PTEST(test_visituri_creates_visit),
+ PTEST(test_visituri_frecency),
+ PTEST(test_visituri_hidden),
+ PTEST(test_visituri_transition_typed),
+ PTEST(test_visituri_transition_embed),
+
+};
+
+#define TEST_NAME "IHistory"
+#include "places_test_harness_tail.h"
diff --git a/toolkit/components/places/tests/gtest/test_casing.cpp b/toolkit/components/places/tests/gtest/test_casing.cpp
new file mode 100644
index 0000000000..079d64bbd0
--- /dev/null
+++ b/toolkit/components/places/tests/gtest/test_casing.cpp
@@ -0,0 +1,29 @@
+/* -*- Mode: C++; tab-width: 2; indent-tabs-mode: nil; c-basic-offset: 2 -*-
+ * vim: sw=2 ts=2 et lcs=trail\:.,tab\:>~ :
+ * 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/. */
+
+#include "gtest/gtest.h"
+#include "mozilla/intl/UnicodeProperties.h"
+
+// Verify the assertion in SQLFunctions.cpp / nextSearchCandidate that the
+// only non-ASCII characters that lower-case to ASCII ones are:
+// * U+0130 LATIN CAPITAL LETTER I WITH DOT ABOVE
+// * U+212A KELVIN SIGN
+TEST(MatchAutocompleteCasing, CaseAssumption)
+{
+ for (uint32_t c = 128; c < 0x110000; c++) {
+ if (c != 304 && c != 8490) {
+ ASSERT_GE(mozilla::intl::UnicodeProperties::ToLower(c), 128U);
+ }
+ }
+}
+
+// Verify the assertion that all ASCII characters lower-case to ASCII.
+TEST(MatchAutocompleteCasing, CaseAssumption2)
+{
+ for (uint32_t c = 0; c < 128; c++) {
+ ASSERT_LT(mozilla::intl::UnicodeProperties::ToLower(c), 128U);
+ }
+}
diff --git a/toolkit/components/places/tests/head_common.js b/toolkit/components/places/tests/head_common.js
new file mode 100644
index 0000000000..d7a465786e
--- /dev/null
+++ b/toolkit/components/places/tests/head_common.js
@@ -0,0 +1,919 @@
+/* -*- indent-tabs-mode: nil; js-indent-level: 2 -*-
+ * 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/. */
+
+const NS_APP_USER_PROFILE_50_DIR = "ProfD";
+
+// Shortcuts to transitions type.
+const TRANSITION_LINK = Ci.nsINavHistoryService.TRANSITION_LINK;
+const TRANSITION_TYPED = Ci.nsINavHistoryService.TRANSITION_TYPED;
+const TRANSITION_BOOKMARK = Ci.nsINavHistoryService.TRANSITION_BOOKMARK;
+const TRANSITION_EMBED = Ci.nsINavHistoryService.TRANSITION_EMBED;
+const TRANSITION_FRAMED_LINK = Ci.nsINavHistoryService.TRANSITION_FRAMED_LINK;
+const TRANSITION_REDIRECT_PERMANENT =
+ Ci.nsINavHistoryService.TRANSITION_REDIRECT_PERMANENT;
+const TRANSITION_REDIRECT_TEMPORARY =
+ Ci.nsINavHistoryService.TRANSITION_REDIRECT_TEMPORARY;
+const TRANSITION_DOWNLOAD = Ci.nsINavHistoryService.TRANSITION_DOWNLOAD;
+const TRANSITION_RELOAD = Ci.nsINavHistoryService.TRANSITION_RELOAD;
+
+const TITLE_LENGTH_MAX = 4096;
+
+var { XPCOMUtils } = ChromeUtils.importESModule(
+ "resource://gre/modules/XPCOMUtils.sys.mjs"
+);
+const { AppConstants } = ChromeUtils.importESModule(
+ "resource://gre/modules/AppConstants.sys.mjs"
+);
+var { PlacesSyncUtils } = ChromeUtils.importESModule(
+ "resource://gre/modules/PlacesSyncUtils.sys.mjs"
+);
+
+ChromeUtils.defineESModuleGetters(this, {
+ BookmarkHTMLUtils: "resource://gre/modules/BookmarkHTMLUtils.sys.mjs",
+ BookmarkJSONUtils: "resource://gre/modules/BookmarkJSONUtils.sys.mjs",
+ FileUtils: "resource://gre/modules/FileUtils.sys.mjs",
+ NetUtil: "resource://gre/modules/NetUtil.sys.mjs",
+ ObjectUtils: "resource://gre/modules/ObjectUtils.sys.mjs",
+ PlacesBackups: "resource://gre/modules/PlacesBackups.sys.mjs",
+ PlacesDBUtils: "resource://gre/modules/PlacesDBUtils.sys.mjs",
+ PlacesTestUtils: "resource://testing-common/PlacesTestUtils.sys.mjs",
+ PlacesTransactions: "resource://gre/modules/PlacesTransactions.sys.mjs",
+ PlacesUtils: "resource://gre/modules/PlacesUtils.sys.mjs",
+ Sqlite: "resource://gre/modules/Sqlite.sys.mjs",
+ TelemetryTestUtils: "resource://testing-common/TelemetryTestUtils.sys.mjs",
+ TestUtils: "resource://testing-common/TestUtils.sys.mjs",
+});
+
+ChromeUtils.defineLazyGetter(this, "SMALLPNG_DATA_URI", function () {
+ return NetUtil.newURI(
+ "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAA" +
+ "AAAA6fptVAAAACklEQVQI12NgAAAAAgAB4iG8MwAAAABJRU5ErkJggg=="
+ );
+});
+const SMALLPNG_DATA_LEN = 67;
+
+ChromeUtils.defineLazyGetter(this, "SMALLSVG_DATA_URI", function () {
+ return NetUtil.newURI(
+ "data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy5" +
+ "3My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAxMDAgMTAwIiBmaWxs" +
+ "PSIjNDI0ZTVhIj4NCiAgPGNpcmNsZSBjeD0iNTAiIGN5PSI1MCIgcj0iN" +
+ "DQiIHN0cm9rZT0iIzQyNGU1YSIgc3Ryb2tlLXdpZHRoPSIxMSIgZmlsbD" +
+ "0ibm9uZSIvPg0KICA8Y2lyY2xlIGN4PSI1MCIgY3k9IjI0LjYiIHI9IjY" +
+ "uNCIvPg0KICA8cmVjdCB4PSI0NSIgeT0iMzkuOSIgd2lkdGg9IjEwLjEi" +
+ "IGhlaWdodD0iNDEuOCIvPg0KPC9zdmc%2BDQo%3D"
+ );
+});
+
+ChromeUtils.defineLazyGetter(this, "PlacesFrecencyRecalculator", () => {
+ return Cc["@mozilla.org/places/frecency-recalculator;1"].getService(
+ Ci.nsIObserver
+ ).wrappedJSObject;
+});
+
+var gTestDir = do_get_cwd();
+
+// Initialize profile.
+var gProfD = do_get_profile(true);
+
+// Remove any old database.
+clearDB();
+
+/**
+ * Shortcut to create a nsIURI.
+ *
+ * @param aSpec
+ * URLString of the uri.
+ */
+function uri(aSpec) {
+ return NetUtil.newURI(aSpec);
+}
+
+/**
+ * Gets the database connection. If the Places connection is invalid it will
+ * try to create a new connection.
+ *
+ * @param [optional] aForceNewConnection
+ * Forces creation of a new connection to the database. When a
+ * connection is asyncClosed it cannot anymore schedule async statements,
+ * though connectionReady will keep returning true (Bug 726990).
+ *
+ * @return The database connection or null if unable to get one.
+ */
+var gDBConn;
+function DBConn(aForceNewConnection) {
+ if (!aForceNewConnection) {
+ let db = PlacesUtils.history.DBConnection;
+ if (db.connectionReady) {
+ return db;
+ }
+ }
+
+ // If the Places database connection has been closed, create a new connection.
+ if (!gDBConn || aForceNewConnection) {
+ let file = Services.dirsvc.get("ProfD", Ci.nsIFile);
+ file.append("places.sqlite");
+ let dbConn = (gDBConn = Services.storage.openDatabase(file));
+
+ // Be sure to cleanly close this connection.
+ promiseTopicObserved("profile-before-change").then(() =>
+ dbConn.asyncClose()
+ );
+ }
+
+ return gDBConn.connectionReady ? gDBConn : null;
+}
+
+/**
+ * Reads data from the provided inputstream.
+ *
+ * @return an array of bytes.
+ */
+function readInputStreamData(aStream) {
+ let bistream = Cc["@mozilla.org/binaryinputstream;1"].createInstance(
+ Ci.nsIBinaryInputStream
+ );
+ try {
+ bistream.setInputStream(aStream);
+ let expectedData = [];
+ let avail;
+ while ((avail = bistream.available())) {
+ expectedData = expectedData.concat(bistream.readByteArray(avail));
+ }
+ return expectedData;
+ } finally {
+ bistream.close();
+ }
+}
+
+/**
+ * Reads the data from the specified nsIFile.
+ *
+ * @param aFile
+ * The nsIFile to read from.
+ * @return an array of bytes.
+ */
+function readFileData(aFile) {
+ let inputStream = Cc[
+ "@mozilla.org/network/file-input-stream;1"
+ ].createInstance(Ci.nsIFileInputStream);
+ // init the stream as RD_ONLY, -1 == default permissions.
+ inputStream.init(aFile, 0x01, -1, null);
+
+ // Check the returned size versus the expected size.
+ let size = inputStream.available();
+ let bytes = readInputStreamData(inputStream);
+ if (size != bytes.length) {
+ throw new Error("Didn't read expected number of bytes");
+ }
+ return bytes;
+}
+
+/**
+ * Reads the data from the named file, verifying the expected file length.
+ *
+ * @param aFileName
+ * This file should be located in the same folder as the test.
+ * @param aExpectedLength
+ * Expected length of the file.
+ *
+ * @return The array of bytes read from the file.
+ */
+function readFileOfLength(aFileName, aExpectedLength) {
+ let data = readFileData(do_get_file(aFileName));
+ Assert.equal(data.length, aExpectedLength);
+ return data;
+}
+
+/**
+ * Returns the base64-encoded version of the given string. This function is
+ * similar to window.btoa, but is available to xpcshell tests also.
+ *
+ * @param aString
+ * Each character in this string corresponds to a byte, and must be a
+ * code point in the range 0-255.
+ *
+ * @return The base64-encoded string.
+ */
+function base64EncodeString(aString) {
+ var stream = Cc["@mozilla.org/io/string-input-stream;1"].createInstance(
+ Ci.nsIStringInputStream
+ );
+ stream.setData(aString, aString.length);
+ var encoder = Cc["@mozilla.org/scriptablebase64encoder;1"].createInstance(
+ Ci.nsIScriptableBase64Encoder
+ );
+ return encoder.encodeToString(stream, aString.length);
+}
+
+/**
+ * Compares two arrays, and returns true if they are equal.
+ *
+ * @param aArray1
+ * First array to compare.
+ * @param aArray2
+ * Second array to compare.
+ */
+function compareArrays(aArray1, aArray2) {
+ if (aArray1.length != aArray2.length) {
+ print("compareArrays: array lengths differ\n");
+ return false;
+ }
+
+ for (let i = 0; i < aArray1.length; i++) {
+ if (aArray1[i] != aArray2[i]) {
+ print(
+ "compareArrays: arrays differ at index " +
+ i +
+ ": " +
+ "(" +
+ aArray1[i] +
+ ") != (" +
+ aArray2[i] +
+ ")\n"
+ );
+ return false;
+ }
+ }
+
+ return true;
+}
+
+/**
+ * Deletes a previously created sqlite file from the profile folder.
+ */
+function clearDB() {
+ try {
+ let file = Services.dirsvc.get("ProfD", Ci.nsIFile);
+ file.append("places.sqlite");
+ if (file.exists()) {
+ file.remove(false);
+ }
+ } catch (ex) {
+ dump("Exception: " + ex);
+ }
+}
+
+/**
+ * Dumps the rows of a table out to the console.
+ *
+ * @param aName
+ * The name of the table or view to output.
+ */
+function dump_table(aName, dbConn) {
+ if (!dbConn) {
+ dbConn = DBConn();
+ }
+ let stmt = dbConn.createStatement("SELECT * FROM " + aName);
+
+ print("\n*** Printing data from " + aName);
+ let count = 0;
+ while (stmt.executeStep()) {
+ let columns = stmt.numEntries;
+
+ if (count == 0) {
+ // Print the column names.
+ for (let i = 0; i < columns; i++) {
+ dump(stmt.getColumnName(i) + "\t");
+ }
+ dump("\n");
+ }
+
+ // Print the rows.
+ for (let i = 0; i < columns; i++) {
+ switch (stmt.getTypeOfIndex(i)) {
+ case Ci.mozIStorageValueArray.VALUE_TYPE_NULL:
+ dump("NULL\t");
+ break;
+ case Ci.mozIStorageValueArray.VALUE_TYPE_INTEGER:
+ dump(stmt.getInt64(i) + "\t");
+ break;
+ case Ci.mozIStorageValueArray.VALUE_TYPE_FLOAT:
+ dump(stmt.getDouble(i) + "\t");
+ break;
+ case Ci.mozIStorageValueArray.VALUE_TYPE_TEXT:
+ dump(stmt.getString(i) + "\t");
+ break;
+ }
+ }
+ dump("\n");
+
+ count++;
+ }
+ print("*** There were a total of " + count + " rows of data.\n");
+
+ stmt.finalize();
+}
+
+/**
+ * Checks if an address is found in the database.
+ * @param aURI
+ * nsIURI or address to look for.
+ * @return place id of the page or 0 if not found
+ */
+function page_in_database(aURI) {
+ let url = aURI instanceof Ci.nsIURI ? aURI.spec : aURI;
+ let stmt = DBConn().createStatement(
+ "SELECT id FROM moz_places WHERE url_hash = hash(:url) AND url = :url"
+ );
+ stmt.params.url = url;
+ try {
+ if (!stmt.executeStep()) {
+ return 0;
+ }
+ return stmt.getInt64(0);
+ } finally {
+ stmt.finalize();
+ }
+}
+
+/**
+ * Checks how many visits exist for a specified page.
+ * @param aURI
+ * nsIURI or address to look for.
+ * @return number of visits found.
+ */
+function visits_in_database(aURI) {
+ let url = aURI instanceof Ci.nsIURI ? aURI.spec : aURI;
+ let stmt = DBConn().createStatement(
+ `SELECT count(*) FROM moz_historyvisits v
+ JOIN moz_places h ON h.id = v.place_id
+ WHERE url_hash = hash(:url) AND url = :url`
+ );
+ stmt.params.url = url;
+ try {
+ if (!stmt.executeStep()) {
+ return 0;
+ }
+ return stmt.getInt64(0);
+ } finally {
+ stmt.finalize();
+ }
+}
+
+/**
+ * Allows waiting for an observer notification once.
+ *
+ * @param aTopic
+ * Notification topic to observe.
+ *
+ * @return {Promise}
+ * @resolves The array [aSubject, aData] from the observed notification.
+ * @rejects Never.
+ */
+function promiseTopicObserved(aTopic) {
+ return new Promise(resolve => {
+ Services.obs.addObserver(function observe(
+ aObsSubject,
+ aObsTopic,
+ aObsData
+ ) {
+ Services.obs.removeObserver(observe, aObsTopic);
+ resolve([aObsSubject, aObsData]);
+ },
+ aTopic);
+ });
+}
+
+/**
+ * Simulates a Places shutdown.
+ */
+var shutdownPlaces = function () {
+ info("shutdownPlaces: starting");
+ let promise = new Promise(resolve => {
+ Services.obs.addObserver(resolve, "places-connection-closed");
+ });
+ let hs = PlacesUtils.history.QueryInterface(Ci.nsIObserver);
+ hs.observe(null, "profile-change-teardown", null);
+ info("shutdownPlaces: sent profile-change-teardown");
+ hs.observe(null, "test-simulate-places-shutdown", null);
+ info("shutdownPlaces: sent test-simulate-places-shutdown");
+ return promise.then(() => {
+ info("shutdownPlaces: complete");
+ });
+};
+
+const FILENAME_BOOKMARKS_HTML = "bookmarks.html";
+const FILENAME_BOOKMARKS_JSON =
+ "bookmarks-" + PlacesBackups.toISODateString(new Date()) + ".json";
+
+/**
+ * Creates a bookmarks.html file in the profile folder from a given source file.
+ *
+ * @param aFilename
+ * Name of the file to copy to the profile folder. This file must
+ * exist in the directory that contains the test files.
+ *
+ * @return nsIFile object for the file.
+ */
+function create_bookmarks_html(aFilename) {
+ if (!aFilename) {
+ do_throw("you must pass a filename to create_bookmarks_html function");
+ }
+ remove_bookmarks_html();
+ let bookmarksHTMLFile = gTestDir.clone();
+ bookmarksHTMLFile.append(aFilename);
+ Assert.ok(bookmarksHTMLFile.exists());
+ bookmarksHTMLFile.copyTo(gProfD, FILENAME_BOOKMARKS_HTML);
+ let profileBookmarksHTMLFile = gProfD.clone();
+ profileBookmarksHTMLFile.append(FILENAME_BOOKMARKS_HTML);
+ Assert.ok(profileBookmarksHTMLFile.exists());
+ return profileBookmarksHTMLFile;
+}
+
+/**
+ * Remove bookmarks.html file from the profile folder.
+ */
+function remove_bookmarks_html() {
+ let profileBookmarksHTMLFile = gProfD.clone();
+ profileBookmarksHTMLFile.append(FILENAME_BOOKMARKS_HTML);
+ if (profileBookmarksHTMLFile.exists()) {
+ profileBookmarksHTMLFile.remove(false);
+ Assert.ok(!profileBookmarksHTMLFile.exists());
+ }
+}
+
+/**
+ * Check bookmarks.html file exists in the profile folder.
+ *
+ * @return nsIFile object for the file.
+ */
+function check_bookmarks_html() {
+ let profileBookmarksHTMLFile = gProfD.clone();
+ profileBookmarksHTMLFile.append(FILENAME_BOOKMARKS_HTML);
+ Assert.ok(profileBookmarksHTMLFile.exists());
+ return profileBookmarksHTMLFile;
+}
+
+/**
+ * Creates a JSON backup in the profile folder folder from a given source file.
+ *
+ * @param aFilename
+ * Name of the file to copy to the profile folder. This file must
+ * exist in the directory that contains the test files.
+ *
+ * @return nsIFile object for the file.
+ */
+function create_JSON_backup(aFilename) {
+ if (!aFilename) {
+ do_throw("you must pass a filename to create_JSON_backup function");
+ }
+ let bookmarksBackupDir = gProfD.clone();
+ bookmarksBackupDir.append("bookmarkbackups");
+ if (!bookmarksBackupDir.exists()) {
+ bookmarksBackupDir.create(Ci.nsIFile.DIRECTORY_TYPE, parseInt("0755", 8));
+ Assert.ok(bookmarksBackupDir.exists());
+ }
+ let profileBookmarksJSONFile = bookmarksBackupDir.clone();
+ profileBookmarksJSONFile.append(FILENAME_BOOKMARKS_JSON);
+ if (profileBookmarksJSONFile.exists()) {
+ profileBookmarksJSONFile.remove();
+ }
+ let bookmarksJSONFile = gTestDir.clone();
+ bookmarksJSONFile.append(aFilename);
+ Assert.ok(bookmarksJSONFile.exists());
+ bookmarksJSONFile.copyTo(bookmarksBackupDir, FILENAME_BOOKMARKS_JSON);
+ profileBookmarksJSONFile = bookmarksBackupDir.clone();
+ profileBookmarksJSONFile.append(FILENAME_BOOKMARKS_JSON);
+ Assert.ok(profileBookmarksJSONFile.exists());
+ return profileBookmarksJSONFile;
+}
+
+/**
+ * Remove bookmarksbackup dir and all backups from the profile folder.
+ */
+function remove_all_JSON_backups() {
+ let bookmarksBackupDir = gProfD.clone();
+ bookmarksBackupDir.append("bookmarkbackups");
+ if (bookmarksBackupDir.exists()) {
+ bookmarksBackupDir.remove(true);
+ Assert.ok(!bookmarksBackupDir.exists());
+ }
+}
+
+/**
+ * Check a JSON backup file for today exists in the profile folder.
+ *
+ * @param aIsAutomaticBackup The boolean indicates whether it's an automatic
+ * backup.
+ * @return nsIFile object for the file.
+ */
+function check_JSON_backup(aIsAutomaticBackup) {
+ let profileBookmarksJSONFile;
+ if (aIsAutomaticBackup) {
+ let bookmarksBackupDir = gProfD.clone();
+ bookmarksBackupDir.append("bookmarkbackups");
+ let files = bookmarksBackupDir.directoryEntries;
+ while (files.hasMoreElements()) {
+ let entry = files.nextFile;
+ if (PlacesBackups.filenamesRegex.test(entry.leafName)) {
+ profileBookmarksJSONFile = entry;
+ break;
+ }
+ }
+ } else {
+ profileBookmarksJSONFile = gProfD.clone();
+ profileBookmarksJSONFile.append("bookmarkbackups");
+ profileBookmarksJSONFile.append(FILENAME_BOOKMARKS_JSON);
+ }
+ Assert.ok(profileBookmarksJSONFile.exists());
+ return profileBookmarksJSONFile;
+}
+
+/**
+ * Returns the hidden status of a url.
+ *
+ * @param aURI
+ * The URI or spec to get hidden for.
+ * @return @return true if the url is hidden, false otherwise.
+ */
+function isUrlHidden(aURI) {
+ let url = aURI instanceof Ci.nsIURI ? aURI.spec : aURI;
+ let stmt = DBConn().createStatement(
+ "SELECT hidden FROM moz_places WHERE url_hash = hash(?1) AND url = ?1"
+ );
+ stmt.bindByIndex(0, url);
+ if (!stmt.executeStep()) {
+ throw new Error("No result for hidden.");
+ }
+ let hidden = stmt.getInt32(0);
+ stmt.finalize();
+
+ return !!hidden;
+}
+
+/**
+ * Compares two times in usecs, considering eventual platform timers skews.
+ *
+ * @param aTimeBefore
+ * The older time in usecs.
+ * @param aTimeAfter
+ * The newer time in usecs.
+ * @return true if times are ordered, false otherwise.
+ */
+function is_time_ordered(before, after) {
+ // Windows has an estimated 16ms timers precision, since Date.now() and
+ // PR_Now() use different code atm, the results can be unordered by this
+ // amount of time. See bug 558745 and bug 557406.
+ let isWindows = "@mozilla.org/windows-registry-key;1" in Cc;
+ // Just to be safe we consider 20ms.
+ let skew = isWindows ? 20000000 : 0;
+ return after - before > -skew;
+}
+
+/**
+ * Shutdowns Places, invoking the callback when the connection has been closed.
+ *
+ * @param aCallback
+ * Function to be called when done.
+ */
+function waitForConnectionClosed(aCallback) {
+ promiseTopicObserved("places-connection-closed").then(aCallback);
+ shutdownPlaces();
+}
+
+/**
+ * Tests if a given guid is valid for use in Places or not.
+ *
+ * @param aGuid
+ * The guid to test.
+ * @param [optional] aStack
+ * The stack frame used to report the error.
+ */
+function do_check_valid_places_guid(aGuid) {
+ Assert.ok(/^[a-zA-Z0-9\-_]{12}$/.test(aGuid), "Should be a valid GUID");
+}
+
+/**
+ * Tests that a guid was set in moz_places for a given uri.
+ *
+ * @param aURI
+ * The uri to check.
+ * @param [optional] aGUID
+ * The expected guid in the database.
+ */
+async function check_guid_for_uri(aURI, aGUID) {
+ let guid = await PlacesTestUtils.getDatabaseValue("moz_places", "guid", {
+ url: aURI,
+ });
+ if (aGUID) {
+ do_check_valid_places_guid(aGUID);
+ Assert.equal(guid, aGUID, "Should have a guid in moz_places for the URI");
+ }
+}
+
+/**
+ * Tests that a guid was set in moz_places for a given bookmark.
+ *
+ * @param aId
+ * The bookmark id to check.
+ * @param [optional] aGUID
+ * The expected guid in the database.
+ */
+async function check_guid_for_bookmark(aId, aGUID) {
+ let guid = await PlacesTestUtils.getDatabaseValue("moz_bookmarks", "guid", {
+ id: aId,
+ });
+ if (aGUID) {
+ do_check_valid_places_guid(aGUID);
+ Assert.equal(guid, aGUID, "Should have the correct GUID for the bookmark");
+ }
+}
+
+/**
+ * Compares 2 arrays returning whether they contains the same elements.
+ *
+ * @param a1
+ * First array to compare.
+ * @param a2
+ * Second array to compare.
+ * @param [optional] sorted
+ * Whether the comparison should take in count position of the elements.
+ * @return true if the arrays contain the same elements, false otherwise.
+ */
+function do_compare_arrays(a1, a2, sorted) {
+ if (a1.length != a2.length) {
+ return false;
+ }
+
+ if (sorted) {
+ return a1.every((e, i) => e == a2[i]);
+ }
+ return (
+ !a1.filter(e => !a2.includes(e)).length &&
+ !a2.filter(e => !a1.includes(e)).length
+ );
+}
+
+/**
+ * Generic nsINavHistoryResultObserver that doesn't implement anything, but
+ * provides dummy methods to prevent errors about an object not having a certain
+ * method.
+ */
+function NavHistoryResultObserver() {}
+
+NavHistoryResultObserver.prototype = {
+ batching() {},
+ containerStateChanged() {},
+ invalidateContainer() {},
+ nodeDateAddedChanged() {},
+ nodeHistoryDetailsChanged() {},
+ nodeIconChanged() {},
+ nodeInserted() {},
+ nodeKeywordChanged() {},
+ nodeLastModifiedChanged() {},
+ nodeMoved() {},
+ nodeRemoved() {},
+ nodeTagsChanged() {},
+ nodeTitleChanged() {},
+ nodeURIChanged() {},
+ sortingChanged() {},
+ QueryInterface: ChromeUtils.generateQI(["nsINavHistoryResultObserver"]),
+};
+
+function checkBookmarkObject(info) {
+ do_check_valid_places_guid(info.guid);
+ do_check_valid_places_guid(info.parentGuid);
+ Assert.ok(typeof info.index == "number", "index should be a number");
+ Assert.ok(
+ info.dateAdded.constructor.name == "Date",
+ "dateAdded should be a Date"
+ );
+ Assert.ok(
+ info.lastModified.constructor.name == "Date",
+ "lastModified should be a Date"
+ );
+ Assert.ok(
+ info.lastModified >= info.dateAdded,
+ "lastModified should never be smaller than dateAdded"
+ );
+ Assert.ok(typeof info.type == "number", "type should be a number");
+}
+
+/**
+ * Reads foreign_count value for a given url.
+ */
+async function foreign_count(url) {
+ if (url instanceof Ci.nsIURI) {
+ url = url.spec;
+ }
+ let db = await PlacesUtils.promiseDBConnection();
+ let rows = await db.executeCached(
+ `SELECT foreign_count FROM moz_places
+ WHERE url_hash = hash(:url) AND url = :url
+ `,
+ { url }
+ );
+ return !rows.length ? 0 : rows[0].getResultByName("foreign_count");
+}
+
+function compareAscending(a, b) {
+ if (a > b) {
+ return 1;
+ }
+ if (a < b) {
+ return -1;
+ }
+ return 0;
+}
+
+function sortBy(array, prop) {
+ return array.sort((a, b) => compareAscending(a[prop], b[prop]));
+}
+
+/**
+ * Asynchronously set the favicon associated with a page.
+ * @param page
+ * The page's URL
+ * @param icon
+ * The URL of the favicon to be set.
+ * @param [optional] forceReload
+ * Whether to enforce reloading the icon.
+ */
+function setFaviconForPage(page, icon, forceReload = true) {
+ let pageURI =
+ page instanceof Ci.nsIURI ? page : NetUtil.newURI(new URL(page).href);
+ let iconURI =
+ icon instanceof Ci.nsIURI ? icon : NetUtil.newURI(new URL(icon).href);
+ return new Promise(resolve => {
+ PlacesUtils.favicons.setAndFetchFaviconForPage(
+ pageURI,
+ iconURI,
+ forceReload,
+ PlacesUtils.favicons.FAVICON_LOAD_NON_PRIVATE,
+ resolve,
+ Services.scriptSecurityManager.getSystemPrincipal()
+ );
+ });
+}
+
+function getFaviconUrlForPage(page, width = 0) {
+ let pageURI =
+ page instanceof Ci.nsIURI ? page : NetUtil.newURI(new URL(page).href);
+ return new Promise((resolve, reject) => {
+ PlacesUtils.favicons.getFaviconURLForPage(
+ pageURI,
+ iconURI => {
+ if (iconURI) {
+ resolve(iconURI.spec);
+ } else {
+ reject("Unable to find an icon for " + pageURI.spec);
+ }
+ },
+ width
+ );
+ });
+}
+
+function getFaviconDataForPage(page, width = 0) {
+ let pageURI =
+ page instanceof Ci.nsIURI ? page : NetUtil.newURI(new URL(page).href);
+ return new Promise(resolve => {
+ PlacesUtils.favicons.getFaviconDataForPage(
+ pageURI,
+ (iconUri, len, data, mimeType) => {
+ resolve({ data, mimeType });
+ },
+ width
+ );
+ });
+}
+
+/**
+ * Asynchronously compares contents from 2 favicon urls.
+ */
+async function compareFavicons(icon1, icon2, msg) {
+ icon1 = new URL(icon1 instanceof Ci.nsIURI ? icon1.spec : icon1);
+ icon2 = new URL(icon2 instanceof Ci.nsIURI ? icon2.spec : icon2);
+
+ function getIconData(icon) {
+ return new Promise((resolve, reject) => {
+ NetUtil.asyncFetch(
+ {
+ uri: icon.href,
+ loadUsingSystemPrincipal: true,
+ contentPolicyType: Ci.nsIContentPolicy.TYPE_INTERNAL_IMAGE_FAVICON,
+ },
+ function (inputStream, status) {
+ if (!Components.isSuccessCode(status)) {
+ reject();
+ }
+ let size = inputStream.available();
+ resolve(NetUtil.readInputStreamToString(inputStream, size));
+ }
+ );
+ });
+ }
+
+ let data1 = await getIconData(icon1);
+ Assert.ok(!!data1.length, "Should fetch icon data");
+ let data2 = await getIconData(icon2);
+ Assert.ok(!!data2.length, "Should fetch icon data");
+ Assert.deepEqual(data1, data2, msg);
+}
+
+/**
+ * Get the internal "root" folder name for an item, specified by its itemGuid.
+ * If the itemGuid does not point to a root folder, null is returned.
+ *
+ * @param itemGuid
+ * the item guid.
+ * @return the internal-root name for the root folder, if itemGuid points
+ * to such folder, null otherwise.
+ */
+function mapItemGuidToInternalRootName(itemGuid) {
+ switch (itemGuid) {
+ case PlacesUtils.bookmarks.rootGuid:
+ return "placesRoot";
+ case PlacesUtils.bookmarks.menuGuid:
+ return "bookmarksMenuFolder";
+ case PlacesUtils.bookmarks.toolbarGuid:
+ return "toolbarFolder";
+ case PlacesUtils.bookmarks.unfiledGuid:
+ return "unfiledBookmarksFolder";
+ case PlacesUtils.bookmarks.mobileGuid:
+ return "mobileFolder";
+ }
+ return null;
+}
+
+const DB_FILENAME = "places.sqlite";
+
+/**
+ * Sets the database to use for the given test. This should be the very first
+ * thing in the test, otherwise this database will not be used!
+ *
+ * @param {string|string[]} path
+ * A filename or path to a database. The database must exist.
+ * If this is a string, then this is assumed to be a filename in the
+ * directory where the test calling this is located.
+ * If this is an array, this is assumed to be a path relative to the
+ * directory that this file, head_common.js, is located.
+ * @param {string} destFileName
+ * The destination filename to copy the database to.
+ * @return {Promise} the final path to the database
+ */
+async function setupPlacesDatabase(path, destFileName = DB_FILENAME) {
+ let currentDir = do_get_cwd().path;
+
+ if (typeof path == "string") {
+ path = [path];
+ } else {
+ currentDir = PathUtils.parent(currentDir);
+ }
+ let src = PathUtils.join(currentDir, ...path);
+ Assert.ok(await IOUtils.exists(src), "Database file found");
+
+ // Ensure that our database doesn't already exist.
+ let dest = PathUtils.join(PathUtils.profileDir, destFileName);
+ Assert.ok(
+ !(await IOUtils.exists(dest)),
+ "Database file should not exist yet"
+ );
+
+ await IOUtils.copy(src, dest);
+ return dest;
+}
+
+/**
+ * Gets the URLs of pages that have a particular annotation.
+ *
+ * @param {String} name The name of the annotation to search for.
+ * @return An array of URLs found.
+ */
+function getPagesWithAnnotation(name) {
+ return PlacesUtils.promiseDBConnection().then(async db => {
+ let rows = await db.execute(
+ `
+ SELECT h.url FROM moz_anno_attributes n
+ JOIN moz_annos a ON n.id = a.anno_attribute_id
+ JOIN moz_places h ON h.id = a.place_id
+ WHERE n.name = :name
+ `,
+ { name }
+ );
+
+ return rows.map(row => row.getResultByName("url"));
+ });
+}
+
+/**
+ * Checks there are no orphan page annotations in the database, and no
+ * orphan anno attribute names.
+ */
+async function assertNoOrphanPageAnnotations() {
+ let db = await PlacesUtils.promiseDBConnection();
+
+ let rows = await db.execute(`
+ SELECT place_id FROM moz_annos
+ WHERE place_id NOT IN (SELECT id FROM moz_places)
+ `);
+
+ Assert.equal(rows.length, 0, "Should not have any orphan page annotations");
+
+ rows = await db.execute(`
+ SELECT id FROM moz_anno_attributes
+ WHERE id NOT IN (SELECT anno_attribute_id FROM moz_annos) AND
+ id NOT IN (SELECT anno_attribute_id FROM moz_items_annos)`);
+}
diff --git a/toolkit/components/places/tests/history/head_history.js b/toolkit/components/places/tests/history/head_history.js
new file mode 100644
index 0000000000..4adce13cce
--- /dev/null
+++ b/toolkit/components/places/tests/history/head_history.js
@@ -0,0 +1,13 @@
+/* -*- indent-tabs-mode: nil; js-indent-level: 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/. */
+
+// Import common head.
+{
+ /* import-globals-from ../head_common.js */
+ let commonFile = do_get_file("../head_common.js", false);
+ let uri = Services.io.newFileURI(commonFile);
+ Services.scriptloader.loadSubScript(uri.spec, this);
+}
diff --git a/toolkit/components/places/tests/history/test_async_history_api.js b/toolkit/components/places/tests/history/test_async_history_api.js
new file mode 100644
index 0000000000..ce0d96b306
--- /dev/null
+++ b/toolkit/components/places/tests/history/test_async_history_api.js
@@ -0,0 +1,1349 @@
+/**
+ * This file tests the async history API exposed by mozIAsyncHistory.
+ */
+
+// Globals
+
+XPCOMUtils.defineLazyServiceGetter(
+ this,
+ "asyncHistory",
+ "@mozilla.org/browser/history;1",
+ "mozIAsyncHistory"
+);
+
+const TEST_DOMAIN = "http://mozilla.org/";
+const URI_VISIT_SAVED = "uri-visit-saved";
+const RECENT_EVENT_THRESHOLD = 15 * 60 * 1000000;
+
+// Helpers
+/**
+ * Object that represents a mozIVisitInfo object.
+ *
+ * @param [optional] aTransitionType
+ * The transition type of the visit. Defaults to TRANSITION_LINK if not
+ * provided.
+ * @param [optional] aVisitTime
+ * The time of the visit. Defaults to now if not provided.
+ */
+function VisitInfo(aTransitionType, aVisitTime) {
+ this.transitionType =
+ aTransitionType === undefined ? TRANSITION_LINK : aTransitionType;
+ this.visitDate = aVisitTime || Date.now() * 1000;
+}
+
+function promiseUpdatePlaces(aPlaces, aOptions = {}) {
+ return new Promise((resolve, reject) => {
+ asyncHistory.updatePlaces(
+ aPlaces,
+ Object.assign(
+ {
+ _errors: [],
+ _results: [],
+ handleError(aResultCode, aPlace) {
+ this._errors.push({ resultCode: aResultCode, info: aPlace });
+ },
+ handleResult(aPlace) {
+ this._results.push(aPlace);
+ },
+ handleCompletion(resultCount) {
+ resolve({
+ errors: this._errors,
+ results: this._results,
+ resultCount,
+ });
+ },
+ },
+ aOptions
+ )
+ );
+ });
+}
+
+/**
+ * Listens for a title change notification, and calls aCallback when it gets it.
+ */
+class TitleChangedObserver {
+ /**
+ * Constructor.
+ *
+ * @param aURI
+ * The URI of the page we expect a notification for.
+ * @param aExpectedTitle
+ * The expected title of the URI we expect a notification for.
+ * @param aCallback
+ * The method to call when we have gotten the proper notification about
+ * the title changing.
+ */
+ constructor(aURI, aExpectedTitle, aCallback) {
+ this.uri = aURI;
+ this.expectedTitle = aExpectedTitle;
+ this.callback = aCallback;
+ this.handlePlacesEvent = this.handlePlacesEvent.bind(this);
+ PlacesObservers.addListener(["page-title-changed"], this.handlePlacesEvent);
+ }
+
+ async handlePlacesEvent(aEvents) {
+ info("'page-title-changed'!!!");
+ Assert.equal(aEvents.length, 1, "Right number of title changed notified");
+ Assert.equal(aEvents[0].type, "page-title-changed");
+ if (this.uri.spec !== aEvents[0].url) {
+ return;
+ }
+ Assert.equal(aEvents[0].title, this.expectedTitle);
+ await check_guid_for_uri(this.uri, aEvents[0].pageGuid);
+ this.callback();
+
+ PlacesObservers.removeListener(
+ ["page-title-changed"],
+ this.handlePlacesEvent
+ );
+ }
+}
+
+/**
+ * Listens for a visit notification, and calls aCallback when it gets it.
+ */
+class VisitObserver {
+ constructor(aURI, aGUID, aCallback) {
+ this.uri = aURI;
+ this.guid = aGUID;
+ this.callback = aCallback;
+ this.handlePlacesEvent = this.handlePlacesEvent.bind(this);
+ PlacesObservers.addListener(["page-visited"], this.handlePlacesEvent);
+ }
+
+ handlePlacesEvent(aEvents) {
+ info("'page-visited'!!!");
+ Assert.equal(aEvents.length, 1, "Right number of visits notified");
+ Assert.equal(aEvents[0].type, "page-visited");
+ let {
+ url,
+ visitId,
+ visitTime,
+ referringVisitId,
+ transitionType,
+ pageGuid,
+ hidden,
+ visitCount,
+ typedCount,
+ lastKnownTitle,
+ } = aEvents[0];
+ let args = [
+ visitId,
+ visitTime,
+ referringVisitId,
+ transitionType,
+ pageGuid,
+ hidden,
+ visitCount,
+ typedCount,
+ lastKnownTitle,
+ ];
+ info("'page-visited' (" + url + args.join(", ") + ")");
+ if (this.uri.spec != url || this.guid != pageGuid) {
+ return;
+ }
+ this.callback(visitTime * 1000, transitionType, lastKnownTitle);
+
+ PlacesObservers.removeListener(["page-visited"], this.handlePlacesEvent);
+ }
+}
+
+/**
+ * Tests that a title was set properly in the database.
+ *
+ * @param aURI
+ * The uri to check.
+ * @param aTitle
+ * The expected title in the database.
+ */
+function do_check_title_for_uri(aURI, aTitle) {
+ let stmt = DBConn().createStatement(
+ `SELECT title
+ FROM moz_places
+ WHERE url_hash = hash(:url) AND url = :url`
+ );
+ stmt.params.url = aURI.spec;
+ Assert.ok(stmt.executeStep());
+ Assert.equal(stmt.row.title, aTitle);
+ stmt.finalize();
+}
+
+// Test Functions
+
+add_task(async function test_interface_exists() {
+ let history = Cc["@mozilla.org/browser/history;1"].getService(Ci.nsISupports);
+ Assert.ok(history instanceof Ci.mozIAsyncHistory);
+});
+
+add_task(async function test_invalid_uri_throws() {
+ // First, test passing in nothing.
+ let place = {
+ visits: [new VisitInfo()],
+ };
+ try {
+ await promiseUpdatePlaces(place);
+ do_throw("Should have thrown!");
+ } catch (e) {
+ Assert.equal(e.result, Cr.NS_ERROR_INVALID_ARG);
+ }
+
+ // Now, test other bogus things.
+ const TEST_VALUES = [
+ null,
+ undefined,
+ {},
+ [],
+ TEST_DOMAIN + "test_invalid_id_throws",
+ ];
+ for (let i = 0; i < TEST_VALUES.length; i++) {
+ place.uri = TEST_VALUES[i];
+ try {
+ await promiseUpdatePlaces(place);
+ do_throw("Should have thrown!");
+ } catch (e) {
+ Assert.equal(e.result, Cr.NS_ERROR_INVALID_ARG);
+ }
+ }
+});
+
+add_task(async function test_invalid_places_throws() {
+ // First, test passing in nothing.
+ try {
+ asyncHistory.updatePlaces();
+ do_throw("Should have thrown!");
+ } catch (e) {
+ Assert.equal(e.result, Cr.NS_ERROR_XPC_NOT_ENOUGH_ARGS);
+ }
+
+ // Now, test other bogus things.
+ const TEST_VALUES = [null, undefined, {}, [], ""];
+ for (let i = 0; i < TEST_VALUES.length; i++) {
+ let value = TEST_VALUES[i];
+ try {
+ await promiseUpdatePlaces(value);
+ do_throw("Should have thrown!");
+ } catch (e) {
+ Assert.equal(e.result, Cr.NS_ERROR_INVALID_ARG);
+ }
+ }
+});
+
+add_task(async function test_invalid_guid_throws() {
+ // First check invalid length guid.
+ let place = {
+ guid: "BAD_GUID",
+ uri: NetUtil.newURI(TEST_DOMAIN + "test_invalid_guid_throws"),
+ visits: [new VisitInfo()],
+ };
+ try {
+ await promiseUpdatePlaces(place);
+ do_throw("Should have thrown!");
+ } catch (e) {
+ Assert.equal(e.result, Cr.NS_ERROR_INVALID_ARG);
+ }
+
+ // Now check invalid character guid.
+ place.guid = "__BADGUID+__";
+ Assert.equal(place.guid.length, 12);
+ try {
+ await promiseUpdatePlaces(place);
+ do_throw("Should have thrown!");
+ } catch (e) {
+ Assert.equal(e.result, Cr.NS_ERROR_INVALID_ARG);
+ }
+});
+
+add_task(async function test_no_visits_throws() {
+ const TEST_URI = NetUtil.newURI(
+ TEST_DOMAIN + "test_no_id_or_guid_no_visits_throws"
+ );
+ const TEST_GUID = "_RANDOMGUID_";
+
+ let log_test_conditions = function (aPlace) {
+ let str =
+ "Testing place with " +
+ (aPlace.uri ? "uri" : "no uri") +
+ ", " +
+ (aPlace.guid ? "guid" : "no guid") +
+ ", " +
+ (aPlace.visits ? "visits array" : "no visits array");
+ info(str);
+ };
+
+ // Loop through every possible case. Note that we don't actually care about
+ // the case where we have no uri, place id, or guid (covered by another test),
+ // but it is easier to just make sure it too throws than to exclude it.
+ let place = {};
+ for (let uri = 1; uri >= 0; uri--) {
+ place.uri = uri ? TEST_URI : undefined;
+
+ for (let guid = 1; guid >= 0; guid--) {
+ place.guid = guid ? TEST_GUID : undefined;
+
+ for (let visits = 1; visits >= 0; visits--) {
+ place.visits = visits ? [] : undefined;
+
+ log_test_conditions(place);
+ try {
+ await promiseUpdatePlaces(place);
+ do_throw("Should have thrown!");
+ } catch (e) {
+ Assert.equal(e.result, Cr.NS_ERROR_INVALID_ARG);
+ }
+ }
+ }
+ }
+});
+
+add_task(async function test_add_visit_no_date_throws() {
+ let place = {
+ uri: NetUtil.newURI(TEST_DOMAIN + "test_add_visit_no_date_throws"),
+ visits: [new VisitInfo()],
+ };
+ delete place.visits[0].visitDate;
+ try {
+ await promiseUpdatePlaces(place);
+ do_throw("Should have thrown!");
+ } catch (e) {
+ Assert.equal(e.result, Cr.NS_ERROR_INVALID_ARG);
+ }
+});
+
+add_task(async function test_add_visit_no_transitionType_throws() {
+ let place = {
+ uri: NetUtil.newURI(
+ TEST_DOMAIN + "test_add_visit_no_transitionType_throws"
+ ),
+ visits: [new VisitInfo()],
+ };
+ delete place.visits[0].transitionType;
+ try {
+ await promiseUpdatePlaces(place);
+ do_throw("Should have thrown!");
+ } catch (e) {
+ Assert.equal(e.result, Cr.NS_ERROR_INVALID_ARG);
+ }
+});
+
+add_task(async function test_add_visit_invalid_transitionType_throws() {
+ // First, test something that has a transition type lower than the first one.
+ let place = {
+ uri: NetUtil.newURI(
+ TEST_DOMAIN + "test_add_visit_invalid_transitionType_throws"
+ ),
+ visits: [new VisitInfo(TRANSITION_LINK - 1)],
+ };
+ try {
+ await promiseUpdatePlaces(place);
+ do_throw("Should have thrown!");
+ } catch (e) {
+ Assert.equal(e.result, Cr.NS_ERROR_INVALID_ARG);
+ }
+
+ // Now, test something that has a transition type greater than the last one.
+ place.visits[0] = new VisitInfo(TRANSITION_RELOAD + 1);
+ try {
+ await promiseUpdatePlaces(place);
+ do_throw("Should have thrown!");
+ } catch (e) {
+ Assert.equal(e.result, Cr.NS_ERROR_INVALID_ARG);
+ }
+});
+
+add_task(async function test_non_addable_uri_errors() {
+ // Array of protocols that nsINavHistoryService::canAddURI returns false for.
+ const URLS = [
+ "about:config",
+ "imap://cyrus.andrew.cmu.edu/archive.imap",
+ "news://new.mozilla.org/mozilla.dev.apps.firefox",
+ "mailbox:Inbox",
+ "cached-favicon:http://mozilla.org/made-up-favicon",
+ "view-source:http://mozilla.org",
+ "chrome://browser/content/browser.xhtml",
+ "resource://gre-resources/hiddenWindow.html",
+ "data:,Hello%2C%20World!",
+ "javascript:alert('hello wolrd!');",
+ "blob:foo",
+ "moz-extension://f49fb5b3-a1e7-cd41-85e1-d61a3950f5e4/index.html",
+ ];
+ let places = [];
+ URLS.forEach(function (url) {
+ try {
+ let place = {
+ uri: NetUtil.newURI(url),
+ title: "test for " + url,
+ visits: [new VisitInfo()],
+ };
+ places.push(place);
+ } catch (e) {
+ if (e.result != Cr.NS_ERROR_FAILURE) {
+ throw e;
+ }
+ // NetUtil.newURI() can throw if e.g. our app knows about imap://
+ // but the account is not set up and so the URL is invalid for us.
+ // Note this in the log but ignore as it's not the subject of this test.
+ info("Could not construct URI for '" + url + "'; ignoring");
+ }
+ });
+
+ let placesResult = await promiseUpdatePlaces(places);
+ if (placesResult.results.length) {
+ do_throw("Unexpected success.");
+ }
+ for (let place of placesResult.errors) {
+ info("Checking '" + place.info.uri.spec + "'");
+ Assert.equal(place.resultCode, Cr.NS_ERROR_INVALID_ARG);
+ Assert.equal(false, await PlacesUtils.history.hasVisits(place.info.uri));
+ }
+ await PlacesTestUtils.promiseAsyncUpdates();
+});
+
+add_task(async function test_duplicate_guid_errors() {
+ // This test ensures that trying to add a visit, with a guid already found in
+ // another visit, fails.
+ let place = {
+ uri: NetUtil.newURI(TEST_DOMAIN + "test_duplicate_guid_fails_first"),
+ visits: [new VisitInfo()],
+ };
+
+ Assert.equal(false, await PlacesUtils.history.hasVisits(place.uri));
+ let placesResult = await promiseUpdatePlaces(place);
+ if (placesResult.errors.length) {
+ do_throw("Unexpected error.");
+ }
+ let placeInfo = placesResult.results[0];
+ Assert.ok(await PlacesUtils.history.hasVisits(placeInfo.uri));
+
+ let badPlace = {
+ uri: NetUtil.newURI(TEST_DOMAIN + "test_duplicate_guid_fails_second"),
+ visits: [new VisitInfo()],
+ guid: placeInfo.guid,
+ };
+
+ Assert.equal(false, await PlacesUtils.history.hasVisits(badPlace.uri));
+ placesResult = await promiseUpdatePlaces(badPlace);
+ if (placesResult.results.length) {
+ do_throw("Unexpected success.");
+ }
+ let badPlaceInfo = placesResult.errors[0];
+ Assert.equal(badPlaceInfo.resultCode, Cr.NS_ERROR_STORAGE_CONSTRAINT);
+ Assert.equal(
+ false,
+ await PlacesUtils.history.hasVisits(badPlaceInfo.info.uri)
+ );
+
+ await PlacesTestUtils.promiseAsyncUpdates();
+});
+
+add_task(async function test_invalid_referrerURI_ignored() {
+ let place = {
+ uri: NetUtil.newURI(TEST_DOMAIN + "test_invalid_referrerURI_ignored"),
+ visits: [new VisitInfo()],
+ };
+ place.visits[0].referrerURI = NetUtil.newURI(
+ place.uri.spec + "_unvisistedURI"
+ );
+ Assert.equal(false, await PlacesUtils.history.hasVisits(place.uri));
+ Assert.equal(
+ false,
+ await PlacesUtils.history.hasVisits(place.visits[0].referrerURI)
+ );
+
+ let placesResult = await promiseUpdatePlaces(place);
+ if (placesResult.errors.length) {
+ do_throw("Unexpected error.");
+ }
+ let placeInfo = placesResult.results[0];
+ Assert.ok(await PlacesUtils.history.hasVisits(placeInfo.uri));
+
+ // Check to make sure we do not visit the invalid referrer.
+ Assert.equal(
+ false,
+ await PlacesUtils.history.hasVisits(place.visits[0].referrerURI)
+ );
+
+ // Check to make sure from_visit is zero in database.
+ let stmt = DBConn().createStatement(
+ `SELECT from_visit
+ FROM moz_historyvisits
+ WHERE id = :visit_id`
+ );
+ stmt.params.visit_id = placeInfo.visits[0].visitId;
+ Assert.ok(stmt.executeStep());
+ Assert.equal(stmt.row.from_visit, 0);
+ stmt.finalize();
+
+ await PlacesTestUtils.promiseAsyncUpdates();
+});
+
+add_task(async function test_nonnsIURI_referrerURI_ignored() {
+ let place = {
+ uri: NetUtil.newURI(TEST_DOMAIN + "test_nonnsIURI_referrerURI_ignored"),
+ visits: [new VisitInfo()],
+ };
+ place.visits[0].referrerURI = place.uri.spec + "_nonnsIURI";
+ Assert.equal(false, await PlacesUtils.history.hasVisits(place.uri));
+
+ let placesResult = await promiseUpdatePlaces(place);
+ if (placesResult.errors.length) {
+ do_throw("Unexpected error.");
+ }
+ let placeInfo = placesResult.results[0];
+ Assert.ok(await PlacesUtils.history.hasVisits(placeInfo.uri));
+
+ // Check to make sure from_visit is zero in database.
+ let stmt = DBConn().createStatement(
+ `SELECT from_visit
+ FROM moz_historyvisits
+ WHERE id = :visit_id`
+ );
+ stmt.params.visit_id = placeInfo.visits[0].visitId;
+ Assert.ok(stmt.executeStep());
+ Assert.equal(stmt.row.from_visit, 0);
+ stmt.finalize();
+
+ await PlacesTestUtils.promiseAsyncUpdates();
+});
+
+add_task(async function test_old_referrer_ignored() {
+ // This tests that a referrer for a visit which is not recent (specifically,
+ // older than 15 minutes as per RECENT_EVENT_THRESHOLD) is not saved by
+ // updatePlaces.
+ let oldTime = Date.now() * 1000 - (RECENT_EVENT_THRESHOLD + 1);
+ let referrerPlace = {
+ uri: NetUtil.newURI(TEST_DOMAIN + "test_old_referrer_ignored_referrer"),
+ visits: [new VisitInfo(TRANSITION_LINK, oldTime)],
+ };
+
+ // First we must add our referrer to the history so that it is not ignored
+ // as being invalid.
+ Assert.equal(false, await PlacesUtils.history.hasVisits(referrerPlace.uri));
+ let placesResult = await promiseUpdatePlaces(referrerPlace);
+ if (placesResult.errors.length) {
+ do_throw("Unexpected error.");
+ }
+
+ // Now that the referrer is added, we can add a page with a valid
+ // referrer to determine if the recency of the referrer is taken into
+ // account.
+ Assert.ok(await PlacesUtils.history.hasVisits(referrerPlace.uri));
+
+ let visitInfo = new VisitInfo();
+ visitInfo.referrerURI = referrerPlace.uri;
+ let place = {
+ uri: NetUtil.newURI(TEST_DOMAIN + "test_old_referrer_ignored_page"),
+ visits: [visitInfo],
+ };
+
+ Assert.equal(false, await PlacesUtils.history.hasVisits(place.uri));
+ placesResult = await promiseUpdatePlaces(place);
+ if (placesResult.errors.length) {
+ do_throw("Unexpected error.");
+ }
+ let placeInfo = placesResult.results[0];
+ Assert.ok(await PlacesUtils.history.hasVisits(place.uri));
+
+ // Though the visit will not contain the referrer, we must examine the
+ // database to be sure.
+ Assert.equal(placeInfo.visits[0].referrerURI, null);
+ let stmt = DBConn().createStatement(
+ `SELECT COUNT(1) AS count
+ FROM moz_historyvisits
+ JOIN moz_places h ON h.id = place_id
+ WHERE url_hash = hash(:page_url) AND url = :page_url
+ AND from_visit = 0`
+ );
+ stmt.params.page_url = place.uri.spec;
+ Assert.ok(stmt.executeStep());
+ Assert.equal(stmt.row.count, 1);
+ stmt.finalize();
+
+ await PlacesTestUtils.promiseAsyncUpdates();
+});
+
+add_task(async function test_place_id_ignored() {
+ let place = {
+ uri: NetUtil.newURI(TEST_DOMAIN + "test_place_id_ignored_first"),
+ visits: [new VisitInfo()],
+ };
+
+ Assert.equal(false, await PlacesUtils.history.hasVisits(place.uri));
+ let placesResult = await promiseUpdatePlaces(place);
+ if (placesResult.errors.length) {
+ do_throw("Unexpected error.");
+ }
+ let placeInfo = placesResult.results[0];
+ Assert.ok(await PlacesUtils.history.hasVisits(place.uri));
+
+ let placeId = placeInfo.placeId;
+ Assert.notEqual(placeId, 0);
+
+ let badPlace = {
+ uri: NetUtil.newURI(TEST_DOMAIN + "test_place_id_ignored_second"),
+ visits: [new VisitInfo()],
+ placeId,
+ };
+
+ Assert.equal(false, await PlacesUtils.history.hasVisits(badPlace.uri));
+ placesResult = await promiseUpdatePlaces(badPlace);
+ if (placesResult.errors.length) {
+ do_throw("Unexpected error.");
+ }
+ placeInfo = placesResult.results[0];
+
+ Assert.notEqual(placeInfo.placeId, placeId);
+ Assert.ok(await PlacesUtils.history.hasVisits(badPlace.uri));
+
+ await PlacesTestUtils.promiseAsyncUpdates();
+});
+
+add_task(async function test_handleCompletion_called_when_complete() {
+ // We test a normal visit, and embeded visit, and a uri that would fail
+ // the canAddURI test to make sure that the notification happens after *all*
+ // of them have had a callback.
+ let places = [
+ {
+ uri: NetUtil.newURI(
+ TEST_DOMAIN + "test_handleCompletion_called_when_complete"
+ ),
+ visits: [new VisitInfo(), new VisitInfo(TRANSITION_EMBED)],
+ },
+ {
+ uri: NetUtil.newURI("data:,Hello%2C%20World!"),
+ visits: [new VisitInfo()],
+ },
+ ];
+ Assert.equal(false, await PlacesUtils.history.hasVisits(places[0].uri));
+ Assert.equal(false, await PlacesUtils.history.hasVisits(places[1].uri));
+
+ const EXPECTED_COUNT_SUCCESS = 2;
+ const EXPECTED_COUNT_FAILURE = 1;
+
+ let { results, errors } = await promiseUpdatePlaces(places);
+
+ Assert.equal(results.length, EXPECTED_COUNT_SUCCESS);
+ Assert.equal(errors.length, EXPECTED_COUNT_FAILURE);
+
+ await PlacesTestUtils.promiseAsyncUpdates();
+});
+
+add_task(async function test_add_visit() {
+ const VISIT_TIME = Date.now() * 1000;
+ let place = {
+ uri: NetUtil.newURI(TEST_DOMAIN + "test_add_visit"),
+ title: "test_add_visit title",
+ visits: [],
+ };
+ for (let t in PlacesUtils.history.TRANSITIONS) {
+ if (t == "EMBED") {
+ continue;
+ }
+ let transitionType = PlacesUtils.history.TRANSITIONS[t];
+ place.visits.push(new VisitInfo(transitionType, VISIT_TIME));
+ }
+ Assert.equal(false, await PlacesUtils.history.hasVisits(place.uri));
+
+ let callbackCount = 0;
+ let placesResult = await promiseUpdatePlaces(place);
+ if (placesResult.errors.length) {
+ do_throw("Unexpected error.");
+ }
+ for (let placeInfo of placesResult.results) {
+ Assert.ok(await PlacesUtils.history.hasVisits(place.uri));
+
+ // Check mozIPlaceInfo properties.
+ Assert.ok(place.uri.equals(placeInfo.uri));
+ Assert.equal(placeInfo.frecency, -1); // We don't pass frecency here!
+ Assert.equal(placeInfo.title, place.title);
+
+ // Check mozIVisitInfo properties.
+ let visits = placeInfo.visits;
+ Assert.equal(visits.length, 1);
+ let visit = visits[0];
+ Assert.equal(visit.visitDate, VISIT_TIME);
+ Assert.ok(
+ Object.values(PlacesUtils.history.TRANSITIONS).includes(
+ visit.transitionType
+ )
+ );
+ Assert.ok(visit.referrerURI === null);
+
+ // For TRANSITION_EMBED visits, many properties will always be zero or
+ // undefined.
+ if (visit.transitionType == TRANSITION_EMBED) {
+ // Check mozIPlaceInfo properties.
+ Assert.equal(placeInfo.placeId, 0, "//");
+ Assert.equal(placeInfo.guid, null);
+
+ // Check mozIVisitInfo properties.
+ Assert.equal(visit.visitId, 0);
+ } else {
+ // But they should be valid for non-embed visits.
+ // Check mozIPlaceInfo properties.
+ Assert.ok(placeInfo.placeId > 0);
+ do_check_valid_places_guid(placeInfo.guid);
+
+ // Check mozIVisitInfo properties.
+ Assert.ok(visit.visitId > 0);
+ }
+
+ // If we have had all of our callbacks, continue running tests.
+ if (++callbackCount == place.visits.length) {
+ await PlacesTestUtils.promiseAsyncUpdates();
+ }
+ }
+});
+
+add_task(async function test_properties_saved() {
+ // Check each transition type to make sure it is saved properly.
+ let places = [];
+ for (let t in PlacesUtils.history.TRANSITIONS) {
+ if (t == "EMBED") {
+ continue;
+ }
+ let transitionType = PlacesUtils.history.TRANSITIONS[t];
+ let place = {
+ uri: NetUtil.newURI(
+ TEST_DOMAIN + "test_properties_saved/" + transitionType
+ ),
+ title: "test_properties_saved test",
+ visits: [new VisitInfo(transitionType)],
+ };
+ Assert.equal(false, await PlacesUtils.history.hasVisits(place.uri));
+ places.push(place);
+ }
+
+ let callbackCount = 0;
+ let placesResult = await promiseUpdatePlaces(places);
+ if (placesResult.errors.length) {
+ do_throw("Unexpected error.");
+ }
+ for (let placeInfo of placesResult.results) {
+ let uri = placeInfo.uri;
+ Assert.ok(await PlacesUtils.history.hasVisits(uri));
+ let visit = placeInfo.visits[0];
+ print(
+ "TEST-INFO | test_properties_saved | updatePlaces callback for " +
+ "transition type " +
+ visit.transitionType
+ );
+
+ // Note that TRANSITION_EMBED should not be in the database.
+ const EXPECTED_COUNT = visit.transitionType == TRANSITION_EMBED ? 0 : 1;
+
+ // mozIVisitInfo::date
+ let stmt = DBConn().createStatement(
+ `SELECT COUNT(1) AS count
+ FROM moz_places h
+ JOIN moz_historyvisits v
+ ON h.id = v.place_id
+ WHERE h.url_hash = hash(:page_url) AND h.url = :page_url
+ AND v.visit_date = :visit_date`
+ );
+ stmt.params.page_url = uri.spec;
+ stmt.params.visit_date = visit.visitDate;
+ Assert.ok(stmt.executeStep());
+ Assert.equal(stmt.row.count, EXPECTED_COUNT);
+ stmt.finalize();
+
+ // mozIVisitInfo::transitionType
+ stmt = DBConn().createStatement(
+ `SELECT COUNT(1) AS count
+ FROM moz_places h
+ JOIN moz_historyvisits v
+ ON h.id = v.place_id
+ WHERE h.url_hash = hash(:page_url) AND h.url = :page_url
+ AND v.visit_type = :transition_type`
+ );
+ stmt.params.page_url = uri.spec;
+ stmt.params.transition_type = visit.transitionType;
+ Assert.ok(stmt.executeStep());
+ Assert.equal(stmt.row.count, EXPECTED_COUNT);
+ stmt.finalize();
+
+ // mozIPlaceInfo::title
+ stmt = DBConn().createStatement(
+ `SELECT COUNT(1) AS count
+ FROM moz_places h
+ WHERE h.url_hash = hash(:page_url) AND h.url = :page_url
+ AND h.title = :title`
+ );
+ stmt.params.page_url = uri.spec;
+ stmt.params.title = placeInfo.title;
+ Assert.ok(stmt.executeStep());
+ Assert.equal(stmt.row.count, EXPECTED_COUNT);
+ stmt.finalize();
+
+ // If we have had all of our callbacks, continue running tests.
+ if (++callbackCount == places.length) {
+ await PlacesTestUtils.promiseAsyncUpdates();
+ }
+ }
+});
+
+add_task(async function test_guid_saved() {
+ let place = {
+ uri: NetUtil.newURI(TEST_DOMAIN + "test_guid_saved"),
+ guid: "__TESTGUID__",
+ visits: [new VisitInfo()],
+ };
+ do_check_valid_places_guid(place.guid);
+ Assert.equal(false, await PlacesUtils.history.hasVisits(place.uri));
+
+ let placesResult = await promiseUpdatePlaces(place);
+ if (placesResult.errors.length) {
+ do_throw("Unexpected error.");
+ }
+ let placeInfo = placesResult.results[0];
+ let uri = placeInfo.uri;
+ Assert.ok(await PlacesUtils.history.hasVisits(uri));
+ Assert.equal(placeInfo.guid, place.guid);
+ await check_guid_for_uri(uri, place.guid);
+ await PlacesTestUtils.promiseAsyncUpdates();
+});
+
+add_task(async function test_referrer_saved() {
+ let places = [
+ {
+ uri: NetUtil.newURI(TEST_DOMAIN + "test_referrer_saved/referrer"),
+ visits: [new VisitInfo()],
+ },
+ {
+ uri: NetUtil.newURI(TEST_DOMAIN + "test_referrer_saved/test"),
+ visits: [new VisitInfo()],
+ },
+ ];
+ places[1].visits[0].referrerURI = places[0].uri;
+ Assert.equal(false, await PlacesUtils.history.hasVisits(places[0].uri));
+ Assert.equal(false, await PlacesUtils.history.hasVisits(places[1].uri));
+
+ let resultCount = 0;
+ let placesResult = await promiseUpdatePlaces(places);
+ if (placesResult.errors.length) {
+ do_throw("Unexpected error.");
+ }
+ for (let placeInfo of placesResult.results) {
+ let uri = placeInfo.uri;
+ Assert.ok(await PlacesUtils.history.hasVisits(uri));
+ let visit = placeInfo.visits[0];
+
+ // We need to insert all of our visits before we can test conditions.
+ if (++resultCount == places.length) {
+ Assert.ok(places[0].uri.equals(visit.referrerURI));
+
+ let stmt = DBConn().createStatement(
+ `SELECT COUNT(1) AS count
+ FROM moz_historyvisits
+ JOIN moz_places h ON h.id = place_id
+ WHERE url_hash = hash(:page_url) AND url = :page_url
+ AND from_visit = (
+ SELECT v.id
+ FROM moz_historyvisits v
+ JOIN moz_places h ON h.id = place_id
+ WHERE url_hash = hash(:referrer) AND url = :referrer
+ )`
+ );
+ stmt.params.page_url = uri.spec;
+ stmt.params.referrer = visit.referrerURI.spec;
+ Assert.ok(stmt.executeStep());
+ Assert.equal(stmt.row.count, 1);
+ stmt.finalize();
+
+ await PlacesTestUtils.promiseAsyncUpdates();
+ }
+ }
+});
+
+add_task(async function test_guid_change_saved() {
+ // First, add a visit for it.
+ let place = {
+ uri: NetUtil.newURI(TEST_DOMAIN + "test_guid_change_saved"),
+ visits: [new VisitInfo()],
+ };
+ Assert.equal(false, await PlacesUtils.history.hasVisits(place.uri));
+
+ let placesResult = await promiseUpdatePlaces(place);
+ if (placesResult.errors.length) {
+ do_throw("Unexpected error.");
+ }
+ // Then, change the guid with visits.
+ place.guid = "_GUIDCHANGE_";
+ place.visits = [new VisitInfo()];
+ placesResult = await promiseUpdatePlaces(place);
+ if (placesResult.errors.length) {
+ do_throw("Unexpected error.");
+ }
+ await check_guid_for_uri(place.uri, place.guid);
+
+ await PlacesTestUtils.promiseAsyncUpdates();
+});
+
+add_task(async function test_title_change_saved() {
+ // First, add a visit for it.
+ let place = {
+ uri: NetUtil.newURI(TEST_DOMAIN + "test_title_change_saved"),
+ title: "original title",
+ visits: [new VisitInfo()],
+ };
+ Assert.equal(false, await PlacesUtils.history.hasVisits(place.uri));
+
+ let placesResult = await promiseUpdatePlaces(place);
+ if (placesResult.errors.length) {
+ do_throw("Unexpected error.");
+ }
+
+ // Now, make sure the empty string clears the title.
+ place.title = "";
+ place.visits = [new VisitInfo()];
+ placesResult = await promiseUpdatePlaces(place);
+ if (placesResult.errors.length) {
+ do_throw("Unexpected error.");
+ }
+ do_check_title_for_uri(place.uri, null);
+
+ // Then, change the title with visits.
+ place.title = "title change";
+ place.visits = [new VisitInfo()];
+ placesResult = await promiseUpdatePlaces(place);
+ if (placesResult.errors.length) {
+ do_throw("Unexpected error.");
+ }
+ do_check_title_for_uri(place.uri, place.title);
+
+ // Lastly, check that the title is cleared if we set it to null.
+ place.title = null;
+ place.visits = [new VisitInfo()];
+ placesResult = await promiseUpdatePlaces(place);
+ if (placesResult.errors.length) {
+ do_throw("Unexpected error.");
+ }
+ do_check_title_for_uri(place.uri, place.title);
+
+ await PlacesTestUtils.promiseAsyncUpdates();
+});
+
+add_task(async function test_no_title_does_not_clear_title() {
+ const TITLE = "test title";
+ // First, add a visit for it.
+ let place = {
+ uri: NetUtil.newURI(TEST_DOMAIN + "test_no_title_does_not_clear_title"),
+ title: TITLE,
+ visits: [new VisitInfo()],
+ };
+ Assert.equal(false, await PlacesUtils.history.hasVisits(place.uri));
+
+ let placesResult = await promiseUpdatePlaces(place);
+ if (placesResult.errors.length) {
+ do_throw("Unexpected error.");
+ }
+ // Now, make sure that not specifying a title does not clear it.
+ delete place.title;
+ place.visits = [new VisitInfo()];
+ placesResult = await promiseUpdatePlaces(place);
+ if (placesResult.errors.length) {
+ do_throw("Unexpected error.");
+ }
+ do_check_title_for_uri(place.uri, TITLE);
+
+ await PlacesTestUtils.promiseAsyncUpdates();
+});
+
+add_task(async function test_title_change_notifies() {
+ // There are three cases to test. The first case is to make sure we do not
+ // get notified if we do not specify a title.
+ let place = {
+ uri: NetUtil.newURI(TEST_DOMAIN + "test_title_change_notifies"),
+ visits: [new VisitInfo()],
+ };
+ Assert.equal(false, await PlacesUtils.history.hasVisits(place.uri));
+
+ new TitleChangedObserver(place.uri, "DO NOT WANT", function () {
+ do_throw("unexpected callback!");
+ });
+
+ let placesResult = await promiseUpdatePlaces(place);
+ if (placesResult.errors.length) {
+ do_throw("Unexpected error.");
+ }
+
+ // The second case to test is that we don't get the notification when we add
+ // it for the first time. The first case will fail before our callback if it
+ // is busted, so we can do this now.
+ place.uri = NetUtil.newURI(place.uri.spec + "/new-visit-with-title");
+ place.title = "title 1";
+ let expectedNotification = false;
+ let titleChangeObserver;
+ let titleChangePromise = new Promise((resolve, reject) => {
+ titleChangeObserver = new TitleChangedObserver(
+ place.uri,
+ place.title,
+ function () {
+ Assert.ok(
+ expectedNotification,
+ "Should not get notified for " +
+ place.uri.spec +
+ " with title " +
+ place.title
+ );
+ if (expectedNotification) {
+ resolve();
+ }
+ }
+ );
+ });
+
+ let visitPromise = new Promise(resolve => {
+ function onVisits(events) {
+ Assert.equal(events.length, 1, "Should only get notified for one visit.");
+ Assert.equal(events[0].type, "page-visited");
+ let { url } = events[0];
+ Assert.equal(
+ url,
+ place.uri.spec,
+ "Should get notified for visiting the new URI."
+ );
+ PlacesObservers.removeListener(["page-visited"], onVisits);
+ resolve();
+ }
+ PlacesObservers.addListener(["page-visited"], onVisits);
+ });
+ asyncHistory.updatePlaces(place);
+ await visitPromise;
+
+ // The third case to test is to make sure we get a notification when
+ // we change an existing place.
+ expectedNotification = true;
+ titleChangeObserver.expectedTitle = place.title = "title 2";
+ place.visits = [new VisitInfo()];
+ asyncHistory.updatePlaces(place);
+
+ await titleChangePromise;
+ await PlacesTestUtils.promiseAsyncUpdates();
+});
+
+add_task(async function test_visit_notifies() {
+ // There are two observers we need to see for each visit. One is an
+ // PlacesObservers and the other is the uri-visit-saved observer topic.
+ let place = {
+ guid: "abcdefghijkl",
+ uri: NetUtil.newURI(TEST_DOMAIN + "test_visit_notifies"),
+ visits: [new VisitInfo()],
+ };
+ Assert.equal(false, await PlacesUtils.history.hasVisits(place.uri));
+
+ function promiseVisitObserver(aPlace) {
+ return new Promise((resolve, reject) => {
+ let callbackCount = 0;
+ let finisher = function () {
+ if (++callbackCount == 2) {
+ resolve();
+ }
+ };
+ new VisitObserver(place.uri, place.guid, function (
+ aVisitDate,
+ aTransitionType
+ ) {
+ let visit = place.visits[0];
+ Assert.equal(visit.visitDate, aVisitDate);
+ Assert.equal(visit.transitionType, aTransitionType);
+
+ finisher();
+ });
+ let observer = function (aSubject, aTopic, aData) {
+ info("observe(" + aSubject + ", " + aTopic + ", " + aData + ")");
+ Assert.ok(aSubject instanceof Ci.nsIURI);
+ Assert.ok(aSubject.equals(place.uri));
+
+ Services.obs.removeObserver(observer, URI_VISIT_SAVED);
+ finisher();
+ };
+ Services.obs.addObserver(observer, URI_VISIT_SAVED);
+ asyncHistory.updatePlaces(place);
+ });
+ }
+
+ await promiseVisitObserver(place);
+ await PlacesTestUtils.promiseAsyncUpdates();
+});
+
+// test with empty mozIVisitInfoCallback object
+add_task(async function test_callbacks_not_supplied() {
+ const URLS = [
+ "imap://cyrus.andrew.cmu.edu/archive.imap", // bad URI
+ "http://mozilla.org/", // valid URI
+ ];
+ let places = [];
+ URLS.forEach(function (url) {
+ try {
+ let place = {
+ uri: NetUtil.newURI(url),
+ title: "test for " + url,
+ visits: [new VisitInfo()],
+ };
+ places.push(place);
+ } catch (e) {
+ if (e.result != Cr.NS_ERROR_FAILURE) {
+ throw e;
+ }
+ // NetUtil.newURI() can throw if e.g. our app knows about imap://
+ // but the account is not set up and so the URL is invalid for us.
+ // Note this in the log but ignore as it's not the subject of this test.
+ info("Could not construct URI for '" + url + "'; ignoring");
+ }
+ });
+
+ asyncHistory.updatePlaces(places, {});
+ await PlacesTestUtils.promiseAsyncUpdates();
+});
+
+// Test that we don't wrongly overwrite typed and hidden when adding new visits.
+add_task(async function test_typed_hidden_not_overwritten() {
+ await PlacesUtils.history.clear();
+ let places = [
+ {
+ uri: NetUtil.newURI("http://mozilla.org/"),
+ title: "test",
+ visits: [new VisitInfo(TRANSITION_TYPED), new VisitInfo(TRANSITION_LINK)],
+ },
+ {
+ uri: NetUtil.newURI("http://mozilla.org/"),
+ title: "test",
+ visits: [new VisitInfo(TRANSITION_FRAMED_LINK)],
+ },
+ ];
+ await promiseUpdatePlaces(places);
+
+ let db = await PlacesUtils.promiseDBConnection();
+ let rows = await db.execute(
+ "SELECT hidden, typed FROM moz_places WHERE url_hash = hash(:url) AND url = :url",
+ { url: "http://mozilla.org/" }
+ );
+ Assert.equal(
+ rows[0].getResultByName("typed"),
+ 1,
+ "The page should be marked as typed"
+ );
+ Assert.equal(
+ rows[0].getResultByName("hidden"),
+ 0,
+ "The page should be marked as not hidden"
+ );
+ await PlacesTestUtils.promiseAsyncUpdates();
+});
+
+add_task(async function test_omit_frecency_notifications() {
+ // When multiple entries are inserted, frecency is calculated delayed, so
+ // we won't get a ranking changed notification until recalculation happens.
+ await PlacesUtils.history.clear();
+ let notified = false;
+ let listener = events => {
+ notified = true;
+ PlacesUtils.observers.removeListener(["pages-rank-changed"], listener);
+ };
+ PlacesUtils.observers.addListener(["pages-rank-changed"], listener);
+ let places = [
+ {
+ uri: NetUtil.newURI("http://mozilla.org/"),
+ title: "test",
+ visits: [new VisitInfo(TRANSITION_TYPED)],
+ },
+ {
+ uri: NetUtil.newURI("http://example.org/"),
+ title: "test",
+ visits: [new VisitInfo(TRANSITION_TYPED)],
+ },
+ ];
+ await promiseUpdatePlaces(places);
+ Assert.ok(!notified);
+ await PlacesFrecencyRecalculator.recalculateAnyOutdatedFrecencies();
+ Assert.ok(notified);
+});
+
+add_task(async function test_ignore_errors() {
+ await PlacesUtils.history.clear();
+ // This test ensures that trying to add a visit, with a guid already found in
+ // another visit, fails - but doesn't report if we told it not to.
+ let place = {
+ uri: NetUtil.newURI(TEST_DOMAIN + "test_duplicate_guid_fails_first"),
+ visits: [new VisitInfo()],
+ };
+
+ Assert.equal(false, await PlacesUtils.history.hasVisits(place.uri));
+ let placesResult = await promiseUpdatePlaces(place);
+ if (placesResult.errors.length) {
+ do_throw("Unexpected error.");
+ }
+ let placeInfo = placesResult.results[0];
+ Assert.ok(await PlacesUtils.history.hasVisits(placeInfo.uri));
+
+ let badPlace = {
+ uri: NetUtil.newURI(TEST_DOMAIN + "test_duplicate_guid_fails_second"),
+ visits: [new VisitInfo()],
+ guid: placeInfo.guid,
+ };
+
+ Assert.equal(false, await PlacesUtils.history.hasVisits(badPlace.uri));
+ placesResult = await promiseUpdatePlaces(badPlace, { ignoreErrors: true });
+ if (placesResult.results.length) {
+ do_throw("Unexpected success.");
+ }
+ Assert.equal(
+ placesResult.errors.length,
+ 0,
+ "Should have seen 0 errors because we disabled reporting."
+ );
+ Assert.equal(
+ placesResult.results.length,
+ 0,
+ "Should have seen 0 results because there were none."
+ );
+ Assert.equal(
+ placesResult.resultCount,
+ 0,
+ "Should know that we updated 0 items from the completion callback."
+ );
+ await PlacesTestUtils.promiseAsyncUpdates();
+});
+
+add_task(async function test_ignore_results() {
+ await PlacesUtils.history.clear();
+ let place = {
+ uri: NetUtil.newURI("http://mozilla.org/"),
+ title: "test",
+ visits: [new VisitInfo()],
+ };
+ let placesResult = await promiseUpdatePlaces(place, { ignoreResults: true });
+ Assert.equal(
+ placesResult.results.length,
+ 0,
+ "Should have seen 0 results because we disabled reporting."
+ );
+ Assert.equal(
+ placesResult.errors.length,
+ 0,
+ "Should have seen 0 errors because there were none."
+ );
+ Assert.equal(
+ placesResult.resultCount,
+ 1,
+ "Should know that we updated 1 item from the completion callback."
+ );
+ await PlacesTestUtils.promiseAsyncUpdates();
+});
+
+add_task(async function test_ignore_results_and_errors() {
+ await PlacesUtils.history.clear();
+ // This test ensures that trying to add a visit, with a guid already found in
+ // another visit, fails - but doesn't report if we told it not to.
+ let place = {
+ uri: NetUtil.newURI(TEST_DOMAIN + "test_duplicate_guid_fails_first"),
+ visits: [new VisitInfo()],
+ };
+
+ Assert.equal(false, await PlacesUtils.history.hasVisits(place.uri));
+ let placesResult = await promiseUpdatePlaces(place);
+ if (placesResult.errors.length) {
+ do_throw("Unexpected error.");
+ }
+ let placeInfo = placesResult.results[0];
+ Assert.ok(await PlacesUtils.history.hasVisits(placeInfo.uri));
+
+ let badPlace = {
+ uri: NetUtil.newURI(TEST_DOMAIN + "test_duplicate_guid_fails_second"),
+ visits: [new VisitInfo()],
+ guid: placeInfo.guid,
+ };
+ let allPlaces = [
+ {
+ uri: NetUtil.newURI(TEST_DOMAIN + "test_other_successful_item"),
+ visits: [new VisitInfo()],
+ },
+ badPlace,
+ ];
+
+ Assert.equal(false, await PlacesUtils.history.hasVisits(badPlace.uri));
+ placesResult = await promiseUpdatePlaces(allPlaces, {
+ ignoreErrors: true,
+ ignoreResults: true,
+ });
+ Assert.equal(
+ placesResult.errors.length,
+ 0,
+ "Should have seen 0 errors because we disabled reporting."
+ );
+ Assert.equal(
+ placesResult.results.length,
+ 0,
+ "Should have seen 0 results because we disabled reporting."
+ );
+ Assert.equal(
+ placesResult.resultCount,
+ 1,
+ "Should know that we updated 1 item from the completion callback."
+ );
+ await PlacesTestUtils.promiseAsyncUpdates();
+});
+
+add_task(async function test_title_on_initial_visit() {
+ let place = {
+ uri: NetUtil.newURI(TEST_DOMAIN + "test_visit_title"),
+ title: "My title",
+ visits: [new VisitInfo()],
+ guid: "mnopqrstuvwx",
+ };
+ let visitPromise = new Promise(resolve => {
+ new VisitObserver(place.uri, place.guid, function (
+ aVisitDate,
+ aTransitionType,
+ aLastKnownTitle
+ ) {
+ Assert.equal(place.title, aLastKnownTitle);
+
+ resolve();
+ });
+ });
+ await promiseUpdatePlaces(place);
+ await visitPromise;
+
+ // Now check an empty title doesn't get reported as null
+ place = {
+ uri: NetUtil.newURI(TEST_DOMAIN + "test_visit_title"),
+ title: "",
+ visits: [new VisitInfo()],
+ guid: "fghijklmnopq",
+ };
+ visitPromise = new Promise(resolve => {
+ new VisitObserver(place.uri, place.guid, function (
+ aVisitDate,
+ aTransitionType,
+ aLastKnownTitle
+ ) {
+ Assert.equal(place.title, aLastKnownTitle);
+
+ resolve();
+ });
+ });
+ await promiseUpdatePlaces(place);
+ await visitPromise;
+
+ // and that a missing title correctly gets reported as null.
+ place = {
+ uri: NetUtil.newURI(TEST_DOMAIN + "test_visit_title"),
+ visits: [new VisitInfo()],
+ guid: "fghijklmnopq",
+ };
+ visitPromise = new Promise(resolve => {
+ new VisitObserver(place.uri, place.guid, function (
+ aVisitDate,
+ aTransitionType,
+ aLastKnownTitle
+ ) {
+ Assert.equal(null, aLastKnownTitle);
+
+ resolve();
+ });
+ });
+ await promiseUpdatePlaces(place);
+ await visitPromise;
+});
diff --git a/toolkit/components/places/tests/history/test_bookmark_unhide.js b/toolkit/components/places/tests/history/test_bookmark_unhide.js
new file mode 100644
index 0000000000..1295c6e8c5
--- /dev/null
+++ b/toolkit/components/places/tests/history/test_bookmark_unhide.js
@@ -0,0 +1,26 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+// Tests that bookmarking an hidden page unhides it.
+
+"use strict";
+
+add_task(async function test_hidden() {
+ const url = "http://moz.com/";
+ await PlacesTestUtils.addVisits({
+ url,
+ transition: TRANSITION_FRAMED_LINK,
+ });
+ Assert.equal(
+ await PlacesTestUtils.getDatabaseValue("moz_places", "hidden", { url }),
+ 1
+ );
+ await PlacesUtils.bookmarks.insert({
+ url,
+ parentGuid: PlacesUtils.bookmarks.toolbarGuid,
+ });
+ Assert.equal(
+ await PlacesTestUtils.getDatabaseValue("moz_places", "hidden", { url }),
+ 0
+ );
+});
diff --git a/toolkit/components/places/tests/history/test_fetch.js b/toolkit/components/places/tests/history/test_fetch.js
new file mode 100644
index 0000000000..899e459403
--- /dev/null
+++ b/toolkit/components/places/tests/history/test_fetch.js
@@ -0,0 +1,270 @@
+/* Any copyright is dedicated to the Public Domain. http://creativecommons.org/publicdomain/zero/1.0/ */
+"use strict";
+
+add_task(async function test_fetch_existent() {
+ await PlacesUtils.history.clear();
+ await PlacesUtils.bookmarks.eraseEverything();
+
+ // Populate places and historyvisits.
+ let uriString = `http://mozilla.com/test_browserhistory/test_fetch`;
+ let uri = NetUtil.newURI(uriString);
+ let title = `Test Visit ${Math.random()}`;
+ let dates = [];
+ let visits = [];
+ let transitions = [
+ PlacesUtils.history.TRANSITION_LINK,
+ PlacesUtils.history.TRANSITION_TYPED,
+ PlacesUtils.history.TRANSITION_BOOKMARK,
+ PlacesUtils.history.TRANSITION_REDIRECT_TEMPORARY,
+ PlacesUtils.history.TRANSITION_REDIRECT_PERMANENT,
+ PlacesUtils.history.TRANSITION_DOWNLOAD,
+ PlacesUtils.history.TRANSITION_FRAMED_LINK,
+ PlacesUtils.history.TRANSITION_RELOAD,
+ ];
+ let guid = "";
+ for (let i = 0; i != transitions.length; i++) {
+ dates.push(new Date(Date.now() - i * 10000000));
+ visits.push({
+ uri,
+ title,
+ transition: transitions[i],
+ visitDate: dates[i],
+ });
+ }
+ await PlacesTestUtils.addVisits(visits);
+ Assert.ok(await PlacesTestUtils.isPageInDB(uri));
+ Assert.equal(await PlacesTestUtils.visitsInDB(uri), visits.length);
+
+ // Store guid for further use in testing.
+ guid = await PlacesTestUtils.getDatabaseValue("moz_places", "guid", {
+ url: uri,
+ });
+ Assert.ok(guid, guid);
+
+ // Initialize the objects to compare against.
+ let idealPageInfo = {
+ url: new URL(uriString),
+ guid,
+ title,
+ };
+ let idealVisits = visits.map(v => {
+ return {
+ date: v.visitDate,
+ transition: v.transition,
+ };
+ });
+
+ // We should check these 4 cases:
+ // 1, 2: visits not included, by URL and guid (same result expected).
+ // 3, 4: visits included, by URL and guid (same result expected).
+ for (let includeVisits of [true, false]) {
+ for (let guidOrURL of [uri, guid]) {
+ let pageInfo = await PlacesUtils.history.fetch(guidOrURL, {
+ includeVisits,
+ });
+ if (includeVisits) {
+ idealPageInfo.visits = idealVisits;
+ } else {
+ // We need to explicitly delete this property since deepEqual looks at
+ // the list of properties as well (`visits in pageInfo` is true here).
+ delete idealPageInfo.visits;
+ }
+
+ // Since idealPageInfo doesn't contain a frecency, check it and delete.
+ Assert.ok(typeof pageInfo.frecency === "number");
+ delete pageInfo.frecency;
+
+ // Visits should be from newer to older.
+ if (includeVisits) {
+ for (let i = 0; i !== pageInfo.visits.length - 1; i++) {
+ Assert.lessOrEqual(
+ pageInfo.visits[i + 1].date.getTime(),
+ pageInfo.visits[i].date.getTime()
+ );
+ }
+ }
+ Assert.deepEqual(idealPageInfo, pageInfo);
+ }
+ }
+});
+
+add_task(async function test_fetch_page_meta_info() {
+ await PlacesUtils.history.clear();
+
+ let TEST_URI = NetUtil.newURI("http://mozilla.com/test_fetch_page_meta_info");
+ await PlacesTestUtils.addVisits(TEST_URI);
+ Assert.ok(page_in_database(TEST_URI));
+
+ // Test fetching the null values
+ let includeMeta = true;
+ let pageInfo = await PlacesUtils.history.fetch(TEST_URI, { includeMeta });
+ Assert.strictEqual(
+ null,
+ pageInfo.previewImageURL,
+ "fetch should return a null previewImageURL"
+ );
+ Assert.strictEqual(
+ "",
+ pageInfo.siteName,
+ "fetch should return a null siteName"
+ );
+ Assert.equal(
+ "",
+ pageInfo.description,
+ "fetch should return a empty string description"
+ );
+
+ // Now set the pageMetaInfo for this page
+ let description = "Test description";
+ let siteName = "Mozilla";
+ let previewImageURL = "http://mozilla.com/test_preview_image.png";
+ await PlacesUtils.history.update({
+ url: TEST_URI,
+ description,
+ previewImageURL,
+ siteName,
+ });
+
+ includeMeta = true;
+ pageInfo = await PlacesUtils.history.fetch(TEST_URI, { includeMeta });
+ Assert.equal(
+ previewImageURL,
+ pageInfo.previewImageURL.href,
+ "fetch should return a previewImageURL"
+ );
+ Assert.equal(siteName, pageInfo.siteName, "fetch should return a siteName");
+ Assert.equal(
+ description,
+ pageInfo.description,
+ "fetch should return a description"
+ );
+
+ includeMeta = false;
+ pageInfo = await PlacesUtils.history.fetch(TEST_URI, { includeMeta });
+ Assert.ok(
+ !("description" in pageInfo),
+ "fetch should not return a description if includeMeta is false"
+ );
+ Assert.ok(
+ !("siteName" in pageInfo),
+ "fetch should not return a siteName if includeMeta is false"
+ );
+ Assert.ok(
+ !("previewImageURL" in pageInfo),
+ "fetch should not return a previewImageURL if includeMeta is false"
+ );
+});
+
+add_task(async function test_fetch_annotations() {
+ await PlacesUtils.history.clear();
+
+ const TEST_URI = "http://mozilla.com/test_fetch_page_meta_info";
+ await PlacesTestUtils.addVisits(TEST_URI);
+ Assert.ok(page_in_database(TEST_URI));
+
+ let includeAnnotations = true;
+ let pageInfo = await PlacesUtils.history.fetch(TEST_URI, {
+ includeAnnotations,
+ });
+ Assert.equal(
+ pageInfo.annotations.size,
+ 0,
+ "fetch should return an empty annotation map"
+ );
+
+ await PlacesUtils.history.update({
+ url: TEST_URI,
+ annotations: new Map([["test/annotation", "testContent"]]),
+ });
+
+ pageInfo = await PlacesUtils.history.fetch(TEST_URI, { includeAnnotations });
+ Assert.equal(
+ pageInfo.annotations.size,
+ 1,
+ "fetch should have only one annotation"
+ );
+
+ Assert.equal(
+ pageInfo.annotations.get("test/annotation"),
+ "testContent",
+ "fetch should return the expected annotation"
+ );
+
+ await PlacesUtils.history.update({
+ url: TEST_URI,
+ annotations: new Map([["test/annotation2", 123]]),
+ });
+
+ pageInfo = await PlacesUtils.history.fetch(TEST_URI, { includeAnnotations });
+ Assert.equal(
+ pageInfo.annotations.size,
+ 2,
+ "fetch should have returned two annotations"
+ );
+ Assert.equal(
+ pageInfo.annotations.get("test/annotation"),
+ "testContent",
+ "fetch should still have the first annotation"
+ );
+ Assert.equal(
+ pageInfo.annotations.get("test/annotation2"),
+ 123,
+ "fetch should have the second annotation"
+ );
+
+ includeAnnotations = false;
+ pageInfo = await PlacesUtils.history.fetch(TEST_URI, { includeAnnotations });
+ Assert.ok(
+ !("annotations" in pageInfo),
+ "fetch should not return annotations if includeAnnotations is false"
+ );
+});
+
+add_task(async function test_fetch_nonexistent() {
+ await PlacesUtils.history.clear();
+ await PlacesUtils.bookmarks.eraseEverything();
+
+ let uri = NetUtil.newURI("http://doesntexist.in.db");
+ let pageInfo = await PlacesUtils.history.fetch(uri);
+ Assert.equal(pageInfo, null);
+});
+
+add_task(async function test_error_cases() {
+ Assert.throws(
+ () => PlacesUtils.history.fetch("3"),
+ /TypeError: URL constructor: 3 is not a valid /
+ );
+ Assert.throws(
+ () => PlacesUtils.history.fetch({ not: "a valid string or guid" }),
+ /TypeError: Invalid url or guid/
+ );
+ Assert.throws(
+ () => PlacesUtils.history.fetch("http://valid.uri.com", "not an object"),
+ /TypeError: options should be/
+ );
+ Assert.throws(
+ () => PlacesUtils.history.fetch("http://valid.uri.com", null),
+ /TypeError: options should be/
+ );
+ Assert.throws(
+ () =>
+ PlacesUtils.history.fetch("http://valid.uri.come", {
+ includeVisits: "not a boolean",
+ }),
+ /TypeError: includeVisits should be a/
+ );
+ Assert.throws(
+ () =>
+ PlacesUtils.history.fetch("http://valid.uri.come", {
+ includeMeta: "not a boolean",
+ }),
+ /TypeError: includeMeta should be a/
+ );
+ Assert.throws(
+ () =>
+ PlacesUtils.history.fetch("http://valid.url.com", {
+ includeAnnotations: "not a boolean",
+ }),
+ /TypeError: includeAnnotations should be a/
+ );
+});
diff --git a/toolkit/components/places/tests/history/test_fetchAnnotatedPages.js b/toolkit/components/places/tests/history/test_fetchAnnotatedPages.js
new file mode 100644
index 0000000000..0f487e8090
--- /dev/null
+++ b/toolkit/components/places/tests/history/test_fetchAnnotatedPages.js
@@ -0,0 +1,146 @@
+/* Any copyright is dedicated to the Public Domain. http://creativecommons.org/publicdomain/zero/1.0/ */
+"use strict";
+
+add_task(async function test_error_cases() {
+ Assert.throws(
+ () => PlacesUtils.history.fetchAnnotatedPages(),
+ /TypeError: annotations should be an Array and not null/,
+ "Should throw an exception for a null parameter"
+ );
+ Assert.throws(
+ () => PlacesUtils.history.fetchAnnotatedPages("3"),
+ /TypeError: annotations should be an Array and not null/,
+ "Should throw an exception for a parameter of the wrong type"
+ );
+ Assert.throws(
+ () => PlacesUtils.history.fetchAnnotatedPages([3]),
+ /TypeError: all annotation values should be strings/,
+ "Should throw an exception for a non-string annotation name"
+ );
+});
+
+add_task(async function test_fetchAnnotatedPages_no_matching() {
+ await PlacesUtils.history.clear();
+ await PlacesUtils.bookmarks.eraseEverything();
+
+ const TEST_URL = "http://example.com/1";
+ await PlacesTestUtils.addVisits(TEST_URL);
+ Assert.ok(
+ await PlacesTestUtils.isPageInDB(TEST_URL),
+ "Should have inserted the page into the database."
+ );
+
+ let result = await PlacesUtils.history.fetchAnnotatedPages(["test/anno"]);
+
+ Assert.equal(result.size, 0, "Should be no items returned.");
+});
+
+add_task(async function test_fetchAnnotatedPages_simple_match() {
+ await PlacesUtils.history.clear();
+
+ const TEST_URL = "http://example.com/1";
+ await PlacesTestUtils.addVisits(TEST_URL);
+ Assert.ok(
+ await PlacesTestUtils.isPageInDB(TEST_URL),
+ "Should have inserted the page into the database."
+ );
+
+ await PlacesUtils.history.update({
+ url: TEST_URL,
+ annotations: new Map([["test/anno", "testContent"]]),
+ });
+
+ let result = await PlacesUtils.history.fetchAnnotatedPages(["test/anno"]);
+
+ Assert.equal(
+ result.size,
+ 1,
+ "Should have returned one match for the annotation"
+ );
+
+ Assert.deepEqual(
+ result.get("test/anno"),
+ [
+ {
+ uri: new URL(TEST_URL),
+ content: "testContent",
+ },
+ ],
+ "Should have returned the page and its content for the annotation"
+ );
+});
+
+add_task(async function test_fetchAnnotatedPages_multiple_match() {
+ await PlacesUtils.history.clear();
+
+ const TEST_URL1 = "http://example.com/1";
+ const TEST_URL2 = "http://example.com/2";
+ const TEST_URL3 = "http://example.com/3";
+ await PlacesTestUtils.addVisits([
+ { uri: TEST_URL1 },
+ { uri: TEST_URL2 },
+ { uri: TEST_URL3 },
+ ]);
+ Assert.ok(
+ await PlacesTestUtils.isPageInDB(TEST_URL1),
+ "Should have inserted the first page into the database."
+ );
+ Assert.ok(
+ await PlacesTestUtils.isPageInDB(TEST_URL2),
+ "Should have inserted the second page into the database."
+ );
+ Assert.ok(
+ await PlacesTestUtils.isPageInDB(TEST_URL3),
+ "Should have inserted the third page into the database."
+ );
+
+ await PlacesUtils.history.update({
+ url: TEST_URL1,
+ annotations: new Map([["test/anno", "testContent1"]]),
+ });
+
+ await PlacesUtils.history.update({
+ url: TEST_URL2,
+ annotations: new Map([
+ ["test/anno", "testContent2"],
+ ["test/anno2", 1234],
+ ]),
+ });
+
+ let result = await PlacesUtils.history.fetchAnnotatedPages([
+ "test/anno",
+ "test/anno2",
+ ]);
+
+ Assert.equal(
+ result.size,
+ 2,
+ "Should have returned matches for both annotations"
+ );
+
+ Assert.deepEqual(
+ result.get("test/anno"),
+ [
+ {
+ uri: new URL(TEST_URL1),
+ content: "testContent1",
+ },
+ {
+ uri: new URL(TEST_URL2),
+ content: "testContent2",
+ },
+ ],
+ "Should have returned two pages and their content for the first annotation"
+ );
+
+ Assert.deepEqual(
+ result.get("test/anno2"),
+ [
+ {
+ uri: new URL(TEST_URL2),
+ content: 1234,
+ },
+ ],
+ "Should have returned one page for the second annotation"
+ );
+});
diff --git a/toolkit/components/places/tests/history/test_fetchMany.js b/toolkit/components/places/tests/history/test_fetchMany.js
new file mode 100644
index 0000000000..53c3f6847e
--- /dev/null
+++ b/toolkit/components/places/tests/history/test_fetchMany.js
@@ -0,0 +1,96 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+add_task(async function test_fetchMany() {
+ await PlacesUtils.history.clear();
+ await PlacesUtils.bookmarks.eraseEverything();
+
+ let pages = [
+ {
+ url: "https://mozilla.org/test1/",
+ title: "test 1",
+ },
+ {
+ url: "https://mozilla.org/test2/",
+ title: "test 2",
+ },
+ {
+ url: "https://mozilla.org/test3/",
+ title: "test 3",
+ },
+ ];
+ await PlacesTestUtils.addVisits(pages);
+
+ // Add missing page info from the database.
+ for (let page of pages) {
+ page.guid = await PlacesTestUtils.getDatabaseValue("moz_places", "guid", {
+ url: page.url,
+ });
+ page.frecency = await PlacesTestUtils.getDatabaseValue(
+ "moz_places",
+ "frecency",
+ { url: page.url }
+ );
+ }
+
+ info("Fetch by url");
+ let fetched = await PlacesUtils.history.fetchMany(pages.map(p => p.url));
+ Assert.equal(fetched.size, 3, "Map should contain same number of entries");
+ for (let page of pages) {
+ Assert.deepEqual(page, fetched.get(page.url));
+ }
+ info("Fetch by GUID");
+ fetched = await PlacesUtils.history.fetchMany(pages.map(p => p.guid));
+ Assert.equal(fetched.size, 3, "Map should contain same number of entries");
+ for (let page of pages) {
+ Assert.deepEqual(page, fetched.get(page.guid));
+ }
+ info("Fetch mixed");
+ let keys = pages.map((p, i) => (i % 2 == 0 ? p.guid : p.url));
+ fetched = await PlacesUtils.history.fetchMany(keys);
+ Assert.equal(fetched.size, 3, "Map should contain same number of entries");
+ for (let key of keys) {
+ let page = pages.find(p => p.guid == key || p.url == key);
+ Assert.deepEqual(page, fetched.get(key));
+ Assert.ok(URL.isInstance(fetched.get(key).url));
+ }
+});
+
+add_task(async function test_fetch_empty() {
+ await PlacesUtils.history.clear();
+ await PlacesUtils.bookmarks.eraseEverything();
+
+ let fetched = await PlacesUtils.history.fetchMany([]);
+ Assert.equal(fetched.size, 0, "Map should contain no entries");
+});
+
+add_task(async function test_fetch_nonexistent() {
+ await PlacesUtils.history.clear();
+ await PlacesUtils.bookmarks.eraseEverything();
+
+ let uri = NetUtil.newURI("http://doesntexist.in.db");
+ let fetched = await PlacesUtils.history.fetchMany([uri]);
+ Assert.equal(fetched.size, 0, "Map should contain no entries");
+});
+
+add_task(async function test_error_cases() {
+ Assert.throws(
+ () => PlacesUtils.history.fetchMany("3"),
+ /TypeError: Input is not an array/
+ );
+ Assert.throws(
+ () => PlacesUtils.history.fetchMany([{ not: "a valid string or guid" }]),
+ /TypeError: Invalid url or guid/
+ );
+ Assert.throws(
+ () =>
+ PlacesUtils.history.fetchMany(["http://valid.uri.com", "not an object"]),
+ /TypeError: URL constructor/
+ );
+ Assert.throws(
+ () => PlacesUtils.history.fetchMany(["http://valid.uri.com", null]),
+ /TypeError: Invalid url or guid/
+ );
+});
diff --git a/toolkit/components/places/tests/history/test_hasVisits.js b/toolkit/components/places/tests/history/test_hasVisits.js
new file mode 100644
index 0000000000..36fc9fd7be
--- /dev/null
+++ b/toolkit/components/places/tests/history/test_hasVisits.js
@@ -0,0 +1,60 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+// Tests for `History.hasVisits` as implemented in History.jsm
+
+"use strict";
+
+add_task(async function test_has_visits_error_cases() {
+ Assert.throws(
+ () => PlacesUtils.history.hasVisits(),
+ /TypeError: Invalid url or guid: undefined/,
+ "passing a null into History.hasVisits should throw a TypeError"
+ );
+ Assert.throws(
+ () => PlacesUtils.history.hasVisits(1),
+ /TypeError: Invalid url or guid: 1/,
+ "passing an invalid url into History.hasVisits should throw a TypeError"
+ );
+ Assert.throws(
+ () => PlacesUtils.history.hasVisits({}),
+ /TypeError: Invalid url or guid: \[object Object\]/,
+ `passing an invalid (not of type URI or nsIURI) object to History.hasVisits
+ should throw a TypeError`
+ );
+});
+
+add_task(async function test_history_has_visits() {
+ const TEST_URL = "http://mozilla.com/";
+ await PlacesUtils.history.clear();
+ Assert.equal(
+ await PlacesUtils.history.hasVisits(TEST_URL),
+ false,
+ "Test Url should not be in history."
+ );
+ Assert.equal(
+ await PlacesUtils.history.hasVisits(Services.io.newURI(TEST_URL)),
+ false,
+ "Test Url should not be in history."
+ );
+ await PlacesTestUtils.addVisits(TEST_URL);
+ Assert.equal(
+ await PlacesUtils.history.hasVisits(TEST_URL),
+ true,
+ "Test Url should be in history."
+ );
+ Assert.equal(
+ await PlacesUtils.history.hasVisits(Services.io.newURI(TEST_URL)),
+ true,
+ "Test Url should be in history."
+ );
+ let guid = await PlacesTestUtils.getDatabaseValue("moz_places", "guid", {
+ url: TEST_URL,
+ });
+ Assert.equal(
+ await PlacesUtils.history.hasVisits(guid),
+ true,
+ "Test Url should be in history."
+ );
+ await PlacesUtils.history.clear();
+});
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 0000000000..a3a820ade9
--- /dev/null
+++ b/toolkit/components/places/tests/history/test_insert.js
@@ -0,0 +1,196 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+// Tests for `History.insert` as implemented in History.jsm
+
+"use strict";
+
+add_task(async function test_insert_error_cases() {
+ const TEST_URL = "http://mozilla.com";
+
+ Assert.throws(
+ () => PlacesUtils.history.insert(),
+ /Error: PageInfo: Input should be /,
+ "passing a null into History.insert should throw an Error"
+ );
+ Assert.throws(
+ () => PlacesUtils.history.insert(1),
+ /Error: PageInfo: Input should be/,
+ "passing a non object into History.insert should throw an Error"
+ );
+ Assert.throws(
+ () => PlacesUtils.history.insert({}),
+ /Error: PageInfo: The following properties were expected/,
+ "passing an object without a url to History.insert should throw an Error"
+ );
+ Assert.throws(
+ () => PlacesUtils.history.insert({ url: 123 }),
+ /Error: PageInfo: Invalid value for property/,
+ "passing an object with an invalid url to History.insert should throw an Error"
+ );
+ Assert.throws(
+ () => PlacesUtils.history.insert({ url: TEST_URL }),
+ /Error: PageInfo: The following properties were expected/,
+ "passing an object without a visits property to History.insert should throw an Error"
+ );
+ Assert.throws(
+ () => PlacesUtils.history.insert({ url: TEST_URL, visits: 1 }),
+ /Error: PageInfo: Invalid value for property/,
+ "passing an object with a non-array visits property to History.insert should throw an Error"
+ );
+ Assert.throws(
+ () => PlacesUtils.history.insert({ url: TEST_URL, visits: [] }),
+ /Error: PageInfo: Invalid value for property/,
+ "passing an object with an empty array as the visits property to History.insert should throw an Error"
+ );
+ Assert.throws(
+ () =>
+ PlacesUtils.history.insert({
+ url: TEST_URL,
+ visits: [
+ {
+ transition: TRANSITION_LINK,
+ date: "a",
+ },
+ ],
+ }),
+ /PageInfo: Invalid value for property/,
+ "passing a visit object with an invalid date to History.insert should throw an Error"
+ );
+ Assert.throws(
+ () =>
+ PlacesUtils.history.insert({
+ url: TEST_URL,
+ visits: [
+ {
+ transition: TRANSITION_LINK,
+ },
+ {
+ transition: TRANSITION_LINK,
+ date: "a",
+ },
+ ],
+ }),
+ /PageInfo: Invalid value for property/,
+ "passing a second visit object with an invalid date to History.insert should throw an Error"
+ );
+ let futureDate = new Date();
+ futureDate.setDate(futureDate.getDate() + 1000);
+ Assert.throws(
+ () =>
+ PlacesUtils.history.insert({
+ url: TEST_URL,
+ visits: [
+ {
+ transition: TRANSITION_LINK,
+ date: futureDate,
+ },
+ ],
+ }),
+ /PageInfo: Invalid value for property/,
+ "passing a visit object with a future date to History.insert should throw an Error"
+ );
+ Assert.throws(
+ () =>
+ PlacesUtils.history.insert({
+ url: TEST_URL,
+ visits: [{ transition: "a" }],
+ }),
+ /PageInfo: Invalid value for property/,
+ "passing a visit object with an invalid transition to History.insert should throw an Error"
+ );
+});
+
+add_task(async function test_history_insert() {
+ const TEST_URL = "http://mozilla.com/";
+
+ let inserter = async function (name, filter, referrer, date, transition) {
+ info(name);
+ info(
+ `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, referrer, date }],
+ };
+
+ pageInfo.url = await filter(uri);
+
+ let result = await 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(await PlacesTestUtils.isPageInDB(uri), "Page was added");
+ Assert.ok(await 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]) {
+ await inserter(
+ "Testing History.insert() with an nsIURI",
+ x => x,
+ referrer,
+ date,
+ transition
+ );
+ await inserter(
+ "Testing History.insert() with a string url",
+ x => x.spec,
+ referrer,
+ date,
+ transition
+ );
+ await inserter(
+ "Testing History.insert() with a URL object",
+ x => URL.fromURI(x),
+ referrer,
+ date,
+ transition
+ );
+ }
+ }
+ }
+ } finally {
+ await PlacesUtils.history.clear();
+ }
+});
diff --git a/toolkit/components/places/tests/history/test_insertMany.js b/toolkit/components/places/tests/history/test_insertMany.js
new file mode 100644
index 0000000000..b2cf60ed91
--- /dev/null
+++ b/toolkit/components/places/tests/history/test_insertMany.js
@@ -0,0 +1,248 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+// Tests for `History.insertMany` as implemented in History.jsm
+
+"use strict";
+
+add_task(async function test_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, {}]),
+ /Error: PageInfo: The following properties were expected/,
+ "passing a second invalid PageInfo object to History.insertMany should throw an Error"
+ );
+});
+
+add_task(async function test_insertMany() {
+ const BAD_URLS = ["about:config", "chrome://browser/content/browser.xhtml"];
+ const GOOD_URLS = [1, 2, 3].map(x => {
+ return `http://mozilla.com/${x}`;
+ });
+
+ let makePageInfos = 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 = await filter(uri);
+ pageInfos.push(pageInfo);
+ }
+ return pageInfos;
+ };
+
+ let inserter = async function (name, filter, useCallbacks) {
+ info(name);
+ info(`filter: ${filter}`);
+ info(`useCallbacks: ${useCallbacks}`);
+ await PlacesUtils.history.clear();
+
+ let result;
+ let allUrls = GOOD_URLS.concat(BAD_URLS);
+ let pageInfos = await makePageInfos(allUrls, filter);
+
+ if (useCallbacks) {
+ let onResultUrls = [];
+ let onErrorUrls = [];
+ result = await 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 {
+ const promiseRankingChanged =
+ PlacesTestUtils.waitForNotification("pages-rank-changed");
+ result = await PlacesUtils.history.insertMany(pageInfos);
+ await PlacesFrecencyRecalculator.recalculateAnyOutdatedFrecencies();
+ await promiseRankingChanged;
+ }
+
+ Assert.equal(undefined, result, "insertMany returned undefined");
+
+ for (let url of allUrls) {
+ let expected = GOOD_URLS.includes(url);
+ Assert.equal(
+ expected,
+ await PlacesTestUtils.isPageInDB(url),
+ `isPageInDB for ${url} is ${expected}`
+ );
+ Assert.equal(
+ expected,
+ await PlacesTestUtils.visitsInDB(url),
+ `visitsInDB for ${url} is ${expected}`
+ );
+ }
+ };
+
+ try {
+ for (let useCallbacks of [false, true]) {
+ await inserter(
+ "Testing History.insertMany() with an nsIURI",
+ x => x,
+ useCallbacks
+ );
+ await inserter(
+ "Testing History.insertMany() with a string url",
+ x => x.spec,
+ useCallbacks
+ );
+ await inserter(
+ "Testing History.insertMany() with a URL object",
+ x => URL.fromURI(x),
+ useCallbacks
+ );
+ }
+ // Test rejection when no items added
+ let pageInfos = await 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 {
+ await PlacesUtils.history.clear();
+ }
+});
+
+add_task(async function test_transitions() {
+ const places = Object.keys(PlacesUtils.history.TRANSITIONS).map(
+ transition => {
+ return {
+ url: `http://places.test/${transition}`,
+ visits: [{ transition: PlacesUtils.history.TRANSITIONS[transition] }],
+ };
+ }
+ );
+ // Should not reject.
+ await PlacesUtils.history.insertMany(places);
+ // Check callbacks.
+ let count = 0;
+ await PlacesUtils.history.insertMany(places, pageInfo => {
+ ++count;
+ });
+ Assert.equal(count, Object.keys(PlacesUtils.history.TRANSITIONS).length);
+});
+
+add_task(async function test_guid() {
+ const guidA = "aaaaaaaaaaaa";
+ const guidB = "bbbbbbbbbbbb";
+ const guidC = "cccccccccccc";
+
+ await PlacesUtils.history.insertMany([
+ {
+ title: "foo",
+ url: "http://example.com/foo",
+ guid: guidA,
+ visits: [{ transition: TRANSITION_LINK, date: new Date() }],
+ },
+ ]);
+
+ Assert.ok(
+ await PlacesUtils.history.fetch(guidA),
+ "Record is inserted with correct GUID"
+ );
+
+ let expectedGuids = new Set([guidB, guidC]);
+ await PlacesUtils.history.insertMany(
+ [
+ {
+ title: "bar",
+ url: "http://example.com/bar",
+ guid: guidB,
+ visits: [{ transition: TRANSITION_LINK, date: new Date() }],
+ },
+ {
+ title: "baz",
+ url: "http://example.com/baz",
+ guid: guidC,
+ visits: [{ transition: TRANSITION_LINK, date: new Date() }],
+ },
+ ],
+ pageInfo => {
+ Assert.ok(expectedGuids.has(pageInfo.guid));
+ expectedGuids.delete(pageInfo.guid);
+ }
+ );
+ Assert.equal(expectedGuids.size, 0);
+
+ Assert.ok(
+ await PlacesUtils.history.fetch(guidB),
+ "Record B is fetchable after insertMany"
+ );
+ Assert.ok(
+ await PlacesUtils.history.fetch(guidC),
+ "Record C is fetchable after insertMany"
+ );
+});
diff --git a/toolkit/components/places/tests/history/test_insert_null_title.js b/toolkit/components/places/tests/history/test_insert_null_title.js
new file mode 100644
index 0000000000..8cdcddd1e8
--- /dev/null
+++ b/toolkit/components/places/tests/history/test_insert_null_title.js
@@ -0,0 +1,78 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+// Tests that passing a null title to history insert or update doesn't overwrite
+// an existing title, while an empty string does.
+
+"use strict";
+
+async function fetchTitle(url) {
+ let entry;
+ await TestUtils.waitForCondition(async () => {
+ entry = await PlacesUtils.history.fetch(url);
+ return !!entry;
+ }, "fetch title for entry");
+ return entry.title;
+}
+
+add_task(async function () {
+ const url = "http://mozilla.com";
+ let title = "Mozilla";
+
+ info("Insert a visit with a title");
+ let result = await PlacesUtils.history.insert({
+ url,
+ title,
+ visits: [{ transition: PlacesUtils.history.TRANSITIONS.LINK }],
+ });
+ Assert.equal(title, result.title, "title should be stored");
+ Assert.equal(title, await fetchTitle(url), "title should be stored");
+
+ // This is shared by the next tests.
+ let promiseTitleChange = PlacesTestUtils.waitForNotification(
+ "page-title-changed",
+ () => (notified = true)
+ );
+
+ info("Insert a visit with a null title, should not clear the previous title");
+ let notified = false;
+ result = await PlacesUtils.history.insert({
+ url,
+ title: null,
+ visits: [{ transition: PlacesUtils.history.TRANSITIONS.LINK }],
+ });
+ Assert.equal(title, result.title, "title should be unchanged");
+ Assert.equal(title, await fetchTitle(url), "title should be unchanged");
+ await Promise.race([
+ promiseTitleChange,
+ new Promise(r => do_timeout(1000, r)),
+ ]);
+ Assert.ok(!notified, "A title change should not be notified");
+
+ info(
+ "Insert a visit without specifying a title, should not clear the previous title"
+ );
+ notified = false;
+ result = await PlacesUtils.history.insert({
+ url,
+ visits: [{ transition: PlacesUtils.history.TRANSITIONS.LINK }],
+ });
+ Assert.equal(title, result.title, "title should be unchanged");
+ Assert.equal(title, await fetchTitle(url), "title should be unchanged");
+ await Promise.race([
+ promiseTitleChange,
+ new Promise(r => do_timeout(1000, r)),
+ ]);
+ Assert.ok(!notified, "A title change should not be notified");
+
+ info("Insert a visit with an empty title, should clear the previous title");
+ result = await PlacesUtils.history.insert({
+ url,
+ title: "",
+ visits: [{ transition: PlacesUtils.history.TRANSITIONS.LINK }],
+ });
+ info("Waiting for the title change notification");
+ await promiseTitleChange;
+ Assert.equal("", result.title, "title should be empty");
+ Assert.equal("", await fetchTitle(url), "title should be empty");
+});
diff --git a/toolkit/components/places/tests/history/test_remove.js b/toolkit/components/places/tests/history/test_remove.js
new file mode 100644
index 0000000000..8c5e941fd0
--- /dev/null
+++ b/toolkit/components/places/tests/history/test_remove.js
@@ -0,0 +1,354 @@
+/* -*- indent-tabs-mode: nil; js-indent-level: 2 -*- */
+/* vim:set ts=2 sw=2 sts=2 et: */
+
+// Tests for `History.remove`, as implemented in History.jsm
+
+"use strict";
+
+// Test removing a single page
+add_task(async function test_remove_single() {
+ await PlacesUtils.history.clear();
+ await PlacesUtils.bookmarks.eraseEverything();
+
+ let WITNESS_URI = NetUtil.newURI(
+ "http://mozilla.com/test_browserhistory/test_remove/" + Math.random()
+ );
+ await PlacesTestUtils.addVisits(WITNESS_URI);
+ Assert.ok(page_in_database(WITNESS_URI));
+
+ let remover = async function (name, filter, options) {
+ info(name);
+ info(JSON.stringify(options));
+ info("Setting up visit");
+
+ let uri = NetUtil.newURI(
+ "http://mozilla.com/test_browserhistory/test_remove/" + Math.random()
+ );
+ let title = "Visit " + Math.random();
+ await PlacesTestUtils.addVisits({ uri, title });
+ Assert.ok(visits_in_database(uri), "History entry created");
+
+ let removeArg = await filter(uri);
+
+ if (options.addBookmark) {
+ await PlacesUtils.bookmarks.insert({
+ parentGuid: PlacesUtils.bookmarks.unfiledGuid,
+ url: uri,
+ title: "test bookmark",
+ });
+ }
+
+ let shouldRemove = !options.addBookmark;
+ let placesEventListener;
+ let promiseObserved = new Promise((resolve, reject) => {
+ placesEventListener = events => {
+ for (const event of events) {
+ switch (event.type) {
+ case "page-title-changed": {
+ reject(
+ "Unexpected page-title-changed event happens on " + event.url
+ );
+ break;
+ }
+ case "history-cleared": {
+ reject("Unexpected history-cleared event happens");
+ break;
+ }
+ case "pages-rank-changed": {
+ try {
+ Assert.ok(!shouldRemove, "Observing pages-rank-changed event");
+ } finally {
+ resolve();
+ }
+ break;
+ }
+ case "page-removed": {
+ Assert.equal(
+ event.isRemovedFromStore,
+ shouldRemove,
+ "Observe page-removed event with right removal type"
+ );
+ Assert.equal(
+ event.url,
+ uri.spec,
+ "Observing effect on the right uri"
+ );
+ resolve();
+ break;
+ }
+ }
+ }
+ };
+ });
+ PlacesObservers.addListener(
+ [
+ "page-title-changed",
+ "history-cleared",
+ "pages-rank-changed",
+ "page-removed",
+ ],
+ placesEventListener
+ );
+
+ info("Performing removal");
+ let removed = false;
+ if (options.useCallback) {
+ let onRowCalled = false;
+ let guid = await PlacesTestUtils.getDatabaseValue("moz_places", "guid", {
+ url: uri,
+ });
+ removed = await PlacesUtils.history.remove(removeArg, page => {
+ Assert.equal(onRowCalled, false, "Callback has not been called yet");
+ onRowCalled = true;
+ Assert.equal(
+ page.url.href,
+ uri.spec,
+ "Callback provides the correct url"
+ );
+ Assert.equal(page.guid, guid, "Callback provides the correct guid");
+ Assert.equal(page.title, title, "Callback provides the correct title");
+ });
+ Assert.ok(onRowCalled, "Callback has been called");
+ } else {
+ removed = await PlacesUtils.history.remove(removeArg);
+ }
+
+ await promiseObserved;
+ PlacesObservers.removeListener(
+ [
+ "page-title-changed",
+ "history-cleared",
+ "pages-rank-changed",
+ "page-removed",
+ ],
+ placesEventListener
+ );
+
+ Assert.equal(visits_in_database(uri), 0, "History entry has disappeared");
+ Assert.notEqual(
+ visits_in_database(WITNESS_URI),
+ 0,
+ "Witness URI still has visits"
+ );
+ Assert.notEqual(
+ page_in_database(WITNESS_URI),
+ 0,
+ "Witness URI is still here"
+ );
+ if (shouldRemove) {
+ Assert.ok(removed, "Something was removed");
+ Assert.equal(page_in_database(uri), 0, "Page has disappeared");
+ } else {
+ Assert.ok(!removed, "The page was not removed, as there was a bookmark");
+ Assert.notEqual(page_in_database(uri), 0, "The page is still present");
+ }
+ };
+
+ try {
+ for (let useCallback of [false, true]) {
+ for (let addBookmark of [false, true]) {
+ let options = { useCallback, addBookmark };
+ await remover(
+ "Testing History.remove() with a single URI",
+ x => x,
+ options
+ );
+ await remover(
+ "Testing History.remove() with a single string url",
+ x => x.spec,
+ options
+ );
+ await remover(
+ "Testing History.remove() with a single string guid",
+ async x =>
+ PlacesTestUtils.getDatabaseValue("moz_places", "guid", { url: x }),
+ options
+ );
+ await remover(
+ "Testing History.remove() with a single URI in an array",
+ x => [x],
+ options
+ );
+ await remover(
+ "Testing History.remove() with a single string url in an array",
+ x => [x.spec],
+ options
+ );
+ await remover(
+ "Testing History.remove() with a single string guid in an array",
+ x =>
+ PlacesTestUtils.getDatabaseValue("moz_places", "guid", { url: x }),
+ options
+ );
+ }
+ }
+ } finally {
+ await PlacesUtils.history.clear();
+ }
+});
+
+add_task(async function cleanup() {
+ await PlacesUtils.history.clear();
+ await PlacesUtils.bookmarks.eraseEverything();
+});
+
+// Test the various error cases
+add_task(async function test_error_cases() {
+ Assert.throws(
+ () => PlacesUtils.history.remove(),
+ /TypeError: Invalid url/,
+ "History.remove with no argument should throw a TypeError"
+ );
+ Assert.throws(
+ () => PlacesUtils.history.remove(null),
+ /TypeError: Invalid url/,
+ "History.remove with `null` should throw a TypeError"
+ );
+ Assert.throws(
+ () => PlacesUtils.history.remove(undefined),
+ /TypeError: Invalid url/,
+ "History.remove with `undefined` should throw a TypeError"
+ );
+ Assert.throws(
+ () => PlacesUtils.history.remove("not a guid, obviously"),
+ /TypeError: .* is not a valid URL/,
+ "History.remove with an ill-formed guid/url argument should throw a TypeError"
+ );
+ Assert.throws(
+ () =>
+ PlacesUtils.history.remove({
+ "not the kind of object we know how to handle": true,
+ }),
+ /TypeError: Invalid url/,
+ "History.remove with an unexpected object should throw a TypeError"
+ );
+ Assert.throws(
+ () => PlacesUtils.history.remove([]),
+ /TypeError: Expected at least one page/,
+ "History.remove with an empty array should throw a TypeError"
+ );
+ Assert.throws(
+ () => PlacesUtils.history.remove([null]),
+ /TypeError: Invalid url or guid/,
+ "History.remove with an array containing null should throw a TypeError"
+ );
+ Assert.throws(
+ () =>
+ PlacesUtils.history.remove([
+ "http://example.org",
+ "not a guid, obviously",
+ ]),
+ /TypeError: .* is not a valid URL/,
+ "History.remove with an array containing an ill-formed guid/url argument should throw a TypeError"
+ );
+ Assert.throws(
+ () => PlacesUtils.history.remove(["0123456789ab" /* valid guid*/, null]),
+ /TypeError: Invalid url or guid: null/,
+ "History.remove with an array containing a guid and a second argument that is null should throw a TypeError"
+ );
+ Assert.throws(
+ () =>
+ PlacesUtils.history.remove([
+ "http://example.org",
+ { "not the kind of object we know how to handle": true },
+ ]),
+ /TypeError: Invalid url/,
+ "History.remove with an array containing an unexpected objecgt should throw a TypeError"
+ );
+ Assert.throws(
+ () =>
+ PlacesUtils.history.remove(
+ "http://example.org",
+ "not a function, obviously"
+ ),
+ /TypeError: Invalid function/,
+ "History.remove with a second argument that is not a function argument should throw a TypeError"
+ );
+ try {
+ PlacesUtils.history.remove(
+ "http://example.org/I/have/clearly/not/been/added",
+ null
+ );
+ Assert.ok(true, "History.remove should ignore `null` as a second argument");
+ } catch (ex) {
+ Assert.ok(
+ false,
+ "History.remove should ignore `null` as a second argument"
+ );
+ }
+});
+
+add_task(async function test_orphans() {
+ let uri = NetUtil.newURI("http://moz.org/");
+ await PlacesTestUtils.addVisits({ uri });
+
+ PlacesUtils.favicons.setAndFetchFaviconForPage(
+ uri,
+ SMALLPNG_DATA_URI,
+ true,
+ PlacesUtils.favicons.FAVICON_LOAD_NON_PRIVATE,
+ null,
+ Services.scriptSecurityManager.getSystemPrincipal()
+ );
+ // Also create a root icon.
+ let faviconURI = Services.io.newURI(uri.spec + "favicon.ico");
+ PlacesUtils.favicons.replaceFaviconDataFromDataURL(
+ faviconURI,
+ SMALLPNG_DATA_URI.spec,
+ 0,
+ Services.scriptSecurityManager.getSystemPrincipal()
+ );
+ PlacesUtils.favicons.setAndFetchFaviconForPage(
+ uri,
+ faviconURI,
+ true,
+ PlacesUtils.favicons.FAVICON_LOAD_NON_PRIVATE,
+ null,
+ Services.scriptSecurityManager.getSystemPrincipal()
+ );
+
+ await PlacesUtils.history.update({
+ url: uri,
+ annotations: new Map([["test", "restval"]]),
+ });
+
+ await PlacesUtils.history.remove(uri);
+ Assert.ok(
+ !(await PlacesTestUtils.isPageInDB(uri)),
+ "Page should have been removed"
+ );
+
+ let db = await PlacesUtils.promiseDBConnection();
+ let rows = await db.execute(`SELECT (SELECT count(*) FROM moz_annos) +
+ (SELECT count(*) FROM moz_icons) +
+ (SELECT count(*) FROM moz_pages_w_icons) +
+ (SELECT count(*) FROM moz_icons_to_pages) AS count`);
+ Assert.equal(rows[0].getResultByName("count"), 0, "Should not find orphans");
+});
+
+add_task(async function test_remove_backslash() {
+ // Backslash is an escape char in Sqlite, we must take care of that when
+ // removing a url containing a backslash.
+ const url = "https://www.mozilla.org/?test=\u005C";
+ await PlacesTestUtils.addVisits(url);
+ Assert.ok(await PlacesUtils.history.remove(url), "A page should be removed");
+ Assert.deepEqual(
+ await PlacesUtils.history.fetch(url),
+ null,
+ "The page should not be found"
+ );
+});
+
+add_task(async function test_url_with_apices() {
+ // Apices may confuse code and cause injection if mishandled.
+ // The ideal test would be with a javascript url, because it would not be
+ // encoded by URL(), unfortunately it would also not be added to history.
+ const url = `http://mozilla.org/\u0022\u0027`;
+ await PlacesTestUtils.addVisits(url);
+ Assert.ok(await PlacesUtils.history.remove(url), "A page should be removed");
+ Assert.deepEqual(
+ await PlacesUtils.history.fetch(url),
+ null,
+ "The page should not be found"
+ );
+});
diff --git a/toolkit/components/places/tests/history/test_removeByFilter.js b/toolkit/components/places/tests/history/test_removeByFilter.js
new file mode 100644
index 0000000000..fb18bf8e74
--- /dev/null
+++ b/toolkit/components/places/tests/history/test_removeByFilter.js
@@ -0,0 +1,497 @@
+"use strict";
+
+/*
+This test will ideally test the following cases
+(each with and without a callback associated with it)
+ Case A: Tests which should remove pages (Positives)
+ Case A 1: Page has multiple visits both in/out of timeframe, all get deleted
+ Case A 2: Page has single uri, removed by host
+ Case A 3: Page has random subhost, with same host, removed by wildcard
+ Case A 4: Page is localhost and localhost:port, removed by host
+ Case A 5: Page is a `file://` type address, removed by empty host
+ Cases A 1,2,3 will be tried with and without bookmarks added (which prevent page deletion)
+ Case B: Tests in which no pages are removed (Inverses)
+ Case B 1 (inverse): Page has no visits in timeframe, and nothing is deleted
+ Case B 2: Page has single uri, not removed since hostname is different
+ Case B 3: Page has multiple subhosts, not removed since wildcard doesn't match
+ Case C: Combinations tests
+ Case C 1: Single hostname, multiple visits, at least one in timeframe and hostname
+ Case C 2: Random subhosts, multiple visits, at least one in timeframe and hostname-wildcard
+*/
+
+add_task(async function test_removeByFilter() {
+ // Cleanup
+ await PlacesUtils.history.clear();
+ await PlacesUtils.bookmarks.eraseEverything();
+
+ // Adding a witness URI
+ let witnessURI = NetUtil.newURI(
+ "http://witnessmozilla.org/test_browserhistory/test_removeByFilter" +
+ Math.random()
+ );
+ await PlacesTestUtils.addVisits(witnessURI);
+ Assert.ok(
+ await PlacesTestUtils.isPageInDB(witnessURI),
+ "Witness URI is in database"
+ );
+
+ let removeByFilterTester = async function (
+ visits,
+ filter,
+ checkBeforeRemove,
+ checkAfterRemove,
+ useCallback,
+ bookmarkedUri
+ ) {
+ // Add visits for URIs
+ await PlacesTestUtils.addVisits(visits);
+ if (
+ bookmarkedUri !== null &&
+ visits.map(v => v.uri).includes(bookmarkedUri)
+ ) {
+ await PlacesUtils.bookmarks.insert({
+ parentGuid: PlacesUtils.bookmarks.unfiledGuid,
+ url: bookmarkedUri,
+ title: "test bookmark",
+ });
+ }
+ await checkBeforeRemove();
+
+ // Take care of any observers (due to bookmarks)
+ let { placesEventListener, promiseObserved } =
+ getObserverPromise(bookmarkedUri);
+ if (placesEventListener) {
+ PlacesObservers.addListener(
+ ["page-title-changed", "history-cleared", "page-removed"],
+ placesEventListener
+ );
+ }
+ // Perfom delete operation on database
+ let removed = false;
+ if (useCallback) {
+ // The amount of callbacks will be the unique URIs to remove from the database
+ let netCallbacksRequired = new Set(visits.map(v => v.uri)).size;
+ removed = await PlacesUtils.history.removeByFilter(filter, pageInfo => {
+ Assert.ok(
+ PlacesUtils.validatePageInfo(pageInfo, false),
+ "pageInfo should follow a basic format"
+ );
+ Assert.ok(
+ netCallbacksRequired > 0,
+ "Callback called as many times as required"
+ );
+ netCallbacksRequired--;
+ });
+ } else {
+ removed = await PlacesUtils.history.removeByFilter(filter);
+ }
+ await checkAfterRemove();
+ await promiseObserved;
+ if (placesEventListener) {
+ await PlacesUtils.bookmarks.eraseEverything();
+ PlacesObservers.removeListener(
+ ["page-title-changed", "history-cleared", "page-removed"],
+ placesEventListener
+ );
+ }
+ Assert.ok(
+ await PlacesTestUtils.isPageInDB(witnessURI),
+ "Witness URI is still in database"
+ );
+ return removed;
+ };
+
+ const remoteUriList = [
+ "http://mozilla.org/test_browserhistory/test_removeByFilter/" +
+ Math.random(),
+ "http://subdomain1.mozilla.org/test_browserhistory/test_removeByFilter/" +
+ Math.random(),
+ "http://subdomain2.mozilla.org/test_browserhistory/test_removeByFilter/" +
+ Math.random(),
+ ];
+ const localhostUriList = [
+ "http://localhost:4500/" + Math.random(),
+ "http://localhost/" + Math.random(),
+ ];
+ const fileUriList = ["file:///home/user/files" + Math.random()];
+ const title = "Title " + Math.random();
+ let sameHostVisits = [
+ {
+ uri: remoteUriList[0],
+ title,
+ visitDate: new Date(2005, 1, 1) * 1000,
+ },
+ {
+ uri: remoteUriList[0],
+ title,
+ visitDate: new Date(2005, 3, 3) * 1000,
+ },
+ {
+ uri: remoteUriList[0],
+ title,
+ visitDate: new Date(2007, 1, 1) * 1000,
+ },
+ ];
+ let randomHostVisits = [
+ {
+ uri: remoteUriList[0],
+ title,
+ visitDate: new Date(2005, 1, 1) * 1000,
+ },
+ {
+ uri: remoteUriList[1],
+ title,
+ visitDate: new Date(2005, 3, 3) * 1000,
+ },
+ {
+ uri: remoteUriList[2],
+ title,
+ visitDate: new Date(2007, 1, 1) * 1000,
+ },
+ ];
+ let localhostVisits = [
+ {
+ uri: localhostUriList[0],
+ title,
+ },
+ {
+ uri: localhostUriList[1],
+ title,
+ },
+ ];
+ let fileVisits = [
+ {
+ uri: fileUriList[0],
+ title,
+ },
+ ];
+ let assertInDB = async function (aUri) {
+ Assert.ok(await PlacesTestUtils.isPageInDB(aUri));
+ };
+ let assertNotInDB = async function (aUri) {
+ Assert.ok(!(await PlacesTestUtils.isPageInDB(aUri)));
+ };
+ for (let callbackUse of [true, false]) {
+ // Case A Positives
+ for (let bookmarkUse of [true, false]) {
+ let bookmarkedUri = arr => undefined;
+ let checkableArray = arr => arr;
+ let checkClosure = assertNotInDB;
+ if (bookmarkUse) {
+ bookmarkedUri = arr => arr[0];
+ checkableArray = arr => arr.slice(1);
+ checkClosure = function (aUri) {};
+ }
+ // Case A 1: Dates
+ await removeByFilterTester(
+ sameHostVisits,
+ { beginDate: new Date(2004, 1, 1), endDate: new Date(2006, 1, 1) },
+ () => assertInDB(remoteUriList[0]),
+ () => checkClosure(remoteUriList[0]),
+ callbackUse,
+ bookmarkedUri(remoteUriList)
+ );
+ // Case A 2: Single Sub-host
+ await removeByFilterTester(
+ sameHostVisits,
+ { host: "mozilla.org" },
+ () => assertInDB(remoteUriList[0]),
+ () => checkClosure(remoteUriList[0]),
+ callbackUse,
+ bookmarkedUri(remoteUriList)
+ );
+ // Case A 3: Multiple subhost
+ await removeByFilterTester(
+ randomHostVisits,
+ { host: ".mozilla.org" },
+ async () => {
+ for (let uri of remoteUriList) {
+ await assertInDB(uri);
+ }
+ },
+ async () => {
+ for (let uri of checkableArray(remoteUriList)) {
+ await checkClosure(uri);
+ }
+ },
+ callbackUse,
+ bookmarkedUri(remoteUriList)
+ );
+ }
+
+ // Case A 4: Localhost
+ await removeByFilterTester(
+ localhostVisits,
+ { host: "localhost" },
+ async () => {
+ for (let uri of localhostUriList) {
+ await assertInDB(uri);
+ }
+ },
+ async () => {
+ for (let uri of localhostUriList) {
+ await assertNotInDB(uri);
+ }
+ },
+ callbackUse
+ );
+ // Case A 5: Local Files
+ await removeByFilterTester(
+ fileVisits,
+ { host: "." },
+ async () => {
+ for (let uri of fileUriList) {
+ await assertInDB(uri);
+ }
+ },
+ async () => {
+ for (let uri of fileUriList) {
+ await assertNotInDB(uri);
+ }
+ },
+ callbackUse
+ );
+
+ // Case B: Tests which do not remove anything (inverses)
+ // Case B 1: Date
+ await removeByFilterTester(
+ sameHostVisits,
+ { beginDate: new Date(2001, 1, 1), endDate: new Date(2002, 1, 1) },
+ () => assertInDB(remoteUriList[0]),
+ () => assertInDB(remoteUriList[0]),
+ callbackUse
+ );
+ // Case B 2 : Single subhost
+ await removeByFilterTester(
+ sameHostVisits,
+ { host: "notthere.org" },
+ () => assertInDB(remoteUriList[0]),
+ () => assertInDB(remoteUriList[0]),
+ callbackUse
+ );
+ // Case B 3 : Multiple subhosts
+ await removeByFilterTester(
+ randomHostVisits,
+ { host: ".notthere.org" },
+ async () => {
+ for (let uri of remoteUriList) {
+ await assertInDB(uri);
+ }
+ },
+ async () => {
+ for (let uri of remoteUriList) {
+ await assertInDB(uri);
+ }
+ },
+ callbackUse
+ );
+ // Case B 4 : invalid local subhost
+ await removeByFilterTester(
+ randomHostVisits,
+ { host: ".org" },
+ async () => {
+ for (let uri of remoteUriList) {
+ await assertInDB(uri);
+ }
+ },
+ async () => {
+ for (let uri of remoteUriList) {
+ await assertInDB(uri);
+ }
+ },
+ callbackUse
+ );
+
+ // Case C: Combination Cases
+ // Case C 1: single subhost
+ await removeByFilterTester(
+ sameHostVisits,
+ {
+ host: "mozilla.org",
+ beginDate: new Date(2004, 1, 1),
+ endDate: new Date(2006, 1, 1),
+ },
+ () => assertInDB(remoteUriList[0]),
+ () => assertNotInDB(remoteUriList[0]),
+ callbackUse
+ );
+ // Case C 2: multiple subhost
+ await removeByFilterTester(
+ randomHostVisits,
+ {
+ host: ".mozilla.org",
+ beginDate: new Date(2005, 1, 1),
+ endDate: new Date(2017, 1, 1),
+ },
+ async () => {
+ for (let uri of remoteUriList) {
+ await assertInDB(uri);
+ }
+ },
+ async () => {
+ for (let uri of remoteUriList) {
+ await assertNotInDB(uri);
+ }
+ },
+ callbackUse
+ );
+ }
+});
+
+// Test various error cases
+add_task(async function test_error_cases() {
+ Assert.throws(
+ () => PlacesUtils.history.removeByFilter(),
+ /TypeError: Expected a filter/
+ );
+ Assert.throws(
+ () => PlacesUtils.history.removeByFilter("obviously, not a filter"),
+ /TypeError: Expected a filter/
+ );
+ Assert.throws(
+ () => PlacesUtils.history.removeByFilter({}),
+ /TypeError: Expected a non-empty filter/
+ );
+ Assert.throws(
+ () => PlacesUtils.history.removeVisitsByFilter({ beginDate: "now" }),
+ /TypeError: Expected a valid Date/
+ );
+ Assert.throws(
+ () => PlacesUtils.history.removeByFilter({ beginDate: Date.now() }),
+ /TypeError: Expected a valid Date/
+ );
+ Assert.throws(
+ () => PlacesUtils.history.removeByFilter({ beginDate: new Date(NaN) }),
+ /TypeError: Expected a valid Date/
+ );
+ Assert.throws(
+ () =>
+ PlacesUtils.history.removeByFilter(
+ { beginDate: new Date() },
+ "obviously, not a callback"
+ ),
+ /TypeError: Invalid function/
+ );
+ Assert.throws(
+ () =>
+ PlacesUtils.history.removeByFilter({
+ beginDate: new Date(1000),
+ endDate: new Date(0),
+ }),
+ /TypeError: `beginDate` should be at least as old/
+ );
+ Assert.throws(
+ () => PlacesUtils.history.removeByFilter({ host: "#" }),
+ /TypeError: Expected well formed hostname string for/
+ );
+ Assert.throws(
+ () => PlacesUtils.history.removeByFilter({ host: "www..org" }),
+ /TypeError: Expected well formed hostname string for/
+ );
+ Assert.throws(
+ () => PlacesUtils.history.removeByFilter({ host: {} }),
+ /TypeError: `host` should be a string/
+ );
+ Assert.throws(
+ () => PlacesUtils.history.removeByFilter({ host: "*.mozilla.org" }),
+ /TypeError: Expected well formed hostname string for/
+ );
+ Assert.throws(
+ () => PlacesUtils.history.removeByFilter({ host: "*" }),
+ /TypeError: Expected well formed hostname string for/
+ );
+ Assert.throws(
+ () => PlacesUtils.history.removeByFilter({ host: "local.host." }),
+ /TypeError: Expected well formed hostname string for/
+ );
+ Assert.throws(
+ () => PlacesUtils.history.removeByFilter({ host: "(local files)" }),
+ /TypeError: Expected well formed hostname string for/
+ );
+ Assert.throws(
+ () => PlacesUtils.history.removeByFilter({ host: "" }),
+ /TypeError: Expected a non-empty filter/
+ );
+});
+
+add_task(async function test_chunking() {
+ await PlacesUtils.history.clear();
+ await PlacesUtils.bookmarks.eraseEverything();
+
+ info("Insert many visited pages");
+ let pages = [];
+ for (let i = 1; i <= 1500; i++) {
+ let visits = [
+ {
+ date: new Date(Date.now() - (86400 + i) * 1000),
+ transition: PlacesUtils.history.TRANSITIONS.TYPED,
+ },
+ ];
+ pages.push(
+ {
+ url: `http://example.com/${i}`,
+ title: `Page ${i}`,
+ visits,
+ },
+ {
+ url: `http://subdomain.example.com/${i}`,
+ title: `Subdomain ${i}`,
+ visits,
+ }
+ );
+ }
+ await PlacesUtils.history.insertMany(pages);
+
+ info("Remove all visited pages");
+ await PlacesUtils.history.removeByFilter({
+ host: ".example.com",
+ });
+});
+
+// Helper functions
+
+function getObserverPromise(bookmarkedUri) {
+ if (!bookmarkedUri) {
+ return { promiseObserved: Promise.resolve() };
+ }
+ let placesEventListener;
+ let promiseObserved = new Promise((resolve, reject) => {
+ placesEventListener = events => {
+ for (const event of events) {
+ switch (event.type) {
+ case "page-title-changed": {
+ reject(new Error("Unexpected page-title-changed event happens"));
+ break;
+ }
+ case "history-cleared": {
+ reject(new Error("Unexpected history-cleared event happens"));
+ break;
+ }
+ case "page-removed": {
+ if (event.isRemovedFromStore) {
+ Assert.notEqual(
+ event.url,
+ bookmarkedUri,
+ "Bookmarked URI should not be deleted"
+ );
+ } else {
+ Assert.equal(
+ event.isPartialVisistsRemoval,
+ false,
+ "Observing page-removed deletes all visits"
+ );
+ Assert.equal(
+ event.url,
+ bookmarkedUri,
+ "Bookmarked URI should have all visits removed but not the page itself"
+ );
+ }
+ resolve();
+ break;
+ }
+ }
+ }
+ };
+ });
+ return { placesEventListener, promiseObserved };
+}
diff --git a/toolkit/components/places/tests/history/test_removeMany.js b/toolkit/components/places/tests/history/test_removeMany.js
new file mode 100644
index 0000000000..ff8c3a21ee
--- /dev/null
+++ b/toolkit/components/places/tests/history/test_removeMany.js
@@ -0,0 +1,206 @@
+/* -*- indent-tabs-mode: nil; js-indent-level: 2 -*- */
+/* vim:set ts=2 sw=2 sts=2 et: */
+
+// Tests for `History.remove` with removing many urls, as implemented in
+// History.jsm.
+
+"use strict";
+
+// Test removing a list of pages
+add_task(async function test_remove_many() {
+ // This is set so that we are guaranteed to trigger REMOVE_PAGES_CHUNKLEN.
+ const SIZE = 310;
+
+ await PlacesUtils.history.clear();
+ await PlacesUtils.bookmarks.eraseEverything();
+
+ info("Adding a witness page");
+ let WITNESS_URI = NetUtil.newURI(
+ "http://mozilla.com/test_browserhistory/test_remove/" + Math.random()
+ );
+ await PlacesTestUtils.addVisits(WITNESS_URI);
+ Assert.ok(page_in_database(WITNESS_URI), "Witness page added");
+
+ info("Generating samples");
+ let pages = [];
+ for (let i = 0; i < SIZE; ++i) {
+ let uri = NetUtil.newURI(
+ "http://mozilla.com/test_browserhistory/test_remove?sample=" +
+ i +
+ "&salt=" +
+ Math.random()
+ );
+ let title = "Visit " + i + ", " + Math.random();
+ let hasBookmark = i % 3 == 0;
+ let page = {
+ uri,
+ title,
+ hasBookmark,
+ // `true` once `onResult` has been called for this page
+ onResultCalled: false,
+ // `true` once page-removed for store has been fired for this page
+ pageRemovedFromStore: false,
+ // `true` once page-removed for all visits has been fired for this page
+ pageRemovedAllVisits: false,
+ };
+ info("Pushing: " + uri.spec);
+ pages.push(page);
+
+ await PlacesTestUtils.addVisits(page);
+ page.guid = await PlacesTestUtils.getDatabaseValue("moz_places", "guid", {
+ url: uri,
+ });
+ if (hasBookmark) {
+ await PlacesUtils.bookmarks.insert({
+ parentGuid: PlacesUtils.bookmarks.unfiledGuid,
+ url: uri,
+ title: "test bookmark " + i,
+ });
+ }
+ Assert.ok(page_in_database(uri), "Page added");
+ }
+
+ info("Mixing key types and introducing dangling keys");
+ let keys = [];
+ for (let i = 0; i < SIZE; ++i) {
+ if (i % 4 == 0) {
+ keys.push(pages[i].uri);
+ keys.push(NetUtil.newURI("http://example.org/dangling/nsIURI/" + i));
+ } else if (i % 4 == 1) {
+ keys.push(new URL(pages[i].uri.spec));
+ keys.push(new URL("http://example.org/dangling/URL/" + i));
+ } else if (i % 4 == 2) {
+ keys.push(pages[i].uri.spec);
+ keys.push("http://example.org/dangling/stringuri/" + i);
+ } else {
+ keys.push(pages[i].guid);
+ keys.push(("guid_" + i + "_01234567890").substr(0, 12));
+ }
+ }
+
+ let onPageRankingChanged = false;
+ const placesEventListener = events => {
+ for (const event of events) {
+ switch (event.type) {
+ case "page-title-changed": {
+ Assert.ok(
+ false,
+ "Unexpected page-title-changed event happens on " + event.url
+ );
+ break;
+ }
+ case "history-cleared": {
+ Assert.ok(false, "Unexpected history-cleared event happens");
+ break;
+ }
+ case "pages-rank-changed": {
+ onPageRankingChanged = true;
+ break;
+ }
+ case "page-removed": {
+ const origin = pages.find(x => x.uri.spec === event.url);
+ Assert.ok(origin);
+
+ if (event.isRemovedFromStore) {
+ Assert.ok(
+ !origin.hasBookmark,
+ "Observing page-removed event on a page without a bookmark"
+ );
+ Assert.ok(
+ !origin.pageRemovedFromStore,
+ "Observing page-removed for store for the first time"
+ );
+ origin.pageRemovedFromStore = true;
+ } else {
+ Assert.ok(
+ !origin.pageRemovedAllVisits,
+ "Observing page-removed for all visits for the first time"
+ );
+ origin.pageRemovedAllVisits = true;
+ }
+ break;
+ }
+ }
+ }
+ };
+
+ PlacesObservers.addListener(
+ [
+ "page-title-changed",
+ "history-cleared",
+ "pages-rank-changed",
+ "page-removed",
+ ],
+ placesEventListener
+ );
+
+ info("Removing the pages and checking the callbacks");
+
+ let removed = await PlacesUtils.history.remove(keys, page => {
+ let origin = pages.find(candidate => candidate.uri.spec == page.url.href);
+
+ Assert.ok(origin, "onResult has a valid page");
+ Assert.ok(!origin.onResultCalled, "onResult has not seen this page yet");
+ origin.onResultCalled = true;
+ Assert.equal(page.guid, origin.guid, "onResult has the right guid");
+ Assert.equal(page.title, origin.title, "onResult has the right title");
+ });
+ Assert.ok(removed, "Something was removed");
+ await PlacesFrecencyRecalculator.recalculateAnyOutdatedFrecencies();
+
+ PlacesObservers.removeListener(
+ [
+ "page-title-changed",
+ "history-cleared",
+ "pages-rank-changed",
+ "page-removed",
+ ],
+ placesEventListener
+ );
+
+ info("Checking out results");
+ // By now the observers should have been called.
+ for (let i = 0; i < pages.length; ++i) {
+ let page = pages[i];
+ Assert.ok(
+ page.onResultCalled,
+ `We have reached the page #${i} from the callback`
+ );
+ Assert.ok(
+ visits_in_database(page.uri) == 0,
+ "History entry has disappeared"
+ );
+ Assert.equal(
+ page_in_database(page.uri) != 0,
+ page.hasBookmark,
+ "Page is present only if it also has bookmarks"
+ );
+ Assert.notEqual(
+ page.pageRemovedFromStore,
+ page.pageRemovedAllVisits,
+ "Either only page-removed event for store or all visits should be called"
+ );
+ }
+
+ Assert.equal(
+ onPageRankingChanged,
+ pages.some(p => p.pageRemovedFromStore || p.pageRemovedAllVisits),
+ "page-rank-changed was fired"
+ );
+
+ Assert.notEqual(
+ visits_in_database(WITNESS_URI),
+ 0,
+ "Witness URI still has visits"
+ );
+ Assert.notEqual(
+ page_in_database(WITNESS_URI),
+ 0,
+ "Witness URI is still here"
+ );
+});
+
+add_task(async function cleanup() {
+ await PlacesUtils.history.clear();
+ await PlacesUtils.bookmarks.eraseEverything();
+});
diff --git a/toolkit/components/places/tests/history/test_removeVisits.js b/toolkit/components/places/tests/history/test_removeVisits.js
new file mode 100644
index 0000000000..3a82132bd8
--- /dev/null
+++ b/toolkit/components/places/tests/history/test_removeVisits.js
@@ -0,0 +1,376 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/ */
+
+const JS_NOW = Date.now();
+const DB_NOW = JS_NOW * 1000;
+const TEST_URI = uri("http://example.com/");
+
+async function cleanup() {
+ await PlacesUtils.history.clear();
+ await PlacesUtils.bookmarks.eraseEverything();
+ // This is needed to remove place: entries.
+ DBConn().executeSimpleSQL("DELETE FROM moz_places");
+}
+
+add_task(async function remove_visits_outside_unbookmarked_uri() {
+ info(
+ "*** TEST: Remove some visits outside valid timeframe from an unbookmarked URI"
+ );
+
+ info("Add 10 visits for the URI from way in the past.");
+ let visits = [];
+ for (let i = 0; i < 10; i++) {
+ visits.push({ uri: TEST_URI, visitDate: DB_NOW - 100000 - i * 1000 });
+ }
+ await PlacesTestUtils.addVisits(visits);
+
+ info("Remove visits using timerange outside the URI's visits.");
+ let filter = {
+ beginDate: new Date(JS_NOW - 10),
+ endDate: new Date(JS_NOW),
+ };
+ await PlacesUtils.history.removeVisitsByFilter(filter);
+ await PlacesFrecencyRecalculator.recalculateAnyOutdatedFrecencies();
+
+ info("URI should still exist in moz_places.");
+ Assert.ok(page_in_database(TEST_URI.spec));
+
+ info("Run a history query and check that all visits still exist.");
+ let query = PlacesUtils.history.getNewQuery();
+ let opts = PlacesUtils.history.getNewQueryOptions();
+ opts.resultType = opts.RESULTS_AS_VISIT;
+ opts.sortingMode = opts.SORT_BY_DATE_DESCENDING;
+ let root = PlacesUtils.history.executeQuery(query, opts).root;
+ root.containerOpen = true;
+ Assert.equal(root.childCount, 10);
+ for (let i = 0; i < root.childCount; i++) {
+ let visitTime = root.getChild(i).time;
+ Assert.equal(visitTime, DB_NOW - 100000 - i * 1000);
+ }
+ root.containerOpen = false;
+
+ Assert.ok(
+ await PlacesUtils.history.hasVisits(TEST_URI),
+ "visit should exist"
+ );
+
+ await PlacesFrecencyRecalculator.recalculateAnyOutdatedFrecencies();
+ info("Frecency should be positive.");
+ Assert.ok(
+ (await PlacesTestUtils.getDatabaseValue("moz_places", "frecency", {
+ url: TEST_URI,
+ })) > 0
+ );
+
+ await cleanup();
+});
+
+add_task(async function remove_visits_outside_bookmarked_uri() {
+ info(
+ "*** TEST: Remove some visits outside valid timeframe from a bookmarked URI"
+ );
+
+ info("Add 10 visits for the URI from way in the past.");
+ let visits = [];
+ for (let i = 0; i < 10; i++) {
+ visits.push({ uri: TEST_URI, visitDate: DB_NOW - 100000 - i * 1000 });
+ }
+ await PlacesTestUtils.addVisits(visits);
+ info("Bookmark the URI.");
+ await PlacesUtils.bookmarks.insert({
+ parentGuid: PlacesUtils.bookmarks.unfiledGuid,
+ url: TEST_URI,
+ title: "bookmark title",
+ });
+
+ info("Remove visits using timerange outside the URI's visits.");
+ let filter = {
+ beginDate: new Date(JS_NOW - 10),
+ endDate: new Date(JS_NOW),
+ };
+ await PlacesUtils.history.removeVisitsByFilter(filter);
+ await PlacesFrecencyRecalculator.recalculateAnyOutdatedFrecencies();
+
+ info("URI should still exist in moz_places.");
+ Assert.ok(page_in_database(TEST_URI.spec));
+
+ info("Run a history query and check that all visits still exist.");
+ let query = PlacesUtils.history.getNewQuery();
+ let opts = PlacesUtils.history.getNewQueryOptions();
+ opts.resultType = opts.RESULTS_AS_VISIT;
+ opts.sortingMode = opts.SORT_BY_DATE_DESCENDING;
+ let root = PlacesUtils.history.executeQuery(query, opts).root;
+ root.containerOpen = true;
+ Assert.equal(root.childCount, 10);
+ for (let i = 0; i < root.childCount; i++) {
+ let visitTime = root.getChild(i).time;
+ Assert.equal(visitTime, DB_NOW - 100000 - i * 1000);
+ }
+ root.containerOpen = false;
+
+ Assert.ok(
+ await PlacesUtils.history.hasVisits(TEST_URI),
+ "visit should exist"
+ );
+ await PlacesFrecencyRecalculator.recalculateAnyOutdatedFrecencies();
+
+ info("Frecency should be positive.");
+ Assert.ok(
+ (await PlacesTestUtils.getDatabaseValue("moz_places", "frecency", {
+ url: TEST_URI,
+ })) > 0
+ );
+
+ await cleanup();
+});
+
+add_task(async function remove_visits_unbookmarked_uri() {
+ info("*** TEST: Remove some visits from an unbookmarked URI");
+
+ info("Add 10 visits for the URI from now to 9 usecs in the past.");
+ let visits = [];
+ for (let i = 0; i < 10; i++) {
+ visits.push({ uri: TEST_URI, visitDate: DB_NOW - i * 1000 });
+ }
+ await PlacesTestUtils.addVisits(visits);
+
+ info("Remove the 5 most recent visits.");
+ let filter = {
+ beginDate: new Date(JS_NOW - 4),
+ endDate: new Date(JS_NOW),
+ };
+ await PlacesUtils.history.removeVisitsByFilter(filter);
+ await PlacesFrecencyRecalculator.recalculateAnyOutdatedFrecencies();
+
+ info("URI should still exist in moz_places.");
+ Assert.ok(page_in_database(TEST_URI.spec));
+
+ info(
+ "Run a history query and check that only the older 5 visits still exist."
+ );
+ let query = PlacesUtils.history.getNewQuery();
+ let opts = PlacesUtils.history.getNewQueryOptions();
+ opts.resultType = opts.RESULTS_AS_VISIT;
+ opts.sortingMode = opts.SORT_BY_DATE_DESCENDING;
+ let root = PlacesUtils.history.executeQuery(query, opts).root;
+ root.containerOpen = true;
+ Assert.equal(root.childCount, 5);
+ for (let i = 0; i < root.childCount; i++) {
+ let visitTime = root.getChild(i).time;
+ Assert.equal(visitTime, DB_NOW - i * 1000 - 5000);
+ }
+ root.containerOpen = false;
+
+ Assert.ok(
+ await PlacesUtils.history.hasVisits(TEST_URI),
+ "visit should exist"
+ );
+ await PlacesFrecencyRecalculator.recalculateAnyOutdatedFrecencies();
+
+ info("Frecency should be positive.");
+ Assert.ok(
+ (await PlacesTestUtils.getDatabaseValue("moz_places", "frecency", {
+ url: TEST_URI,
+ })) > 0
+ );
+
+ await cleanup();
+});
+
+add_task(async function remove_visits_bookmarked_uri() {
+ info("*** TEST: Remove some visits from a bookmarked URI");
+
+ info("Add 10 visits for the URI from now to 9 usecs in the past.");
+ let visits = [];
+ for (let i = 0; i < 10; i++) {
+ visits.push({ uri: TEST_URI, visitDate: DB_NOW - i * 1000 });
+ }
+ await PlacesTestUtils.addVisits(visits);
+ info("Bookmark the URI.");
+ await PlacesUtils.bookmarks.insert({
+ parentGuid: PlacesUtils.bookmarks.unfiledGuid,
+ url: TEST_URI,
+ title: "bookmark title",
+ });
+
+ info("Remove the 5 most recent visits.");
+ let filter = {
+ beginDate: new Date(JS_NOW - 4),
+ endDate: new Date(JS_NOW),
+ };
+ await PlacesUtils.history.removeVisitsByFilter(filter);
+ await PlacesFrecencyRecalculator.recalculateAnyOutdatedFrecencies();
+
+ info("URI should still exist in moz_places.");
+ Assert.ok(page_in_database(TEST_URI.spec));
+
+ info(
+ "Run a history query and check that only the older 5 visits still exist."
+ );
+ let query = PlacesUtils.history.getNewQuery();
+ let opts = PlacesUtils.history.getNewQueryOptions();
+ opts.resultType = opts.RESULTS_AS_VISIT;
+ opts.sortingMode = opts.SORT_BY_DATE_DESCENDING;
+ let root = PlacesUtils.history.executeQuery(query, opts).root;
+ root.containerOpen = true;
+ Assert.equal(root.childCount, 5);
+ for (let i = 0; i < root.childCount; i++) {
+ let visitTime = root.getChild(i).time;
+ Assert.equal(visitTime, DB_NOW - i * 1000 - 5000);
+ }
+ root.containerOpen = false;
+
+ Assert.ok(
+ await PlacesUtils.history.hasVisits(TEST_URI),
+ "visit should exist"
+ );
+ await PlacesFrecencyRecalculator.recalculateAnyOutdatedFrecencies();
+
+ info("Frecency should be positive.");
+ Assert.ok(
+ (await PlacesTestUtils.getDatabaseValue("moz_places", "frecency", {
+ url: TEST_URI,
+ })) > 0
+ );
+
+ await cleanup();
+});
+
+add_task(async function remove_all_visits_unbookmarked_uri() {
+ info("*** TEST: Remove all visits from an unbookmarked URI");
+
+ info("Add some visits for the URI.");
+ let visits = [];
+ for (let i = 0; i < 10; i++) {
+ visits.push({ uri: TEST_URI, visitDate: DB_NOW - i * 1000 });
+ }
+ await PlacesTestUtils.addVisits(visits);
+
+ info("Remove all visits.");
+ let filter = {
+ beginDate: new Date(JS_NOW - 10),
+ endDate: new Date(JS_NOW),
+ };
+ await PlacesUtils.history.removeVisitsByFilter(filter);
+ await PlacesFrecencyRecalculator.recalculateAnyOutdatedFrecencies();
+
+ info("URI should no longer exist in moz_places.");
+ Assert.ok(!page_in_database(TEST_URI.spec));
+
+ info("Run a history query and check that no visits exist.");
+ let query = PlacesUtils.history.getNewQuery();
+ let opts = PlacesUtils.history.getNewQueryOptions();
+ opts.resultType = opts.RESULTS_AS_VISIT;
+ opts.sortingMode = opts.SORT_BY_DATE_DESCENDING;
+ let root = PlacesUtils.history.executeQuery(query, opts).root;
+ root.containerOpen = true;
+ Assert.equal(root.childCount, 0);
+ root.containerOpen = false;
+
+ Assert.equal(
+ false,
+ await PlacesUtils.history.hasVisits(TEST_URI),
+ "visit should not exist"
+ );
+
+ await cleanup();
+});
+
+add_task(async function remove_all_visits_bookmarked_uri() {
+ info("*** TEST: Remove all visits from a bookmarked URI");
+
+ info("Add some visits for the URI.");
+ let visits = [];
+ for (let i = 0; i < 10; i++) {
+ visits.push({ uri: TEST_URI, visitDate: DB_NOW - i * 1000 });
+ }
+ await PlacesTestUtils.addVisits(visits);
+ info("Bookmark the URI.");
+ await PlacesUtils.bookmarks.insert({
+ parentGuid: PlacesUtils.bookmarks.unfiledGuid,
+ url: TEST_URI,
+ title: "bookmark title",
+ });
+ await PlacesFrecencyRecalculator.recalculateAnyOutdatedFrecencies();
+ let initialFrecency = await PlacesTestUtils.getDatabaseValue(
+ "moz_places",
+ "frecency",
+ { url: TEST_URI }
+ );
+
+ info("Remove all visits.");
+ let filter = {
+ beginDate: new Date(JS_NOW - 10),
+ endDate: new Date(JS_NOW),
+ };
+ await PlacesUtils.history.removeVisitsByFilter(filter);
+ await PlacesFrecencyRecalculator.recalculateAnyOutdatedFrecencies();
+
+ info("URI should still exist in moz_places.");
+ Assert.ok(page_in_database(TEST_URI.spec));
+
+ info("Run a history query and check that no visits exist.");
+ let query = PlacesUtils.history.getNewQuery();
+ let opts = PlacesUtils.history.getNewQueryOptions();
+ opts.resultType = opts.RESULTS_AS_VISIT;
+ opts.sortingMode = opts.SORT_BY_DATE_DESCENDING;
+ let root = PlacesUtils.history.executeQuery(query, opts).root;
+ root.containerOpen = true;
+ Assert.equal(root.childCount, 0);
+ root.containerOpen = false;
+
+ Assert.equal(
+ false,
+ await PlacesUtils.history.hasVisits(TEST_URI),
+ "visit should not exist"
+ );
+
+ info("URI should be bookmarked");
+ Assert.ok(await PlacesUtils.bookmarks.fetch({ url: TEST_URI }));
+ await PlacesFrecencyRecalculator.recalculateAnyOutdatedFrecencies();
+
+ info("Frecency should be smaller.");
+ Assert.ok(
+ (await PlacesTestUtils.getDatabaseValue("moz_places", "frecency", {
+ url: TEST_URI,
+ })) < initialFrecency
+ );
+
+ await cleanup();
+});
+
+add_task(async function remove_all_visits_bookmarked_uri() {
+ info(
+ "*** TEST: Remove some visits from a zero frecency URI retains zero frecency"
+ );
+
+ info("Add some visits for the URI.");
+ await PlacesTestUtils.addVisits([
+ {
+ uri: TEST_URI,
+ transition: TRANSITION_FRAMED_LINK,
+ visitDate: DB_NOW - 86400000000000,
+ },
+ { uri: TEST_URI, transition: TRANSITION_FRAMED_LINK, visitDate: DB_NOW },
+ ]);
+
+ info("Remove newer visit.");
+ let filter = {
+ beginDate: new Date(JS_NOW - 10),
+ endDate: new Date(JS_NOW),
+ };
+ await PlacesUtils.history.removeVisitsByFilter(filter);
+ await PlacesFrecencyRecalculator.recalculateAnyOutdatedFrecencies();
+
+ info("URI should still exist in moz_places.");
+ Assert.ok(page_in_database(TEST_URI.spec));
+ info("Frecency should be zero.");
+ Assert.equal(
+ await PlacesTestUtils.getDatabaseValue("moz_places", "frecency", {
+ url: TEST_URI,
+ }),
+ 0
+ );
+
+ await cleanup();
+});
diff --git a/toolkit/components/places/tests/history/test_removeVisitsByFilter.js b/toolkit/components/places/tests/history/test_removeVisitsByFilter.js
new file mode 100644
index 0000000000..5681ab22bc
--- /dev/null
+++ b/toolkit/components/places/tests/history/test_removeVisitsByFilter.js
@@ -0,0 +1,408 @@
+/* -*- indent-tabs-mode: nil; js-indent-level: 2 -*- */
+/* vim:set ts=2 sw=2 sts=2 et: */
+
+// Tests for `History.removeVisitsByFilter`, as implemented in History.jsm
+
+"use strict";
+
+add_task(async function test_removeVisitsByFilter() {
+ let referenceDate = new Date(1999, 9, 9, 9, 9);
+
+ // Populate a database with 20 entries, remove a subset of entries,
+ // ensure consistency.
+ let remover = async function (options) {
+ info("Remover with options " + JSON.stringify(options));
+ let SAMPLE_SIZE = options.sampleSize;
+
+ await PlacesUtils.history.clear();
+ await PlacesUtils.bookmarks.eraseEverything();
+
+ // Populate the database.
+ // Create `SAMPLE_SIZE` visits, from the oldest to the newest.
+
+ let bookmarkIndices = new Set(options.bookmarks);
+ let visits = [];
+ let rankingChangePromises = [];
+ let uriDeletePromises = new Map();
+ let getURL = options.url
+ ? i =>
+ "http://mozilla.com/test_browserhistory/test_removeVisitsByFilter/removeme/byurl/" +
+ Math.floor(i / (SAMPLE_SIZE / 5)) +
+ "/"
+ : i =>
+ "http://mozilla.com/test_browserhistory/test_removeVisitsByFilter/removeme/" +
+ i +
+ "/" +
+ Math.random();
+ for (let i = 0; i < SAMPLE_SIZE; ++i) {
+ let spec = getURL(i);
+ let uri = NetUtil.newURI(spec);
+ let jsDate = new Date(Number(referenceDate) + 3600 * 1000 * i);
+ let dbDate = jsDate * 1000;
+ let hasBookmark = bookmarkIndices.has(i);
+ let hasOwnBookmark = hasBookmark;
+ if (!hasOwnBookmark && options.url) {
+ // Also mark as bookmarked if one of the earlier bookmarked items has the same URL.
+ hasBookmark = options.bookmarks
+ .filter(n => n < i)
+ .some(n => visits[n].uri.spec == spec && visits[n].test.hasBookmark);
+ }
+ info("Generating " + uri.spec + ", " + dbDate);
+ let visit = {
+ uri,
+ title: "visit " + i,
+ visitDate: dbDate,
+ test: {
+ // `visitDate`, as a Date
+ jsDate,
+ // `true` if we expect that the visit will be removed
+ toRemove: false,
+ // `true` if `onRow` informed of the removal of this visit
+ announcedByOnRow: false,
+ // `true` if there is a bookmark for this URI, i.e. of the page
+ // should not be entirely removed.
+ hasBookmark,
+ },
+ };
+ visits.push(visit);
+ if (hasOwnBookmark) {
+ info("Adding a bookmark to visit " + i);
+ await PlacesUtils.bookmarks.insert({
+ url: uri,
+ parentGuid: PlacesUtils.bookmarks.unfiledGuid,
+ title: "test bookmark",
+ });
+ info("Bookmark added");
+ }
+ }
+
+ info("Adding visits");
+ await PlacesTestUtils.addVisits(visits);
+
+ info("Preparing filters");
+ let filter = {};
+ let beginIndex = 0;
+ let endIndex = visits.length - 1;
+ if ("begin" in options) {
+ let ms = Number(visits[options.begin].test.jsDate) - 1000;
+ filter.beginDate = new Date(ms);
+ beginIndex = options.begin;
+ }
+ if ("end" in options) {
+ let ms = Number(visits[options.end].test.jsDate) + 1000;
+ filter.endDate = new Date(ms);
+ endIndex = options.end;
+ }
+ if ("limit" in options) {
+ endIndex = beginIndex + options.limit - 1; // -1 because the start index is inclusive.
+ filter.limit = options.limit;
+ }
+ let removedItems = visits.slice(beginIndex);
+ endIndex -= beginIndex;
+ if (options.url) {
+ let rawURL = "";
+ switch (options.url) {
+ case 1:
+ filter.url = new URL(removedItems[0].uri.spec);
+ rawURL = filter.url.href;
+ break;
+ case 2:
+ filter.url = removedItems[0].uri;
+ rawURL = filter.url.spec;
+ break;
+ case 3:
+ filter.url = removedItems[0].uri.spec;
+ rawURL = filter.url;
+ break;
+ }
+ endIndex = Math.min(
+ endIndex,
+ removedItems.findIndex((v, index) => v.uri.spec != rawURL) - 1
+ );
+ }
+ removedItems.splice(endIndex + 1);
+ let remainingItems = visits.filter(v => !removedItems.includes(v));
+ for (let i = 0; i < removedItems.length; i++) {
+ let test = removedItems[i].test;
+ info("Marking visit " + (beginIndex + i) + " as expecting removal");
+ test.toRemove = true;
+ if (
+ test.hasBookmark ||
+ (options.url &&
+ remainingItems.some(v => v.uri.spec == removedItems[i].uri.spec))
+ ) {
+ rankingChangePromises.push(Promise.withResolvers());
+ } else if (!options.url || i == 0) {
+ uriDeletePromises.set(
+ removedItems[i].uri.spec,
+ Promise.withResolvers()
+ );
+ }
+ }
+
+ const placesEventListener = events => {
+ for (const event of events) {
+ switch (event.type) {
+ case "page-title-changed": {
+ this.deferred.reject(
+ "Unexpected page-title-changed event happens on " + event.url
+ );
+ break;
+ }
+ case "history-cleared": {
+ info("history-cleared");
+ this.deferred.reject("Unexpected history-cleared event happens");
+ break;
+ }
+ case "pages-rank-changed": {
+ info("pages-rank-changed");
+ for (const deferred of rankingChangePromises) {
+ deferred.resolve();
+ }
+ break;
+ }
+ }
+ }
+ };
+ PlacesObservers.addListener(
+ ["page-title-changed", "history-cleared", "pages-rank-changed"],
+ placesEventListener
+ );
+
+ let cbarg;
+ if (options.useCallback) {
+ info("Setting up callback");
+ cbarg = [
+ info => {
+ for (let visit of visits) {
+ info("Comparing " + info.date + " and " + visit.test.jsDate);
+ if (Math.abs(visit.test.jsDate - info.date) < 100) {
+ // Assume rounding errors
+ Assert.ok(
+ !visit.test.announcedByOnRow,
+ "This is the first time we announce the removal of this visit"
+ );
+ Assert.ok(
+ visit.test.toRemove,
+ "This is a visit we intended to remove"
+ );
+ visit.test.announcedByOnRow = true;
+ return;
+ }
+ }
+ Assert.ok(false, "Could not find the visit we attempt to remove");
+ },
+ ];
+ } else {
+ info("No callback");
+ cbarg = [];
+ }
+ let result = await PlacesUtils.history.removeVisitsByFilter(
+ filter,
+ ...cbarg
+ );
+
+ Assert.ok(result, "Removal succeeded");
+
+ // Make sure that we have eliminated exactly the entries we expected
+ // to eliminate.
+ for (let i = 0; i < visits.length; ++i) {
+ let visit = visits[i];
+ info("Controlling the results on visit " + i);
+ let remainingVisitsForURI = remainingItems.filter(
+ v => visit.uri.spec == v.uri.spec
+ ).length;
+ Assert.equal(
+ visits_in_database(visit.uri),
+ remainingVisitsForURI,
+ "Visit is still present iff expected"
+ );
+ if (options.useCallback) {
+ Assert.equal(
+ visit.test.toRemove,
+ visit.test.announcedByOnRow,
+ "Visit removal has been announced by onResult iff expected"
+ );
+ }
+ if (visit.test.hasBookmark || remainingVisitsForURI) {
+ Assert.notEqual(
+ page_in_database(visit.uri),
+ 0,
+ "The page should still appear in the db"
+ );
+ } else {
+ Assert.equal(
+ page_in_database(visit.uri),
+ 0,
+ "The page should have been removed from the db"
+ );
+ }
+ }
+
+ // Make sure that the observer has been called wherever applicable.
+ info("Checking URI delete promises.");
+ await Promise.all(Array.from(uriDeletePromises.values()));
+ info("Checking frecency change promises.");
+ await Promise.all(rankingChangePromises);
+ PlacesObservers.removeListener(
+ ["page-title-changed", "history-cleared", "pages-rank-changed"],
+ placesEventListener
+ );
+ };
+
+ let size = 20;
+ for (let range of [
+ { begin: 0 },
+ { end: 19 },
+ { begin: 0, end: 10 },
+ { begin: 3, end: 4 },
+ { begin: 5, end: 8, limit: 2 },
+ { begin: 10, end: 18, limit: 5 },
+ ]) {
+ for (let bookmarks of [[], [5, 6]]) {
+ let options = {
+ sampleSize: size,
+ bookmarks,
+ };
+ if ("begin" in range) {
+ options.begin = range.begin;
+ }
+ if ("end" in range) {
+ options.end = range.end;
+ }
+ if ("limit" in range) {
+ options.limit = range.limit;
+ }
+ await remover(options);
+ options.url = 1;
+ await remover(options);
+ options.url = 2;
+ await remover(options);
+ options.url = 3;
+ await remover(options);
+ }
+ }
+ await PlacesUtils.history.clear();
+});
+
+// Test the various error cases
+add_task(async function test_error_cases() {
+ Assert.throws(
+ () => PlacesUtils.history.removeVisitsByFilter(),
+ /TypeError: Expected a filter/
+ );
+ Assert.throws(
+ () => PlacesUtils.history.removeVisitsByFilter("obviously, not a filter"),
+ /TypeError: Expected a filter/
+ );
+ Assert.throws(
+ () => PlacesUtils.history.removeVisitsByFilter({}),
+ /TypeError: Expected a non-empty filter/
+ );
+ Assert.throws(
+ () => PlacesUtils.history.removeVisitsByFilter({ beginDate: "now" }),
+ /TypeError: Expected a valid Date/
+ );
+ Assert.throws(
+ () => PlacesUtils.history.removeVisitsByFilter({ beginDate: Date.now() }),
+ /TypeError: Expected a valid Date/
+ );
+ Assert.throws(
+ () =>
+ PlacesUtils.history.removeVisitsByFilter({ beginDate: new Date(NaN) }),
+ /TypeError: Expected a valid Date/
+ );
+ Assert.throws(
+ () =>
+ PlacesUtils.history.removeVisitsByFilter(
+ { beginDate: new Date() },
+ "obviously, not a callback"
+ ),
+ /TypeError: Invalid function/
+ );
+ Assert.throws(
+ () =>
+ PlacesUtils.history.removeVisitsByFilter({
+ beginDate: new Date(1000),
+ endDate: new Date(0),
+ }),
+ /TypeError: `beginDate` should be at least as old/
+ );
+ Assert.throws(
+ () => PlacesUtils.history.removeVisitsByFilter({ limit: {} }),
+ /Expected a non-zero positive integer as a limit/
+ );
+ Assert.throws(
+ () => PlacesUtils.history.removeVisitsByFilter({ limit: -1 }),
+ /Expected a non-zero positive integer as a limit/
+ );
+ Assert.throws(
+ () => PlacesUtils.history.removeVisitsByFilter({ limit: 0.1 }),
+ /Expected a non-zero positive integer as a limit/
+ );
+ Assert.throws(
+ () => PlacesUtils.history.removeVisitsByFilter({ limit: Infinity }),
+ /Expected a non-zero positive integer as a limit/
+ );
+ Assert.throws(
+ () => PlacesUtils.history.removeVisitsByFilter({ url: {} }),
+ /Expected a valid URL for `url`/
+ );
+ Assert.throws(
+ () => PlacesUtils.history.removeVisitsByFilter({ url: 0 }),
+ /Expected a valid URL for `url`/
+ );
+ Assert.throws(
+ () =>
+ PlacesUtils.history.removeVisitsByFilter({
+ beginDate: new Date(1000),
+ endDate: new Date(0),
+ }),
+ /TypeError: `beginDate` should be at least as old/
+ );
+ Assert.throws(
+ () =>
+ PlacesUtils.history.removeVisitsByFilter({
+ beginDate: new Date(1000),
+ endDate: new Date(0),
+ }),
+ /TypeError: `beginDate` should be at least as old/
+ );
+ Assert.throws(
+ () => PlacesUtils.history.removeVisitsByFilter({ transition: -1 }),
+ /TypeError: `transition` should be valid/
+ );
+});
+
+add_task(async function test_orphans() {
+ let uri = NetUtil.newURI("http://moz.org/");
+ await PlacesTestUtils.addVisits({ uri });
+
+ PlacesUtils.favicons.setAndFetchFaviconForPage(
+ uri,
+ SMALLPNG_DATA_URI,
+ true,
+ PlacesUtils.favicons.FAVICON_LOAD_NON_PRIVATE,
+ null,
+ Services.scriptSecurityManager.getSystemPrincipal()
+ );
+ await PlacesUtils.history.update({
+ url: uri,
+ annotations: new Map([["test", "restval"]]),
+ });
+
+ await PlacesUtils.history.removeVisitsByFilter({
+ beginDate: new Date(1999, 9, 9, 9, 9),
+ endDate: new Date(),
+ });
+ Assert.ok(
+ !(await PlacesTestUtils.isPageInDB(uri)),
+ "Page should have been removed"
+ );
+ let db = await PlacesUtils.promiseDBConnection();
+ let rows = await db.execute(`SELECT (SELECT count(*) FROM moz_annos) +
+ (SELECT count(*) FROM moz_icons) +
+ (SELECT count(*) FROM moz_pages_w_icons) +
+ (SELECT count(*) FROM moz_icons_to_pages) AS count`);
+ Assert.equal(rows[0].getResultByName("count"), 0, "Should not find orphans");
+});
diff --git a/toolkit/components/places/tests/history/test_sameUri_titleChanged.js b/toolkit/components/places/tests/history/test_sameUri_titleChanged.js
new file mode 100644
index 0000000000..016e5402fa
--- /dev/null
+++ b/toolkit/components/places/tests/history/test_sameUri_titleChanged.js
@@ -0,0 +1,48 @@
+// Test that repeated additions of the same URI to history, properly
+// update from_visit and notify titleChanged.
+
+add_task(async function test() {
+ let uri = "http://test.com/";
+
+ const promiseTitleChangedNotifications =
+ PlacesTestUtils.waitForNotification("page-title-changed");
+
+ // This repeats the url on purpose, don't merge it into a single place entry.
+ await PlacesTestUtils.addVisits([
+ { uri, title: "test" },
+ { uri, referrer: uri, title: "test2" },
+ ]);
+
+ const events = await promiseTitleChangedNotifications;
+ Assert.equal(events.length, 1, "Right number of title changed notified");
+ Assert.equal(events[0].url, uri, "Should notify the proper url");
+
+ let options = PlacesUtils.history.getNewQueryOptions();
+ let query = PlacesUtils.history.getNewQuery();
+ query.uri = NetUtil.newURI(uri);
+ options.resultType = options.RESULTS_AS_VISIT;
+ let root = PlacesUtils.history.executeQuery(query, options).root;
+ root.containerOpen = true;
+
+ Assert.equal(root.childCount, 2);
+
+ let child = root.getChild(0);
+ Assert.equal(
+ child.visitType,
+ TRANSITION_LINK,
+ "Visit type should be TRANSITION_LINK"
+ );
+ Assert.equal(child.visitId, 1, "Visit ID should be 1");
+ Assert.equal(child.title, "test2", "Should have the correct title");
+
+ child = root.getChild(1);
+ Assert.equal(
+ child.visitType,
+ TRANSITION_LINK,
+ "Visit type should be TRANSITION_LINK"
+ );
+ Assert.equal(child.visitId, 2, "Visit ID should be 2");
+ Assert.equal(child.title, "test2", "Should have the correct title");
+
+ root.containerOpen = false;
+});
diff --git a/toolkit/components/places/tests/history/test_update.js b/toolkit/components/places/tests/history/test_update.js
new file mode 100644
index 0000000000..d7beafd368
--- /dev/null
+++ b/toolkit/components/places/tests/history/test_update.js
@@ -0,0 +1,700 @@
+/* -*- indent-tabs-mode: nil; js-indent-level: 2 -*- */
+/* vim:set ts=2 sw=2 sts=2 et: */
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+// Tests for `History.update` as implemented in History.jsm
+
+"use strict";
+
+add_task(async function test_error_cases() {
+ Assert.throws(
+ () => PlacesUtils.history.update("not an object"),
+ /Error: PageInfo: Input should be a valid object/,
+ "passing a string as pageInfo should throw an Error"
+ );
+ Assert.throws(
+ () => PlacesUtils.history.update(null),
+ /Error: PageInfo: Input should be/,
+ "passing a null as pageInfo should throw an Error"
+ );
+ Assert.throws(
+ () =>
+ PlacesUtils.history.update({
+ description: "Test description",
+ }),
+ /Error: PageInfo: The following properties were expected: url, guid/,
+ "not included a url or a guid should throw"
+ );
+ Assert.throws(
+ () => PlacesUtils.history.update({ url: "not a valid url string" }),
+ /Error: PageInfo: Invalid value for property/,
+ "passing an invalid url should throw an Error"
+ );
+ Assert.throws(
+ () =>
+ PlacesUtils.history.update({
+ url: "http://valid.uri.com",
+ description: 123,
+ }),
+ /Error: PageInfo: Invalid value for property/,
+ "passing a non-string description in pageInfo should throw an Error"
+ );
+ Assert.throws(
+ () =>
+ PlacesUtils.history.update({
+ url: "http://valid.uri.com",
+ guid: "invalid guid",
+ description: "Test description",
+ }),
+ /Error: PageInfo: Invalid value for property/,
+ "passing a invalid guid in pageInfo should throw an Error"
+ );
+ Assert.throws(
+ () =>
+ PlacesUtils.history.update({
+ url: "http://valid.uri.com",
+ previewImageURL: "not a valid url string",
+ }),
+ /Error: PageInfo: Invalid value for property/,
+ "passing an invlid preview image url in pageInfo should throw an Error"
+ );
+ Assert.throws(
+ () => {
+ let imageName = "a-very-long-string".repeat(10000);
+ let previewImageURL = `http://valid.uri.com/${imageName}.png`;
+ PlacesUtils.history.update({
+ url: "http://valid.uri.com",
+ previewImageURL,
+ });
+ },
+ /Error: PageInfo: Invalid value for property/,
+ "passing an oversized previewImageURL in pageInfo should throw an Error"
+ );
+ Assert.throws(
+ () => PlacesUtils.history.update({ url: "http://valid.uri.com" }),
+ /TypeError: pageInfo object must at least/,
+ "passing a pageInfo with neither description, previewImageURL, nor annotations should throw a TypeError"
+ );
+ Assert.throws(
+ () =>
+ PlacesUtils.history.update({
+ url: "http://valid.uri.com",
+ annotations: "asd",
+ }),
+ /Error: PageInfo: Invalid value for property/,
+ "passing a pageInfo with incorrect annotations type should throw an Error"
+ );
+ Assert.throws(
+ () =>
+ PlacesUtils.history.update({
+ url: "http://valid.uri.com",
+ annotations: new Map(),
+ }),
+ /Error: PageInfo: Invalid value for property/,
+ "passing a pageInfo with an empty annotations type should throw an Error"
+ );
+ Assert.throws(
+ () =>
+ PlacesUtils.history.update({
+ url: "http://valid.uri.com",
+ annotations: new Map([[1234, "value"]]),
+ }),
+ /Error: PageInfo: Invalid value for property/,
+ "passing a pageInfo with an invalid key type should throw an Error"
+ );
+ Assert.throws(
+ () =>
+ PlacesUtils.history.update({
+ url: "http://valid.uri.com",
+ annotations: new Map([["test", ["myarray"]]]),
+ }),
+ /Error: PageInfo: Invalid value for property/,
+ "passing a pageInfo with an invalid key type should throw an Error"
+ );
+ Assert.throws(
+ () =>
+ PlacesUtils.history.update({
+ url: "http://valid.uri.com",
+ annotations: new Map([["test", { anno: "value" }]]),
+ }),
+ /Error: PageInfo: Invalid value for property/,
+ "passing a pageInfo with an invalid key type should throw an Error"
+ );
+});
+
+add_task(async function test_description_change_saved() {
+ await PlacesUtils.history.clear();
+
+ let TEST_URL = "http://mozilla.org/test_description_change_saved";
+ await PlacesTestUtils.addVisits(TEST_URL);
+ Assert.ok(await PlacesTestUtils.isPageInDB(TEST_URL));
+
+ let description = "Test description";
+ await PlacesUtils.history.update({ url: TEST_URL, description });
+ let descriptionInDB = await PlacesTestUtils.getDatabaseValue(
+ "moz_places",
+ "description",
+ { url: TEST_URL }
+ );
+ Assert.equal(
+ description,
+ descriptionInDB,
+ "description should be updated via URL as expected"
+ );
+
+ description = "";
+ await PlacesUtils.history.update({ url: TEST_URL, description });
+ descriptionInDB = await PlacesTestUtils.getDatabaseValue(
+ "moz_places",
+ "description",
+ { url: TEST_URL }
+ );
+ Assert.strictEqual(
+ null,
+ descriptionInDB,
+ "an empty description should set it to null in the database"
+ );
+
+ let guid = await PlacesTestUtils.getDatabaseValue("moz_places", "guid", {
+ url: TEST_URL,
+ });
+ description = "Test description";
+ await PlacesUtils.history.update({ url: TEST_URL, guid, description });
+ descriptionInDB = await PlacesTestUtils.getDatabaseValue(
+ "moz_places",
+ "description",
+ { url: TEST_URL }
+ );
+ Assert.equal(
+ description,
+ descriptionInDB,
+ "description should be updated via GUID as expected"
+ );
+
+ description = "Test descipriton".repeat(1000);
+ await PlacesUtils.history.update({ url: TEST_URL, description });
+ descriptionInDB = await PlacesTestUtils.getDatabaseValue(
+ "moz_places",
+ "description",
+ { url: TEST_URL }
+ );
+ Assert.ok(
+ !!descriptionInDB.length < description.length,
+ "a long description should be truncated"
+ );
+
+ description = null;
+ await PlacesUtils.history.update({ url: TEST_URL, description });
+ descriptionInDB = await PlacesTestUtils.getDatabaseValue(
+ "moz_places",
+ "description",
+ { url: TEST_URL }
+ );
+ Assert.strictEqual(
+ description,
+ descriptionInDB,
+ "a null description should set it to null in the database"
+ );
+});
+
+add_task(async function test_siteName_change_saved() {
+ await PlacesUtils.history.clear();
+
+ let TEST_URL = "http://mozilla.org/test_siteName_change_saved";
+ await PlacesTestUtils.addVisits(TEST_URL);
+ Assert.ok(await PlacesTestUtils.isPageInDB(TEST_URL));
+
+ let siteName = "Test site name";
+ await PlacesUtils.history.update({ url: TEST_URL, siteName });
+ let siteNameInDB = await PlacesTestUtils.getDatabaseValue(
+ "moz_places",
+ "site_name",
+ { url: TEST_URL }
+ );
+ Assert.equal(
+ siteName,
+ siteNameInDB,
+ "siteName should be updated via URL as expected"
+ );
+
+ siteName = "";
+ await PlacesUtils.history.update({ url: TEST_URL, siteName });
+ siteNameInDB = await PlacesTestUtils.getDatabaseValue(
+ "moz_places",
+ "site_name",
+ { url: TEST_URL }
+ );
+ Assert.strictEqual(
+ null,
+ siteNameInDB,
+ "an empty siteName should set it to null in the database"
+ );
+
+ let guid = await PlacesTestUtils.getDatabaseValue("moz_places", "guid", {
+ url: TEST_URL,
+ });
+ siteName = "Test site name";
+ await PlacesUtils.history.update({ url: TEST_URL, guid, siteName });
+ siteNameInDB = await PlacesTestUtils.getDatabaseValue(
+ "moz_places",
+ "site_name",
+ { url: TEST_URL }
+ );
+ Assert.equal(
+ siteName,
+ siteNameInDB,
+ "siteName should be updated via GUID as expected"
+ );
+
+ siteName = "Test site name".repeat(1000);
+ await PlacesUtils.history.update({ url: TEST_URL, siteName });
+ siteNameInDB = await PlacesTestUtils.getDatabaseValue(
+ "moz_places",
+ "site_name",
+ {
+ url: TEST_URL,
+ }
+ );
+ Assert.ok(
+ !!siteNameInDB.length < siteName.length,
+ "a long siteName should be truncated"
+ );
+
+ siteName = null;
+ await PlacesUtils.history.update({ url: TEST_URL, siteName });
+ siteNameInDB = await PlacesTestUtils.getDatabaseValue(
+ "moz_places",
+ "site_name",
+ {
+ url: TEST_URL,
+ }
+ );
+ Assert.strictEqual(
+ siteName,
+ siteNameInDB,
+ "a null siteName should set it to null in the database"
+ );
+});
+
+add_task(async function test_previewImageURL_change_saved() {
+ await PlacesUtils.history.clear();
+
+ let TEST_URL = "http://mozilla.org/test_previewImageURL_change_saved";
+ let IMAGE_URL = "http://mozilla.org/test_preview_image.png";
+ await PlacesTestUtils.addVisits(TEST_URL);
+ Assert.ok(await PlacesTestUtils.isPageInDB(TEST_URL));
+
+ let previewImageURL = IMAGE_URL;
+ await PlacesUtils.history.update({ url: TEST_URL, previewImageURL });
+ let previewImageURLInDB = await PlacesTestUtils.getDatabaseValue(
+ "moz_places",
+ "preview_image_url",
+ {
+ url: TEST_URL,
+ }
+ );
+ Assert.equal(
+ previewImageURL,
+ previewImageURLInDB,
+ "previewImageURL should be updated via URL as expected"
+ );
+
+ previewImageURL = null;
+ await PlacesUtils.history.update({ url: TEST_URL, previewImageURL });
+ previewImageURLInDB = await PlacesTestUtils.getDatabaseValue(
+ "moz_places",
+ "preview_image_url",
+ {
+ url: TEST_URL,
+ }
+ );
+ Assert.strictEqual(
+ null,
+ previewImageURLInDB,
+ "a null previewImageURL should set it to null in the database"
+ );
+
+ let guid = await PlacesTestUtils.getDatabaseValue("moz_places", "guid", {
+ url: TEST_URL,
+ });
+ previewImageURL = IMAGE_URL;
+ await PlacesUtils.history.update({ guid, previewImageURL });
+ previewImageURLInDB = await PlacesTestUtils.getDatabaseValue(
+ "moz_places",
+ "preview_image_url",
+ {
+ url: TEST_URL,
+ }
+ );
+ Assert.equal(
+ previewImageURL,
+ previewImageURLInDB,
+ "previewImageURL should be updated via GUID as expected"
+ );
+
+ previewImageURL = "";
+ await PlacesUtils.history.update({ url: TEST_URL, previewImageURL });
+ previewImageURLInDB = await PlacesTestUtils.getDatabaseValue(
+ "moz_places",
+ "preview_image_url",
+ {
+ url: TEST_URL,
+ }
+ );
+ Assert.strictEqual(
+ null,
+ previewImageURLInDB,
+ "an empty previewImageURL should set it to null in the database"
+ );
+});
+
+add_task(async function test_change_description_and_preview_saved() {
+ await PlacesUtils.history.clear();
+
+ let TEST_URL = "http://mozilla.org/test_change_both_saved";
+ await PlacesTestUtils.addVisits(TEST_URL);
+ Assert.ok(await PlacesTestUtils.isPageInDB(TEST_URL));
+
+ let description = "Test description";
+ let previewImageURL = "http://mozilla.org/test_preview_image.png";
+
+ await PlacesUtils.history.update({
+ url: TEST_URL,
+ description,
+ previewImageURL,
+ });
+ let descriptionInDB = await PlacesTestUtils.getDatabaseValue(
+ "moz_places",
+ "description",
+ {
+ url: TEST_URL,
+ }
+ );
+ let previewImageURLInDB = await PlacesTestUtils.getDatabaseValue(
+ "moz_places",
+ "preview_image_url",
+ {
+ url: TEST_URL,
+ }
+ );
+ Assert.equal(
+ description,
+ descriptionInDB,
+ "description should be updated via URL as expected"
+ );
+ Assert.equal(
+ previewImageURL,
+ previewImageURLInDB,
+ "previewImageURL should be updated via URL as expected"
+ );
+
+ // Update description should not touch other fields
+ description = null;
+ await PlacesUtils.history.update({ url: TEST_URL, description });
+ descriptionInDB = await PlacesTestUtils.getDatabaseValue(
+ "moz_places",
+ "description",
+ {
+ url: TEST_URL,
+ }
+ );
+ previewImageURLInDB = await PlacesTestUtils.getDatabaseValue(
+ "moz_places",
+ "preview_image_url",
+ {
+ url: TEST_URL,
+ }
+ );
+ Assert.strictEqual(
+ description,
+ descriptionInDB,
+ "description should be updated via URL as expected"
+ );
+ Assert.equal(
+ previewImageURL,
+ previewImageURLInDB,
+ "previewImageURL should not be updated"
+ );
+});
+
+/**
+ * Gets annotation information from the database for the specified URL and
+ * annotation name.
+ *
+ * @param {String} pageUrl The URL to search for.
+ * @param {String} annoName The name of the annotation to search for.
+ * @return {Array} An array of objects containing the annotations found.
+ */
+async function getAnnotationInfoFromDB(pageUrl, annoName) {
+ let db = await PlacesUtils.promiseDBConnection();
+
+ let rows = await db.execute(
+ `
+ SELECT a.content, a.flags, a.expiration, a.type FROM moz_anno_attributes n
+ JOIN moz_annos a ON n.id = a.anno_attribute_id
+ JOIN moz_places h ON h.id = a.place_id
+ WHERE h.url_hash = hash(:pageUrl) AND h.url = :pageUrl
+ AND n.name = :annoName
+ `,
+ { annoName, pageUrl }
+ );
+
+ let result = rows.map(row => {
+ return {
+ content: row.getResultByName("content"),
+ flags: row.getResultByName("flags"),
+ expiration: row.getResultByName("expiration"),
+ type: row.getResultByName("type"),
+ };
+ });
+
+ return result;
+}
+
+add_task(async function test_simple_change_annotations() {
+ await PlacesUtils.history.clear();
+
+ const TEST_URL = "http://mozilla.org/test_change_both_saved";
+ await PlacesTestUtils.addVisits(TEST_URL);
+ Assert.ok(
+ await PlacesTestUtils.isPageInDB(TEST_URL),
+ "Should have inserted the page into the database."
+ );
+
+ await PlacesUtils.history.update({
+ url: TEST_URL,
+ annotations: new Map([["test/annotation", "testContent"]]),
+ });
+
+ let pageInfo = await PlacesUtils.history.fetch(TEST_URL, {
+ includeAnnotations: true,
+ });
+
+ Assert.equal(
+ pageInfo.annotations.size,
+ 1,
+ "Should have one annotation for the page"
+ );
+ Assert.equal(
+ pageInfo.annotations.get("test/annotation"),
+ "testContent",
+ "Should have the correct annotation"
+ );
+
+ let annotationInfo = await getAnnotationInfoFromDB(
+ TEST_URL,
+ "test/annotation"
+ );
+ Assert.deepEqual(
+ {
+ content: "testContent",
+ flags: 0,
+ type: PlacesUtils.history.ANNOTATION_TYPE_STRING,
+ expiration: PlacesUtils.history.ANNOTATION_EXPIRE_NEVER,
+ },
+ annotationInfo[0],
+ "Should have stored the correct annotation data in the db"
+ );
+
+ await PlacesUtils.history.update({
+ url: TEST_URL,
+ annotations: new Map([["test/annotation2", "testAnno"]]),
+ });
+
+ pageInfo = await PlacesUtils.history.fetch(TEST_URL, {
+ includeAnnotations: true,
+ });
+
+ Assert.equal(
+ pageInfo.annotations.size,
+ 2,
+ "Should have two annotations for the page"
+ );
+ Assert.equal(
+ pageInfo.annotations.get("test/annotation"),
+ "testContent",
+ "Should have the correct value for the first annotation"
+ );
+ Assert.equal(
+ pageInfo.annotations.get("test/annotation2"),
+ "testAnno",
+ "Should have the correct value for the second annotation"
+ );
+
+ await PlacesUtils.history.update({
+ url: TEST_URL,
+ annotations: new Map([["test/annotation", 1234]]),
+ });
+
+ pageInfo = await PlacesUtils.history.fetch(TEST_URL, {
+ includeAnnotations: true,
+ });
+
+ Assert.equal(
+ pageInfo.annotations.size,
+ 2,
+ "Should still have two annotations for the page"
+ );
+ Assert.equal(
+ pageInfo.annotations.get("test/annotation"),
+ 1234,
+ "Should have the updated the first annotation value"
+ );
+ Assert.equal(
+ pageInfo.annotations.get("test/annotation2"),
+ "testAnno",
+ "Should have kept the value for the second annotation"
+ );
+
+ annotationInfo = await getAnnotationInfoFromDB(TEST_URL, "test/annotation");
+ Assert.deepEqual(
+ {
+ content: 1234,
+ flags: 0,
+ type: PlacesUtils.history.ANNOTATION_TYPE_INT64,
+ expiration: PlacesUtils.history.ANNOTATION_EXPIRE_NEVER,
+ },
+ annotationInfo[0],
+ "Should have updated the annotation data in the db"
+ );
+
+ await PlacesUtils.history.update({
+ url: TEST_URL,
+ annotations: new Map([["test/annotation", null]]),
+ });
+
+ pageInfo = await PlacesUtils.history.fetch(TEST_URL, {
+ includeAnnotations: true,
+ });
+
+ Assert.equal(
+ pageInfo.annotations.size,
+ 1,
+ "Should have removed only the first annotation"
+ );
+ Assert.strictEqual(
+ pageInfo.annotations.get("test/annotation"),
+ undefined,
+ "Should have removed only the first annotation"
+ );
+ Assert.equal(
+ pageInfo.annotations.get("test/annotation2"),
+ "testAnno",
+ "Should have kept the value for the second annotation"
+ );
+
+ await PlacesUtils.history.update({
+ url: TEST_URL,
+ annotations: new Map([["test/annotation2", null]]),
+ });
+
+ pageInfo = await PlacesUtils.history.fetch(TEST_URL, {
+ includeAnnotations: true,
+ });
+
+ Assert.equal(pageInfo.annotations.size, 0, "Should have no annotations left");
+
+ let db = await PlacesUtils.promiseDBConnection();
+ let rows = await db.execute(`
+ SELECT * FROM moz_annos
+ `);
+ Assert.equal(rows.length, 0, "Should be no annotations left in the db");
+});
+
+add_task(async function test_change_multiple_annotations() {
+ await PlacesUtils.history.clear();
+
+ const TEST_URL = "http://mozilla.org/test_change_both_saved";
+ await PlacesTestUtils.addVisits(TEST_URL);
+ Assert.ok(
+ await PlacesTestUtils.isPageInDB(TEST_URL),
+ "Should have inserted the page into the database."
+ );
+
+ await PlacesUtils.history.update({
+ url: TEST_URL,
+ annotations: new Map([
+ ["test/annotation", "testContent"],
+ ["test/annotation2", "testAnno"],
+ ]),
+ });
+
+ let pageInfo = await PlacesUtils.history.fetch(TEST_URL, {
+ includeAnnotations: true,
+ });
+
+ Assert.equal(
+ pageInfo.annotations.size,
+ 2,
+ "Should have inserted the two annotations for the page."
+ );
+ Assert.equal(
+ pageInfo.annotations.get("test/annotation"),
+ "testContent",
+ "Should have the correct value for the first annotation"
+ );
+ Assert.equal(
+ pageInfo.annotations.get("test/annotation2"),
+ "testAnno",
+ "Should have the correct value for the second annotation"
+ );
+
+ await PlacesUtils.history.update({
+ url: TEST_URL,
+ annotations: new Map([
+ ["test/annotation", 123456],
+ ["test/annotation2", 135246],
+ ]),
+ });
+
+ pageInfo = await PlacesUtils.history.fetch(TEST_URL, {
+ includeAnnotations: true,
+ });
+
+ Assert.equal(
+ pageInfo.annotations.size,
+ 2,
+ "Should have two annotations for the page"
+ );
+ Assert.equal(
+ pageInfo.annotations.get("test/annotation"),
+ 123456,
+ "Should have the correct value for the first annotation"
+ );
+ Assert.equal(
+ pageInfo.annotations.get("test/annotation2"),
+ 135246,
+ "Should have the correct value for the second annotation"
+ );
+
+ await PlacesUtils.history.update({
+ url: TEST_URL,
+ annotations: new Map([
+ ["test/annotation", null],
+ ["test/annotation2", null],
+ ]),
+ });
+
+ pageInfo = await PlacesUtils.history.fetch(TEST_URL, {
+ includeAnnotations: true,
+ });
+
+ Assert.equal(pageInfo.annotations.size, 0, "Should have no annotations left");
+});
+
+add_task(async function test_annotations_nonexisting_page() {
+ info("Adding annotations to a non existing page should be silent");
+ await PlacesUtils.history.update({
+ url: "http://nonexisting.moz/",
+ annotations: new Map([["test/annotation", null]]),
+ });
+});
+
+add_task(async function test_annotations_nonexisting_page() {
+ info("Adding annotations to a non existing page should be silent");
+ await PlacesUtils.history.update({
+ url: "http://nonexisting.moz/",
+ annotations: new Map([["test/annotation", null]]),
+ });
+});
diff --git a/toolkit/components/places/tests/history/test_updatePlaces_embed.js b/toolkit/components/places/tests/history/test_updatePlaces_embed.js
new file mode 100644
index 0000000000..a2831f2f58
--- /dev/null
+++ b/toolkit/components/places/tests/history/test_updatePlaces_embed.js
@@ -0,0 +1,81 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+// Tests that updatePlaces properly handled callbacks for embed visits.
+
+"use strict";
+
+XPCOMUtils.defineLazyServiceGetter(
+ this,
+ "asyncHistory",
+ "@mozilla.org/browser/history;1",
+ "mozIAsyncHistory"
+);
+
+add_task(async function test_embed_visit() {
+ let place = {
+ uri: NetUtil.newURI("http://places.test/"),
+ visits: [
+ {
+ transitionType: PlacesUtils.history.TRANSITIONS.EMBED,
+ visitDate: PlacesUtils.toPRTime(new Date()),
+ },
+ ],
+ };
+ let errors = 0;
+ let results = 0;
+ let updated = await new Promise(resolve => {
+ asyncHistory.updatePlaces(place, {
+ ignoreErrors: true,
+ ignoreResults: true,
+ handleError(aResultCode, aPlace) {
+ errors++;
+ },
+ handleResult(aPlace) {
+ results++;
+ },
+ handleCompletion(resultCount) {
+ resolve(resultCount);
+ },
+ });
+ });
+ Assert.equal(errors, 0, "There should be no error callback");
+ Assert.equal(results, 0, "There should be no result callback");
+ Assert.equal(updated, 1, "The visit should have been added");
+});
+
+add_task(async function test_misc_visits() {
+ let place = {
+ uri: NetUtil.newURI("http://places.test/"),
+ visits: [
+ {
+ transitionType: PlacesUtils.history.TRANSITIONS.EMBED,
+ visitDate: PlacesUtils.toPRTime(new Date()),
+ },
+ {
+ transitionType: PlacesUtils.history.TRANSITIONS.LINK,
+ visitDate: PlacesUtils.toPRTime(new Date()),
+ },
+ ],
+ };
+ let errors = 0;
+ let results = 0;
+ let updated = await new Promise(resolve => {
+ asyncHistory.updatePlaces(place, {
+ ignoreErrors: true,
+ ignoreResults: true,
+ handleError(aResultCode, aPlace) {
+ errors++;
+ },
+ handleResult(aPlace) {
+ results++;
+ },
+ handleCompletion(resultCount) {
+ resolve(resultCount);
+ },
+ });
+ });
+ Assert.equal(errors, 0, "There should be no error callback");
+ Assert.equal(results, 0, "There should be no result callback");
+ Assert.equal(updated, 2, "The visit should have been added");
+});
diff --git a/toolkit/components/places/tests/history/xpcshell.toml b/toolkit/components/places/tests/history/xpcshell.toml
new file mode 100644
index 0000000000..8728743f1a
--- /dev/null
+++ b/toolkit/components/places/tests/history/xpcshell.toml
@@ -0,0 +1,36 @@
+[DEFAULT]
+head = "head_history.js"
+
+["test_async_history_api.js"]
+
+["test_bookmark_unhide.js"]
+
+["test_fetch.js"]
+
+["test_fetchAnnotatedPages.js"]
+
+["test_fetchMany.js"]
+
+["test_hasVisits.js"]
+
+["test_insert.js"]
+
+["test_insertMany.js"]
+
+["test_insert_null_title.js"]
+
+["test_remove.js"]
+
+["test_removeByFilter.js"]
+
+["test_removeMany.js"]
+
+["test_removeVisits.js"]
+
+["test_removeVisitsByFilter.js"]
+
+["test_sameUri_titleChanged.js"]
+
+["test_update.js"]
+
+["test_updatePlaces_embed.js"]
diff --git a/toolkit/components/places/tests/legacy/head_legacy.js b/toolkit/components/places/tests/legacy/head_legacy.js
new file mode 100644
index 0000000000..06e7fda560
--- /dev/null
+++ b/toolkit/components/places/tests/legacy/head_legacy.js
@@ -0,0 +1,14 @@
+/* -*- indent-tabs-mode: nil; js-indent-level: 2 -*- */
+/* 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/. */
+
+// Import common head.
+{
+ /* import-globals-from ../head_common.js */
+ let commonFile = do_get_file("../head_common.js", false);
+ let uri = Services.io.newFileURI(commonFile);
+ Services.scriptloader.loadSubScript(uri.spec, this);
+}
+
+// Put any other stuff relative to this test folder below.
diff --git a/toolkit/components/places/tests/legacy/test_bookmarks.js b/toolkit/components/places/tests/legacy/test_bookmarks.js
new file mode 100644
index 0000000000..526541f749
--- /dev/null
+++ b/toolkit/components/places/tests/legacy/test_bookmarks.js
@@ -0,0 +1,519 @@
+/* -*- indent-tabs-mode: nil; js-indent-level: 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/. */
+
+var bs = PlacesUtils.bookmarks;
+var hs = PlacesUtils.history;
+var os = PlacesUtils.observers;
+
+var bookmarksObserver = {
+ handlePlacesEvents(events) {
+ Assert.equal(events.length, 1);
+ let event = events[0];
+ switch (event.type) {
+ case "bookmark-added":
+ bookmarksObserver._itemAddedId = event.id;
+ bookmarksObserver._itemAddedParent = event.parentId;
+ bookmarksObserver._itemAddedIndex = event.index;
+ bookmarksObserver._itemAddedURI = event.url
+ ? Services.io.newURI(event.url)
+ : null;
+ bookmarksObserver._itemAddedTitle = event.title;
+
+ // Ensure that we've created a guid for this item.
+ let stmt = DBConn().createStatement(
+ `SELECT guid
+ FROM moz_bookmarks
+ WHERE id = :item_id`
+ );
+ stmt.params.item_id = event.id;
+ Assert.ok(stmt.executeStep());
+ Assert.ok(!stmt.getIsNull(0));
+ do_check_valid_places_guid(stmt.row.guid);
+ Assert.equal(stmt.row.guid, event.guid);
+ stmt.finalize();
+ break;
+ case "bookmark-removed":
+ bookmarksObserver._itemRemovedId = event.id;
+ bookmarksObserver._itemRemovedFolder = event.parentId;
+ bookmarksObserver._itemRemovedIndex = event.index;
+ break;
+ case "bookmark-title-changed":
+ bookmarksObserver._itemTitleChangedId = event.id;
+ bookmarksObserver._itemTitleChangedTitle = event.title;
+ break;
+ }
+ },
+};
+
+var root;
+// Index at which items should begin.
+var bmStartIndex = 0;
+
+add_task(async function setup() {
+ // Get bookmarks menu folder id.
+ root = await PlacesTestUtils.promiseItemId(PlacesUtils.bookmarks.menuGuid);
+});
+
+add_task(async function test_bookmarks() {
+ os.addListener(
+ ["bookmark-added", "bookmark-removed", "bookmark-title-changed"],
+ bookmarksObserver.handlePlacesEvents
+ );
+
+ // test special folders
+ Assert.ok(bs.tagsFolder > 0);
+
+ // create a folder to hold all the tests
+ // this makes the tests more tolerant of changes to default_places.html
+ let testRoot = bs.createFolder(
+ root,
+ "places bookmarks xpcshell tests",
+ bs.DEFAULT_INDEX
+ );
+ let testRootGuid = await PlacesTestUtils.promiseItemGuid(testRoot);
+ Assert.equal(bookmarksObserver._itemAddedId, testRoot);
+ Assert.equal(bookmarksObserver._itemAddedParent, root);
+ Assert.equal(bookmarksObserver._itemAddedIndex, bmStartIndex);
+ Assert.equal(bookmarksObserver._itemAddedURI, null);
+ let testStartIndex = 0;
+
+ // insert a bookmark.
+ // the time before we insert, in microseconds
+ let beforeInsert = Date.now() * 1000;
+ Assert.ok(beforeInsert > 0);
+
+ let newId = bs.insertBookmark(
+ testRoot,
+ uri("http://google.com/"),
+ bs.DEFAULT_INDEX,
+ ""
+ );
+ Assert.equal(bookmarksObserver._itemAddedId, newId);
+ Assert.equal(bookmarksObserver._itemAddedParent, testRoot);
+ Assert.equal(bookmarksObserver._itemAddedIndex, testStartIndex);
+ Assert.ok(bookmarksObserver._itemAddedURI.equals(uri("http://google.com/")));
+
+ // after just inserting, modified should not be set
+ let lastModified = PlacesUtils.toPRTime(
+ (
+ await PlacesUtils.bookmarks.fetch(
+ await PlacesTestUtils.promiseItemGuid(newId)
+ )
+ ).lastModified
+ );
+
+ // The time before we set the title, in microseconds.
+ let beforeSetTitle = Date.now() * 1000;
+ Assert.ok(beforeSetTitle >= beforeInsert);
+
+ // Workaround possible VM timers issues moving lastModified and dateAdded
+ // to the past.
+ lastModified -= 1000;
+ bs.setItemLastModified(newId, lastModified);
+
+ // set bookmark title
+ bs.setItemTitle(newId, "Google");
+ Assert.equal(bookmarksObserver._itemTitleChangedId, newId);
+ Assert.equal(bookmarksObserver._itemTitleChangedTitle, "Google");
+
+ // check lastModified after we set the title
+ let lastModified2 = PlacesUtils.toPRTime(
+ (
+ await PlacesUtils.bookmarks.fetch(
+ await PlacesTestUtils.promiseItemGuid(newId)
+ )
+ ).lastModified
+ );
+ info("test setItemTitle");
+ info("beforeSetTitle = " + beforeSetTitle);
+ info("lastModified = " + lastModified);
+ info("lastModified2 = " + lastModified2);
+ Assert.ok(is_time_ordered(lastModified, lastModified2));
+
+ // get item title
+ let title = bs.getItemTitle(newId);
+ Assert.equal(title, "Google");
+
+ // get item title bad input
+ try {
+ bs.getItemTitle(-3);
+ do_throw("getItemTitle accepted bad input");
+ } catch (ex) {}
+
+ // create a folder at a specific index
+ let workFolder = bs.createFolder(testRoot, "Work", 0);
+ Assert.equal(bookmarksObserver._itemAddedId, workFolder);
+ Assert.equal(bookmarksObserver._itemAddedParent, testRoot);
+ Assert.equal(bookmarksObserver._itemAddedIndex, 0);
+ Assert.equal(bookmarksObserver._itemAddedURI, null);
+
+ Assert.equal(bs.getItemTitle(workFolder), "Work");
+ bs.setItemTitle(workFolder, "Work #");
+ Assert.equal(bs.getItemTitle(workFolder), "Work #");
+
+ // add item into subfolder, specifying index
+ let newId2 = bs.insertBookmark(
+ workFolder,
+ uri("http://developer.mozilla.org/"),
+ 0,
+ ""
+ );
+ Assert.equal(bookmarksObserver._itemAddedId, newId2);
+ Assert.equal(bookmarksObserver._itemAddedParent, workFolder);
+ Assert.equal(bookmarksObserver._itemAddedIndex, 0);
+
+ // change item
+ bs.setItemTitle(newId2, "DevMo");
+ Assert.equal(bookmarksObserver._itemTitleChangedId, newId2);
+ Assert.equal(bookmarksObserver._itemTitleChangedTitle, "DevMo");
+
+ // insert item into subfolder
+ let newId3 = bs.insertBookmark(
+ workFolder,
+ uri("http://msdn.microsoft.com/"),
+ bs.DEFAULT_INDEX,
+ ""
+ );
+ Assert.equal(bookmarksObserver._itemAddedId, newId3);
+ Assert.equal(bookmarksObserver._itemAddedParent, workFolder);
+ Assert.equal(bookmarksObserver._itemAddedIndex, 1);
+
+ // change item
+ bs.setItemTitle(newId3, "MSDN");
+ Assert.equal(bookmarksObserver._itemTitleChangedId, newId3);
+ Assert.equal(bookmarksObserver._itemTitleChangedTitle, "MSDN");
+
+ // remove item
+ bs.removeItem(newId2);
+ Assert.equal(bookmarksObserver._itemRemovedId, newId2);
+ Assert.equal(bookmarksObserver._itemRemovedFolder, workFolder);
+ Assert.equal(bookmarksObserver._itemRemovedIndex, 0);
+
+ // insert item into subfolder
+ let newId4 = bs.insertBookmark(
+ workFolder,
+ uri("http://developer.mozilla.org/"),
+ bs.DEFAULT_INDEX,
+ ""
+ );
+ Assert.equal(bookmarksObserver._itemAddedId, newId4);
+ Assert.equal(bookmarksObserver._itemAddedParent, workFolder);
+ Assert.equal(bookmarksObserver._itemAddedIndex, 1);
+
+ // create folder
+ let homeFolder = bs.createFolder(testRoot, "Home", bs.DEFAULT_INDEX);
+ Assert.equal(bookmarksObserver._itemAddedId, homeFolder);
+ Assert.equal(bookmarksObserver._itemAddedParent, testRoot);
+ Assert.equal(bookmarksObserver._itemAddedIndex, 2);
+
+ // insert item
+ let newId5 = bs.insertBookmark(
+ homeFolder,
+ uri("http://espn.com/"),
+ bs.DEFAULT_INDEX,
+ ""
+ );
+ Assert.equal(bookmarksObserver._itemAddedId, newId5);
+ Assert.equal(bookmarksObserver._itemAddedParent, homeFolder);
+ Assert.equal(bookmarksObserver._itemAddedIndex, 0);
+
+ // change item
+ bs.setItemTitle(newId5, "ESPN");
+ Assert.equal(bookmarksObserver._itemTitleChangedId, newId5);
+ Assert.equal(bookmarksObserver._itemTitleChangedTitle, "ESPN");
+
+ // insert query item
+ let uri6 = uri(
+ "place:domain=google.com&type=" +
+ Ci.nsINavHistoryQueryOptions.RESULTS_AS_SITE_QUERY
+ );
+ let newId6 = bs.insertBookmark(testRoot, uri6, bs.DEFAULT_INDEX, "");
+ Assert.equal(bookmarksObserver._itemAddedParent, testRoot);
+ Assert.equal(bookmarksObserver._itemAddedIndex, 3);
+
+ // change item
+ bs.setItemTitle(newId6, "Google Sites");
+ Assert.equal(bookmarksObserver._itemTitleChangedId, newId6);
+ Assert.equal(bookmarksObserver._itemTitleChangedTitle, "Google Sites");
+
+ // test bookmark id in query output
+ try {
+ let options = hs.getNewQueryOptions();
+ let query = hs.getNewQuery();
+ query.setParents([testRootGuid]);
+ let result = hs.executeQuery(query, options);
+ let rootNode = result.root;
+ rootNode.containerOpen = true;
+ let cc = rootNode.childCount;
+ info("bookmark itemId test: CC = " + cc);
+ Assert.ok(cc > 0);
+ for (let i = 0; i < cc; ++i) {
+ let node = rootNode.getChild(i);
+ if (
+ node.type == node.RESULT_TYPE_FOLDER ||
+ node.type == node.RESULT_TYPE_URI ||
+ node.type == node.RESULT_TYPE_SEPARATOR ||
+ node.type == node.RESULT_TYPE_QUERY
+ ) {
+ Assert.ok(node.itemId > 0);
+ } else {
+ Assert.equal(node.itemId, -1);
+ }
+ }
+ rootNode.containerOpen = false;
+ } catch (ex) {
+ do_throw("bookmarks query: " + ex);
+ }
+
+ // test that multiple bookmarks with same URI show up right in bookmark
+ // folder queries, todo: also to do for complex folder queries
+ try {
+ // test uri
+ let mURI = uri("http://multiple.uris.in.query");
+
+ let testFolder = bs.createFolder(testRoot, "test Folder", bs.DEFAULT_INDEX);
+ let testFolderGuid = await PlacesTestUtils.promiseItemGuid(testFolder);
+ // add 2 bookmarks
+ bs.insertBookmark(testFolder, mURI, bs.DEFAULT_INDEX, "title 1");
+ bs.insertBookmark(testFolder, mURI, bs.DEFAULT_INDEX, "title 2");
+
+ // query
+ let options = hs.getNewQueryOptions();
+ let query = hs.getNewQuery();
+ query.setParents([testFolderGuid]);
+ let result = hs.executeQuery(query, options);
+ let rootNode = result.root;
+ rootNode.containerOpen = true;
+ let cc = rootNode.childCount;
+ Assert.equal(cc, 2);
+ Assert.equal(rootNode.getChild(0).title, "title 1");
+ Assert.equal(rootNode.getChild(1).title, "title 2");
+ rootNode.containerOpen = false;
+ } catch (ex) {
+ do_throw("bookmarks query: " + ex);
+ }
+
+ // test change bookmark uri
+ let newId10 = bs.insertBookmark(
+ testRoot,
+ uri("http://foo10.com/"),
+ bs.DEFAULT_INDEX,
+ ""
+ );
+
+ // Workaround possible VM timers issues moving lastModified and dateAdded
+ // to the past.
+ lastModified -= 1000;
+ bs.setItemLastModified(newId10, lastModified);
+
+ // insert a bookmark with title ZZZXXXYYY and then search for it.
+ // this test confirms that we can find bookmarks that we haven't visited
+ // (which are "hidden") and that we can find by title.
+ // see bug #369887 for more details
+ let newId13 = bs.insertBookmark(
+ testRoot,
+ uri("http://foobarcheese.com/"),
+ bs.DEFAULT_INDEX,
+ ""
+ );
+ Assert.equal(bookmarksObserver._itemAddedId, newId13);
+ Assert.equal(bookmarksObserver._itemAddedParent, testRoot);
+ Assert.equal(bookmarksObserver._itemAddedIndex, 6);
+
+ // set bookmark title
+ bs.setItemTitle(newId13, "ZZZXXXYYY");
+ Assert.equal(bookmarksObserver._itemTitleChangedId, newId13);
+ Assert.equal(bookmarksObserver._itemTitleChangedTitle, "ZZZXXXYYY");
+
+ // test search on bookmark title ZZZXXXYYY
+ try {
+ let options = hs.getNewQueryOptions();
+ options.excludeQueries = 1;
+ options.queryType = Ci.nsINavHistoryQueryOptions.QUERY_TYPE_BOOKMARKS;
+ let query = hs.getNewQuery();
+ query.searchTerms = "ZZZXXXYYY";
+ let result = hs.executeQuery(query, options);
+ let rootNode = result.root;
+ rootNode.containerOpen = true;
+ let cc = rootNode.childCount;
+ Assert.equal(cc, 1);
+ let node = rootNode.getChild(0);
+ Assert.equal(node.title, "ZZZXXXYYY");
+ Assert.ok(node.itemId > 0);
+ rootNode.containerOpen = false;
+ } catch (ex) {
+ do_throw("bookmarks query: " + ex);
+ }
+
+ // test dateAdded and lastModified properties
+ // for a search query
+ try {
+ let options = hs.getNewQueryOptions();
+ options.excludeQueries = 1;
+ options.queryType = Ci.nsINavHistoryQueryOptions.QUERY_TYPE_BOOKMARKS;
+ let query = hs.getNewQuery();
+ query.searchTerms = "ZZZXXXYYY";
+ let result = hs.executeQuery(query, options);
+ let rootNode = result.root;
+ rootNode.containerOpen = true;
+ let cc = rootNode.childCount;
+ Assert.equal(cc, 1);
+ let node = rootNode.getChild(0);
+
+ Assert.equal(typeof node.dateAdded, "number");
+ Assert.ok(node.dateAdded > 0);
+
+ Assert.equal(typeof node.lastModified, "number");
+ Assert.ok(node.lastModified > 0);
+
+ rootNode.containerOpen = false;
+ } catch (ex) {
+ do_throw("bookmarks query: " + ex);
+ }
+
+ // test dateAdded and lastModified properties
+ // for a folder query
+ try {
+ let options = hs.getNewQueryOptions();
+ let query = hs.getNewQuery();
+ query.setParents([testRootGuid]);
+ let result = hs.executeQuery(query, options);
+ let rootNode = result.root;
+ rootNode.containerOpen = true;
+ let cc = rootNode.childCount;
+ Assert.ok(cc > 0);
+ for (let i = 0; i < cc; i++) {
+ let node = rootNode.getChild(i);
+
+ if (node.type == node.RESULT_TYPE_URI) {
+ Assert.equal(typeof node.dateAdded, "number");
+ Assert.ok(node.dateAdded > 0);
+
+ Assert.equal(typeof node.lastModified, "number");
+ Assert.ok(node.lastModified > 0);
+ break;
+ }
+ }
+ rootNode.containerOpen = false;
+ } catch (ex) {
+ do_throw("bookmarks query: " + ex);
+ }
+
+ // check setItemLastModified()
+ let newId14 = bs.insertBookmark(
+ testRoot,
+ uri("http://bar.tld/"),
+ bs.DEFAULT_INDEX,
+ ""
+ );
+ bs.setItemLastModified(newId14, 1234000000000000);
+ let fakeLastModified = PlacesUtils.toPRTime(
+ (
+ await PlacesUtils.bookmarks.fetch(
+ await PlacesTestUtils.promiseItemGuid(newId14)
+ )
+ ).lastModified
+ );
+ Assert.equal(fakeLastModified, 1234000000000000);
+
+ // bug 378820
+ let uri1 = uri("http://foo.tld/a");
+ bs.insertBookmark(testRoot, uri1, bs.DEFAULT_INDEX, "");
+ await PlacesTestUtils.addVisits(uri1);
+
+ // bug 646993 - test bookmark titles longer than the maximum allowed length
+ let title15 = Array(TITLE_LENGTH_MAX + 5).join("X");
+ let title15expected = title15.substring(0, TITLE_LENGTH_MAX);
+ let newId15 = bs.insertBookmark(
+ testRoot,
+ uri("http://evil.com/"),
+ bs.DEFAULT_INDEX,
+ title15
+ );
+
+ Assert.equal(bs.getItemTitle(newId15).length, title15expected.length);
+ Assert.equal(bookmarksObserver._itemAddedTitle, title15expected);
+ // test title length after updates
+ bs.setItemTitle(newId15, title15 + " updated");
+ Assert.equal(bs.getItemTitle(newId15).length, title15expected.length);
+ Assert.equal(bookmarksObserver._itemTitleChangedId, newId15);
+ Assert.equal(bookmarksObserver._itemTitleChangedTitle, title15expected);
+
+ await testSimpleFolderResult();
+});
+
+async function testSimpleFolderResult() {
+ // the time before we create a folder, in microseconds
+ // Workaround possible VM timers issues subtracting 1us.
+ let beforeCreate = Date.now() * 1000 - 1;
+ Assert.ok(beforeCreate > 0);
+
+ // create a folder
+ let parent = bs.createFolder(root, "test", bs.DEFAULT_INDEX);
+ let parentGuid = await PlacesTestUtils.promiseItemGuid(parent);
+
+ // the time before we insert, in microseconds
+ // Workaround possible VM timers issues subtracting 1ms.
+ let beforeInsert = Date.now() * 1000 - 1;
+ Assert.ok(beforeInsert > 0);
+
+ // re-set item title separately so can test nodes' last modified
+ let item = bs.insertBookmark(
+ parent,
+ uri("about:blank"),
+ bs.DEFAULT_INDEX,
+ ""
+ );
+ bs.setItemTitle(item, "test bookmark");
+
+ // see above
+ let folder = bs.createFolder(parent, "test folder", bs.DEFAULT_INDEX);
+ bs.setItemTitle(folder, "test folder");
+
+ let longName = Array(TITLE_LENGTH_MAX + 5).join("A");
+ let folderLongName = bs.createFolder(parent, longName, bs.DEFAULT_INDEX);
+ Assert.equal(
+ bookmarksObserver._itemAddedTitle,
+ longName.substring(0, TITLE_LENGTH_MAX)
+ );
+
+ let options = hs.getNewQueryOptions();
+ let query = hs.getNewQuery();
+ query.setParents([parentGuid]);
+ let result = hs.executeQuery(query, options);
+ let rootNode = result.root;
+ rootNode.containerOpen = true;
+ Assert.equal(rootNode.childCount, 3);
+
+ let node = rootNode.getChild(0);
+ Assert.equal(node.itemId, item);
+ Assert.ok(node.dateAdded > 0);
+ Assert.ok(node.lastModified > 0);
+ Assert.equal(node.title, "test bookmark");
+ node = rootNode.getChild(1);
+ Assert.equal(node.itemId, folder);
+ Assert.equal(node.title, "test folder");
+ Assert.ok(node.dateAdded > 0);
+ Assert.ok(node.lastModified > 0);
+ node = rootNode.getChild(2);
+ Assert.equal(node.itemId, folderLongName);
+ Assert.equal(node.title, longName.substring(0, TITLE_LENGTH_MAX));
+ Assert.ok(node.dateAdded > 0);
+ Assert.ok(node.lastModified > 0);
+
+ // update with another long title
+ bs.setItemTitle(folderLongName, longName + " updated");
+ Assert.equal(bookmarksObserver._itemTitleChangedId, folderLongName);
+ Assert.equal(
+ bookmarksObserver._itemTitleChangedTitle,
+ longName.substring(0, TITLE_LENGTH_MAX)
+ );
+
+ node = rootNode.getChild(2);
+ Assert.equal(node.title, longName.substring(0, TITLE_LENGTH_MAX));
+
+ rootNode.containerOpen = false;
+}
diff --git a/toolkit/components/places/tests/legacy/test_bookmarks_setNullTitle.js b/toolkit/components/places/tests/legacy/test_bookmarks_setNullTitle.js
new file mode 100644
index 0000000000..ff224c3402
--- /dev/null
+++ b/toolkit/components/places/tests/legacy/test_bookmarks_setNullTitle.js
@@ -0,0 +1,50 @@
+/* -*- indent-tabs-mode: nil; js-indent-level: 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/. */
+
+/**
+ * Both setItemTitle and insertBookmark should default to the empty string
+ * for null titles.
+ */
+
+const bs = Cc["@mozilla.org/browser/nav-bookmarks-service;1"].getService(
+ Ci.nsINavBookmarksService
+);
+
+const TEST_URL = "http://www.mozilla.org";
+
+function run_test() {
+ // Insert a bookmark with an empty title.
+ var itemId = bs.insertBookmark(
+ bs.tagsFolder,
+ uri(TEST_URL),
+ bs.DEFAULT_INDEX,
+ ""
+ );
+ // Check returned title is an empty string.
+ Assert.equal(bs.getItemTitle(itemId), "");
+ // Set title to null.
+ bs.setItemTitle(itemId, null);
+ // Check returned title defaults to an empty string.
+ Assert.equal(bs.getItemTitle(itemId), "");
+ // Cleanup.
+ bs.removeItem(itemId);
+
+ // Insert a bookmark with a null title.
+ itemId = bs.insertBookmark(
+ bs.tagsFolder,
+ uri(TEST_URL),
+ bs.DEFAULT_INDEX,
+ null
+ );
+ // Check returned title defaults to an empty string.
+ Assert.equal(bs.getItemTitle(itemId), "");
+ // Set title to an empty string.
+ bs.setItemTitle(itemId, "");
+ // Check returned title is an empty string.
+ Assert.equal(bs.getItemTitle(itemId), "");
+ // Cleanup.
+ bs.removeItem(itemId);
+}
diff --git a/toolkit/components/places/tests/legacy/test_protectRoots.js b/toolkit/components/places/tests/legacy/test_protectRoots.js
new file mode 100644
index 0000000000..f6a7fd0fe8
--- /dev/null
+++ b/toolkit/components/places/tests/legacy/test_protectRoots.js
@@ -0,0 +1,21 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+add_task(async () => {
+ const ROOTS = [
+ PlacesUtils.bookmarks.rootGuid,
+ ...PlacesUtils.bookmarks.userContentRoots,
+ PlacesUtils.bookmarks.tagsGuid,
+ ];
+
+ for (let guid of ROOTS) {
+ Assert.ok(PlacesUtils.isRootItem(guid));
+
+ let id = await PlacesTestUtils.promiseItemId(guid);
+
+ try {
+ PlacesUtils.bookmarks.removeItem(id);
+ do_throw("Trying to remove a root should throw");
+ } catch (ex) {}
+ }
+});
diff --git a/toolkit/components/places/tests/legacy/xpcshell.toml b/toolkit/components/places/tests/legacy/xpcshell.toml
new file mode 100644
index 0000000000..519deef4ff
--- /dev/null
+++ b/toolkit/components/places/tests/legacy/xpcshell.toml
@@ -0,0 +1,11 @@
+[DEFAULT]
+# This directory is for tests for the legacy, sync APIs as somewhere to put them
+# until we remove the APIs themselves.
+head = "head_legacy.js"
+firefox-appdir = "browser"
+
+["test_bookmarks.js"]
+
+["test_bookmarks_setNullTitle.js"]
+
+["test_protectRoots.js"]
diff --git a/toolkit/components/places/tests/maintenance/corruptDB.sqlite b/toolkit/components/places/tests/maintenance/corruptDB.sqlite
new file mode 100644
index 0000000000..b234246cac
Binary files /dev/null and b/toolkit/components/places/tests/maintenance/corruptDB.sqlite differ
diff --git a/toolkit/components/places/tests/maintenance/corruptPayload.sqlite b/toolkit/components/places/tests/maintenance/corruptPayload.sqlite
new file mode 100644
index 0000000000..16717bda80
Binary files /dev/null and b/toolkit/components/places/tests/maintenance/corruptPayload.sqlite differ
diff --git a/toolkit/components/places/tests/maintenance/head.js b/toolkit/components/places/tests/maintenance/head.js
new file mode 100644
index 0000000000..3117ab323d
--- /dev/null
+++ b/toolkit/components/places/tests/maintenance/head.js
@@ -0,0 +1,119 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+// Import common head.
+{
+ /* import-globals-from ../head_common.js */
+ let commonFile = do_get_file("../head_common.js", false);
+ let uri = Services.io.newFileURI(commonFile);
+ Services.scriptloader.loadSubScript(uri.spec, this);
+}
+
+// Put any other stuff relative to this test folder below.
+
+ChromeUtils.defineESModuleGetters(this, {
+ PlacesDBUtils: "resource://gre/modules/PlacesDBUtils.sys.mjs",
+});
+
+async function createCorruptDb(filename) {
+ let path = PathUtils.join(PathUtils.profileDir, filename);
+ await IOUtils.remove(path, { ignoreAbsent: true });
+ // Create a corrupt database.
+ let dir = do_get_cwd().path;
+ let src = PathUtils.join(dir, "corruptDB.sqlite");
+ await IOUtils.copy(src, path);
+}
+
+/**
+ * Used in _replaceOnStartup_ tests as common test code. It checks whether we
+ * are properly cloning or replacing a corrupt database.
+ *
+ * @param {string[]} src
+ * Array of strings which form a path to a test database, relative to
+ * the parent of this test folder.
+ * @param {string} filename
+ * Database file name
+ * @param {boolean} shouldClone
+ * Whether we expect the database to be cloned
+ * @param {boolean} dbStatus
+ * The expected final database status
+ */
+async function test_database_replacement(src, filename, shouldClone, dbStatus) {
+ registerCleanupFunction(() => {
+ Services.prefs.clearUserPref("places.database.cloneOnCorruption");
+ });
+ Services.prefs.setBoolPref("places.database.cloneOnCorruption", shouldClone);
+
+ // Only the main database file (places.sqlite) will be cloned, because
+ // attached databased would break due to OS file lockings.
+ let willClone = shouldClone && filename == DB_FILENAME;
+
+ // Ensure that our databases don't exist yet.
+ let dest = PathUtils.join(PathUtils.profileDir, filename);
+ Assert.ok(
+ !(await IOUtils.exists(dest)),
+ `"${filename} should not exist initially`
+ );
+ let corrupt = PathUtils.join(PathUtils.profileDir, `${filename}.corrupt`);
+ Assert.ok(
+ !(await IOUtils.exists(corrupt)),
+ `${filename}.corrupt should not exist initially`
+ );
+
+ let dir = PathUtils.parent(do_get_cwd().path);
+ src = PathUtils.join(dir, ...src);
+ await IOUtils.copy(src, dest);
+
+ // Create some unique stuff to check later.
+ let db = await Sqlite.openConnection({ path: dest });
+ await db.execute(`CREATE TABLE moz_cloned (id INTEGER PRIMARY KEY)`);
+ await db.execute(`CREATE TABLE not_cloned (id INTEGER PRIMARY KEY)`);
+ await db.execute(`DELETE FROM moz_cloned`); // Shouldn't throw.
+ await db.close();
+
+ // Open the database with Places.
+ Services.prefs.setCharPref(
+ "places.database.replaceDatabaseOnStartup",
+ filename
+ );
+ Assert.equal(PlacesUtils.history.databaseStatus, dbStatus);
+
+ Assert.ok(await IOUtils.exists(dest), "The database should exist");
+
+ // Check the new database still contains our special data.
+ db = await Sqlite.openConnection({ path: dest });
+ if (willClone) {
+ await db.execute(`DELETE FROM moz_cloned`); // Shouldn't throw.
+ }
+
+ // Check the new database is really a new one.
+ await Assert.rejects(
+ db.execute(`DELETE FROM not_cloned`),
+ /no such table/,
+ "The database should have been replaced"
+ );
+ await db.close();
+
+ if (willClone) {
+ Assert.ok(
+ !(await IOUtils.exists(corrupt)),
+ "The corrupt db should not exist"
+ );
+ Assert.ok(
+ !(await IOUtils.exists(corrupt + "-wal")),
+ "The corrupt db wal should not exist"
+ );
+ Assert.ok(
+ !(await IOUtils.exists(corrupt + "-shm")),
+ "The corrupt db shm should not exist"
+ );
+ } else {
+ Assert.ok(await IOUtils.exists(corrupt), "The corrupt db should exist");
+ }
+
+ Assert.equal(
+ Services.prefs.getCharPref("places.database.replaceDatabaseOnStartup", ""),
+ "",
+ "The replaceDatabaseOnStartup pref should have been unset"
+ );
+}
diff --git a/toolkit/components/places/tests/maintenance/test_corrupt_favicons.js b/toolkit/components/places/tests/maintenance/test_corrupt_favicons.js
new file mode 100644
index 0000000000..2021428a62
--- /dev/null
+++ b/toolkit/components/places/tests/maintenance/test_corrupt_favicons.js
@@ -0,0 +1,16 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+// Tests that history initialization correctly handles a corrupt favicons file
+// that can't be opened.
+
+add_task(async function () {
+ await createCorruptDb("favicons.sqlite");
+
+ Assert.equal(
+ PlacesUtils.history.databaseStatus,
+ PlacesUtils.history.DATABASE_STATUS_CREATE
+ );
+ let db = await PlacesUtils.promiseDBConnection();
+ await db.execute("SELECT * FROM moz_icons"); // Should not fail.
+});
diff --git a/toolkit/components/places/tests/maintenance/test_corrupt_favicons_schema.js b/toolkit/components/places/tests/maintenance/test_corrupt_favicons_schema.js
new file mode 100644
index 0000000000..299bbca65d
--- /dev/null
+++ b/toolkit/components/places/tests/maintenance/test_corrupt_favicons_schema.js
@@ -0,0 +1,23 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+// Tests that history initialization correctly handles a corrupt places schema.
+
+add_task(async function () {
+ let path = await setupPlacesDatabase(["migration", "favicons_v41.sqlite"]);
+
+ // Ensure the database will go through a migration that depends on moz_places
+ // and break the schema by dropping that table.
+ let db = await Sqlite.openConnection({ path });
+ await db.setSchemaVersion(38);
+ await db.execute("DROP TABLE moz_icons");
+ await db.close();
+
+ Assert.equal(
+ PlacesUtils.history.databaseStatus,
+ PlacesUtils.history.DATABASE_STATUS_CORRUPT
+ );
+ db = await PlacesUtils.promiseDBConnection();
+ let rows = await db.execute("SELECT 1 FROM moz_icons");
+ Assert.equal(rows.length, 0, "Found no icons");
+});
diff --git a/toolkit/components/places/tests/maintenance/test_corrupt_places_schema.js b/toolkit/components/places/tests/maintenance/test_corrupt_places_schema.js
new file mode 100644
index 0000000000..6f184d517f
--- /dev/null
+++ b/toolkit/components/places/tests/maintenance/test_corrupt_places_schema.js
@@ -0,0 +1,21 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+// Tests that history initialization correctly handles a corrupt places schema.
+
+add_task(async function () {
+ let path = await setupPlacesDatabase(["migration", "places_v52.sqlite"]);
+
+ // Ensure the database will go through a migration that depends on moz_places
+ // and break the schema by dropping that table.
+ let db = await Sqlite.openConnection({ path });
+ await db.execute("DROP TABLE moz_places");
+ await db.close();
+
+ Assert.equal(
+ PlacesUtils.history.databaseStatus,
+ PlacesUtils.history.DATABASE_STATUS_CORRUPT
+ );
+ db = await PlacesUtils.promiseDBConnection();
+ await db.execute("SELECT * FROM moz_places LIMIT 1"); // Should not fail.
+});
diff --git a/toolkit/components/places/tests/maintenance/test_corrupt_telemetry.js b/toolkit/components/places/tests/maintenance/test_corrupt_telemetry.js
new file mode 100644
index 0000000000..d6659267da
--- /dev/null
+++ b/toolkit/components/places/tests/maintenance/test_corrupt_telemetry.js
@@ -0,0 +1,24 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+// Tests that history initialization correctly handles a request to forcibly
+// replace the current database.
+
+add_task(async function () {
+ await createCorruptDb("places.sqlite");
+
+ let count = Services.telemetry
+ .getHistogramById("PLACES_DATABASE_CORRUPTION_HANDLING_STAGE")
+ .snapshot().values[3];
+ Assert.equal(count, undefined, "There should be no telemetry");
+
+ Assert.equal(
+ PlacesUtils.history.databaseStatus,
+ PlacesUtils.history.DATABASE_STATUS_CORRUPT
+ );
+
+ count = Services.telemetry
+ .getHistogramById("PLACES_DATABASE_CORRUPTION_HANDLING_STAGE")
+ .snapshot().values[3];
+ Assert.equal(count, 1, "Telemetry should have been added");
+});
diff --git a/toolkit/components/places/tests/maintenance/test_favicons_replaceOnStartup.js b/toolkit/components/places/tests/maintenance/test_favicons_replaceOnStartup.js
new file mode 100644
index 0000000000..d48b32f5d6
--- /dev/null
+++ b/toolkit/components/places/tests/maintenance/test_favicons_replaceOnStartup.js
@@ -0,0 +1,14 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+// Tests that history initialization correctly handles a request to forcibly
+// replace the current database.
+
+add_task(async function () {
+ await test_database_replacement(
+ ["migration", "favicons_v41.sqlite"],
+ "favicons.sqlite",
+ false,
+ PlacesUtils.history.DATABASE_STATUS_CREATE
+ );
+});
diff --git a/toolkit/components/places/tests/maintenance/test_favicons_replaceOnStartup_clone.js b/toolkit/components/places/tests/maintenance/test_favicons_replaceOnStartup_clone.js
new file mode 100644
index 0000000000..f6ff2379a0
--- /dev/null
+++ b/toolkit/components/places/tests/maintenance/test_favicons_replaceOnStartup_clone.js
@@ -0,0 +1,16 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+// Tests that history initialization correctly handles a request to forcibly
+// replace the current database.
+
+add_task(async function () {
+ // In reality, this won't try to clone the database, because attached
+ // databases cannot be supported when cloning. This test also verifies that.
+ await test_database_replacement(
+ ["migration", "favicons_v41.sqlite"],
+ "favicons.sqlite",
+ true,
+ PlacesUtils.history.DATABASE_STATUS_CREATE
+ );
+});
diff --git a/toolkit/components/places/tests/maintenance/test_integrity_replacement.js b/toolkit/components/places/tests/maintenance/test_integrity_replacement.js
new file mode 100644
index 0000000000..dde8fd16a3
--- /dev/null
+++ b/toolkit/components/places/tests/maintenance/test_integrity_replacement.js
@@ -0,0 +1,17 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+// Tests that integrity check will replace a corrupt database.
+
+add_task(async function () {
+ await setupPlacesDatabase("corruptPayload.sqlite");
+ await Assert.rejects(
+ PlacesDBUtils.checkIntegrity(),
+ /will be replaced on next startup/,
+ "Should reject on corruption"
+ );
+ Assert.equal(
+ Services.prefs.getCharPref("places.database.replaceDatabaseOnStartup"),
+ DB_FILENAME
+ );
+});
diff --git a/toolkit/components/places/tests/maintenance/test_places_purge_caches.js b/toolkit/components/places/tests/maintenance/test_places_purge_caches.js
new file mode 100644
index 0000000000..dc3e8452f1
--- /dev/null
+++ b/toolkit/components/places/tests/maintenance/test_places_purge_caches.js
@@ -0,0 +1,31 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+// Test whether purge-caches event works collectry when maintenance the places.
+
+add_task(async function test_history() {
+ await PlacesTestUtils.addVisits({ uri: "http://example.com/" });
+ await assertPurgingCaches();
+});
+
+add_task(async function test_bookmark() {
+ await PlacesTestUtils.addBookmarkWithDetails({ uri: "http://example.com/" });
+ await assertPurgingCaches();
+});
+
+async function assertPurgingCaches() {
+ const query = PlacesUtils.history.getNewQuery();
+ const options = PlacesUtils.history.getNewQueryOptions();
+ const result = PlacesUtils.history.executeQuery(query, options);
+ result.root.containerOpen = true;
+
+ const onInvalidateContainer = new Promise(resolve => {
+ const resultObserver = new NavHistoryResultObserver();
+ resultObserver.invalidateContainer = resolve;
+ result.addObserver(resultObserver, false);
+ });
+
+ await PlacesDBUtils.maintenanceOnIdle();
+ await onInvalidateContainer;
+ ok(true, "InvalidateContainer is called");
+}
diff --git a/toolkit/components/places/tests/maintenance/test_places_replaceOnStartup.js b/toolkit/components/places/tests/maintenance/test_places_replaceOnStartup.js
new file mode 100644
index 0000000000..0c389bb18d
--- /dev/null
+++ b/toolkit/components/places/tests/maintenance/test_places_replaceOnStartup.js
@@ -0,0 +1,14 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+// Tests that history initialization correctly handles a request to forcibly
+// replace the current database.
+
+add_task(async function () {
+ await test_database_replacement(
+ ["migration", "places_v52.sqlite"],
+ "places.sqlite",
+ false,
+ PlacesUtils.history.DATABASE_STATUS_CORRUPT
+ );
+});
diff --git a/toolkit/components/places/tests/maintenance/test_places_replaceOnStartup_clone.js b/toolkit/components/places/tests/maintenance/test_places_replaceOnStartup_clone.js
new file mode 100644
index 0000000000..7b984a2bef
--- /dev/null
+++ b/toolkit/components/places/tests/maintenance/test_places_replaceOnStartup_clone.js
@@ -0,0 +1,14 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+// Tests that history initialization correctly handles a request to forcibly
+// replace the current database.
+
+add_task(async function () {
+ await test_database_replacement(
+ ["migration", "places_v52.sqlite"],
+ "places.sqlite",
+ true,
+ PlacesUtils.history.DATABASE_STATUS_UPGRADED
+ );
+});
diff --git a/toolkit/components/places/tests/maintenance/test_preventive_maintenance.js b/toolkit/components/places/tests/maintenance/test_preventive_maintenance.js
new file mode 100644
index 0000000000..5aeb565aa1
--- /dev/null
+++ b/toolkit/components/places/tests/maintenance/test_preventive_maintenance.js
@@ -0,0 +1,2744 @@
+/* -*- indent-tabs-mode: nil; js-indent-level: 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/. */
+
+/**
+ * Test preventive maintenance
+ * For every maintenance query create an uncoherent db and check that we take
+ * correct fix steps, without polluting valid data.
+ */
+
+// ------------------------------------------------------------------------------
+// Helpers
+
+var defaultBookmarksMaxId = 0;
+async function cleanDatabase() {
+ // First clear any bookmarks the "proper way" to ensure caches like GuidHelper
+ // are properly cleared.
+ await PlacesUtils.bookmarks.eraseEverything();
+ await PlacesUtils.withConnectionWrapper("cleanDatabase", async db => {
+ await db.executeTransaction(async () => {
+ await db.executeCached("DELETE FROM moz_places");
+ await db.executeCached("DELETE FROM moz_origins");
+ await db.executeCached("DELETE FROM moz_historyvisits");
+ await db.executeCached("DELETE FROM moz_anno_attributes");
+ await db.executeCached("DELETE FROM moz_annos");
+ await db.executeCached("DELETE FROM moz_inputhistory");
+ await db.executeCached("DELETE FROM moz_keywords");
+ await db.executeCached("DELETE FROM moz_icons");
+ await db.executeCached("DELETE FROM moz_pages_w_icons");
+ await db.executeCached(
+ "DELETE FROM moz_bookmarks WHERE id > " + defaultBookmarksMaxId
+ );
+ await db.executeCached("DELETE FROM moz_bookmarks_deleted");
+ await db.executeCached("DELETE FROM moz_places_metadata_search_queries");
+ });
+ });
+}
+
+async function addPlace(
+ aUrl,
+ aFavicon,
+ aGuid = PlacesUtils.history.makeGuid(),
+ aHash = null
+) {
+ let href = new URL(
+ aUrl || `http://www.mozilla.org/${encodeURIComponent(aGuid)}`
+ ).href;
+ let id;
+ await PlacesUtils.withConnectionWrapper("cleanDatabase", async db => {
+ await db.executeTransaction(async () => {
+ id = (
+ await db.executeCached(
+ `INSERT INTO moz_places (url, url_hash, guid)
+ VALUES (:url, IFNULL(:hash, hash(:url)), :guid)
+ RETURNING id`,
+ {
+ url: href,
+ hash: aHash,
+ guid: aGuid,
+ }
+ )
+ )[0].getResultByIndex(0);
+
+ if (aFavicon) {
+ await db.executeCached(
+ `INSERT INTO moz_pages_w_icons (page_url, page_url_hash)
+ VALUES (:url, IFNULL(:hash, hash(:url)))`,
+ {
+ url: href,
+ hash: aHash,
+ }
+ );
+ await db.executeCached(
+ `INSERT INTO moz_icons_to_pages (page_id, icon_id)
+ VALUES (
+ (SELECT id FROM moz_pages_w_icons WHERE page_url_hash = IFNULL(:hash, hash(:url))),
+ :favicon
+ )`,
+ {
+ url: href,
+ hash: aHash,
+ favicon: aFavicon,
+ }
+ );
+ }
+ });
+ });
+ return id;
+}
+
+async function addBookmark(
+ aPlaceId,
+ aType,
+ aParentGuid = PlacesUtils.bookmarks.unfiledGuid,
+ aKeywordId,
+ aTitle,
+ aGuid = PlacesUtils.history.makeGuid(),
+ aSyncStatus = PlacesUtils.bookmarks.SYNC_STATUS.NEW,
+ aSyncChangeCounter = 0
+) {
+ return PlacesUtils.withConnectionWrapper("addBookmark", async db => {
+ return (
+ await db.executeCached(
+ `INSERT INTO moz_bookmarks (fk, type, parent, keyword_id,
+ title, guid, syncStatus, syncChangeCounter)
+ VALUES (:place_id, :type,
+ (SELECT id FROM moz_bookmarks WHERE guid = :parent), :keyword_id,
+ :title, :guid, :sync_status, :change_counter)
+ RETURNING id`,
+ {
+ place_id: aPlaceId || null,
+ type: aType || null,
+ parent: aParentGuid,
+ keyword_id: aKeywordId || null,
+ title: typeof aTitle == "string" ? aTitle : null,
+ guid: aGuid,
+ sync_status: aSyncStatus,
+ change_counter: aSyncChangeCounter,
+ }
+ )
+ )[0].getResultByIndex(0);
+ });
+}
+
+// ------------------------------------------------------------------------------
+// Tests
+
+var tests = [];
+
+// ------------------------------------------------------------------------------
+
+tests.push({
+ name: "A.1",
+ desc: "Remove obsolete annotations from moz_annos",
+
+ _obsoleteWeaveAttribute: "weave/test",
+ _placeId: null,
+
+ async setup() {
+ // Add a place to ensure place_id = 1 is valid.
+ this._placeId = await addPlace();
+ // Add an obsolete attribute.
+ await PlacesUtils.withConnectionWrapper("setup", async db => {
+ await db.executeTransaction(async () => {
+ db.executeCached(
+ "INSERT INTO moz_anno_attributes (name) VALUES (:anno)",
+ { anno: this._obsoleteWeaveAttribute }
+ );
+
+ db.executeCached(
+ `INSERT INTO moz_annos (place_id, anno_attribute_id)
+ VALUES (:place_id,
+ (SELECT id FROM moz_anno_attributes WHERE name = :anno)
+ )`,
+ {
+ place_id: this._placeId,
+ anno: this._obsoleteWeaveAttribute,
+ }
+ );
+ });
+ });
+ },
+
+ async check() {
+ // Check that the obsolete annotation has been removed.
+ Assert.strictEqual(
+ await PlacesTestUtils.getDatabaseValue("moz_anno_attributes", "id", {
+ name: this._obsoleteWeaveAttribute,
+ }),
+ undefined
+ );
+ },
+});
+
+tests.push({
+ name: "A.3",
+ desc: "Remove unused attributes",
+
+ _usedPageAttribute: "usedPage",
+ _unusedAttribute: "unused",
+ _placeId: null,
+ _bookmarkId: null,
+
+ async setup() {
+ // Add a place to ensure place_id = 1 is valid
+ this._placeId = await addPlace();
+ // add a bookmark
+ this._bookmarkId = await addBookmark(
+ this._placeId,
+ PlacesUtils.bookmarks.TYPE_BOOKMARK
+ );
+ await PlacesUtils.withConnectionWrapper("setup", async db => {
+ await db.executeTransaction(async () => {
+ // Add a used attribute and an unused one.
+ await db.executeCached(
+ `INSERT INTO moz_anno_attributes (name)
+ VALUES (:anno1), (:anno2)`,
+ {
+ anno1: this._usedPageAttribute,
+ anno2: this._unusedAttribute,
+ }
+ );
+ await db.executeCached(
+ `INSERT INTO moz_annos (place_id, anno_attribute_id)
+ VALUES(:place_id, (SELECT id FROM moz_anno_attributes WHERE name = :anno))`,
+ {
+ place_id: this._placeId,
+ anno: this._usedPageAttribute,
+ }
+ );
+ });
+ });
+ },
+
+ async check() {
+ // Check that used attributes are still there
+ let value = await PlacesTestUtils.getDatabaseValue(
+ "moz_anno_attributes",
+ "id",
+ {
+ name: this._usedPageAttribute,
+ }
+ );
+ Assert.notStrictEqual(value, undefined);
+ // Check that unused attribute has been removed
+ value = await PlacesTestUtils.getDatabaseValue(
+ "moz_anno_attributes",
+ "id",
+ {
+ name: this._unusedAttribute,
+ }
+ );
+ Assert.strictEqual(value, undefined);
+ },
+});
+
+// ------------------------------------------------------------------------------
+
+tests.push({
+ name: "B.1",
+ desc: "Remove annotations with an invalid attribute",
+
+ _usedPageAttribute: "usedPage",
+ _placeId: null,
+
+ async setup() {
+ // Add a place to ensure place_id = 1 is valid
+ this._placeId = await addPlace();
+ await PlacesUtils.withConnectionWrapper("setup", async db => {
+ await db.executeTransaction(async () => {
+ // Add a used attribute.
+ await db.executeCached(
+ "INSERT INTO moz_anno_attributes (name) VALUES (:anno)",
+ { anno: this._usedPageAttribute }
+ );
+ await db.executeCached(
+ `INSERT INTO moz_annos (place_id, anno_attribute_id)
+ VALUES(:place_id, (SELECT id FROM moz_anno_attributes WHERE name = :anno))`,
+ {
+ place_id: this._placeId,
+ anno: this._usedPageAttribute,
+ }
+ );
+ // Add an annotation with a nonexistent attribute
+ await db.executeCached(
+ `INSERT INTO moz_annos (place_id, anno_attribute_id)
+ VALUES(:place_id, 1337)`,
+ { place_id: this._placeId }
+ );
+ });
+ });
+ },
+
+ async check() {
+ // Check that used attribute is still there
+ let db = await PlacesUtils.promiseDBConnection();
+ let rows = await db.executeCached(
+ "SELECT id FROM moz_anno_attributes WHERE name = :anno",
+ { anno: this._usedPageAttribute }
+ );
+ Assert.equal(rows.length, 1);
+ // check that annotation with valid attribute is still there
+ rows = await db.executeCached(
+ `SELECT id FROM moz_annos
+ WHERE anno_attribute_id = (SELECT id FROM moz_anno_attributes WHERE name = :anno)`,
+ { anno: this._usedPageAttribute }
+ );
+ Assert.equal(rows.length, 1);
+ // Check that annotation with bogus attribute has been removed
+ let value = await PlacesTestUtils.getDatabaseValue("moz_annos", "id", {
+ anno_attribute_id: 1337,
+ });
+ Assert.strictEqual(value, undefined);
+ },
+});
+
+// ------------------------------------------------------------------------------
+
+tests.push({
+ name: "B.2",
+ desc: "Remove orphan page annotations",
+
+ _usedPageAttribute: "usedPage",
+ _placeId: null,
+
+ async setup() {
+ // Add a place to ensure place_id = 1 is valid
+ this._placeId = await addPlace();
+ await PlacesUtils.withConnectionWrapper("setup", async db => {
+ await db.executeTransaction(async () => {
+ // Add a used attribute.
+ await db.executeCached(
+ "INSERT INTO moz_anno_attributes (name) VALUES (:anno)",
+ { anno: this._usedPageAttribute }
+ );
+ await db.executeCached(
+ `INSERT INTO moz_annos (place_id, anno_attribute_id)
+ VALUES(:place_id, (SELECT id FROM moz_anno_attributes WHERE name = :anno))`,
+ { place_id: this._placeId, anno: this._usedPageAttribute }
+ );
+ // Add an annotation to a nonexistent page
+ await db.executeCached(
+ `INSERT INTO moz_annos (place_id, anno_attribute_id)
+ VALUES(:place_id, (SELECT id FROM moz_anno_attributes WHERE name = :anno))`,
+ { place_id: 1337, anno: this._usedPageAttribute }
+ );
+ });
+ });
+ },
+
+ async check() {
+ // Check that used attribute is still there
+ let db = await PlacesUtils.promiseDBConnection();
+ let rows = await db.executeCached(
+ "SELECT id FROM moz_anno_attributes WHERE name = :anno",
+ { anno: this._usedPageAttribute }
+ );
+ Assert.equal(rows.length, 1);
+ // check that annotation with valid attribute is still there
+ rows = await db.executeCached(
+ `SELECT id FROM moz_annos
+ WHERE anno_attribute_id = (SELECT id FROM moz_anno_attributes WHERE name = :anno)`,
+ { anno: this._usedPageAttribute }
+ );
+ Assert.equal(rows.length, 1);
+ // Check that an annotation to a nonexistent page has been removed
+ let value = await PlacesTestUtils.getDatabaseValue("moz_annos", "id", {
+ place_id: 1337,
+ });
+ Assert.strictEqual(value, undefined);
+ },
+});
+
+// ------------------------------------------------------------------------------
+
+tests.push({
+ name: "D.9",
+ desc: "Remove items without a valid place",
+
+ _validItemId: null,
+ _invalidItemId: null,
+ _invalidSyncedItemId: null,
+ placeId: null,
+
+ _changeCounterStmt: null,
+ _menuChangeCounter: -1,
+
+ async setup() {
+ // Add a place to ensure place_id = 1 is valid
+ this.placeId = await addPlace();
+ // Insert a valid bookmark
+ this._validItemId = await addBookmark(
+ this.placeId,
+ PlacesUtils.bookmarks.TYPE_BOOKMARK
+ );
+ // Insert a bookmark with an invalid place
+ this._invalidItemId = await addBookmark(
+ 1337,
+ PlacesUtils.bookmarks.TYPE_BOOKMARK
+ );
+ // Insert a synced bookmark with an invalid place. We should write a
+ // tombstone when we remove it.
+ this._invalidSyncedItemId = await addBookmark(
+ 1337,
+ PlacesUtils.bookmarks.TYPE_BOOKMARK,
+ PlacesUtils.bookmarks.menuGuid,
+ null,
+ null,
+ "bookmarkAAAA",
+ PlacesUtils.bookmarks.SYNC_STATUS.NORMAL
+ );
+ // Insert a folder referencing a nonexistent place ID. D.5 should convert
+ // it to a bookmark; D.9 should remove it.
+ this._invalidWrongTypeItemId = await addBookmark(
+ 1337,
+ PlacesUtils.bookmarks.TYPE_FOLDER
+ );
+
+ let value = await PlacesTestUtils.getDatabaseValue(
+ "moz_bookmarks",
+ "syncChangeCounter",
+ {
+ guid: PlacesUtils.bookmarks.menuGuid,
+ }
+ );
+ Assert.equal(value, 0);
+ this._menuChangeCounter = value;
+ },
+
+ async check() {
+ // Check that valid bookmark is still there
+ let value = await PlacesTestUtils.getDatabaseValue("moz_bookmarks", "id", {
+ id: this._validItemId,
+ });
+ Assert.notStrictEqual(value, undefined);
+ // Check that invalid bookmarks have been removed
+ for (let id of [
+ this._invalidItemId,
+ this._invalidSyncedItemId,
+ this._invalidWrongTypeItemId,
+ ]) {
+ value = await PlacesTestUtils.getDatabaseValue("moz_bookmarks", "id", {
+ id,
+ });
+ Assert.strictEqual(value, undefined);
+ }
+
+ value = await PlacesTestUtils.getDatabaseValue(
+ "moz_bookmarks",
+ "syncChangeCounter",
+ { guid: PlacesUtils.bookmarks.menuGuid }
+ );
+ Assert.equal(value, 1);
+ Assert.equal(value, this._menuChangeCounter + 1);
+
+ let tombstones = await PlacesTestUtils.fetchSyncTombstones();
+ Assert.deepEqual(
+ tombstones.map(info => info.guid),
+ ["bookmarkAAAA"]
+ );
+ },
+});
+
+// ------------------------------------------------------------------------------
+
+tests.push({
+ name: "D.1",
+ desc: "Remove items that are not uri bookmarks from tag containers",
+
+ _tagId: null,
+ _bookmarkId: null,
+ _separatorId: null,
+ _folderId: null,
+ _placeId: null,
+
+ async setup() {
+ // Add a place to ensure place_id = 1 is valid
+ this._placeId = await addPlace();
+ // Create a tag
+ this._tagId = await addBookmark(
+ null,
+ PlacesUtils.bookmarks.TYPE_FOLDER,
+ PlacesUtils.bookmarks.tagsGuid
+ );
+ let tagGuid = await PlacesTestUtils.promiseItemGuid(this._tagId);
+ // Insert a bookmark in the tag
+ this._bookmarkId = await addBookmark(
+ this._placeId,
+ PlacesUtils.bookmarks.TYPE_BOOKMARK,
+ tagGuid
+ );
+ // Insert a separator in the tag
+ this._separatorId = await addBookmark(
+ null,
+ PlacesUtils.bookmarks.TYPE_SEPARATOR,
+ tagGuid
+ );
+ // Insert a folder in the tag
+ this._folderId = await addBookmark(
+ null,
+ PlacesUtils.bookmarks.TYPE_FOLDER,
+ tagGuid
+ );
+ },
+
+ async check() {
+ // Check that valid bookmark is still there
+ let value = await PlacesTestUtils.getDatabaseValue("moz_bookmarks", "id", {
+ type: PlacesUtils.bookmarks.TYPE_BOOKMARK,
+ parent: this._tagId,
+ });
+ Assert.notStrictEqual(value, undefined);
+ // Check that separator is no more there
+ value = await PlacesTestUtils.getDatabaseValue("moz_bookmarks", "id", {
+ type: PlacesUtils.bookmarks.TYPE_SEPARATOR,
+ parent: this._tagId,
+ });
+ Assert.equal(value, undefined);
+ // Check that folder is no more there
+ value = await PlacesTestUtils.getDatabaseValue("moz_bookmarks", "id", {
+ type: PlacesUtils.bookmarks.TYPE_FOLDER,
+ parent: this._tagId,
+ });
+ Assert.equal(value, undefined);
+ },
+});
+
+// ------------------------------------------------------------------------------
+
+tests.push({
+ name: "D.2",
+ desc: "Remove empty tags",
+
+ _tagId: null,
+ _bookmarkId: null,
+ _emptyTagId: null,
+ _placeId: null,
+
+ async setup() {
+ // Add a place to ensure place_id = 1 is valid
+ this._placeId = await addPlace();
+ // Create a tag
+ this._tagId = await addBookmark(
+ null,
+ PlacesUtils.bookmarks.TYPE_FOLDER,
+ PlacesUtils.bookmarks.tagsGuid
+ );
+ let tagGuid = await PlacesTestUtils.promiseItemGuid(this._tagId);
+ // Insert a bookmark in the tag
+ this._bookmarkId = await addBookmark(
+ this._placeId,
+ PlacesUtils.bookmarks.TYPE_BOOKMARK,
+ tagGuid
+ );
+ // Create another tag (empty)
+ this._emptyTagId = await addBookmark(
+ null,
+ PlacesUtils.bookmarks.TYPE_FOLDER,
+ PlacesUtils.bookmarks.tagsGuid
+ );
+ },
+
+ async check() {
+ let db = await PlacesUtils.promiseDBConnection();
+ // Check that valid bookmark is still there
+ let value = await PlacesTestUtils.getDatabaseValue("moz_bookmarks", "id", {
+ id: this._bookmarkId,
+ type: PlacesUtils.bookmarks.TYPE_BOOKMARK,
+ parent: this._tagId,
+ });
+ Assert.notStrictEqual(value, undefined);
+ let rows = await db.executeCached(
+ `SELECT b.id FROM moz_bookmarks b
+ JOIN moz_bookmarks p ON p.id = b.parent
+ WHERE b.id = :id AND b.type = :type AND p.guid = :parent`,
+ {
+ id: this._tagId,
+ type: PlacesUtils.bookmarks.TYPE_FOLDER,
+ parent: PlacesUtils.bookmarks.tagsGuid,
+ }
+ );
+ Assert.equal(rows.length, 1);
+ rows = await db.executeCached(
+ `SELECT b.id FROM moz_bookmarks b
+ JOIN moz_bookmarks p ON p.id = b.parent
+ WHERE b.id = :id AND b.type = :type AND p.guid = :parent`,
+ {
+ id: this._emptyTagId,
+ type: PlacesUtils.bookmarks.TYPE_FOLDER,
+ parent: PlacesUtils.bookmarks.tagsGuid,
+ }
+ );
+ Assert.equal(rows.length, 0);
+ },
+});
+
+// ------------------------------------------------------------------------------
+
+tests.push({
+ name: "D.3",
+ desc: "Move orphan items to unsorted folder",
+
+ _orphanBookmarkId: null,
+ _orphanSeparatorId: null,
+ _orphanFolderId: null,
+ _bookmarkId: null,
+ _placeId: null,
+
+ async setup() {
+ // Add a place to ensure place_id = 1 is valid
+ this._placeId = await addPlace();
+ // Insert an orphan bookmark
+ this._orphanBookmarkId = await addBookmark(
+ this._placeId,
+ PlacesUtils.bookmarks.TYPE_BOOKMARK,
+ 8888
+ );
+ // Insert an orphan separator
+ this._orphanSeparatorId = await addBookmark(
+ null,
+ PlacesUtils.bookmarks.TYPE_SEPARATOR,
+ 8888
+ );
+ // Insert a orphan folder
+ this._orphanFolderId = await addBookmark(
+ null,
+ PlacesUtils.bookmarks.TYPE_FOLDER,
+ 8888
+ );
+ let folderGuid = await PlacesTestUtils.promiseItemGuid(
+ this._orphanFolderId
+ );
+ // Create a child of the last created folder
+ this._bookmarkId = await addBookmark(
+ this._placeId,
+ PlacesUtils.bookmarks.TYPE_BOOKMARK,
+ folderGuid
+ );
+ },
+
+ async check() {
+ // Check that bookmarks are now children of a real folder (unfiled)
+ let expectedInfos = [
+ {
+ id: this._orphanBookmarkId,
+ parent: PlacesUtils.bookmarks.unfiledGuid,
+ syncChangeCounter: 1,
+ },
+ {
+ id: this._orphanSeparatorId,
+ parent: PlacesUtils.bookmarks.unfiledGuid,
+ syncChangeCounter: 1,
+ },
+ {
+ id: this._orphanFolderId,
+ parent: PlacesUtils.bookmarks.unfiledGuid,
+ syncChangeCounter: 1,
+ },
+ {
+ id: this._bookmarkId,
+ parent: await PlacesTestUtils.promiseItemGuid(this._orphanFolderId),
+ syncChangeCounter: 0,
+ },
+ {
+ id: await PlacesTestUtils.promiseItemId(
+ PlacesUtils.bookmarks.unfiledGuid
+ ),
+ parent: PlacesUtils.bookmarks.rootGuid,
+ syncChangeCounter: 3,
+ },
+ ];
+ let db = await PlacesUtils.promiseDBConnection();
+ for (let { id, parent, syncChangeCounter } of expectedInfos) {
+ let rows = await db.executeCached(
+ `
+ SELECT b.id, b.syncChangeCounter
+ FROM moz_bookmarks b
+ JOIN moz_bookmarks p ON b.parent = p.id
+ WHERE b.id = :item_id
+ AND p.guid = :parent`,
+ { item_id: id, parent }
+ );
+ Assert.equal(rows.length, 1);
+
+ let actualChangeCounter = rows[0].getResultByName("syncChangeCounter");
+ Assert.equal(actualChangeCounter, syncChangeCounter);
+ }
+ },
+});
+
+// ------------------------------------------------------------------------------
+
+tests.push({
+ name: "D.5",
+ desc: "Fix wrong item types | folders and separators",
+
+ _separatorId: null,
+ _separatorGuid: null,
+ _folderId: null,
+ _folderGuid: null,
+ _syncedFolderId: null,
+ _syncedFolderGuid: null,
+ _placeId: null,
+
+ async setup() {
+ // Add a place to ensure place_id = 1 is valid
+ this._placeId = await addPlace();
+ // Add a separator with a fk
+ this._separatorId = await addBookmark(
+ this._placeId,
+ PlacesUtils.bookmarks.TYPE_SEPARATOR
+ );
+ this._separatorGuid = await PlacesTestUtils.promiseItemGuid(
+ this._separatorId
+ );
+ // Add a folder with a fk
+ this._folderId = await addBookmark(
+ this._placeId,
+ PlacesUtils.bookmarks.TYPE_FOLDER
+ );
+ this._folderGuid = await PlacesTestUtils.promiseItemGuid(this._folderId);
+ // Add a synced folder with a fk
+ this._syncedFolderId = await addBookmark(
+ this._placeId,
+ PlacesUtils.bookmarks.TYPE_FOLDER,
+ PlacesUtils.bookmarks.toolbarGuid,
+ null,
+ null,
+ "itemAAAAAAAA",
+ PlacesUtils.bookmarks.SYNC_STATUS.NORMAL
+ );
+ this._syncedFolderGuid = await PlacesTestUtils.promiseItemGuid(
+ this._syncedFolderId
+ );
+ },
+
+ async check() {
+ let db = await PlacesUtils.promiseDBConnection();
+ // Check that items with an fk have been converted to bookmarks
+ let rows = await db.executeCached(
+ `SELECT id, guid, syncChangeCounter
+ FROM moz_bookmarks
+ WHERE id = :item_id AND type = :type`,
+ { item_id: this._separatorId, type: PlacesUtils.bookmarks.TYPE_BOOKMARK }
+ );
+ Assert.equal(rows.length, 1);
+ let expected = [
+ {
+ id: this._folderId,
+ oldGuid: this._folderGuid,
+ },
+ {
+ id: this._syncedFolderId,
+ oldGuid: this._syncedFolderGuid,
+ },
+ ];
+ for (let { id, oldGuid } of expected) {
+ rows = await db.executeCached(
+ `SELECT id, guid, syncChangeCounter
+ FROM moz_bookmarks
+ WHERE id = :item_id AND type = :type`,
+ { item_id: id, type: PlacesUtils.bookmarks.TYPE_BOOKMARK }
+ );
+ Assert.equal(rows.length, 1);
+ Assert.notEqual(rows[0].getResultByName("guid"), oldGuid);
+ Assert.equal(rows[0].getResultByName("syncChangeCounter"), 1);
+ }
+
+ let tombstones = await PlacesTestUtils.fetchSyncTombstones();
+ Assert.deepEqual(
+ tombstones.map(info => info.guid),
+ ["itemAAAAAAAA"]
+ );
+ },
+});
+
+// ------------------------------------------------------------------------------
+
+tests.push({
+ name: "D.6",
+ desc: "Fix wrong item types | bookmarks",
+
+ _validBookmarkId: null,
+ _validBookmarkGuid: null,
+ _invalidBookmarkId: null,
+ _invalidBookmarkGuid: null,
+ _invalidSyncedBookmarkId: null,
+ _invalidSyncedBookmarkGuid: null,
+ _placeId: null,
+
+ async setup() {
+ // Add a place to ensure place_id = 1 is valid
+ this._placeId = await addPlace();
+ // Add a bookmark with a valid place id
+ this._validBookmarkId = await addBookmark(
+ this._placeId,
+ PlacesUtils.bookmarks.TYPE_BOOKMARK
+ );
+ this._validBookmarkGuid = await PlacesTestUtils.promiseItemGuid(
+ this._validBookmarkId
+ );
+ // Add a bookmark with a null place id
+ this._invalidBookmarkId = await addBookmark(
+ null,
+ PlacesUtils.bookmarks.TYPE_BOOKMARK
+ );
+ this._invalidBookmarkGuid = await PlacesTestUtils.promiseItemGuid(
+ this._invalidBookmarkId
+ );
+ // Add a synced bookmark with a null place id
+ this._invalidSyncedBookmarkId = await addBookmark(
+ null,
+ PlacesUtils.bookmarks.TYPE_BOOKMARK,
+ PlacesUtils.bookmarks.toolbarGuid,
+ null,
+ null,
+ "bookmarkAAAA",
+ PlacesUtils.bookmarks.SYNC_STATUS.NORMAL
+ );
+ this._invalidSyncedBookmarkGuid = await PlacesTestUtils.promiseItemGuid(
+ this._invalidSyncedBookmarkId
+ );
+ },
+
+ async check() {
+ let db = await PlacesUtils.promiseDBConnection();
+ // Check valid bookmark
+ let rows = await db.executeCached(
+ `SELECT id, guid, syncChangeCounter
+ FROM moz_bookmarks
+ WHERE id = :item_id AND type = :type`,
+ {
+ item_id: this._validBookmarkId,
+ type: PlacesUtils.bookmarks.TYPE_BOOKMARK,
+ }
+ );
+ Assert.equal(rows.length, 1);
+ Assert.equal(rows[0].getResultByName("syncChangeCounter"), 0);
+ Assert.equal(
+ await PlacesTestUtils.promiseItemId(this._validBookmarkGuid),
+ this._validBookmarkId
+ );
+
+ // Check invalid bookmarks have been converted to folders
+ let expected = [
+ {
+ id: this._invalidBookmarkId,
+ oldGuid: this._invalidBookmarkGuid,
+ },
+ {
+ id: this._invalidSyncedBookmarkId,
+ oldGuid: this._invalidSyncedBookmarkGuid,
+ },
+ ];
+ for (let { id, oldGuid } of expected) {
+ rows = await db.executeCached(
+ `SELECT id, guid, syncChangeCounter
+ FROM moz_bookmarks
+ WHERE id = :item_id AND type = :type`,
+ { item_id: id, type: PlacesUtils.bookmarks.TYPE_FOLDER }
+ );
+ Assert.equal(rows.length, 1);
+ Assert.notEqual(rows[0].getResultByName("guid"), oldGuid);
+ Assert.equal(rows[0].getResultByName("syncChangeCounter"), 1);
+ }
+
+ let tombstones = await PlacesTestUtils.fetchSyncTombstones();
+ Assert.deepEqual(
+ tombstones.map(info => info.guid),
+ ["bookmarkAAAA"]
+ );
+ },
+});
+
+// ------------------------------------------------------------------------------
+
+tests.push({
+ name: "D.7",
+ desc: "Fix missing item types",
+
+ _placeId: null,
+ _bookmarkId: null,
+ _bookmarkGuid: null,
+ _syncedBookmarkId: null,
+ _syncedBookmarkGuid: null,
+ _folderId: null,
+ _folderGuid: null,
+ _syncedFolderId: null,
+ _syncedFolderGuid: null,
+
+ async setup() {
+ // Item without a type but with a place ID; should be converted to a
+ // bookmark. The synced bookmark should be handled the same way, but with
+ // a tombstone.
+ this._placeId = await addPlace();
+ this._bookmarkId = await addBookmark(this._placeId);
+ this._bookmarkGuid = await PlacesTestUtils.promiseItemGuid(
+ this._bookmarkId
+ );
+ this._syncedBookmarkId = await addBookmark(
+ this._placeId,
+ null,
+ PlacesUtils.bookmarks.toolbarGuid,
+ null,
+ null,
+ "bookmarkAAAA",
+ PlacesUtils.bookmarks.SYNC_STATUS.NORMAL
+ );
+ this._syncedBookmarkGuid = await PlacesTestUtils.promiseItemGuid(
+ this._syncedBookmarkId
+ );
+
+ // Item without a type and without a place ID; should be converted to a
+ // folder.
+ this._folderId = await addBookmark();
+ this._folderGuid = await PlacesTestUtils.promiseItemGuid(this._folderId);
+ this._syncedFolderId = await addBookmark(
+ null,
+ null,
+ PlacesUtils.bookmarks.toolbarGuid,
+ null,
+ null,
+ "folderBBBBBB",
+ PlacesUtils.bookmarks.SYNC_STATUS.NORMAL
+ );
+ this._syncedFolderGuid = await PlacesTestUtils.promiseItemGuid(
+ this._syncedFolderId
+ );
+ },
+
+ async check() {
+ let db = await PlacesUtils.promiseDBConnection();
+ let expected = [
+ {
+ id: this._bookmarkId,
+ oldGuid: this._bookmarkGuid,
+ type: PlacesUtils.bookmarks.TYPE_BOOKMARK,
+ syncChangeCounter: 1,
+ },
+ {
+ id: this._syncedBookmarkId,
+ oldGuid: this._syncedBookmarkGuid,
+ type: PlacesUtils.bookmarks.TYPE_BOOKMARK,
+ syncChangeCounter: 1,
+ },
+ {
+ id: this._folderId,
+ oldGuid: this._folderGuid,
+ type: PlacesUtils.bookmarks.TYPE_FOLDER,
+ syncChangeCounter: 1,
+ },
+ {
+ id: this._syncedFolderId,
+ oldGuid: this._syncedFolderGuid,
+ type: PlacesUtils.bookmarks.TYPE_FOLDER,
+ syncChangeCounter: 1,
+ },
+ ];
+ for (let { id, oldGuid, type, syncChangeCounter } of expected) {
+ let rows = await db.executeCached(
+ `SELECT id, guid, type, syncChangeCounter
+ FROM moz_bookmarks
+ WHERE id = :item_id`,
+ { item_id: id }
+ );
+ Assert.equal(rows.length, 1);
+ Assert.equal(rows[0].getResultByName("type"), type);
+ Assert.equal(
+ rows[0].getResultByName("syncChangeCounter"),
+ syncChangeCounter
+ );
+ Assert.notEqual(rows[0].getResultByName("guid"), oldGuid);
+ }
+
+ let tombstones = await PlacesTestUtils.fetchSyncTombstones();
+ Assert.deepEqual(
+ tombstones.map(info => info.guid),
+ ["bookmarkAAAA", "folderBBBBBB"]
+ );
+ },
+});
+
+// ------------------------------------------------------------------------------
+
+tests.push({
+ name: "D.8",
+ desc: "Fix wrong parents",
+
+ _bookmarkId: null,
+ _separatorId: null,
+ _bookmarkId1: null,
+ _bookmarkId2: null,
+ _placeId: null,
+
+ async setup() {
+ // Add a place to ensure place_id = 1 is valid
+ this._placeId = await addPlace();
+ // Insert a bookmark
+ this._bookmarkId = await addBookmark(
+ this._placeId,
+ PlacesUtils.bookmarks.TYPE_BOOKMARK
+ );
+ // Insert a separator
+ this._separatorId = await addBookmark(
+ null,
+ PlacesUtils.bookmarks.TYPE_SEPARATOR
+ );
+ // Create 3 children of these items
+ let bookmarkGuid = await PlacesTestUtils.promiseItemGuid(this._bookmarkId);
+ this._bookmarkId1 = await addBookmark(
+ this._placeId,
+ PlacesUtils.bookmarks.TYPE_BOOKMARK,
+ bookmarkGuid
+ );
+ let separatorGuid = await PlacesTestUtils.promiseItemGuid(
+ this._separatorId
+ );
+ this._bookmarkId2 = await addBookmark(
+ this._placeId,
+ PlacesUtils.bookmarks.TYPE_BOOKMARK,
+ separatorGuid
+ );
+ },
+
+ async check() {
+ // Check that bookmarks are now children of a real folder (unfiled)
+ let expectedInfos = [
+ {
+ id: this._bookmarkId1,
+ parent: PlacesUtils.bookmarks.unfiledGuid,
+ syncChangeCounter: 1,
+ },
+ {
+ id: this._bookmarkId2,
+ parent: PlacesUtils.bookmarks.unfiledGuid,
+ syncChangeCounter: 1,
+ },
+ {
+ id: await PlacesTestUtils.promiseItemId(
+ PlacesUtils.bookmarks.unfiledGuid
+ ),
+ parent: PlacesUtils.bookmarks.rootGuid,
+ syncChangeCounter: 2,
+ },
+ ];
+ let db = await PlacesUtils.promiseDBConnection();
+ for (let { id, parent, syncChangeCounter } of expectedInfos) {
+ let rows = await db.executeCached(
+ `
+ SELECT b.id, b.syncChangeCounter
+ FROM moz_bookmarks b
+ JOIN moz_bookmarks p ON b.parent = p.id
+ WHERE b.id = :item_id AND p.guid = :parent`,
+ { item_id: id, parent }
+ );
+ Assert.equal(rows.length, 1);
+
+ let actualChangeCounter = rows[0].getResultByName("syncChangeCounter");
+ Assert.equal(actualChangeCounter, syncChangeCounter);
+ }
+ },
+});
+
+// ------------------------------------------------------------------------------
+
+tests.push({
+ name: "D.10",
+ desc: "Recalculate positions",
+
+ _unfiledBookmarks: [],
+ _toolbarBookmarks: [],
+
+ async setup() {
+ const NUM_BOOKMARKS = 20;
+ let children = [];
+ for (let i = 0; i < NUM_BOOKMARKS; i++) {
+ children.push({
+ title: "testbookmark",
+ url: "http://example.com",
+ });
+ }
+
+ // Add bookmarks to two folders to better perturbate the table.
+ await PlacesUtils.bookmarks.insertTree({
+ guid: PlacesUtils.bookmarks.unfiledGuid,
+ children,
+ source: PlacesUtils.bookmarks.SOURCES.SYNC,
+ });
+ await PlacesUtils.bookmarks.insertTree({
+ guid: PlacesUtils.bookmarks.toolbarGuid,
+ children,
+ source: PlacesUtils.bookmarks.SOURCES.SYNC,
+ });
+
+ async function randomize_positions(aParent, aResultArray) {
+ await PlacesUtils.withConnectionWrapper("setup", async db => {
+ await db.executeTransaction(async () => {
+ for (let i = 0; i < NUM_BOOKMARKS / 2; i++) {
+ await db.executeCached(
+ `UPDATE moz_bookmarks SET position = :rand
+ WHERE id IN (
+ SELECT b.id FROM moz_bookmarks b
+ JOIN moz_bookmarks p ON b.parent = p.id
+ WHERE p.guid = :parent
+ ORDER BY RANDOM() LIMIT 1
+ )`,
+ {
+ parent: aParent,
+ rand: Math.round(Math.random() * (NUM_BOOKMARKS - 1)),
+ }
+ );
+ }
+
+ // Build the expected ordered list of bookmarks.
+ let rows = await db.executeCached(
+ `SELECT b.id
+ FROM moz_bookmarks b
+ JOIN moz_bookmarks p ON b.parent = p.id
+ WHERE p.guid = :parent
+ ORDER BY b.position ASC, b.ROWID ASC`,
+ { parent: aParent }
+ );
+ rows.forEach(r => {
+ aResultArray.push(r.getResultByName("id"));
+ });
+ await PlacesTestUtils.dumpTable({
+ db,
+ table: "moz_bookmarks",
+ columns: ["id", "parent", "position"],
+ });
+ });
+ });
+ }
+
+ // Set random positions for the added bookmarks.
+ await randomize_positions(
+ PlacesUtils.bookmarks.unfiledGuid,
+ this._unfiledBookmarks
+ );
+ await randomize_positions(
+ PlacesUtils.bookmarks.toolbarGuid,
+ this._toolbarBookmarks
+ );
+
+ let syncInfos = await PlacesTestUtils.fetchBookmarkSyncFields(
+ PlacesUtils.bookmarks.unfiledGuid,
+ PlacesUtils.bookmarks.toolbarGuid
+ );
+ Assert.ok(syncInfos.every(info => info.syncChangeCounter === 0));
+ },
+
+ async check() {
+ let db = await PlacesUtils.promiseDBConnection();
+
+ async function check_order(aParent, aResultArray) {
+ // Build the expected ordered list of bookmarks.
+ let childRows = await db.executeCached(
+ `SELECT b.id, b.position, b.syncChangeCounter
+ FROM moz_bookmarks b
+ JOIN moz_bookmarks p ON b.parent = p.id
+ WHERE p.guid = :parent
+ ORDER BY b.position ASC`,
+ { parent: aParent }
+ );
+ for (let row of childRows) {
+ let id = row.getResultByName("id");
+ let position = row.getResultByName("position");
+ if (aResultArray.indexOf(id) != position) {
+ info("Expected order: " + aResultArray);
+ await PlacesTestUtils.dumpTable({
+ db,
+ table: "moz_bookmarks",
+ columns: ["id", "parent", "position"],
+ });
+ do_throw(`Unexpected bookmarks order for ${aParent}.`);
+ }
+ }
+
+ let parentRows = await db.executeCached(
+ `SELECT syncChangeCounter FROM moz_bookmarks
+ WHERE guid = :parent`,
+ { parent: aParent }
+ );
+ for (let row of parentRows) {
+ let actualChangeCounter = row.getResultByName("syncChangeCounter");
+ Assert.ok(actualChangeCounter > 0);
+ }
+ }
+
+ await check_order(
+ PlacesUtils.bookmarks.unfiledGuid,
+ this._unfiledBookmarks
+ );
+ await check_order(
+ PlacesUtils.bookmarks.toolbarGuid,
+ this._toolbarBookmarks
+ );
+ },
+});
+
+// ------------------------------------------------------------------------------
+
+tests.push({
+ name: "D.13",
+ desc: "Fix empty-named tags",
+ _taggedItemIds: {},
+
+ async setup() {
+ // Add a place to ensure place_id = 1 is valid
+ let placeId = await addPlace();
+ // Create a empty-named tag.
+ this._untitledTagId = await addBookmark(
+ null,
+ PlacesUtils.bookmarks.TYPE_FOLDER,
+ PlacesUtils.bookmarks.tagsGuid,
+ null,
+ ""
+ );
+ // Insert a bookmark in the tag, otherwise it will be removed.
+ let untitledTagGuid = await PlacesTestUtils.promiseItemGuid(
+ this._untitledTagId
+ );
+ await addBookmark(
+ placeId,
+ PlacesUtils.bookmarks.TYPE_BOOKMARK,
+ untitledTagGuid
+ );
+ // Create a empty-named folder.
+ this._untitledFolderId = await addBookmark(
+ null,
+ PlacesUtils.bookmarks.TYPE_FOLDER,
+ PlacesUtils.bookmarks.toolbarGuid,
+ null,
+ ""
+ );
+ // Create a titled tag.
+ this._titledTagId = await addBookmark(
+ null,
+ PlacesUtils.bookmarks.TYPE_FOLDER,
+ PlacesUtils.bookmarks.tagsGuid,
+ null,
+ "titledTag"
+ );
+ // Insert a bookmark in the tag, otherwise it will be removed.
+ let titledTagGuid = await PlacesTestUtils.promiseItemGuid(
+ this._titledTagId
+ );
+ await addBookmark(
+ placeId,
+ PlacesUtils.bookmarks.TYPE_BOOKMARK,
+ titledTagGuid
+ );
+ // Create a titled folder.
+ this._titledFolderId = await addBookmark(
+ null,
+ PlacesUtils.bookmarks.TYPE_FOLDER,
+ PlacesUtils.bookmarks.toolbarGuid,
+ null,
+ "titledFolder"
+ );
+
+ // Create two tagged bookmarks in different folders.
+ this._taggedItemIds.inMenu = await addBookmark(
+ placeId,
+ PlacesUtils.bookmarks.TYPE_BOOKMARK,
+ PlacesUtils.bookmarks.menuGuid,
+ null,
+ "Tagged bookmark in menu"
+ );
+ this._taggedItemIds.inToolbar = await addBookmark(
+ placeId,
+ PlacesUtils.bookmarks.TYPE_BOOKMARK,
+ PlacesUtils.bookmarks.toolbarGuid,
+ null,
+ "Tagged bookmark in toolbar"
+ );
+ },
+
+ async check() {
+ // Check that valid bookmark is still there
+ let value = await PlacesTestUtils.getDatabaseValue(
+ "moz_bookmarks",
+ "title",
+ { id: this._untitledTagId }
+ );
+ Assert.equal(value, "(notitle)");
+ value = await PlacesTestUtils.getDatabaseValue("moz_bookmarks", "title", {
+ id: this._untitledFolderId,
+ });
+ Assert.equal(value, "");
+ value = await PlacesTestUtils.getDatabaseValue("moz_bookmarks", "title", {
+ id: this._titledTagId,
+ });
+ Assert.equal(value, "titledTag");
+ value = await PlacesTestUtils.getDatabaseValue("moz_bookmarks", "title", {
+ id: this._titledFolderId,
+ });
+ Assert.equal(value, "titledFolder");
+
+ let db = await PlacesUtils.promiseDBConnection();
+ let rows = await db.executeCached(
+ `SELECT syncChangeCounter FROM moz_bookmarks
+ WHERE id IN (:taggedInMenu, :taggedInToolbar)`,
+ {
+ taggedInMenu: this._taggedItemIds.inMenu,
+ taggedInToolbar: this._taggedItemIds.inToolbar,
+ }
+ );
+ for (let row of rows) {
+ Assert.greaterOrEqual(row.getResultByName("syncChangeCounter"), 1);
+ }
+ },
+});
+
+// ------------------------------------------------------------------------------
+
+tests.push({
+ name: "E.1",
+ desc: "Remove orphan icon entries",
+
+ _placeId: null,
+
+ async setup() {
+ await PlacesUtils.withConnectionWrapper("setup", async db => {
+ await db.executeTransaction(async () => {
+ // Insert favicon entries
+ await db.executeCached(
+ `INSERT INTO moz_icons (id, icon_url, fixed_icon_url_hash, root) VALUES(:favicon_id, :url, hash(fixup_url(:url)), :root)`,
+ [
+ {
+ favicon_id: 1,
+ url: "http://www1.mozilla.org/favicon.ico",
+ root: 0,
+ },
+ {
+ favicon_id: 2,
+ url: "http://www2.mozilla.org/favicon.ico",
+ root: 0,
+ },
+ {
+ favicon_id: 3,
+ url: "http://www3.mozilla.org/favicon.ico",
+ root: 1,
+ },
+ ]
+ );
+
+ // Insert orphan page.
+ await db.executeCached(
+ `INSERT INTO moz_pages_w_icons (id, page_url, page_url_hash)
+ VALUES(:page_id, :url, hash(:url))`,
+ { page_id: 99, url: "http://w99.mozilla.org/" }
+ );
+ });
+ });
+
+ // Insert a place using the existing favicon entry
+ this._placeId = await addPlace("http://www.mozilla.org", 1);
+ },
+
+ async check() {
+ // Check that used icon is still there
+ let value = await PlacesTestUtils.getDatabaseValue("moz_icons", "id", {
+ id: 1,
+ });
+ Assert.notStrictEqual(value, undefined);
+ // Check that unused icon has been removed
+ value = await PlacesTestUtils.getDatabaseValue("moz_icons", "id", {
+ id: 2,
+ });
+ Assert.strictEqual(value, undefined);
+ // Check that unused icon has been removed
+ value = await PlacesTestUtils.getDatabaseValue("moz_icons", "id", {
+ id: 3,
+ });
+ Assert.strictEqual(value, undefined);
+ // Check that the orphan page is gone.
+ value = await PlacesTestUtils.getDatabaseValue("moz_pages_w_icons", "id", {
+ id: 99,
+ });
+ Assert.strictEqual(value, undefined);
+ },
+});
+
+// ------------------------------------------------------------------------------
+
+tests.push({
+ name: "F.1",
+ desc: "Remove orphan visits",
+
+ _placeId: null,
+ _invalidPlaceId: 1337,
+
+ async setup() {
+ // Add a place to ensure place_id = 1 is valid
+ this._placeId = await addPlace();
+ // Add a valid visit and an invalid one
+ await PlacesUtils.withConnectionWrapper("setup", async db => {
+ await db.executeCached(
+ `INSERT INTO moz_historyvisits(place_id)
+ VALUES (:place_id_1), (:place_id_2)`,
+ { place_id_1: this._placeId, place_id_2: this._invalidPlaceId }
+ );
+ });
+ },
+
+ async check() {
+ // Check that valid visit is still there
+ let value = await PlacesTestUtils.getDatabaseValue(
+ "moz_historyvisits",
+ "id",
+ {
+ place_id: this._placeId,
+ }
+ );
+ Assert.notStrictEqual(value, undefined);
+ // Check that invalid visit has been removed
+ value = await PlacesTestUtils.getDatabaseValue("moz_historyvisits", "id", {
+ place_id: this._invalidPlaceId,
+ });
+ Assert.strictEqual(value, undefined);
+ },
+});
+
+// ------------------------------------------------------------------------------
+
+tests.push({
+ name: "G.1",
+ desc: "Remove orphan input history",
+
+ _placeId: null,
+ _invalidPlaceId: 1337,
+
+ async setup() {
+ // Add a place to ensure place_id = 1 is valid
+ this._placeId = await addPlace();
+ // Add input history entries
+ await PlacesUtils.withConnectionWrapper("setup", async db => {
+ await db.executeCached(
+ `INSERT INTO moz_inputhistory (place_id, input)
+ VALUES (:place_id_1, :input_1), (:place_id_2, :input_2)`,
+ {
+ place_id_1: this._placeId,
+ input_1: "moz",
+ place_id_2: this._invalidPlaceId,
+ input_2: "moz",
+ }
+ );
+ });
+ },
+
+ async check() {
+ // Check that inputhistory on valid place is still there
+ let value = await PlacesTestUtils.getDatabaseValue(
+ "moz_inputhistory",
+ "place_id",
+ { place_id: this._placeId }
+ );
+ Assert.notStrictEqual(value, undefined);
+ // Check that inputhistory on invalid place has gone
+ value = await PlacesTestUtils.getDatabaseValue(
+ "moz_inputhistory",
+ "place_id",
+ { place_id: this._invalidPlaceId }
+ );
+ Assert.strictEqual(value, undefined);
+ },
+});
+
+// ------------------------------------------------------------------------------
+
+tests.push({
+ name: "I.1",
+ desc: "Remove unused keywords",
+
+ _bookmarkId: null,
+ _placeId: null,
+
+ async setup() {
+ // Insert 2 keywords
+ let bm = await PlacesUtils.bookmarks.insert({
+ url: "http://testkw.moz.org/",
+ parentGuid: PlacesUtils.bookmarks.unfiledGuid,
+ });
+ await PlacesUtils.keywords.insert({
+ url: bm.url,
+ keyword: "used",
+ });
+
+ await PlacesUtils.withConnectionWrapper("setup", async db => {
+ await db.executeCached(
+ `INSERT INTO moz_keywords (id, keyword, place_id)
+ VALUES(NULL, :keyword, :place_id)`,
+ { keyword: "unused", place_id: 100 }
+ );
+ });
+ },
+
+ async check() {
+ // Check that "used" keyword is still there
+ let value = await PlacesTestUtils.getDatabaseValue("moz_keywords", "id", {
+ keyword: "used",
+ });
+ Assert.notStrictEqual(value, undefined);
+ // Check that "unused" keyword has gone
+ value = await PlacesTestUtils.getDatabaseValue("moz_keywords", "id", {
+ keyword: "unused",
+ });
+ Assert.strictEqual(value, undefined);
+ },
+});
+
+// ------------------------------------------------------------------------------
+
+tests.push({
+ name: "L.1",
+ desc: "remove duplicate URLs",
+ _placeA: -1,
+ _placeD: -1,
+ _placeE: -1,
+ _bookmarkIds: [],
+
+ async setup() {
+ // Place with visits, an autocomplete history entry, anno, and a bookmark.
+ this._placeA = await addPlace("http://example.com", null, "placeAAAAAAA");
+
+ // Duplicate Place with different visits and a keyword.
+ let placeB = await addPlace("http://example.com", null, "placeBBBBBBB");
+
+ // Another duplicate with conflicting autocomplete history entry and
+ // two more bookmarks.
+ let placeC = await addPlace("http://example.com", null, "placeCCCCCCC");
+
+ // Unrelated, unique Place.
+ this._placeD = await addPlace(
+ "http://example.net",
+ null,
+ "placeDDDDDDD",
+ 1234
+ );
+
+ // Another unrelated Place, with the same hash as D, but different URL.
+ this._placeE = await addPlace(
+ "http://example.info",
+ null,
+ "placeEEEEEEE",
+ 1234
+ );
+
+ let visits = [
+ {
+ placeId: this._placeA,
+ date: new Date(2017, 1, 2),
+ type: PlacesUtils.history.TRANSITIONS.LINK,
+ },
+ {
+ placeId: this._placeA,
+ date: new Date(2018, 3, 4),
+ type: PlacesUtils.history.TRANSITIONS.TYPED,
+ },
+ {
+ placeId: placeB,
+ date: new Date(2016, 5, 6),
+ type: PlacesUtils.history.TRANSITIONS.TYPED,
+ },
+ {
+ // Duplicate visit; should keep both when we merge.
+ placeId: placeB,
+ date: new Date(2018, 3, 4),
+ type: PlacesUtils.history.TRANSITIONS.TYPED,
+ },
+ {
+ placeId: this._placeD,
+ date: new Date(2018, 7, 8),
+ type: PlacesUtils.history.TRANSITIONS.LINK,
+ },
+ {
+ placeId: this._placeE,
+ date: new Date(2018, 8, 9),
+ type: PlacesUtils.history.TRANSITIONS.TYPED,
+ },
+ ];
+
+ let inputs = [
+ {
+ placeId: this._placeA,
+ input: "exam",
+ count: 4,
+ },
+ {
+ placeId: placeC,
+ input: "exam",
+ count: 3,
+ },
+ {
+ placeId: placeC,
+ input: "ex",
+ count: 5,
+ },
+ {
+ placeId: this._placeD,
+ input: "amp",
+ count: 3,
+ },
+ ];
+
+ let annos = [
+ {
+ name: "anno",
+ placeId: this._placeA,
+ content: "splish",
+ },
+ {
+ // Anno that's already set on A; should be ignored when we merge.
+ name: "anno",
+ placeId: placeB,
+ content: "oops",
+ },
+ {
+ name: "other-anno",
+ placeId: placeB,
+ content: "splash",
+ },
+ {
+ name: "other-anno",
+ placeId: this._placeD,
+ content: "sploosh",
+ },
+ ];
+
+ let bookmarks = [
+ {
+ placeId: this._placeA,
+ parentGuid: PlacesUtils.bookmarks.unfiledGuid,
+ title: "A",
+ guid: "bookmarkAAAA",
+ },
+ {
+ placeId: placeB,
+ parentGuid: PlacesUtils.bookmarks.mobileGuid,
+ title: "B",
+ guid: "bookmarkBBBB",
+ },
+ {
+ placeId: placeC,
+ parentGuid: PlacesUtils.bookmarks.menuGuid,
+ title: "C1",
+ guid: "bookmarkCCC1",
+ },
+ {
+ placeId: placeC,
+ parentGuid: PlacesUtils.bookmarks.toolbarGuid,
+ title: "C2",
+ guid: "bookmarkCCC2",
+ },
+ {
+ placeId: this._placeD,
+ parentGuid: PlacesUtils.bookmarks.toolbarGuid,
+ title: "D",
+ guid: "bookmarkDDDD",
+ },
+ {
+ placeId: this._placeE,
+ parentGuid: PlacesUtils.bookmarks.unfiledGuid,
+ title: "E",
+ guid: "bookmarkEEEE",
+ },
+ ];
+
+ let keywords = [
+ {
+ placeId: placeB,
+ keyword: "hi",
+ },
+ {
+ placeId: this._placeD,
+ keyword: "bye",
+ },
+ ];
+
+ for (let { placeId, parentGuid, title, guid } of bookmarks) {
+ let itemId = await addBookmark(
+ placeId,
+ PlacesUtils.bookmarks.TYPE_BOOKMARK,
+ parentGuid,
+ null,
+ title,
+ guid
+ );
+ this._bookmarkIds.push(itemId);
+ }
+
+ await PlacesUtils.withConnectionWrapper(
+ "L.1: Insert foreign key refs",
+ function (db) {
+ return db.executeTransaction(async function () {
+ for (let { placeId, date, type } of visits) {
+ await db.executeCached(
+ `INSERT INTO moz_historyvisits(place_id, visit_date, visit_type)
+ VALUES(:placeId, :date, :type)`,
+ { placeId, date: PlacesUtils.toPRTime(date), type }
+ );
+ }
+
+ for (let params of inputs) {
+ await db.executeCached(
+ `INSERT INTO moz_inputhistory(place_id, input, use_count)
+ VALUES(:placeId, :input, :count)`,
+ params
+ );
+ }
+
+ for (let { name, placeId, content } of annos) {
+ await db.executeCached(
+ `INSERT OR IGNORE INTO moz_anno_attributes(name)
+ VALUES(:name)`,
+ { name }
+ );
+
+ await db.executeCached(
+ `INSERT INTO moz_annos(place_id, anno_attribute_id, content)
+ VALUES(:placeId, (SELECT id FROM moz_anno_attributes
+ WHERE name = :name), :content)`,
+ { placeId, name, content }
+ );
+ }
+
+ for (let param of keywords) {
+ await db.executeCached(
+ `INSERT INTO moz_keywords(keyword, place_id)
+ VALUES(:keyword, :placeId)`,
+ param
+ );
+ }
+ });
+ }
+ );
+ },
+
+ async check() {
+ let db = await PlacesUtils.promiseDBConnection();
+
+ let placeRows = await db.execute(`
+ SELECT id, guid, foreign_count FROM moz_places
+ ORDER BY guid`);
+ let placeInfos = placeRows.map(row => ({
+ id: row.getResultByName("id"),
+ guid: row.getResultByName("guid"),
+ foreignCount: row.getResultByName("foreign_count"),
+ }));
+ Assert.deepEqual(
+ placeInfos,
+ [
+ {
+ id: this._placeA,
+ guid: "placeAAAAAAA",
+ foreignCount: 5, // 4 bookmarks + 1 keyword
+ },
+ {
+ id: this._placeD,
+ guid: "placeDDDDDDD",
+ foreignCount: 2, // 1 bookmark + 1 keyword
+ },
+ {
+ id: this._placeE,
+ guid: "placeEEEEEEE",
+ foreignCount: 1, // 1 bookmark
+ },
+ ],
+ "Should remove duplicate Places B and C"
+ );
+
+ let visitRows = await db.execute(`
+ SELECT place_id, visit_date, visit_type FROM moz_historyvisits
+ ORDER BY visit_date`);
+ let visitInfos = visitRows.map(row => ({
+ placeId: row.getResultByName("place_id"),
+ date: PlacesUtils.toDate(row.getResultByName("visit_date")),
+ type: row.getResultByName("visit_type"),
+ }));
+ Assert.deepEqual(
+ visitInfos,
+ [
+ {
+ placeId: this._placeA,
+ date: new Date(2016, 5, 6),
+ type: PlacesUtils.history.TRANSITIONS.TYPED,
+ },
+ {
+ placeId: this._placeA,
+ date: new Date(2017, 1, 2),
+ type: PlacesUtils.history.TRANSITIONS.LINK,
+ },
+ {
+ placeId: this._placeA,
+ date: new Date(2018, 3, 4),
+ type: PlacesUtils.history.TRANSITIONS.TYPED,
+ },
+ {
+ placeId: this._placeA,
+ date: new Date(2018, 3, 4),
+ type: PlacesUtils.history.TRANSITIONS.TYPED,
+ },
+ {
+ placeId: this._placeD,
+ date: new Date(2018, 7, 8),
+ type: PlacesUtils.history.TRANSITIONS.LINK,
+ },
+ {
+ placeId: this._placeE,
+ date: new Date(2018, 8, 9),
+ type: PlacesUtils.history.TRANSITIONS.TYPED,
+ },
+ ],
+ "Should merge history visits"
+ );
+
+ let inputRows = await db.execute(`
+ SELECT place_id, input, use_count FROM moz_inputhistory
+ ORDER BY use_count ASC`);
+ let inputInfos = inputRows.map(row => ({
+ placeId: row.getResultByName("place_id"),
+ input: row.getResultByName("input"),
+ count: row.getResultByName("use_count"),
+ }));
+ Assert.deepEqual(
+ inputInfos,
+ [
+ {
+ placeId: this._placeD,
+ input: "amp",
+ count: 3,
+ },
+ {
+ placeId: this._placeA,
+ input: "ex",
+ count: 5,
+ },
+ {
+ placeId: this._placeA,
+ input: "exam",
+ count: 7,
+ },
+ ],
+ "Should merge autocomplete history"
+ );
+
+ let annoRows = await db.execute(`
+ SELECT a.place_id, n.name, a.content FROM moz_annos a
+ JOIN moz_anno_attributes n ON n.id = a.anno_attribute_id
+ ORDER BY n.name, a.content ASC`);
+ let annoInfos = annoRows.map(row => ({
+ placeId: row.getResultByName("place_id"),
+ name: row.getResultByName("name"),
+ content: row.getResultByName("content"),
+ }));
+ Assert.deepEqual(
+ annoInfos,
+ [
+ {
+ placeId: this._placeA,
+ name: "anno",
+ content: "splish",
+ },
+ {
+ placeId: this._placeA,
+ name: "other-anno",
+ content: "splash",
+ },
+ {
+ placeId: this._placeD,
+ name: "other-anno",
+ content: "sploosh",
+ },
+ ],
+ "Should merge page annos"
+ );
+
+ let itemRows = await db.execute(
+ `
+ SELECT guid, fk, syncChangeCounter FROM moz_bookmarks
+ WHERE id IN (${new Array(this._bookmarkIds.length).fill("?").join(",")})
+ ORDER BY guid ASC`,
+ this._bookmarkIds
+ );
+ let itemInfos = itemRows.map(row => ({
+ guid: row.getResultByName("guid"),
+ placeId: row.getResultByName("fk"),
+ syncChangeCounter: row.getResultByName("syncChangeCounter"),
+ }));
+ Assert.deepEqual(
+ itemInfos,
+ [
+ {
+ guid: "bookmarkAAAA",
+ placeId: this._placeA,
+ syncChangeCounter: 1,
+ },
+ {
+ guid: "bookmarkBBBB",
+ placeId: this._placeA,
+ syncChangeCounter: 1,
+ },
+ {
+ guid: "bookmarkCCC1",
+ placeId: this._placeA,
+ syncChangeCounter: 1,
+ },
+ {
+ guid: "bookmarkCCC2",
+ placeId: this._placeA,
+ syncChangeCounter: 1,
+ },
+ {
+ guid: "bookmarkDDDD",
+ placeId: this._placeD,
+ syncChangeCounter: 0,
+ },
+ {
+ guid: "bookmarkEEEE",
+ placeId: this._placeE,
+ syncChangeCounter: 0,
+ },
+ ],
+ "Should merge bookmarks and bump change counter"
+ );
+
+ let keywordRows = await db.execute(`
+ SELECT keyword, place_id FROM moz_keywords
+ ORDER BY keyword ASC`);
+ let keywordInfos = keywordRows.map(row => ({
+ keyword: row.getResultByName("keyword"),
+ placeId: row.getResultByName("place_id"),
+ }));
+ Assert.deepEqual(
+ keywordInfos,
+ [
+ {
+ keyword: "bye",
+ placeId: this._placeD,
+ },
+ {
+ keyword: "hi",
+ placeId: this._placeA,
+ },
+ ],
+ "Should merge all keywords"
+ );
+ },
+});
+
+// ------------------------------------------------------------------------------
+
+tests.push({
+ name: "L.2",
+ desc: "Recalculate visit_count and last_visit_date",
+
+ async setup() {
+ async function setVisitCount(aURL, aValue) {
+ await PlacesUtils.withConnectionWrapper("setVisitCount", async db => {
+ await db.executeCached(
+ `UPDATE moz_places SET visit_count = :count
+ WHERE url_hash = hash(:url) AND url = :url`,
+ { count: aValue, url: aURL }
+ );
+ });
+ }
+ async function setLastVisitDate(aURL, aValue) {
+ await PlacesUtils.withConnectionWrapper("setVisitCount", async db => {
+ await db.executeCached(
+ `UPDATE moz_places SET last_visit_date = :date
+ WHERE url_hash = hash(:url) AND url = :url`,
+ { date: aValue, url: aURL }
+ );
+ });
+ }
+
+ let now = Date.now() * 1000;
+ // Add a page with 1 visit.
+ let url = "http://1.moz.org/";
+ await PlacesTestUtils.addVisits({ uri: uri(url), visitDate: now++ });
+ // Add a page with 1 visit and set wrong visit_count.
+ url = "http://2.moz.org/";
+ await PlacesTestUtils.addVisits({ uri: uri(url), visitDate: now++ });
+ await setVisitCount(url, 10);
+ // Add a page with 1 visit and set wrong last_visit_date.
+ url = "http://3.moz.org/";
+ await PlacesTestUtils.addVisits({ uri: uri(url), visitDate: now++ });
+ await setLastVisitDate(url, now++);
+ // Add a page with 1 visit and set wrong stats.
+ url = "http://4.moz.org/";
+ await PlacesTestUtils.addVisits({ uri: uri(url), visitDate: now++ });
+ await setVisitCount(url, 10);
+ await setLastVisitDate(url, now++);
+
+ // Add a page without visits.
+ url = "http://5.moz.org/";
+ await addPlace(url);
+ // Add a page without visits and set wrong visit_count.
+ url = "http://6.moz.org/";
+ await addPlace(url);
+ await setVisitCount(url, 10);
+ // Add a page without visits and set wrong last_visit_date.
+ url = "http://7.moz.org/";
+ await addPlace(url);
+ await setLastVisitDate(url, now++);
+ // Add a page without visits and set wrong stats.
+ url = "http://8.moz.org/";
+ await addPlace(url);
+ await setVisitCount(url, 10);
+ await setLastVisitDate(url, now++);
+ },
+
+ async check() {
+ let db = await PlacesUtils.promiseDBConnection();
+ let rows = await db.executeCached(
+ `SELECT h.id, h.last_visit_date as v_date
+ FROM moz_places h
+ LEFT JOIN moz_historyvisits v ON v.place_id = h.id AND visit_type NOT IN (0,4,7,8,9)
+ GROUP BY h.id HAVING h.visit_count <> count(v.id)
+ UNION ALL
+ SELECT h.id, MAX(v.visit_date) as v_date
+ FROM moz_places h
+ LEFT JOIN moz_historyvisits v ON v.place_id = h.id
+ GROUP BY h.id HAVING h.last_visit_date IS NOT v_date`
+ );
+ Assert.equal(rows.length, 0);
+ },
+});
+
+// ------------------------------------------------------------------------------
+
+tests.push({
+ name: "L.3",
+ desc: "recalculate hidden for redirects.",
+
+ async setup() {
+ await PlacesTestUtils.addVisits([
+ {
+ uri: NetUtil.newURI("http://l3.moz.org/"),
+ transition: TRANSITION_TYPED,
+ },
+ {
+ uri: NetUtil.newURI("http://l3.moz.org/redirecting/"),
+ transition: TRANSITION_TYPED,
+ },
+ {
+ uri: NetUtil.newURI("http://l3.moz.org/redirecting2/"),
+ transition: TRANSITION_REDIRECT_TEMPORARY,
+ referrer: NetUtil.newURI("http://l3.moz.org/redirecting/"),
+ },
+ {
+ uri: NetUtil.newURI("http://l3.moz.org/target/"),
+ transition: TRANSITION_REDIRECT_PERMANENT,
+ referrer: NetUtil.newURI("http://l3.moz.org/redirecting2/"),
+ },
+ ]);
+ },
+
+ async check() {
+ let db = await PlacesUtils.promiseDBConnection();
+ let rows = await db.executeCached(
+ "SELECT h.url FROM moz_places h WHERE h.hidden = 1"
+ );
+ Assert.equal(rows.length, 2);
+ for (let row of rows) {
+ let url = row.getResultByIndex(0);
+ Assert.ok(/redirecting/.test(url));
+ }
+ },
+});
+
+// ------------------------------------------------------------------------------
+
+tests.push({
+ name: "L.4",
+ desc: "recalculate foreign_count.",
+
+ async setup() {
+ this._pageGuid = (
+ await PlacesUtils.history.insert({
+ url: "http://l4.moz.org/",
+ visits: [{ date: new Date() }],
+ })
+ ).guid;
+ await PlacesUtils.bookmarks.insert({
+ url: "http://l4.moz.org/",
+ parentGuid: PlacesUtils.bookmarks.unfiledGuid,
+ });
+ await PlacesUtils.keywords.insert({
+ url: "http://l4.moz.org/",
+ keyword: "kw",
+ });
+ Assert.equal(
+ await PlacesTestUtils.getDatabaseValue("moz_places", "foreign_count", {
+ guid: this._pageGuid,
+ }),
+ 2
+ );
+ },
+
+ async check() {
+ Assert.equal(
+ await PlacesTestUtils.getDatabaseValue("moz_places", "foreign_count", {
+ guid: this._pageGuid,
+ }),
+ 2
+ );
+ },
+});
+
+// ------------------------------------------------------------------------------
+
+tests.push({
+ name: "L.5",
+ desc: "recalculate hashes when missing.",
+
+ async setup() {
+ this._pageGuid = (
+ await PlacesUtils.history.insert({
+ url: "http://l5.moz.org/",
+ visits: [{ date: new Date() }],
+ })
+ ).guid;
+ Assert.greater(
+ await PlacesTestUtils.getDatabaseValue("moz_places", "url_hash", {
+ guid: this._pageGuid,
+ }),
+ 0
+ );
+ await PlacesUtils.withConnectionWrapper(
+ "change url hash",
+ async function (db) {
+ await db.execute(`UPDATE moz_places SET url_hash = 0`);
+ }
+ );
+ Assert.equal(
+ await PlacesTestUtils.getDatabaseValue("moz_places", "url_hash", {
+ guid: this._pageGuid,
+ }),
+ 0
+ );
+ },
+
+ async check() {
+ Assert.greater(
+ await PlacesTestUtils.getDatabaseValue("moz_places", "url_hash", {
+ guid: this._pageGuid,
+ }),
+ 0
+ );
+ },
+});
+
+// ------------------------------------------------------------------------------
+
+tests.push({
+ name: "L.6",
+ desc: "fix invalid Place GUIDs",
+ _placeIds: [],
+
+ async setup() {
+ let placeWithValidGuid = await addPlace(
+ "http://example.com/a",
+ null,
+ "placeAAAAAAA"
+ );
+ this._placeIds.push(placeWithValidGuid);
+
+ let placeWithEmptyGuid = await addPlace("http://example.com/b", null, "");
+ this._placeIds.push(placeWithEmptyGuid);
+
+ let placeWithoutGuid = await addPlace("http://example.com/c", null, null);
+ this._placeIds.push(placeWithoutGuid);
+
+ let placeWithInvalidGuid = await addPlace(
+ "http://example.com/c",
+ null,
+ "{123456}"
+ );
+ this._placeIds.push(placeWithInvalidGuid);
+ },
+
+ async check() {
+ let db = await PlacesUtils.promiseDBConnection();
+ let updatedRows = await db.execute(
+ `
+ SELECT id, guid
+ FROM moz_places
+ WHERE id IN (?, ?, ?, ?)`,
+ this._placeIds
+ );
+
+ for (let row of updatedRows) {
+ let id = row.getResultByName("id");
+ let guid = row.getResultByName("guid");
+ if (id == this._placeIds[0]) {
+ Assert.equal(guid, "placeAAAAAAA");
+ } else {
+ Assert.ok(PlacesUtils.isValidGuid(guid));
+ }
+ }
+ },
+});
+
+// ------------------------------------------------------------------------------
+
+tests.push({
+ name: "S.1",
+ desc: "fix invalid GUIDs for synced bookmarks",
+ _bookmarkInfos: [],
+
+ async setup() {
+ let folderWithInvalidGuid = await addBookmark(
+ null,
+ PlacesUtils.bookmarks.TYPE_FOLDER,
+ PlacesUtils.bookmarks.menuGuid,
+ /* aKeywordId */ null,
+ "NORMAL folder with invalid GUID",
+ "{123456}",
+ PlacesUtils.bookmarks.SYNC_STATUS.NORMAL
+ );
+
+ let placeIdForBookmarkWithoutGuid = await addPlace();
+ let bookmarkWithoutGuid = await addBookmark(
+ placeIdForBookmarkWithoutGuid,
+ PlacesUtils.bookmarks.TYPE_BOOKMARK,
+ "{123456}",
+ /* aKeywordId */ null,
+ "NEW bookmark without GUID",
+ /* aGuid */ null
+ );
+
+ let placeIdForBookmarkWithInvalidGuid = await addPlace();
+ let bookmarkWithInvalidGuid = await addBookmark(
+ placeIdForBookmarkWithInvalidGuid,
+ PlacesUtils.bookmarks.TYPE_BOOKMARK,
+ "{123456}",
+ /* aKeywordId */ null,
+ "NORMAL bookmark with invalid GUID",
+ "bookmarkAAAA\n",
+ PlacesUtils.bookmarks.SYNC_STATUS.NORMAL
+ );
+
+ let placeIdForBookmarkWithValidGuid = await addPlace();
+ let bookmarkWithValidGuid = await addBookmark(
+ placeIdForBookmarkWithValidGuid,
+ PlacesUtils.bookmarks.TYPE_BOOKMARK,
+ "{123456}",
+ /* aKeywordId */ null,
+ "NORMAL bookmark with valid GUID",
+ "bookmarkBBBB",
+ PlacesUtils.bookmarks.SYNC_STATUS.NORMAL
+ );
+
+ this._bookmarkInfos.push(
+ {
+ id: await PlacesTestUtils.promiseItemId(PlacesUtils.bookmarks.menuGuid),
+ syncChangeCounter: 1,
+ syncStatus: PlacesUtils.bookmarks.SYNC_STATUS.NORMAL,
+ },
+ {
+ id: folderWithInvalidGuid,
+ syncChangeCounter: 3,
+ syncStatus: PlacesUtils.bookmarks.SYNC_STATUS.NEW,
+ },
+ {
+ id: bookmarkWithoutGuid,
+ syncChangeCounter: 1,
+ syncStatus: PlacesUtils.bookmarks.SYNC_STATUS.NEW,
+ },
+ {
+ id: bookmarkWithInvalidGuid,
+ syncChangeCounter: 1,
+ syncStatus: PlacesUtils.bookmarks.SYNC_STATUS.NEW,
+ },
+ {
+ id: bookmarkWithValidGuid,
+ syncChangeCounter: 0,
+ syncStatus: PlacesUtils.bookmarks.SYNC_STATUS.NORMAL,
+ }
+ );
+ },
+
+ async check() {
+ let db = await PlacesUtils.promiseDBConnection();
+ let updatedRows = await db.execute(
+ `SELECT id, guid, syncChangeCounter, syncStatus
+ FROM moz_bookmarks
+ WHERE id IN (?, ?, ?, ?, ?)`,
+ this._bookmarkInfos.map(info => info.id)
+ );
+
+ for (let row of updatedRows) {
+ let id = row.getResultByName("id");
+ let guid = row.getResultByName("guid");
+ Assert.ok(PlacesUtils.isValidGuid(guid));
+
+ let cachedGuid = await PlacesTestUtils.promiseItemGuid(id);
+ Assert.equal(cachedGuid, guid);
+
+ let expectedInfo = this._bookmarkInfos.find(info => info.id == id);
+
+ let syncChangeCounter = row.getResultByName("syncChangeCounter");
+ Assert.equal(syncChangeCounter, expectedInfo.syncChangeCounter);
+
+ let syncStatus = row.getResultByName("syncStatus");
+ Assert.equal(syncStatus, expectedInfo.syncStatus);
+ }
+
+ let tombstones = await PlacesTestUtils.fetchSyncTombstones();
+ Assert.deepEqual(
+ tombstones.map(info => info.guid),
+ ["bookmarkAAAA\n", "{123456}"]
+ );
+ },
+});
+
+tests.push({
+ name: "S.2",
+ desc: "drop tombstones for bookmarks that aren't deleted",
+
+ async setup() {
+ let placeId = await addPlace();
+ await addBookmark(
+ placeId,
+ PlacesUtils.bookmarks.TYPE_BOOKMARK,
+ PlacesUtils.bookmarks.menuGuid,
+ null,
+ "",
+ "bookmarkAAAA"
+ );
+
+ await PlacesUtils.withConnectionWrapper("Insert tombstones", db =>
+ db.executeTransaction(async function () {
+ for (let guid of ["bookmarkAAAA", "bookmarkBBBB"]) {
+ await db.executeCached(
+ `INSERT INTO moz_bookmarks_deleted(guid)
+ VALUES(:guid)`,
+ { guid }
+ );
+ }
+ })
+ );
+ },
+
+ async check() {
+ let tombstones = await PlacesTestUtils.fetchSyncTombstones();
+ Assert.deepEqual(
+ tombstones.map(info => info.guid),
+ ["bookmarkBBBB"]
+ );
+ },
+});
+
+tests.push({
+ name: "S.3",
+ desc: "set missing added and last modified dates",
+ _placeVisits: [],
+ _bookmarksWithDates: [],
+
+ async setup() {
+ let placeIdWithVisits = await addPlace();
+ let placeIdWithZeroVisit = await addPlace();
+ this._placeVisits.push(
+ {
+ placeId: placeIdWithVisits,
+ visitDate: PlacesUtils.toPRTime(new Date(2017, 9, 4)),
+ },
+ {
+ placeId: placeIdWithVisits,
+ visitDate: PlacesUtils.toPRTime(new Date(2017, 9, 8)),
+ },
+ {
+ placeId: placeIdWithZeroVisit,
+ visitDate: 0,
+ }
+ );
+
+ this._bookmarksWithDates.push(
+ {
+ guid: "bookmarkAAAA",
+ placeId: null,
+ parent: PlacesUtils.bookmarks.menuGuid,
+ dateAdded: null,
+ lastModified: PlacesUtils.toPRTime(new Date(2017, 9, 1)),
+ },
+ {
+ guid: "bookmarkBBBB",
+ placeId: null,
+ parent: PlacesUtils.bookmarks.menuGuid,
+ dateAdded: PlacesUtils.toPRTime(new Date(2017, 9, 2)),
+ lastModified: null,
+ },
+ {
+ guid: "bookmarkCCCC",
+ placeId: null,
+ parent: PlacesUtils.bookmarks.unfiledGuid,
+ dateAdded: null,
+ lastModified: null,
+ },
+ {
+ guid: "bookmarkDDDD",
+ placeId: placeIdWithVisits,
+ parent: PlacesUtils.bookmarks.mobileGuid,
+ dateAdded: null,
+ lastModified: null,
+ },
+ {
+ guid: "bookmarkEEEE",
+ placeId: placeIdWithVisits,
+ parent: PlacesUtils.bookmarks.unfiledGuid,
+ dateAdded: PlacesUtils.toPRTime(new Date(2017, 9, 3)),
+ lastModified: PlacesUtils.toPRTime(new Date(2017, 9, 6)),
+ },
+ {
+ guid: "bookmarkFFFF",
+ placeId: placeIdWithZeroVisit,
+ parent: PlacesUtils.bookmarks.unfiledGuid,
+ dateAdded: 0,
+ lastModified: 0,
+ }
+ );
+
+ await PlacesUtils.withConnectionWrapper(
+ "S.3: Insert bookmarks and visits",
+ db =>
+ db.executeTransaction(async () => {
+ await db.execute(
+ `INSERT INTO moz_historyvisits(place_id, visit_date)
+ VALUES(:placeId, :visitDate)`,
+ this._placeVisits
+ );
+
+ await db.execute(
+ `INSERT INTO moz_bookmarks(fk, type, parent, guid, dateAdded,
+ lastModified)
+ VALUES(:placeId, 1, (SELECT id FROM moz_bookmarks WHERE guid = :parent),
+ :guid, :dateAdded, :lastModified)`,
+ this._bookmarksWithDates
+ );
+
+ await db.execute(
+ `UPDATE moz_bookmarks SET dateAdded = 0, lastModified = NULL
+ WHERE guid = :toolbarFolder`,
+ { toolbarFolder: PlacesUtils.bookmarks.toolbarGuid }
+ );
+ })
+ );
+ },
+
+ async check() {
+ let db = await PlacesUtils.promiseDBConnection();
+ let updatedRows = await db.execute(
+ `SELECT guid, dateAdded, lastModified
+ FROM moz_bookmarks
+ WHERE guid = :guid`,
+ [
+ { guid: PlacesUtils.bookmarks.toolbarGuid },
+ ...this._bookmarksWithDates.map(({ guid }) => ({ guid })),
+ ]
+ );
+
+ for (let row of updatedRows) {
+ let guid = row.getResultByName("guid");
+
+ let dateAdded = row.getResultByName("dateAdded");
+ Assert.ok(Number.isInteger(dateAdded));
+
+ let lastModified = row.getResultByName("lastModified");
+ Assert.ok(Number.isInteger(lastModified));
+
+ switch (guid) {
+ // Last modified date exists, so we should use it for date added.
+ case "bookmarkAAAA": {
+ let expectedInfo = this._bookmarksWithDates[0];
+ Assert.equal(dateAdded, expectedInfo.lastModified);
+ Assert.equal(lastModified, expectedInfo.lastModified);
+ break;
+ }
+
+ // Date added exists, so we should use it for last modified date.
+ case "bookmarkBBBB": {
+ let expectedInfo = this._bookmarksWithDates[1];
+ Assert.equal(dateAdded, expectedInfo.dateAdded);
+ Assert.equal(lastModified, expectedInfo.dateAdded);
+ break;
+ }
+
+ // C has no visits, date added, or last modified time, F has zeros for
+ // all, and the toolbar has a zero date added and no last modified time.
+ // In all cases, we should fall back to the current time.
+ case "bookmarkCCCC":
+ case "bookmarkFFFF":
+ case PlacesUtils.bookmarks.toolbarGuid: {
+ let nowAsPRTime = PlacesUtils.toPRTime(new Date());
+ Assert.greater(dateAdded, 0);
+ Assert.equal(dateAdded, lastModified);
+ Assert.ok(dateAdded <= nowAsPRTime);
+ break;
+ }
+
+ // Neither date added nor last modified exists, but we have two
+ // visits, so we should fall back to the earliest and latest visit
+ // dates.
+ case "bookmarkDDDD": {
+ let oldestVisit = this._placeVisits[0];
+ Assert.equal(dateAdded, oldestVisit.visitDate);
+ let newestVisit = this._placeVisits[1];
+ Assert.equal(lastModified, newestVisit.visitDate);
+ break;
+ }
+
+ // We have two visits, but both date added and last modified exist,
+ // so we shouldn't update them.
+ case "bookmarkEEEE": {
+ let expectedInfo = this._bookmarksWithDates[4];
+ Assert.equal(dateAdded, expectedInfo.dateAdded);
+ Assert.equal(lastModified, expectedInfo.lastModified);
+ break;
+ }
+
+ default:
+ throw new Error(`Unexpected row for bookmark ${guid}`);
+ }
+ }
+ },
+});
+
+// ------------------------------------------------------------------------------
+
+tests.push({
+ name: "S.4",
+ desc: "reset added dates that are ahead of last modified dates",
+ _bookmarksWithDates: [],
+
+ async setup() {
+ this._bookmarksWithDates.push({
+ guid: "bookmarkGGGG",
+ parent: PlacesUtils.bookmarks.unfiledGuid,
+ dateAdded: PlacesUtils.toPRTime(new Date(2017, 9, 6)),
+ lastModified: PlacesUtils.toPRTime(new Date(2017, 9, 3)),
+ });
+
+ await PlacesUtils.withConnectionWrapper(
+ "S.4: Insert bookmarks and visits",
+ db =>
+ db.executeTransaction(async () => {
+ await db.execute(
+ `INSERT INTO moz_bookmarks(type, parent, guid, dateAdded,
+ lastModified)
+ VALUES(1, (SELECT id FROM moz_bookmarks WHERE guid = :parent),
+ :guid, :dateAdded, :lastModified)`,
+ this._bookmarksWithDates
+ );
+ })
+ );
+ },
+
+ async check() {
+ let db = await PlacesUtils.promiseDBConnection();
+ let updatedRows = await db.execute(
+ `SELECT guid, dateAdded, lastModified
+ FROM moz_bookmarks
+ WHERE guid = :guid`,
+ this._bookmarksWithDates.map(({ guid }) => ({ guid }))
+ );
+
+ for (let row of updatedRows) {
+ let guid = row.getResultByName("guid");
+ let dateAdded = row.getResultByName("dateAdded");
+ let lastModified = row.getResultByName("lastModified");
+ switch (guid) {
+ case "bookmarkGGGG": {
+ let expectedInfo = this._bookmarksWithDates[0];
+ Assert.equal(dateAdded, expectedInfo.lastModified);
+ Assert.equal(lastModified, expectedInfo.lastModified);
+ break;
+ }
+
+ default:
+ throw new Error(`Unexpected row for bookmark ${guid}`);
+ }
+ }
+ },
+});
+
+// ------------------------------------------------------------------------------
+
+tests.push({
+ name: "Z",
+ desc: "Sanity: Preventive maintenance does not touch valid items",
+
+ _uri1: uri("http://www1.mozilla.org"),
+ _uri2: uri("http://www2.mozilla.org"),
+ _folder: null,
+ _bookmark: null,
+ _bookmarkId: null,
+ _separator: null,
+
+ async setup() {
+ // use valid api calls to create a bunch of items
+ await PlacesTestUtils.addVisits([{ uri: this._uri1 }, { uri: this._uri2 }]);
+
+ let bookmarks = await PlacesUtils.bookmarks.insertTree({
+ guid: PlacesUtils.bookmarks.toolbarGuid,
+ children: [
+ {
+ title: "testfolder",
+ type: PlacesUtils.bookmarks.TYPE_FOLDER,
+ children: [
+ {
+ title: "testbookmark",
+ url: this._uri1,
+ },
+ ],
+ },
+ ],
+ });
+
+ this._folder = bookmarks[0];
+ this._bookmark = bookmarks[1];
+ this._bookmarkId = await PlacesTestUtils.promiseItemId(bookmarks[1].guid);
+
+ this._separator = await PlacesUtils.bookmarks.insert({
+ parentGuid: PlacesUtils.bookmarks.unfiledGuid,
+ type: PlacesUtils.bookmarks.TYPE_SEPARATOR,
+ });
+
+ PlacesUtils.tagging.tagURI(this._uri1, ["testtag"]);
+ PlacesUtils.favicons.setAndFetchFaviconForPage(
+ this._uri2,
+ SMALLPNG_DATA_URI,
+ false,
+ PlacesUtils.favicons.FAVICON_LOAD_NON_PRIVATE,
+ null,
+ Services.scriptSecurityManager.getSystemPrincipal()
+ );
+ await PlacesUtils.keywords.insert({
+ url: this._uri1.spec,
+ keyword: "testkeyword",
+ });
+ await PlacesUtils.history.update({
+ url: this._uri2,
+ annotations: new Map([["anno", "anno"]]),
+ });
+ },
+
+ async check() {
+ // Check that all items are correct
+ let isVisited = await PlacesUtils.history.hasVisits(this._uri1);
+ Assert.ok(isVisited);
+ isVisited = await PlacesUtils.history.hasVisits(this._uri2);
+ Assert.ok(isVisited);
+
+ Assert.equal(
+ (await PlacesUtils.bookmarks.fetch(this._bookmark.guid)).url,
+ this._uri1.spec
+ );
+ let folder = await PlacesUtils.bookmarks.fetch(this._folder.guid);
+ Assert.equal(folder.index, 0);
+ Assert.equal(folder.type, PlacesUtils.bookmarks.TYPE_FOLDER);
+ Assert.equal(
+ (await PlacesUtils.bookmarks.fetch(this._separator.guid)).type,
+ PlacesUtils.bookmarks.TYPE_SEPARATOR
+ );
+
+ Assert.equal(PlacesUtils.tagging.getTagsForURI(this._uri1).length, 1);
+ Assert.equal(
+ (await PlacesUtils.keywords.fetch({ url: this._uri1.spec })).keyword,
+ "testkeyword"
+ );
+ let pageInfo = await PlacesUtils.history.fetch(this._uri2, {
+ includeAnnotations: true,
+ });
+ Assert.equal(pageInfo.annotations.get("anno"), "anno");
+
+ await new Promise(resolve => {
+ PlacesUtils.favicons.getFaviconURLForPage(this._uri2, aFaviconURI => {
+ Assert.ok(aFaviconURI.equals(SMALLPNG_DATA_URI));
+ resolve();
+ });
+ });
+ },
+});
+
+// ------------------------------------------------------------------------------
+
+add_task(async function test_preventive_maintenance() {
+ let db = await PlacesUtils.promiseDBConnection();
+ // Get current bookmarks max ID for cleanup
+ defaultBookmarksMaxId = (
+ await db.executeCached("SELECT MAX(id) FROM moz_bookmarks")
+ )[0].getResultByIndex(0);
+ Assert.ok(defaultBookmarksMaxId > 0);
+
+ for (let test of tests) {
+ await PlacesTestUtils.markBookmarksAsSynced();
+
+ info("\nExecuting test: " + test.name + "\n*** " + test.desc + "\n");
+ await test.setup();
+
+ Services.prefs.clearUserPref("places.database.lastMaintenance");
+ await PlacesDBUtils.maintenanceOnIdle();
+
+ // Check the lastMaintenance time has been saved.
+ Assert.notEqual(
+ Services.prefs.getIntPref("places.database.lastMaintenance"),
+ null
+ );
+
+ await test.check();
+
+ await cleanDatabase();
+ }
+
+ // Sanity check: all roots should be intact
+ Assert.strictEqual(
+ (await PlacesUtils.bookmarks.fetch(PlacesUtils.bookmarks.rootGuid))
+ .parentGuid,
+ undefined
+ );
+ Assert.deepEqual(
+ (await PlacesUtils.bookmarks.fetch(PlacesUtils.bookmarks.menuGuid))
+ .parentGuid,
+ PlacesUtils.bookmarks.rootGuid
+ );
+ Assert.deepEqual(
+ (await PlacesUtils.bookmarks.fetch(PlacesUtils.bookmarks.tagsGuid))
+ .parentGuid,
+ PlacesUtils.bookmarks.rootGuid
+ );
+ Assert.deepEqual(
+ (await PlacesUtils.bookmarks.fetch(PlacesUtils.bookmarks.unfiledGuid))
+ .parentGuid,
+ PlacesUtils.bookmarks.rootGuid
+ );
+ Assert.deepEqual(
+ (await PlacesUtils.bookmarks.fetch(PlacesUtils.bookmarks.toolbarGuid))
+ .parentGuid,
+ PlacesUtils.bookmarks.rootGuid
+ );
+});
+
+// ------------------------------------------------------------------------------
+
+add_task(async function test_idle_daily() {
+ const { sinon } = ChromeUtils.importESModule(
+ "resource://testing-common/Sinon.sys.mjs"
+ );
+ const sandbox = sinon.createSandbox();
+ sandbox.stub(PlacesDBUtils, "maintenanceOnIdle");
+ Services.prefs.clearUserPref("places.database.lastMaintenance");
+ Cc["@mozilla.org/places/databaseUtilsIdleMaintenance;1"]
+ .getService(Ci.nsIObserver)
+ .observe(null, "idle-daily", "");
+ Assert.ok(
+ PlacesDBUtils.maintenanceOnIdle.calledOnce,
+ "maintenanceOnIdle was invoked"
+ );
+ sandbox.restore();
+});
diff --git a/toolkit/components/places/tests/maintenance/test_preventive_maintenance_checkAndFixDatabase.js b/toolkit/components/places/tests/maintenance/test_preventive_maintenance_checkAndFixDatabase.js
new file mode 100644
index 0000000000..0ff33bb3ba
--- /dev/null
+++ b/toolkit/components/places/tests/maintenance/test_preventive_maintenance_checkAndFixDatabase.js
@@ -0,0 +1,36 @@
+/* -*- indent-tabs-mode: nil; js-indent-level: 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/. */
+
+/**
+ * Test preventive maintenance checkAndFixDatabase.
+ */
+
+add_task(async function () {
+ // We must initialize places first, or we won't have a db to check.
+ Assert.equal(
+ PlacesUtils.history.databaseStatus,
+ PlacesUtils.history.DATABASE_STATUS_CREATE
+ );
+
+ let tasksStatusMap = await PlacesDBUtils.checkAndFixDatabase();
+ let numberOfTasksRun = tasksStatusMap.size;
+ let successfulTasks = [];
+ let failedTasks = [];
+ tasksStatusMap.forEach(val => {
+ if (val.succeeded && val.logs) {
+ successfulTasks.push(val);
+ } else {
+ failedTasks.push(val);
+ }
+ });
+ Assert.equal(numberOfTasksRun, 6, "Check that we have run all tasks.");
+ Assert.equal(
+ successfulTasks.length,
+ 6,
+ "Check that we have run all tasks successfully"
+ );
+ Assert.equal(failedTasks.length, 0, "Check that no task is failing");
+});
diff --git a/toolkit/components/places/tests/maintenance/test_preventive_maintenance_runTasks.js b/toolkit/components/places/tests/maintenance/test_preventive_maintenance_runTasks.js
new file mode 100644
index 0000000000..9d11199824
--- /dev/null
+++ b/toolkit/components/places/tests/maintenance/test_preventive_maintenance_runTasks.js
@@ -0,0 +1,31 @@
+/* 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/. */
+
+/**
+ * Test preventive maintenance runTasks.
+ */
+
+add_task(async function () {
+ let tasksStatusMap = await PlacesDBUtils.runTasks([
+ PlacesDBUtils.removeOldCorruptDBs,
+ ]);
+ let numberOfTasksRun = tasksStatusMap.size;
+ let successfulTasks = [];
+ let failedTasks = [];
+ tasksStatusMap.forEach(val => {
+ if (val.succeeded) {
+ successfulTasks.push(val);
+ } else {
+ failedTasks.push(val);
+ }
+ });
+
+ Assert.equal(numberOfTasksRun, 1, "Check that we have run all tasks.");
+ Assert.equal(
+ successfulTasks.length,
+ 1,
+ "Check that we have run all tasks successfully"
+ );
+ Assert.equal(failedTasks.length, 0, "Check that no task is failing");
+});
diff --git a/toolkit/components/places/tests/maintenance/xpcshell.toml b/toolkit/components/places/tests/maintenance/xpcshell.toml
new file mode 100644
index 0000000000..bcb5246fbe
--- /dev/null
+++ b/toolkit/components/places/tests/maintenance/xpcshell.toml
@@ -0,0 +1,33 @@
+[DEFAULT]
+head = "head.js"
+firefox-appdir = "browser"
+support-files = [
+ "corruptDB.sqlite",
+ "corruptPayload.sqlite",
+]
+
+["test_corrupt_favicons.js"]
+
+["test_corrupt_favicons_schema.js"]
+
+["test_corrupt_places_schema.js"]
+
+["test_corrupt_telemetry.js"]
+
+["test_favicons_replaceOnStartup.js"]
+
+["test_favicons_replaceOnStartup_clone.js"]
+
+["test_integrity_replacement.js"]
+
+["test_places_purge_caches.js"]
+
+["test_places_replaceOnStartup.js"]
+
+["test_places_replaceOnStartup_clone.js"]
+
+["test_preventive_maintenance.js"]
+
+["test_preventive_maintenance_checkAndFixDatabase.js"]
+
+["test_preventive_maintenance_runTasks.js"]
diff --git a/toolkit/components/places/tests/migration/favicons_v41.sqlite b/toolkit/components/places/tests/migration/favicons_v41.sqlite
new file mode 100644
index 0000000000..a59d9d286f
Binary files /dev/null and b/toolkit/components/places/tests/migration/favicons_v41.sqlite differ
diff --git a/toolkit/components/places/tests/migration/head_migration.js b/toolkit/components/places/tests/migration/head_migration.js
new file mode 100644
index 0000000000..a58aada16a
--- /dev/null
+++ b/toolkit/components/places/tests/migration/head_migration.js
@@ -0,0 +1,47 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+// Import common head.
+{
+ /* import-globals-from ../head_common.js */
+ let commonFile = do_get_file("../head_common.js", false);
+ let uri = Services.io.newFileURI(commonFile);
+ Services.scriptloader.loadSubScript(uri.spec, this);
+}
+
+// Put any other stuff relative to this test folder below.
+
+const CURRENT_SCHEMA_VERSION = Ci.nsINavHistoryService.DATABASE_SCHEMA_VERSION;
+const FIRST_UPGRADABLE_SCHEMA_VERSION = 52;
+
+async function assertAnnotationsRemoved(db, expectedAnnos) {
+ for (let anno of expectedAnnos) {
+ let rows = await db.execute(
+ `
+ SELECT id FROM moz_anno_attributes
+ WHERE name = :anno
+ `,
+ { anno }
+ );
+
+ Assert.equal(rows.length, 0, `${anno} should not exist in the database`);
+ }
+}
+
+async function assertNoOrphanAnnotations(db) {
+ let rows = await db.execute(`
+ SELECT item_id FROM moz_items_annos
+ WHERE item_id NOT IN (SELECT id from moz_bookmarks)
+ `);
+
+ Assert.equal(rows.length, 0, `Should have no orphan annotations.`);
+
+ rows = await db.execute(`
+ SELECT id FROM moz_anno_attributes
+ WHERE id NOT IN (SELECT id from moz_items_annos)
+ `);
+
+ Assert.equal(rows.length, 0, `Should have no orphan annotation attributes.`);
+}
diff --git a/toolkit/components/places/tests/migration/places_outdated.sqlite b/toolkit/components/places/tests/migration/places_outdated.sqlite
new file mode 100644
index 0000000000..2852a4cf97
Binary files /dev/null and b/toolkit/components/places/tests/migration/places_outdated.sqlite differ
diff --git a/toolkit/components/places/tests/migration/places_v52.sqlite b/toolkit/components/places/tests/migration/places_v52.sqlite
new file mode 100644
index 0000000000..f4d32f6c94
Binary files /dev/null and b/toolkit/components/places/tests/migration/places_v52.sqlite differ
diff --git a/toolkit/components/places/tests/migration/places_v54.sqlite b/toolkit/components/places/tests/migration/places_v54.sqlite
new file mode 100644
index 0000000000..a203b28c10
Binary files /dev/null and b/toolkit/components/places/tests/migration/places_v54.sqlite differ
diff --git a/toolkit/components/places/tests/migration/places_v66.sqlite b/toolkit/components/places/tests/migration/places_v66.sqlite
new file mode 100644
index 0000000000..9578ee11e6
Binary files /dev/null and b/toolkit/components/places/tests/migration/places_v66.sqlite differ
diff --git a/toolkit/components/places/tests/migration/places_v68.sqlite b/toolkit/components/places/tests/migration/places_v68.sqlite
new file mode 100644
index 0000000000..414fa170ec
Binary files /dev/null and b/toolkit/components/places/tests/migration/places_v68.sqlite differ
diff --git a/toolkit/components/places/tests/migration/places_v69.sqlite b/toolkit/components/places/tests/migration/places_v69.sqlite
new file mode 100644
index 0000000000..bc3053c18e
Binary files /dev/null and b/toolkit/components/places/tests/migration/places_v69.sqlite differ
diff --git a/toolkit/components/places/tests/migration/places_v70.sqlite b/toolkit/components/places/tests/migration/places_v70.sqlite
new file mode 100644
index 0000000000..907e7f5046
Binary files /dev/null and b/toolkit/components/places/tests/migration/places_v70.sqlite differ
diff --git a/toolkit/components/places/tests/migration/places_v72.sqlite b/toolkit/components/places/tests/migration/places_v72.sqlite
new file mode 100644
index 0000000000..59d0d8fdab
Binary files /dev/null and b/toolkit/components/places/tests/migration/places_v72.sqlite differ
diff --git a/toolkit/components/places/tests/migration/places_v74.sqlite b/toolkit/components/places/tests/migration/places_v74.sqlite
new file mode 100644
index 0000000000..e7078a054f
Binary files /dev/null and b/toolkit/components/places/tests/migration/places_v74.sqlite differ
diff --git a/toolkit/components/places/tests/migration/places_v75.sqlite b/toolkit/components/places/tests/migration/places_v75.sqlite
new file mode 100644
index 0000000000..2dd624b945
Binary files /dev/null and b/toolkit/components/places/tests/migration/places_v75.sqlite differ
diff --git a/toolkit/components/places/tests/migration/test_current_from_downgraded.js b/toolkit/components/places/tests/migration/test_current_from_downgraded.js
new file mode 100644
index 0000000000..5daec14e2f
--- /dev/null
+++ b/toolkit/components/places/tests/migration/test_current_from_downgraded.js
@@ -0,0 +1,29 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+// This test ensures we can pass twice through migration methods without
+// failing, that is what happens in case of a downgrade followed by an upgrade.
+
+add_task(async function setup() {
+ let dbFile = PathUtils.join(
+ do_get_cwd().path,
+ `places_v${CURRENT_SCHEMA_VERSION}.sqlite`
+ );
+ Assert.ok(await IOUtils.exists(dbFile));
+ await setupPlacesDatabase(`places_v${CURRENT_SCHEMA_VERSION}.sqlite`);
+ // Downgrade the schema version to the first supported one.
+ let path = PathUtils.join(PathUtils.profileDir, DB_FILENAME);
+ let db = await Sqlite.openConnection({ path });
+ await db.setSchemaVersion(FIRST_UPGRADABLE_SCHEMA_VERSION);
+ await db.close();
+});
+
+add_task(async function database_is_valid() {
+ Assert.equal(
+ PlacesUtils.history.databaseStatus,
+ PlacesUtils.history.DATABASE_STATUS_UPGRADED
+ );
+
+ let db = await PlacesUtils.promiseDBConnection();
+ Assert.equal(await db.getSchemaVersion(), CURRENT_SCHEMA_VERSION);
+});
diff --git a/toolkit/components/places/tests/migration/test_current_from_outdated.js b/toolkit/components/places/tests/migration/test_current_from_outdated.js
new file mode 100644
index 0000000000..e7fad5b3a4
--- /dev/null
+++ b/toolkit/components/places/tests/migration/test_current_from_outdated.js
@@ -0,0 +1,47 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+/**
+ * This file tests migration from a preliminary schema version 6 that
+ * lacks frecency column and moz_inputhistory table.
+ */
+
+add_task(async function setup() {
+ await setupPlacesDatabase("places_outdated.sqlite");
+});
+
+add_task(async function corrupt_database_not_exists() {
+ let corruptPath = PathUtils.join(
+ PathUtils.profileDir,
+ "places.sqlite.corrupt"
+ );
+ Assert.ok(
+ !(await IOUtils.exists(corruptPath)),
+ "Corrupt file should not exist"
+ );
+});
+
+add_task(async function database_is_valid() {
+ Assert.equal(
+ PlacesUtils.history.databaseStatus,
+ PlacesUtils.history.DATABASE_STATUS_CORRUPT
+ );
+
+ let db = await PlacesUtils.promiseDBConnection();
+ Assert.equal(await db.getSchemaVersion(), CURRENT_SCHEMA_VERSION);
+});
+
+add_task(async function check_columns() {
+ // Check the database has been replaced, these would throw otherwise.
+ let db = await PlacesUtils.promiseDBConnection();
+ await db.execute("SELECT frecency from moz_places");
+ await db.execute("SELECT 1 from moz_inputhistory");
+});
+
+add_task(async function corrupt_database_exists() {
+ let corruptPath = PathUtils.join(
+ PathUtils.profileDir,
+ "places.sqlite.corrupt"
+ );
+ Assert.ok(await IOUtils.exists(corruptPath), "Corrupt file should exist");
+});
diff --git a/toolkit/components/places/tests/migration/test_current_from_v53.js b/toolkit/components/places/tests/migration/test_current_from_v53.js
new file mode 100644
index 0000000000..ce7b31c8df
--- /dev/null
+++ b/toolkit/components/places/tests/migration/test_current_from_v53.js
@@ -0,0 +1,23 @@
+add_task(async function setup() {
+ // Since this migration doesn't affect places.sqlite, we can reuse v43.
+ await setupPlacesDatabase("places_v52.sqlite");
+ await setupPlacesDatabase("favicons_v41.sqlite", "favicons.sqlite");
+});
+
+add_task(async function database_is_valid() {
+ // Accessing the database for the first time triggers migration.
+ Assert.equal(
+ PlacesUtils.history.databaseStatus,
+ PlacesUtils.history.DATABASE_STATUS_UPGRADED
+ );
+
+ let db = await PlacesUtils.promiseDBConnection();
+ Assert.equal(await db.getSchemaVersion(), CURRENT_SCHEMA_VERSION);
+
+ let count = (
+ await db.execute(
+ `SELECT count(*) FROM moz_icons_to_pages WHERE expire_ms = 0`
+ )
+ )[0].getResultByIndex(0);
+ Assert.equal(count, 0, "All the expirations should be set");
+});
diff --git a/toolkit/components/places/tests/migration/test_current_from_v54.js b/toolkit/components/places/tests/migration/test_current_from_v54.js
new file mode 100644
index 0000000000..94c8a26474
--- /dev/null
+++ b/toolkit/components/places/tests/migration/test_current_from_v54.js
@@ -0,0 +1,58 @@
+add_task(async function setup() {
+ // Since this migration doesn't affect places.sqlite, we can reuse v43.
+ await setupPlacesDatabase("places_v54.sqlite");
+ await setupPlacesDatabase("favicons_v41.sqlite", "favicons.sqlite");
+});
+
+add_task(async function database_is_valid() {
+ // Accessing the database for the first time triggers migration.
+ Assert.equal(
+ PlacesUtils.history.databaseStatus,
+ PlacesUtils.history.DATABASE_STATUS_UPGRADED
+ );
+
+ let db = await PlacesUtils.promiseDBConnection();
+ Assert.equal(await db.getSchemaVersion(), CURRENT_SCHEMA_VERSION);
+
+ for (let table of [
+ "moz_places_metadata",
+ "moz_places_metadata_search_queries",
+ ]) {
+ let count = (
+ await db.execute(`SELECT count(*) FROM ${table}`)
+ )[0].getResultByIndex(0);
+ Assert.equal(count, 0, `Empty table ${table}`);
+ }
+
+ for (let table of [
+ "moz_places_metadata_snapshots",
+ "moz_places_metadata_snapshots_extra",
+ "moz_places_metadata_snapshots_groups",
+ "moz_places_metadata_groups_to_snapshots",
+ "moz_session_metadata",
+ "moz_session_to_places",
+ ]) {
+ await Assert.rejects(
+ db.execute(`SELECT count(*) FROM ${table}`),
+ /no such table/,
+ `Table ${table} should not exist`
+ );
+ }
+});
+
+add_task(async function scrolling_fields_in_database() {
+ let db = await PlacesUtils.promiseDBConnection();
+ await db.execute(
+ `SELECT scrolling_time,scrolling_distance FROM moz_places_metadata`
+ );
+});
+
+add_task(async function site_name_field_in_database() {
+ let db = await PlacesUtils.promiseDBConnection();
+ await db.execute(`SELECT site_name FROM moz_places`);
+});
+
+add_task(async function previews_tombstones_in_database() {
+ let db = await PlacesUtils.promiseDBConnection();
+ await db.execute(`SELECT hash FROM moz_previews_tombstones`);
+});
diff --git a/toolkit/components/places/tests/migration/test_current_from_v66.js b/toolkit/components/places/tests/migration/test_current_from_v66.js
new file mode 100644
index 0000000000..5ea14f3b9d
--- /dev/null
+++ b/toolkit/components/places/tests/migration/test_current_from_v66.js
@@ -0,0 +1,53 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+add_task(async function setup() {
+ const path = await setupPlacesDatabase("places_v66.sqlite");
+
+ const db = await Sqlite.openConnection({ path });
+ await db.execute(`
+ INSERT INTO moz_inputhistory (input, use_count, place_id)
+ VALUES
+ ('abc', 1, 1),
+ ('aBc', 0.9, 1),
+ ('ABC', 5, 1),
+ ('ABC', 1, 2),
+ ('DEF', 1, 3)
+ `);
+ await db.close();
+});
+
+add_task(async function database_is_valid() {
+ // Accessing the database for the first time triggers migration.
+ Assert.equal(
+ PlacesUtils.history.databaseStatus,
+ PlacesUtils.history.DATABASE_STATUS_UPGRADED
+ );
+
+ let db = await PlacesUtils.promiseDBConnection();
+ Assert.equal(await db.getSchemaVersion(), CURRENT_SCHEMA_VERSION);
+});
+
+add_task(async function moz_inputhistory() {
+ await PlacesUtils.withConnectionWrapper("test_sqlite_migration", async db => {
+ const rows = await db.execute(
+ "SELECT * FROM moz_inputhistory ORDER BY place_id"
+ );
+
+ Assert.equal(rows.length, 3);
+
+ Assert.equal(rows[0].getResultByName("place_id"), 1);
+ Assert.equal(rows[0].getResultByName("input"), "abc");
+ Assert.equal(rows[0].getResultByName("use_count"), 5);
+
+ Assert.equal(rows[1].getResultByName("place_id"), 2);
+ Assert.equal(rows[1].getResultByName("input"), "abc");
+ Assert.equal(rows[1].getResultByName("use_count"), 1);
+
+ Assert.equal(rows[2].getResultByName("place_id"), 3);
+ Assert.equal(rows[2].getResultByName("input"), "def");
+ Assert.equal(rows[2].getResultByName("use_count"), 1);
+ });
+});
diff --git a/toolkit/components/places/tests/migration/test_current_from_v68.js b/toolkit/components/places/tests/migration/test_current_from_v68.js
new file mode 100644
index 0000000000..689fcbfd40
--- /dev/null
+++ b/toolkit/components/places/tests/migration/test_current_from_v68.js
@@ -0,0 +1,35 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+add_task(async function setup() {
+ const path = await setupPlacesDatabase("places_v68.sqlite");
+
+ const db = await Sqlite.openConnection({ path });
+ await db.execute("INSERT INTO moz_historyvisits (from_visit) VALUES (-1)");
+ await db.close();
+});
+
+add_task(async function database_is_valid() {
+ // Accessing the database for the first time triggers migration.
+ Assert.equal(
+ PlacesUtils.history.databaseStatus,
+ PlacesUtils.history.DATABASE_STATUS_UPGRADED
+ );
+
+ const db = await PlacesUtils.promiseDBConnection();
+ Assert.equal(await db.getSchemaVersion(), CURRENT_SCHEMA_VERSION);
+});
+
+add_task(async function moz_historyvisits() {
+ await PlacesUtils.withConnectionWrapper("test_sqlite_migration", async db => {
+ const rows = await db.execute(
+ "SELECT * FROM moz_historyvisits WHERE from_visit=-1"
+ );
+
+ Assert.equal(rows.length, 1);
+ Assert.equal(rows[0].getResultByName("source"), 0);
+ Assert.equal(rows[0].getResultByName("triggeringPlaceId"), null);
+ });
+});
diff --git a/toolkit/components/places/tests/migration/test_current_from_v69.js b/toolkit/components/places/tests/migration/test_current_from_v69.js
new file mode 100644
index 0000000000..09c66fb66e
--- /dev/null
+++ b/toolkit/components/places/tests/migration/test_current_from_v69.js
@@ -0,0 +1,84 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+add_task(async function setup() {
+ const path = await setupPlacesDatabase("places_v69.sqlite");
+
+ const db = await Sqlite.openConnection({ path });
+ await db.execute(`
+ INSERT INTO moz_places (url, guid, url_hash, origin_id, frecency)
+ VALUES
+ ('https://test1.com', '___________1', '123456', 100, 0),
+ ('https://test2.com', '___________2', '123456', 101, -1),
+ ('https://test3.com', '___________3', '123456', 102, -1234)
+ `);
+ await db.execute(`
+ INSERT INTO moz_origins (id, prefix, host, frecency)
+ VALUES
+ (100, 'https://', 'test1.com', 0),
+ (101, 'https://', 'test2.com', 0),
+ (102, 'https://', 'test3.com', 0)
+ `);
+ await db.close();
+});
+
+add_task(async function database_is_valid() {
+ // Accessing the database for the first time triggers migration.
+ Assert.equal(
+ PlacesUtils.history.databaseStatus,
+ PlacesUtils.history.DATABASE_STATUS_UPGRADED
+ );
+
+ const db = await PlacesUtils.promiseDBConnection();
+ Assert.equal(await db.getSchemaVersion(), CURRENT_SCHEMA_VERSION);
+});
+
+add_task(async function moz_historyvisits() {
+ await PlacesUtils.withConnectionWrapper("test_sqlite_migration", async db => {
+ function expectedFrecency(guid) {
+ switch (guid) {
+ case "___________1":
+ return 0;
+ case "___________2":
+ return -1;
+ case "___________3":
+ return 1234;
+ default:
+ throw new Error("Unknown guid");
+ }
+ }
+ const rows = await db.execute(
+ "SELECT guid, frecency FROM moz_places WHERE url_hash = '123456'"
+ );
+ for (let row of rows) {
+ Assert.equal(
+ row.getResultByName("frecency"),
+ expectedFrecency(row.getResultByName("guid")),
+ "Check expected frecency"
+ );
+ }
+ const origins = new Map(
+ (await db.execute("SELECT host, frecency FROM moz_origins")).map(r => [
+ r.getResultByName("host"),
+ r.getResultByName("frecency"),
+ ])
+ );
+ Assert.equal(origins.get("test1.com"), 0);
+ Assert.equal(origins.get("test2.com"), 0);
+ Assert.equal(origins.get("test3.com"), 1234);
+
+ const statSum = (
+ await db.execute(
+ "SELECT value FROM moz_meta WHERE key = 'origin_frecency_sum'"
+ )
+ )[0].getResultByName("value");
+ const sum = (
+ await db.execute(
+ "SELECT SUM(frecency) AS sum from moz_origins WHERE frecency > 0"
+ )
+ )[0].getResultByName("sum");
+ Assert.equal(sum, statSum, "Check stats were updated");
+ });
+});
diff --git a/toolkit/components/places/tests/migration/test_current_from_v70.js b/toolkit/components/places/tests/migration/test_current_from_v70.js
new file mode 100644
index 0000000000..e5e41852e3
--- /dev/null
+++ b/toolkit/components/places/tests/migration/test_current_from_v70.js
@@ -0,0 +1,96 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+add_task(async function setup() {
+ let path = await setupPlacesDatabase("places_v70.sqlite");
+
+ let db = await Sqlite.openConnection({ path });
+ await db.execute(`
+ INSERT INTO moz_places (url, guid, url_hash, origin_id, frecency, foreign_count)
+ VALUES
+ ('https://test1.com', '___________1', '123456', 100, 0, 2),
+ ('https://test2.com', '___________2', '123456', 101, -1, 2),
+ ('https://test3.com', '___________3', '123456', 102, -1234, 1)
+ `);
+ await db.execute(`
+ INSERT INTO moz_origins (id, prefix, host, frecency)
+ VALUES
+ (100, 'https://', 'test1.com', 0),
+ (101, 'https://', 'test2.com', 0),
+ (102, 'https://', 'test3.com', 0)
+ `);
+ await db.execute(
+ `INSERT INTO moz_session_metadata
+ (id, guid)
+ VALUES (0, "0")
+ `
+ );
+
+ await db.execute(
+ `INSERT INTO moz_places_metadata_snapshots
+ (place_id, created_at, first_interaction_at, last_interaction_at)
+ VALUES ((SELECT id FROM moz_places WHERE guid = :guid), 0, 0, 0)
+ `,
+ { guid: "___________1" }
+ );
+ await db.execute(
+ `INSERT INTO moz_bookmarks
+ (fk, guid)
+ VALUES ((SELECT id FROM moz_places WHERE guid = :guid), :guid)
+ `,
+ { guid: "___________1" }
+ );
+
+ await db.execute(
+ `INSERT INTO moz_places_metadata_snapshots
+ (place_id, created_at, first_interaction_at, last_interaction_at)
+ VALUES ((SELECT id FROM moz_places WHERE guid = :guid), 0, 0, 0)
+ `,
+ { guid: "___________2" }
+ );
+ await db.execute(
+ `INSERT INTO moz_session_to_places
+ (session_id, place_id)
+ VALUES (0, (SELECT id FROM moz_places WHERE guid = :guid))
+ `,
+ { guid: "___________2" }
+ );
+
+ await db.execute(
+ `INSERT INTO moz_session_to_places
+ (session_id, place_id)
+ VALUES (0, (SELECT id FROM moz_places WHERE guid = :guid))
+ `,
+ { guid: "___________3" }
+ );
+
+ await db.close();
+});
+
+add_task(async function database_is_valid() {
+ // Accessing the database for the first time triggers migration.
+ Assert.equal(
+ PlacesUtils.history.databaseStatus,
+ PlacesUtils.history.DATABASE_STATUS_UPGRADED
+ );
+
+ const db = await PlacesUtils.promiseDBConnection();
+ Assert.equal(await db.getSchemaVersion(), CURRENT_SCHEMA_VERSION);
+
+ let rows = await db.execute("SELECT guid, foreign_count FROM moz_places");
+ for (let row of rows) {
+ let guid = row.getResultByName("guid");
+ let count = row.getResultByName("foreign_count");
+ if (guid == "___________1") {
+ Assert.equal(count, 1, "test1 should have the correct foreign_count");
+ }
+ if (guid == "___________2") {
+ Assert.equal(count, 0, "test2 should have the correct foreign_count");
+ }
+ if (guid == "___________3") {
+ Assert.equal(count, 0, "test3 should have the correct foreign_count");
+ }
+ }
+});
diff --git a/toolkit/components/places/tests/migration/test_current_from_v72.js b/toolkit/components/places/tests/migration/test_current_from_v72.js
new file mode 100644
index 0000000000..626279fce4
--- /dev/null
+++ b/toolkit/components/places/tests/migration/test_current_from_v72.js
@@ -0,0 +1,29 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+add_task(async function setup() {
+ await setupPlacesDatabase("places_v72.sqlite");
+});
+
+add_task(async function database_is_valid() {
+ // Accessing the database for the first time triggers migration.
+ Assert.equal(
+ PlacesUtils.history.databaseStatus,
+ PlacesUtils.history.DATABASE_STATUS_UPGRADED
+ );
+
+ const db = await PlacesUtils.promiseDBConnection();
+ Assert.equal(await db.getSchemaVersion(), CURRENT_SCHEMA_VERSION);
+
+ await db.execute(
+ "SELECT recalc_frecency, alt_frecency, recalc_alt_frecency FROM moz_origins"
+ );
+
+ await db.execute("SELECT alt_frecency, recalc_alt_frecency FROM moz_places");
+ Assert.ok(
+ await db.indexExists("moz_places_altfrecencyindex"),
+ "Should have created an index"
+ );
+});
diff --git a/toolkit/components/places/tests/migration/test_current_from_v74.js b/toolkit/components/places/tests/migration/test_current_from_v74.js
new file mode 100644
index 0000000000..82c535f78f
--- /dev/null
+++ b/toolkit/components/places/tests/migration/test_current_from_v74.js
@@ -0,0 +1,22 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+add_task(async function setup() {
+ await setupPlacesDatabase("places_v74.sqlite");
+});
+
+add_task(async function database_is_valid() {
+ // Accessing the database for the first time triggers migration.
+ Assert.equal(
+ PlacesUtils.history.databaseStatus,
+ PlacesUtils.history.DATABASE_STATUS_UPGRADED
+ );
+
+ const db = await PlacesUtils.promiseDBConnection();
+ Assert.equal(await db.getSchemaVersion(), CURRENT_SCHEMA_VERSION);
+
+ await db.execute("SELECT * FROM moz_places_extra");
+ await db.execute("SELECT * from moz_historyvisits_extra");
+});
diff --git a/toolkit/components/places/tests/migration/xpcshell.toml b/toolkit/components/places/tests/migration/xpcshell.toml
new file mode 100644
index 0000000000..b127fa501f
--- /dev/null
+++ b/toolkit/components/places/tests/migration/xpcshell.toml
@@ -0,0 +1,38 @@
+[DEFAULT]
+head = "head_migration.js"
+skip-if = ["condprof"] # Not worth running conditioned profiles on these migration
+# tests for databases. See discussion in bug 1838791.
+
+support-files = [
+ "favicons_v41.sqlite",
+ "places_outdated.sqlite",
+ "places_v52.sqlite",
+ "places_v54.sqlite",
+ "places_v66.sqlite",
+ "places_v68.sqlite",
+ "places_v69.sqlite",
+ "places_v70.sqlite",
+ "places_v72.sqlite",
+ "places_v74.sqlite",
+ "places_v75.sqlite",
+]
+
+["test_current_from_downgraded.js"]
+
+["test_current_from_outdated.js"]
+
+["test_current_from_v53.js"]
+
+["test_current_from_v54.js"]
+
+["test_current_from_v66.js"]
+
+["test_current_from_v68.js"]
+
+["test_current_from_v69.js"]
+
+["test_current_from_v70.js"]
+
+["test_current_from_v72.js"]
+
+["test_current_from_v74.js"]
diff --git a/toolkit/components/places/tests/moz.build b/toolkit/components/places/tests/moz.build
new file mode 100644
index 0000000000..60c57f53b8
--- /dev/null
+++ b/toolkit/components/places/tests/moz.build
@@ -0,0 +1,77 @@
+# -*- Mode: python; indent-tabs-mode: nil; tab-width: 40 -*-
+# vim: set filetype=python:
+# 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/.
+
+TEST_DIRS += ["gtest"]
+
+TESTING_JS_MODULES += [
+ "PlacesTestUtils.sys.mjs",
+]
+
+XPCSHELL_TESTS_MANIFESTS += [
+ "bookmarks/xpcshell.toml",
+ "expiration/xpcshell.toml",
+ "favicons/xpcshell.toml",
+ "history/xpcshell.toml",
+ "legacy/xpcshell.toml",
+ "maintenance/xpcshell.toml",
+ "migration/xpcshell.toml",
+ "queries/xpcshell.toml",
+ "sync/xpcshell.toml",
+ "unit/xpcshell.toml",
+]
+
+BROWSER_CHROME_MANIFESTS += [
+ "browser/browser.toml",
+ "browser/previews/browser.toml",
+]
+
+MOCHITEST_CHROME_MANIFESTS += [
+ "chrome/chrome.toml",
+]
+
+TEST_HARNESS_FILES.xpcshell.toolkit.components.places.tests += [
+ "head_common.js",
+]
+
+TEST_HARNESS_FILES.testing.mochitest.tests.toolkit.components.places.tests.browser += [
+ "browser/1601563-1.html",
+ "browser/1601563-2.html",
+ "browser/399606-history.go-0.html",
+ "browser/399606-httprefresh.html",
+ "browser/399606-location.reload.html",
+ "browser/399606-location.replace.html",
+ "browser/399606-window.location.href.html",
+ "browser/399606-window.location.html",
+ "browser/461710_link_page-2.html",
+ "browser/461710_link_page-3.html",
+ "browser/461710_link_page.html",
+ "browser/461710_visited_page.html",
+ "browser/begin.html",
+ "browser/favicon-normal16.png",
+ "browser/favicon-normal32.png",
+ "browser/favicon.html",
+ "browser/final.html",
+ "browser/history_post.html",
+ "browser/history_post.sjs",
+ "browser/redirect-target.html",
+ "browser/redirect.sjs",
+ "browser/redirect_once.sjs",
+ "browser/redirect_self.sjs",
+ "browser/redirect_thrice.sjs",
+ "browser/redirect_twice.sjs",
+ "browser/redirect_twice_perma.sjs",
+ "browser/title1.html",
+ "browser/title2.html",
+]
+
+TEST_HARNESS_FILES.testing.mochitest.tests.toolkit.components.places.tests.chrome += [
+ "chrome/bad_links.atom",
+ "chrome/link-less-items-no-site-uri.rss",
+ "chrome/link-less-items.rss",
+ "chrome/rss_as_html.rss",
+ "chrome/rss_as_html.rss^headers^",
+ "chrome/sample_feed.atom",
+]
diff --git a/toolkit/components/places/tests/queries/head_queries.js b/toolkit/components/places/tests/queries/head_queries.js
new file mode 100644
index 0000000000..ebb6eb4455
--- /dev/null
+++ b/toolkit/components/places/tests/queries/head_queries.js
@@ -0,0 +1,342 @@
+/* -*- indent-tabs-mode: nil; js-indent-level: 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/. */
+
+// Import common head.
+{
+ /* import-globals-from ../head_common.js */
+ let commonFile = do_get_file("../head_common.js", false);
+ let uri = Services.io.newFileURI(commonFile);
+ Services.scriptloader.loadSubScript(uri.spec, this);
+}
+
+// Put any other stuff relative to this test folder below.
+
+// Some Useful Date constants - PRTime uses microseconds, so convert
+const DAY_MICROSEC = 86400000000;
+const today = PlacesUtils.toPRTime(Date.now());
+const yesterday = today - DAY_MICROSEC;
+const lastweek = today - DAY_MICROSEC * 7;
+const daybefore = today - DAY_MICROSEC * 2;
+const old = today - DAY_MICROSEC * 3;
+const futureday = today + DAY_MICROSEC * 3;
+const olderthansixmonths = today - DAY_MICROSEC * 31 * 7;
+
+/**
+ * Generalized function to pull in an array of objects of data and push it into
+ * the database. It does NOT do any checking to see that the input is
+ * appropriate. This function is an asynchronous task, it can be called using
+ * "Task.spawn" or using the "yield" function inside another task.
+ */
+async function task_populateDB(aArray) {
+ // Iterate over aArray and execute all instructions.
+ for (let arrayItem of aArray) {
+ try {
+ // make the data object into a query data object in order to create proper
+ // default values for anything left unspecified
+ var qdata = new queryData(arrayItem);
+ if (qdata.isVisit) {
+ // Then we should add a visit for this node
+ await PlacesTestUtils.addVisits({
+ uri: uri(qdata.uri),
+ transition: qdata.transType,
+ visitDate: qdata.lastVisit,
+ referrer: qdata.referrer ? uri(qdata.referrer) : null,
+ title: qdata.title,
+ });
+ if (qdata.visitCount && !qdata.isDetails) {
+ // Set a fake visit_count, this is not a real count but can be used
+ // to test sorting by visit_count.
+ await PlacesTestUtils.updateDatabaseValues(
+ "moz_places",
+ { visit_count: qdata.visitCount },
+ { url: qdata.uri }
+ );
+ }
+ }
+
+ if (qdata.isRedirect) {
+ // This must be async to properly enqueue after the updateFrecency call
+ // done by the visit addition.
+ await PlacesTestUtils.updateDatabaseValues(
+ "moz_places",
+ { hidden: 1 },
+ { url: qdata.uri }
+ );
+ }
+
+ if (qdata.isDetails) {
+ // Then we add extraneous page details for testing
+ await PlacesTestUtils.addVisits({
+ uri: uri(qdata.uri),
+ visitDate: qdata.lastVisit,
+ title: qdata.title,
+ });
+ }
+
+ if (qdata.markPageAsTyped) {
+ PlacesUtils.history.markPageAsTyped(uri(qdata.uri));
+ }
+
+ if (qdata.isPageAnnotation) {
+ await PlacesUtils.history.update({
+ url: qdata.uri,
+ annotations: new Map([
+ [qdata.annoName, qdata.removeAnnotation ? null : qdata.annoVal],
+ ]),
+ });
+ }
+
+ if (qdata.isFolder) {
+ await PlacesUtils.bookmarks.insert({
+ parentGuid: qdata.parentGuid,
+ type: PlacesUtils.bookmarks.TYPE_FOLDER,
+ title: qdata.title,
+ index: qdata.index,
+ });
+ }
+
+ if (qdata.isBookmark) {
+ let data = {
+ parentGuid: qdata.parentGuid,
+ index: qdata.index,
+ title: qdata.title,
+ url: qdata.uri,
+ };
+
+ if (qdata.dateAdded) {
+ data.dateAdded = new Date(qdata.dateAdded / 1000);
+ }
+
+ if (qdata.lastModified) {
+ data.lastModified = new Date(qdata.lastModified / 1000);
+ }
+
+ await PlacesUtils.bookmarks.insert(data);
+
+ if (qdata.keyword) {
+ await PlacesUtils.keywords.insert({
+ url: qdata.uri,
+ keyword: qdata.keyword,
+ });
+ }
+ }
+
+ if (qdata.isTag) {
+ PlacesUtils.tagging.tagURI(uri(qdata.uri), qdata.tagArray);
+ }
+
+ if (qdata.isSeparator) {
+ await PlacesUtils.bookmarks.insert({
+ parentGuid: qdata.parentGuid,
+ type: PlacesUtils.bookmarks.TYPE_SEPARATOR,
+ index: qdata.index,
+ });
+ }
+ } catch (ex) {
+ // use the arrayItem object here in case instantiation of qdata failed
+ info("Problem with this URI: " + arrayItem.uri);
+ do_throw("Error creating database: " + ex + "\n");
+ }
+ }
+}
+
+/**
+ * The Query Data Object - this object encapsulates data for our queries and is
+ * used to parameterize our calls to the Places APIs to put data into the
+ * database. It also has some interesting meta functions to determine which APIs
+ * should be called, and to determine if this object should show up in the
+ * resulting query.
+ * Its parameter is an object specifying which attributes you want to set.
+ * For ex:
+ * var myobj = new queryData({isVisit: true, uri:"http://mozilla.com", title="foo"});
+ * Note that it doesn't do any input checking on that object.
+ */
+function queryData(obj) {
+ this.isVisit = obj.isVisit ? obj.isVisit : false;
+ this.isBookmark = obj.isBookmark ? obj.isBookmark : false;
+ this.uri = obj.uri ? obj.uri : "";
+ this.lastVisit = obj.lastVisit ? obj.lastVisit : today;
+ this.referrer = obj.referrer ? obj.referrer : null;
+ this.transType = obj.transType
+ ? obj.transType
+ : Ci.nsINavHistoryService.TRANSITION_TYPED;
+ this.isRedirect = obj.isRedirect ? obj.isRedirect : false;
+ this.isDetails = obj.isDetails ? obj.isDetails : false;
+ this.title = obj.title ? obj.title : "";
+ this.markPageAsTyped = obj.markPageAsTyped ? obj.markPageAsTyped : false;
+ this.isPageAnnotation = obj.isPageAnnotation ? obj.isPageAnnotation : false;
+ this.removeAnnotation = !!obj.removeAnnotation;
+ this.annoName = obj.annoName ? obj.annoName : "";
+ this.annoVal = obj.annoVal ? obj.annoVal : "";
+ this.itemId = obj.itemId ? obj.itemId : 0;
+ this.annoMimeType = obj.annoMimeType ? obj.annoMimeType : "";
+ this.isTag = obj.isTag ? obj.isTag : false;
+ this.tagArray = obj.tagArray ? obj.tagArray : null;
+ this.parentGuid = obj.parentGuid || PlacesUtils.bookmarks.unfiledGuid;
+ this.feedURI = obj.feedURI ? obj.feedURI : "";
+ this.index = obj.index ? obj.index : PlacesUtils.bookmarks.DEFAULT_INDEX;
+ this.isFolder = obj.isFolder ? obj.isFolder : false;
+ this.contractId = obj.contractId ? obj.contractId : "";
+ this.lastModified = obj.lastModified ? obj.lastModified : null;
+ this.dateAdded = obj.dateAdded ? obj.dateAdded : null;
+ this.keyword = obj.keyword ? obj.keyword : "";
+ this.visitCount = obj.visitCount ? obj.visitCount : 0;
+ this.isSeparator = obj.hasOwnProperty("isSeparator") && obj.isSeparator;
+
+ // And now, the attribute for whether or not this object should appear in the
+ // resulting query
+ this.isInQuery = obj.isInQuery ? obj.isInQuery : false;
+}
+
+// All attributes are set in the constructor above
+queryData.prototype = {};
+
+/**
+ * Helper function to compare an array of query objects with a result set.
+ * It assumes the array of query objects contains the SAME SORT as the result
+ * set. It checks the the uri, title, time, and bookmarkIndex properties of
+ * the results, where appropriate.
+ */
+function compareArrayToResult(aArray, aRoot) {
+ info("Comparing Array to Results");
+
+ var wasOpen = aRoot.containerOpen;
+ if (!wasOpen) {
+ aRoot.containerOpen = true;
+ }
+
+ // check expected number of results against actual
+ var expectedResultCount = aArray.filter(function (aEl) {
+ return aEl.isInQuery;
+ }).length;
+ if (expectedResultCount != aRoot.childCount) {
+ // Debugging code for failures.
+ dump_table("moz_places");
+ dump_table("moz_historyvisits");
+ info("Found children:");
+ for (let i = 0; i < aRoot.childCount; i++) {
+ info(aRoot.getChild(i).uri);
+ }
+ info("Expected:");
+ for (let i = 0; i < aArray.length; i++) {
+ if (aArray[i].isInQuery) {
+ info(aArray[i].uri);
+ }
+ }
+ }
+ Assert.equal(expectedResultCount, aRoot.childCount);
+
+ var inQueryIndex = 0;
+ for (var i = 0; i < aArray.length; i++) {
+ if (aArray[i].isInQuery) {
+ var child = aRoot.getChild(inQueryIndex);
+ // do_print("testing testData[" + i + "] vs result[" + inQueryIndex + "]");
+ if (!aArray[i].isFolder && !aArray[i].isSeparator) {
+ info(
+ "testing testData[" + aArray[i].uri + "] vs result[" + child.uri + "]"
+ );
+ if (aArray[i].uri != child.uri) {
+ dump_table("moz_places");
+ do_throw("Expected " + aArray[i].uri + " found " + child.uri);
+ }
+ }
+ if (!aArray[i].isSeparator && aArray[i].title != child.title) {
+ do_throw("Expected " + aArray[i].title + " found " + child.title);
+ }
+ if (
+ aArray[i].hasOwnProperty("lastVisit") &&
+ aArray[i].lastVisit != child.time
+ ) {
+ do_throw("Expected " + aArray[i].lastVisit + " found " + child.time);
+ }
+ if (
+ aArray[i].hasOwnProperty("index") &&
+ aArray[i].index != PlacesUtils.bookmarks.DEFAULT_INDEX &&
+ aArray[i].index != child.bookmarkIndex
+ ) {
+ do_throw(
+ "Expected " + aArray[i].index + " found " + child.bookmarkIndex
+ );
+ }
+
+ inQueryIndex++;
+ }
+ }
+
+ if (!wasOpen) {
+ aRoot.containerOpen = false;
+ }
+ info("Comparing Array to Results passes");
+}
+
+/**
+ * Helper function to check to see if one object either is or is not in the
+ * result set. It can accept either a queryData object or an array of queryData
+ * objects. If it gets an array, it only compares the first object in the array
+ * to see if it is in the result set.
+ * @returns {nsINavHistoryResultNode}: Either the node, if found, or null.
+ * If input is an array, returns a result only for the first node.
+ * To compare entire array, use the function above.
+ */
+function nodeInResult(aQueryData, aRoot) {
+ var rv = null;
+ var uri;
+ var wasOpen = aRoot.containerOpen;
+ if (!wasOpen) {
+ aRoot.containerOpen = true;
+ }
+
+ // If we have an array, pluck out the first item. If an object, pluc out the
+ // URI, we just compare URI's here.
+ if ("uri" in aQueryData) {
+ uri = aQueryData.uri;
+ } else {
+ uri = aQueryData[0].uri;
+ }
+
+ for (var i = 0; i < aRoot.childCount; i++) {
+ let node = aRoot.getChild(i);
+ if (uri == node.uri) {
+ rv = node;
+ break;
+ }
+ }
+ if (!wasOpen) {
+ aRoot.containerOpen = false;
+ }
+ return rv;
+}
+
+/**
+ * A nice helper function for debugging things. It prints the contents of a
+ * result set.
+ */
+function displayResultSet(aRoot) {
+ var wasOpen = aRoot.containerOpen;
+ if (!wasOpen) {
+ aRoot.containerOpen = true;
+ }
+
+ if (!aRoot.hasChildren) {
+ // Something wrong? Empty result set?
+ info("Result Set Empty");
+ return;
+ }
+
+ for (var i = 0; i < aRoot.childCount; ++i) {
+ info(
+ "Result Set URI: " +
+ aRoot.getChild(i).uri +
+ " Title: " +
+ aRoot.getChild(i).title +
+ " Visit Time: " +
+ aRoot.getChild(i).time
+ );
+ }
+ if (!wasOpen) {
+ aRoot.containerOpen = false;
+ }
+}
diff --git a/toolkit/components/places/tests/queries/readme.txt b/toolkit/components/places/tests/queries/readme.txt
new file mode 100644
index 0000000000..19414f96ed
--- /dev/null
+++ b/toolkit/components/places/tests/queries/readme.txt
@@ -0,0 +1,16 @@
+These are tests specific to the Places Query API.
+
+We are tracking the coverage of these tests here:
+http://wiki.mozilla.org/QA/TDAI/Projects/Places_Tests
+
+When creating one of these tests, you need to update those tables so that we
+know how well our test coverage is of this area. Furthermore, when adding tests
+ensure to cover live update (changing the query set) by performing the following
+operations on the query set you get after running the query:
+* Adding a new item to the query set
+* Updating an existing item so that it matches the query set
+* Change an existing item so that it does not match the query set
+* Do multiple of the above inside an Update Batch transaction.
+* Try these transactions in different orders.
+
+Use the stub test to help you create a test with the proper structure.
diff --git a/toolkit/components/places/tests/queries/test_async.js b/toolkit/components/places/tests/queries/test_async.js
new file mode 100644
index 0000000000..8e895748ab
--- /dev/null
+++ b/toolkit/components/places/tests/queries/test_async.js
@@ -0,0 +1,379 @@
+/* -*- indent-tabs-mode: nil; js-indent-level: 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/. */
+
+var tests = [
+ {
+ desc:
+ "nsNavHistoryFolderResultNode: Basic test, asynchronously open and " +
+ "close container with a single child",
+
+ loading(node, newState, oldState) {
+ this.checkStateChanged("loading", 1);
+ this.checkArgs("loading", node, oldState, node.STATE_CLOSED);
+ },
+
+ opened(node, newState, oldState) {
+ this.checkStateChanged("opened", 1);
+ this.checkState("loading", 1);
+ this.checkArgs("opened", node, oldState, node.STATE_LOADING);
+
+ print("Checking node children");
+ compareArrayToResult(this.data, node);
+
+ print("Closing container");
+ node.containerOpen = false;
+ },
+
+ closed(node, newState, oldState) {
+ this.checkStateChanged("closed", 1);
+ this.checkState("opened", 1);
+ this.checkArgs("closed", node, oldState, node.STATE_OPENED);
+ this.success();
+ },
+ },
+
+ {
+ desc:
+ "nsNavHistoryFolderResultNode: After async open and no changes, " +
+ "second open should be synchronous",
+
+ loading(node, newState, oldState) {
+ this.checkStateChanged("loading", 1);
+ this.checkState("closed", 0);
+ this.checkArgs("loading", node, oldState, node.STATE_CLOSED);
+ },
+
+ opened(node, newState, oldState) {
+ let cnt = this.checkStateChanged("opened", 1, 2);
+ let expectOldState = cnt === 1 ? node.STATE_LOADING : node.STATE_CLOSED;
+ this.checkArgs("opened", node, oldState, expectOldState);
+
+ print("Checking node children");
+ compareArrayToResult(this.data, node);
+
+ print("Closing container");
+ node.containerOpen = false;
+ },
+
+ closed(node, newState, oldState) {
+ let cnt = this.checkStateChanged("closed", 1, 2);
+ this.checkArgs("closed", node, oldState, node.STATE_OPENED);
+
+ switch (cnt) {
+ case 1:
+ node.containerOpen = true;
+ break;
+ case 2:
+ this.success();
+ break;
+ }
+ },
+ },
+
+ {
+ desc:
+ "nsNavHistoryFolderResultNode: After closing container in " +
+ "loading(), opened() should not be called",
+
+ loading(node, newState, oldState) {
+ this.checkStateChanged("loading", 1);
+ this.checkArgs("loading", node, oldState, node.STATE_CLOSED);
+ print("Closing container");
+ node.containerOpen = false;
+ },
+
+ opened(node, newState, oldState) {
+ do_throw("opened should not be called");
+ },
+
+ closed(node, newState, oldState) {
+ this.checkStateChanged("closed", 1);
+ this.checkState("loading", 1);
+ this.checkArgs("closed", node, oldState, node.STATE_LOADING);
+ this.success();
+ },
+ },
+];
+
+/**
+ * Instances of this class become the prototypes of the test objects above.
+ * Each test can therefore use the methods of this class, or they can override
+ * them if they want. To run a test, call setup() and then run().
+ */
+function Test() {
+ // This maps a state name to the number of times it's been observed.
+ this.stateCounts = {};
+ // Promise object resolved when the next test can be run.
+ this.deferNextTest = Promise.withResolvers();
+}
+
+Test.prototype = {
+ /**
+ * Call this when an observer observes a container state change to sanity
+ * check the arguments.
+ *
+ * @param aNewState
+ * The name of the new state. Used only for printing out helpful info.
+ * @param aNode
+ * The node argument passed to containerStateChanged.
+ * @param aOldState
+ * The old state argument passed to containerStateChanged.
+ * @param aExpectOldState
+ * The expected old state.
+ */
+ checkArgs(aNewState, aNode, aOldState, aExpectOldState) {
+ print("Node passed on " + aNewState + " should be result.root");
+ Assert.equal(this.result.root, aNode);
+ print("Old state passed on " + aNewState + " should be " + aExpectOldState);
+
+ // aOldState comes from xpconnect and will therefore be defined. It may be
+ // zero, though, so use strict equality just to make sure aExpectOldState is
+ // also defined.
+ Assert.ok(aOldState === aExpectOldState);
+ },
+
+ /**
+ * Call this when an observer observes a container state change. It registers
+ * the state change and ensures that it has been observed the given number
+ * of times. See checkState for parameter explanations.
+ *
+ * @return The number of times aState has been observed, including the new
+ * observation.
+ */
+ checkStateChanged(aState, aExpectedMin, aExpectedMax) {
+ print(aState + " state change observed");
+ if (!this.stateCounts.hasOwnProperty(aState)) {
+ this.stateCounts[aState] = 0;
+ }
+ this.stateCounts[aState]++;
+ return this.checkState(aState, aExpectedMin, aExpectedMax);
+ },
+
+ /**
+ * Ensures that the state has been observed the given number of times.
+ *
+ * @param aState
+ * The name of the state.
+ * @param aExpectedMin
+ * The state must have been observed at least this number of times.
+ * @param aExpectedMax
+ * The state must have been observed at most this number of times.
+ * This parameter is optional. If undefined, it's set to
+ * aExpectedMin.
+ * @return The number of times aState has been observed, including the new
+ * observation.
+ */
+ checkState(aState, aExpectedMin, aExpectedMax) {
+ let cnt = this.stateCounts[aState] || 0;
+ if (aExpectedMax === undefined) {
+ aExpectedMax = aExpectedMin;
+ }
+ if (aExpectedMin === aExpectedMax) {
+ print(
+ aState +
+ " should be observed only " +
+ aExpectedMin +
+ " times (actual = " +
+ cnt +
+ ")"
+ );
+ } else {
+ print(
+ aState +
+ " should be observed at least " +
+ aExpectedMin +
+ " times and at most " +
+ aExpectedMax +
+ " times (actual = " +
+ cnt +
+ ")"
+ );
+ }
+ Assert.ok(cnt >= aExpectedMin && cnt <= aExpectedMax);
+ return cnt;
+ },
+
+ /**
+ * Asynchronously opens the root of the test's result.
+ */
+ openContainer() {
+ // Set up the result observer. It delegates to this object's callbacks and
+ // wraps them in a try-catch so that errors don't get eaten.
+ let self = this;
+ this.observer = {
+ containerStateChanged(container, oldState, newState) {
+ print(
+ "New state passed to containerStateChanged() should equal the " +
+ "container's current state"
+ );
+ Assert.equal(newState, container.state);
+
+ try {
+ switch (newState) {
+ case Ci.nsINavHistoryContainerResultNode.STATE_LOADING:
+ self.loading(container, newState, oldState);
+ break;
+ case Ci.nsINavHistoryContainerResultNode.STATE_OPENED:
+ self.opened(container, newState, oldState);
+ break;
+ case Ci.nsINavHistoryContainerResultNode.STATE_CLOSED:
+ self.closed(container, newState, oldState);
+ break;
+ default:
+ do_throw("Unexpected new state! " + newState);
+ }
+ } catch (err) {
+ do_throw(err);
+ }
+ },
+ };
+ this.result.addObserver(this.observer);
+
+ print("Opening container");
+ this.result.root.containerOpen = true;
+ },
+
+ /**
+ * Starts the test and returns a promise resolved when the test completes.
+ */
+ run() {
+ this.openContainer();
+ return this.deferNextTest.promise;
+ },
+
+ /**
+ * This must be called before run(). It adds a bookmark and sets up the
+ * test's result. Override if need be.
+ */
+ async setup() {
+ // Populate the database with different types of bookmark items.
+ this.data = DataHelper.makeDataArray([
+ { type: "bookmark" },
+ { type: "separator" },
+ { type: "folder" },
+ { type: "bookmark", uri: "place:terms=foo" },
+ ]);
+ await task_populateDB(this.data);
+
+ // Make a query.
+ this.query = PlacesUtils.history.getNewQuery();
+ this.query.setParents([DataHelper.defaults.bookmark.parentGuid]);
+ this.opts = PlacesUtils.history.getNewQueryOptions();
+ this.opts.asyncEnabled = true;
+ this.result = PlacesUtils.history.executeQuery(this.query, this.opts);
+ },
+
+ /**
+ * Call this when the test has succeeded. It cleans up resources and starts
+ * the next test.
+ */
+ success() {
+ this.result.removeObserver(this.observer);
+
+ // Resolve the promise object that indicates that the next test can be run.
+ this.deferNextTest.resolve();
+ },
+};
+
+/**
+ * This makes it a little bit easier to use the functions of head_queries.js.
+ */
+var DataHelper = {
+ defaults: {
+ bookmark: {
+ parentGuid: PlacesUtils.bookmarks.unfiledGuid,
+ uri: "http://example.com/",
+ title: "test bookmark",
+ },
+
+ folder: {
+ parentGuid: PlacesUtils.bookmarks.unfiledGuid,
+ title: "test folder",
+ },
+
+ separator: {
+ parentGuid: PlacesUtils.bookmarks.unfiledGuid,
+ },
+ },
+
+ /**
+ * Converts an array of simple bookmark item descriptions to the more verbose
+ * format required by task_populateDB() in head_queries.js.
+ *
+ * @param aData
+ * An array of objects, each of which describes a bookmark item.
+ * @return An array of objects suitable for passing to populateDB().
+ */
+ makeDataArray: function DH_makeDataArray(aData) {
+ let self = this;
+ return aData.map(function (dat) {
+ let type = dat.type;
+ dat = self._makeDataWithDefaults(dat, self.defaults[type]);
+ switch (type) {
+ case "bookmark":
+ return {
+ isBookmark: true,
+ uri: dat.uri,
+ parentGuid: dat.parentGuid,
+ index: PlacesUtils.bookmarks.DEFAULT_INDEX,
+ title: dat.title,
+ isInQuery: true,
+ };
+ case "separator":
+ return {
+ isSeparator: true,
+ parentGuid: dat.parentGuid,
+ index: PlacesUtils.bookmarks.DEFAULT_INDEX,
+ isInQuery: true,
+ };
+ case "folder":
+ return {
+ isFolder: true,
+ parentGuid: dat.parentGuid,
+ index: PlacesUtils.bookmarks.DEFAULT_INDEX,
+ title: dat.title,
+ isInQuery: true,
+ };
+ default:
+ do_throw("Unknown data type when populating DB: " + type);
+ return undefined;
+ }
+ });
+ },
+
+ /**
+ * Returns a copy of aData, except that any properties that are undefined but
+ * defined in aDefaults are set to the corresponding values in aDefaults.
+ *
+ * @param aData
+ * An object describing a bookmark item.
+ * @param aDefaults
+ * An object describing the default bookmark item.
+ * @return A copy of aData with defaults values set.
+ */
+ _makeDataWithDefaults: function DH__makeDataWithDefaults(aData, aDefaults) {
+ let dat = {};
+ for (let [prop, val] of Object.entries(aDefaults)) {
+ dat[prop] = aData.hasOwnProperty(prop) ? aData[prop] : val;
+ }
+ return dat;
+ },
+};
+
+add_task(async function test_async() {
+ for (let test of tests) {
+ await PlacesUtils.bookmarks.eraseEverything();
+
+ Object.setPrototypeOf(test, new Test());
+ await test.setup();
+
+ print("------ Running test: " + test.desc);
+ await test.run();
+ }
+
+ await PlacesUtils.bookmarks.eraseEverything();
+ print("All tests done, exiting");
+});
diff --git a/toolkit/components/places/tests/queries/test_bookmarks.js b/toolkit/components/places/tests/queries/test_bookmarks.js
new file mode 100644
index 0000000000..b5f2ef754f
--- /dev/null
+++ b/toolkit/components/places/tests/queries/test_bookmarks.js
@@ -0,0 +1,105 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/ */
+
+add_task(async function test_eraseEverything() {
+ info("Test folder with eraseEverything");
+ await PlacesUtils.bookmarks.insertTree({
+ guid: PlacesUtils.bookmarks.unfiledGuid,
+ children: [
+ {
+ title: "remove-folder",
+ type: PlacesUtils.bookmarks.TYPE_FOLDER,
+ children: [
+ { url: "http://mozilla.org/", title: "title 1" },
+ { url: "http://mozilla.org/", title: "title 2" },
+ { title: "sub-folder", type: PlacesUtils.bookmarks.TYPE_FOLDER },
+ { type: PlacesUtils.bookmarks.TYPE_SEPARATOR },
+ ],
+ },
+ ],
+ });
+
+ let unfiled = PlacesUtils.getFolderContents(
+ PlacesUtils.bookmarks.unfiledGuid
+ ).root;
+ Assert.equal(unfiled.childCount, 1, "There should be 1 folder");
+ let folder = unfiled.getChild(0);
+ // Test dateAdded and lastModified properties.
+ Assert.equal(typeof folder.dateAdded, "number");
+ Assert.ok(folder.dateAdded > 0);
+ Assert.equal(typeof folder.lastModified, "number");
+ Assert.ok(folder.lastModified > 0);
+
+ let root = PlacesUtils.getFolderContents(folder.bookmarkGuid).root;
+ Assert.equal(root.childCount, 4, "The folder should have 4 children");
+ for (let i = 0; i < root.childCount; ++i) {
+ let node = root.getChild(i);
+ Assert.greater(node.itemId, 0, "The node should have an itemId");
+ }
+ Assert.equal(root.getChild(0).title, "title 1");
+ Assert.equal(root.getChild(1).title, "title 2");
+
+ await PlacesUtils.bookmarks.eraseEverything();
+
+ // Refetch the guid to refresh the data.
+ unfiled = PlacesUtils.getFolderContents(
+ PlacesUtils.bookmarks.unfiledGuid
+ ).root;
+ Assert.equal(unfiled.childCount, 0);
+ unfiled.containerOpen = false;
+});
+
+add_task(async function test_search_title() {
+ let title = "ZZZXXXYYY";
+ let bm = await PlacesUtils.bookmarks.insert({
+ parentGuid: PlacesUtils.bookmarks.unfiledGuid,
+ url: "http://mozilla.org/",
+ title,
+ });
+ let query = PlacesUtils.history.getNewQuery();
+ query.searchTerms = "ZZZXXXYYY";
+ let options = PlacesUtils.history.getNewQueryOptions();
+ options.queryType = Ci.nsINavHistoryQueryOptions.QUERY_TYPE_BOOKMARKS;
+ let root = PlacesUtils.history.executeQuery(query, options).root;
+ root.containerOpen = true;
+ Assert.equal(root.childCount, 1);
+ let node = root.getChild(0);
+ Assert.equal(node.title, title);
+
+ // Test dateAdded and lastModified properties.
+ Assert.equal(typeof node.dateAdded, "number");
+ Assert.ok(node.dateAdded > 0);
+ Assert.equal(typeof node.lastModified, "number");
+ Assert.ok(node.lastModified > 0);
+ Assert.equal(node.bookmarkGuid, bm.guid);
+
+ await PlacesUtils.bookmarks.remove(bm);
+ Assert.equal(root.childCount, 0);
+ root.containerOpen = false;
+});
+
+add_task(async function test_long_title() {
+ let title = Array(TITLE_LENGTH_MAX + 5).join("A");
+ let bm = await PlacesUtils.bookmarks.insert({
+ parentGuid: PlacesUtils.bookmarks.unfiledGuid,
+ url: "http://mozilla.org/",
+ title,
+ });
+ let root = PlacesUtils.getFolderContents(
+ PlacesUtils.bookmarks.unfiledGuid
+ ).root;
+ root.containerOpen = true;
+ Assert.equal(root.childCount, 1);
+ let node = root.getChild(0);
+ Assert.equal(node.title, title.substr(0, TITLE_LENGTH_MAX));
+
+ // Update with another long title.
+ let newTitle = Array(TITLE_LENGTH_MAX + 5).join("B");
+ bm.title = newTitle;
+ await PlacesUtils.bookmarks.update(bm);
+ Assert.equal(node.title, newTitle.substr(0, TITLE_LENGTH_MAX));
+
+ await PlacesUtils.bookmarks.remove(bm);
+ Assert.equal(root.childCount, 0);
+ root.containerOpen = false;
+});
diff --git a/toolkit/components/places/tests/queries/test_containersQueries_sorting.js b/toolkit/components/places/tests/queries/test_containersQueries_sorting.js
new file mode 100644
index 0000000000..9cdc0f2a52
--- /dev/null
+++ b/toolkit/components/places/tests/queries/test_containersQueries_sorting.js
@@ -0,0 +1,492 @@
+/* -*- indent-tabs-mode: nil; js-indent-level: 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/. */
+
+/**
+ * Testing behavior of bug 473157
+ * "Want to sort history in container view without sorting the containers"
+ * and regression bug 488783
+ * Tags list no longer sorted (alphabetized).
+ * This test is for global testing sorting containers queries.
+ */
+
+// Globals and Constants
+
+var resultTypes = [
+ {
+ value: Ci.nsINavHistoryQueryOptions.RESULTS_AS_DATE_QUERY,
+ name: "RESULTS_AS_DATE_QUERY",
+ },
+ {
+ value: Ci.nsINavHistoryQueryOptions.RESULTS_AS_SITE_QUERY,
+ name: "RESULTS_AS_SITE_QUERY",
+ },
+ {
+ value: Ci.nsINavHistoryQueryOptions.RESULTS_AS_DATE_SITE_QUERY,
+ name: "RESULTS_AS_DATE_SITE_QUERY",
+ },
+ {
+ value: Ci.nsINavHistoryQueryOptions.RESULTS_AS_TAGS_ROOT,
+ name: "RESULTS_AS_TAGS_ROOT",
+ },
+];
+
+var sortingModes = [
+ {
+ value: Ci.nsINavHistoryQueryOptions.SORT_BY_TITLE_ASCENDING,
+ name: "SORT_BY_TITLE_ASCENDING",
+ },
+ {
+ value: Ci.nsINavHistoryQueryOptions.SORT_BY_TITLE_DESCENDING,
+ name: "SORT_BY_TITLE_DESCENDING",
+ },
+ {
+ value: Ci.nsINavHistoryQueryOptions.SORT_BY_DATE_ASCENDING,
+ name: "SORT_BY_DATE_ASCENDING",
+ },
+ {
+ value: Ci.nsINavHistoryQueryOptions.SORT_BY_DATE_DESCENDING,
+ name: "SORT_BY_DATE_DESCENDING",
+ },
+ {
+ value: Ci.nsINavHistoryQueryOptions.SORT_BY_DATEADDED_ASCENDING,
+ name: "SORT_BY_DATEADDED_ASCENDING",
+ },
+ {
+ value: Ci.nsINavHistoryQueryOptions.SORT_BY_DATEADDED_DESCENDING,
+ name: "SORT_BY_DATEADDED_DESCENDING",
+ },
+];
+
+// These pages will be added from newest to oldest and from less visited to most
+// visited.
+var pages = [
+ "http://www.mozilla.org/c/",
+ "http://www.mozilla.org/a/",
+ "http://www.mozilla.org/b/",
+ "http://www.mozilla.com/c/",
+ "http://www.mozilla.com/a/",
+ "http://www.mozilla.com/b/",
+];
+
+var tags = ["mozilla", "Development", "test"];
+
+// Test Runner
+
+/**
+ * Enumerates all the sequences of the cartesian product of the arrays contained
+ * in aSequences. Examples:
+ *
+ * cartProd([[1, 2, 3], ["a", "b"]], callback);
+ * // callback is called 3 * 2 = 6 times with the following arrays:
+ * // [1, "a"], [1, "b"], [2, "a"], [2, "b"], [3, "a"], [3, "b"]
+ *
+ * cartProd([["a"], [1, 2, 3], ["X", "Y"]], callback);
+ * // callback is called 1 * 3 * 2 = 6 times with the following arrays:
+ * // ["a", 1, "X"], ["a", 1, "Y"], ["a", 2, "X"], ["a", 2, "Y"],
+ * // ["a", 3, "X"], ["a", 3, "Y"]
+ *
+ * cartProd([[1], [2], [3], [4]], callback);
+ * // callback is called 1 * 1 * 1 * 1 = 1 time with the following array:
+ * // [1, 2, 3, 4]
+ *
+ * cartProd([], callback);
+ * // callback is 0 times
+ *
+ * cartProd([[1, 2, 3, 4]], callback);
+ * // callback is called 4 times with the following arrays:
+ * // [1], [2], [3], [4]
+ *
+ * @param aSequences
+ * an array that contains an arbitrary number of arrays
+ * @param aCallback
+ * a function that is passed each sequence of the product as it's
+ * computed
+ * @return the total number of sequences in the product
+ */
+function cartProd(aSequences, aCallback) {
+ if (aSequences.length === 0) {
+ return 0;
+ }
+
+ // For each sequence in aSequences, we maintain a pointer (an array index,
+ // really) to the element we're currently enumerating in that sequence
+ var seqEltPtrs = aSequences.map(i => 0);
+
+ var numProds = 0;
+ var done = false;
+ while (!done) {
+ numProds++;
+
+ // prod = sequence in product we're currently enumerating
+ var prod = [];
+ for (var i = 0; i < aSequences.length; i++) {
+ prod.push(aSequences[i][seqEltPtrs[i]]);
+ }
+ aCallback(prod);
+
+ // The next sequence in the product differs from the current one by just a
+ // single element. Determine which element that is. We advance the
+ // "rightmost" element pointer to the "right" by one. If we move past the
+ // end of that pointer's sequence, reset the pointer to the first element
+ // in its sequence and then try the sequence to the "left", and so on.
+
+ // seqPtr = index of rightmost input sequence whose element pointer is not
+ // past the end of the sequence
+ var seqPtr = aSequences.length - 1;
+ while (!done) {
+ // Advance the rightmost element pointer.
+ seqEltPtrs[seqPtr]++;
+
+ // The rightmost element pointer is past the end of its sequence.
+ if (seqEltPtrs[seqPtr] >= aSequences[seqPtr].length) {
+ seqEltPtrs[seqPtr] = 0;
+ seqPtr--;
+
+ // All element pointers are past the ends of their sequences.
+ if (seqPtr < 0) {
+ done = true;
+ }
+ } else {
+ break;
+ }
+ }
+ }
+ return numProds;
+}
+
+/**
+ * Test a query based on passed-in options.
+ *
+ * @param aSequence
+ * array of options we will use to query.
+ */
+function test_query_callback(aSequence) {
+ Assert.equal(aSequence.length, 2);
+ var resultType = aSequence[0];
+ var sortingMode = aSequence[1];
+ print(
+ "\n\n*** Testing default sorting for resultType (" +
+ resultType.name +
+ ") and sortingMode (" +
+ sortingMode.name +
+ ")"
+ );
+
+ // Skip invalid combinations sorting queries by none.
+ if (
+ resultType.value == Ci.nsINavHistoryQueryOptions.RESULTS_AS_TAGS_ROOT &&
+ (sortingMode.value == Ci.nsINavHistoryQueryOptions.SORT_BY_DATE_ASCENDING ||
+ sortingMode.value == Ci.nsINavHistoryQueryOptions.SORT_BY_DATE_DESCENDING)
+ ) {
+ // This is a bookmark query, we can't sort by visit date.
+ sortingMode.value = Ci.nsINavHistoryQueryOptions.SORT_BY_NONE;
+ }
+ if (
+ resultType.value == Ci.nsINavHistoryQueryOptions.RESULTS_AS_DATE_QUERY ||
+ resultType.value == Ci.nsINavHistoryQueryOptions.RESULTS_AS_SITE_QUERY ||
+ resultType.value == Ci.nsINavHistoryQueryOptions.RESULTS_AS_DATE_SITE_QUERY
+ ) {
+ // This is an history query, we can't sort by date added.
+ if (
+ sortingMode.value ==
+ Ci.nsINavHistoryQueryOptions.SORT_BY_DATEADDED_ASCENDING ||
+ sortingMode.value ==
+ Ci.nsINavHistoryQueryOptions.SORT_BY_DATEADDED_DESCENDING
+ ) {
+ sortingMode.value = Ci.nsINavHistoryQueryOptions.SORT_BY_NONE;
+ }
+ }
+
+ // Create a new query with required options.
+ var query = PlacesUtils.history.getNewQuery();
+ var options = PlacesUtils.history.getNewQueryOptions();
+ options.resultType = resultType.value;
+ options.sortingMode = sortingMode.value;
+
+ // Compare resultset with expectedData.
+ var result = PlacesUtils.history.executeQuery(query, options);
+ var root = result.root;
+ root.containerOpen = true;
+
+ if (
+ resultType.value == Ci.nsINavHistoryQueryOptions.RESULTS_AS_DATE_QUERY ||
+ resultType.value == Ci.nsINavHistoryQueryOptions.RESULTS_AS_DATE_SITE_QUERY
+ ) {
+ // Date containers are always sorted by date descending.
+ check_children_sorting(
+ root,
+ Ci.nsINavHistoryQueryOptions.SORT_BY_DATE_DESCENDING
+ );
+ } else {
+ check_children_sorting(root, sortingMode.value);
+ }
+
+ // Now Check sorting of the first child container.
+ var container = root
+ .getChild(0)
+ .QueryInterface(Ci.nsINavHistoryContainerResultNode);
+ container.containerOpen = true;
+
+ if (
+ resultType.value == Ci.nsINavHistoryQueryOptions.RESULTS_AS_DATE_SITE_QUERY
+ ) {
+ // Has more than one level of containers, first we check the sorting of
+ // the first level (site containers), those won't inherit sorting...
+ check_children_sorting(
+ container,
+ Ci.nsINavHistoryQueryOptions.SORT_BY_TITLE_ASCENDING
+ );
+ // ...then we check sorting of the contained urls, we can't inherit sorting
+ // since the above level does not inherit it, so they will be sorted by
+ // title ascending.
+ let innerContainer = container
+ .getChild(0)
+ .QueryInterface(Ci.nsINavHistoryContainerResultNode);
+ innerContainer.containerOpen = true;
+ check_children_sorting(
+ innerContainer,
+ Ci.nsINavHistoryQueryOptions.SORT_BY_TITLE_ASCENDING
+ );
+ innerContainer.containerOpen = false;
+ } else if (
+ resultType.value == Ci.nsINavHistoryQueryOptions.RESULTS_AS_TAGS_ROOT
+ ) {
+ // Sorting mode for tag contents is hardcoded for now, to allow for faster
+ // duplicates filtering.
+ check_children_sorting(
+ container,
+ Ci.nsINavHistoryQueryOptions.SORT_BY_NONE
+ );
+ } else {
+ check_children_sorting(container, sortingMode.value);
+ }
+
+ container.containerOpen = false;
+ root.containerOpen = false;
+
+ test_result_sortingMode_change(result, resultType, sortingMode);
+}
+
+/**
+ * Sets sortingMode on aResult and checks for correct sorting of children.
+ * Containers should not change their sorting, while contained uri nodes should.
+ *
+ * @param aResult
+ * nsINavHistoryResult generated by our query.
+ * @param aResultType
+ * required result type.
+ * @param aOriginalSortingMode
+ * the sorting mode from query's options.
+ */
+function test_result_sortingMode_change(
+ aResult,
+ aResultType,
+ aOriginalSortingMode
+) {
+ var root = aResult.root;
+ // Now we set sortingMode on the result and check that containers are not
+ // sorted while children are.
+ sortingModes.forEach(function sortingModeChecker(aForcedSortingMode) {
+ print(
+ "\n* Test setting sortingMode (" +
+ aForcedSortingMode.name +
+ ") " +
+ "on result with resultType (" +
+ aResultType.name +
+ ") " +
+ "currently sorted as (" +
+ aOriginalSortingMode.name +
+ ")"
+ );
+
+ aResult.sortingMode = aForcedSortingMode.value;
+ root.containerOpen = true;
+
+ if (
+ aResultType.value == Ci.nsINavHistoryQueryOptions.RESULTS_AS_DATE_QUERY ||
+ aResultType.value ==
+ Ci.nsINavHistoryQueryOptions.RESULTS_AS_DATE_SITE_QUERY
+ ) {
+ // Date containers are always sorted by date descending.
+ check_children_sorting(
+ root,
+ Ci.nsINavHistoryQueryOptions.SORT_BY_DATE_DESCENDING
+ );
+ } else if (
+ aResultType.value == Ci.nsINavHistoryQueryOptions.RESULTS_AS_SITE_QUERY &&
+ (aOriginalSortingMode.value ==
+ Ci.nsINavHistoryQueryOptions.SORT_BY_DATE_ASCENDING ||
+ aOriginalSortingMode.value ==
+ Ci.nsINavHistoryQueryOptions.SORT_BY_DATE_DESCENDING)
+ ) {
+ // Site containers don't have a good time property to sort by.
+ check_children_sorting(root, Ci.nsINavHistoryQueryOptions.SORT_BY_NONE);
+ } else {
+ check_children_sorting(root, aOriginalSortingMode.value);
+ }
+
+ // Now Check sorting of the first child container.
+ var container = root
+ .getChild(0)
+ .QueryInterface(Ci.nsINavHistoryContainerResultNode);
+ container.containerOpen = true;
+
+ if (
+ aResultType.value ==
+ Ci.nsINavHistoryQueryOptions.RESULTS_AS_DATE_SITE_QUERY
+ ) {
+ // Has more than one level of containers, first we check the sorting of
+ // the first level (site containers), those won't be sorted...
+ check_children_sorting(
+ container,
+ Ci.nsINavHistoryQueryOptions.SORT_BY_TITLE_ASCENDING
+ );
+ // ...then we check sorting of the second level of containers, result
+ // will sort them through recursiveSort.
+ let innerContainer = container
+ .getChild(0)
+ .QueryInterface(Ci.nsINavHistoryContainerResultNode);
+ innerContainer.containerOpen = true;
+ check_children_sorting(innerContainer, aForcedSortingMode.value);
+ innerContainer.containerOpen = false;
+ } else {
+ if (
+ aResultType.value ==
+ Ci.nsINavHistoryQueryOptions.RESULTS_AS_DATE_QUERY ||
+ aResultType.value ==
+ Ci.nsINavHistoryQueryOptions.RESULTS_AS_DATE_SITE_QUERY ||
+ aResultType.value == Ci.nsINavHistoryQueryOptions.RESULTS_AS_SITE_QUERY
+ ) {
+ // Date containers are always sorted by date descending.
+ check_children_sorting(root, Ci.nsINavHistoryQueryOptions.SORT_BY_NONE);
+ } else {
+ check_children_sorting(root, aOriginalSortingMode.value);
+ }
+
+ // Children should always be sorted.
+ check_children_sorting(container, aForcedSortingMode.value);
+ }
+
+ container.containerOpen = false;
+ root.containerOpen = false;
+ });
+}
+
+/**
+ * Test if children of aRootNode are correctly sorted.
+ * @param aRootNode
+ * already opened root node from our query's result.
+ * @param aExpectedSortingMode
+ * The sortingMode we expect results to be.
+ */
+function check_children_sorting(aRootNode, aExpectedSortingMode) {
+ var results = [];
+ print("Found children:");
+ for (let i = 0; i < aRootNode.childCount; i++) {
+ results[i] = aRootNode.getChild(i);
+ print(i + " " + results[i].title);
+ }
+
+ // Helper for case insensitive string comparison.
+ function caseInsensitiveStringComparator(a, b) {
+ var aLC = a.toLowerCase();
+ var bLC = b.toLowerCase();
+ if (aLC < bLC) {
+ return -1;
+ }
+ if (aLC > bLC) {
+ return 1;
+ }
+ return 0;
+ }
+
+ // Get a comparator based on expected sortingMode.
+ var comparator;
+ switch (aExpectedSortingMode) {
+ case Ci.nsINavHistoryQueryOptions.SORT_BY_NONE:
+ comparator = function (a, b) {
+ return 0;
+ };
+ break;
+ case Ci.nsINavHistoryQueryOptions.SORT_BY_TITLE_ASCENDING:
+ comparator = function (a, b) {
+ return caseInsensitiveStringComparator(a.title, b.title);
+ };
+ break;
+ case Ci.nsINavHistoryQueryOptions.SORT_BY_TITLE_DESCENDING:
+ comparator = function (a, b) {
+ return -caseInsensitiveStringComparator(a.title, b.title);
+ };
+ break;
+ case Ci.nsINavHistoryQueryOptions.SORT_BY_DATE_ASCENDING:
+ comparator = function (a, b) {
+ return a.time - b.time;
+ };
+ break;
+ case Ci.nsINavHistoryQueryOptions.SORT_BY_DATE_DESCENDING:
+ comparator = function (a, b) {
+ return b.time - a.time;
+ };
+ // fall through - we shouldn't do this, see bug 1572437.
+ case Ci.nsINavHistoryQueryOptions.SORT_BY_DATEADDED_ASCENDING:
+ comparator = function (a, b) {
+ return a.dateAdded - b.dateAdded;
+ };
+ break;
+ case Ci.nsINavHistoryQueryOptions.SORT_BY_DATEADDED_DESCENDING:
+ comparator = function (a, b) {
+ return b.dateAdded - a.dateAdded;
+ };
+ break;
+ default:
+ do_throw("Unknown sorting type: " + aExpectedSortingMode);
+ }
+
+ // Make an independent copy of the results array and sort it.
+ var sortedResults = results.slice();
+ sortedResults.sort(comparator);
+ // Actually compare returned children with our sorted array.
+ for (let i = 0; i < sortedResults.length; i++) {
+ if (sortedResults[i].title != results[i].title) {
+ print(
+ i +
+ " index wrong, expected " +
+ sortedResults[i].title +
+ " found " +
+ results[i].title
+ );
+ }
+ Assert.equal(sortedResults[i].title, results[i].title);
+ }
+}
+
+// Main
+
+add_task(async function test_containersQueries_sorting() {
+ // Add visits, bookmarks and tags to our database.
+ var timeInMilliseconds = Date.now();
+ var visitCount = 0;
+ var dayOffset = 0;
+ var visits = [];
+ pages.forEach(aPageUrl =>
+ visits.push({
+ isVisit: true,
+ isBookmark: true,
+ transType: Ci.nsINavHistoryService.TRANSITION_TYPED,
+ uri: aPageUrl,
+ title: aPageUrl,
+ // subtract 5 hours per iteration, to expose more than one day container.
+ lastVisit: (timeInMilliseconds - 18000 * 1000 * dayOffset++) * 1000,
+ visitCount: visitCount++,
+ isTag: true,
+ tagArray: tags,
+ isInQuery: true,
+ })
+ );
+ await task_populateDB(visits);
+
+ cartProd([resultTypes, sortingModes], test_query_callback);
+});
diff --git a/toolkit/components/places/tests/queries/test_downloadHistory_liveUpdate.js b/toolkit/components/places/tests/queries/test_downloadHistory_liveUpdate.js
new file mode 100644
index 0000000000..ba0f528b62
--- /dev/null
+++ b/toolkit/components/places/tests/queries/test_downloadHistory_liveUpdate.js
@@ -0,0 +1,121 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+// This test ensures that download history (filtered by transition) queries
+// don't invalidate (and requery) too often.
+
+function accumulateNotifications(result) {
+ let notifications = [];
+ let resultObserver = new Proxy(NavHistoryResultObserver, {
+ get(target, name) {
+ if (name == "check") {
+ result.removeObserver(resultObserver, false);
+ return expectedNotifications =>
+ Assert.deepEqual(notifications, expectedNotifications);
+ }
+ // ignore a few uninteresting notifications.
+ if (["QueryInterface", "containerStateChanged"].includes(name)) {
+ return () => {};
+ }
+ return () => {
+ notifications.push(name);
+ };
+ },
+ });
+ result.addObserver(resultObserver, false);
+ return resultObserver;
+}
+
+add_task(async function test_downloadhistory_query_notifications() {
+ const MAX_RESULTS = 5;
+ let query = PlacesUtils.history.getNewQuery();
+ query.setTransitions([PlacesUtils.history.TRANSITIONS.DOWNLOAD]);
+ let options = PlacesUtils.history.getNewQueryOptions();
+ options.sortingMode = Ci.nsINavHistoryQueryOptions.SORT_BY_DATE_DESCENDING;
+ options.maxResults = MAX_RESULTS;
+ let result = PlacesUtils.history.executeQuery(query, options);
+ let notifications = accumulateNotifications(result);
+ let root = PlacesUtils.asContainer(result.root);
+ root.containerOpen = true;
+ // Add more maxResults downloads in order.
+ let transitions = Object.values(PlacesUtils.history.TRANSITIONS);
+ for (let transition of transitions) {
+ let uri = "http://fx-search.com/" + transition;
+ await PlacesTestUtils.addVisits({
+ uri,
+ transition,
+ title: "test " + transition,
+ });
+ // For each visit also set apart:
+ // - a bookmark
+ // - an annotation
+ // - an icon
+ await PlacesUtils.bookmarks.insert({
+ url: uri,
+ parentGuid: PlacesUtils.bookmarks.unfiledGuid,
+ });
+ await PlacesUtils.history.update({
+ url: uri,
+ annotations: new Map([["test/anno", "testValue"]]),
+ });
+ await PlacesTestUtils.addFavicons(new Map([[uri, SMALLPNG_DATA_URI.spec]]));
+ }
+ // Remove all the visits one by one.
+ for (let transition of transitions) {
+ let uri = Services.io.newURI("http://fx-search.com/" + transition);
+ await PlacesUtils.history.remove(uri);
+ }
+ root.containerOpen = false;
+ // We pretty much don't want to see invalidateContainer here, because that
+ // means we requeried.
+ // We also don't want to see changes caused by filtered-out transition types.
+ notifications.check([
+ "nodeHistoryDetailsChanged",
+ "nodeInserted",
+ "nodeTitleChanged",
+ "nodeIconChanged",
+ "nodeRemoved",
+ ]);
+});
+
+add_task(async function test_downloadhistory_query_filtering() {
+ const MAX_RESULTS = 3;
+ let query = PlacesUtils.history.getNewQuery();
+ query.setTransitions([PlacesUtils.history.TRANSITIONS.DOWNLOAD]);
+ let options = PlacesUtils.history.getNewQueryOptions();
+ options.sortingMode = Ci.nsINavHistoryQueryOptions.SORT_BY_DATE_DESCENDING;
+ options.maxResults = MAX_RESULTS;
+ let result = PlacesUtils.history.executeQuery(query, options);
+ let root = PlacesUtils.asContainer(result.root);
+ root.containerOpen = true;
+ Assert.equal(root.childCount, 0, "No visits found");
+ // Add more than maxResults downloads.
+ let uris = [];
+ // Define a monotonic visit date to ensure results order stability.
+ let visitDate = Date.now() * 1000;
+ for (let i = 0; i < MAX_RESULTS + 1; ++i, visitDate += 1000) {
+ let uri = `http://fx-search.com/download/${i}`;
+ await PlacesTestUtils.addVisits({
+ uri,
+ transition: PlacesUtils.history.TRANSITIONS.DOWNLOAD,
+ visitDate,
+ });
+ uris.push(uri);
+ }
+ // Add an older download visit out of the maxResults timeframe.
+ await PlacesTestUtils.addVisits({
+ uri: `http://fx-search.com/download/unordered`,
+ transition: PlacesUtils.history.TRANSITIONS.DOWNLOAD,
+ visitDate: new Date(Date.now() - 7200000),
+ });
+
+ Assert.equal(root.childCount, MAX_RESULTS, "Result should be limited");
+ // Invert the uris array because we are sorted by date descending.
+ uris.reverse();
+ for (let i = 0; i < root.childCount; ++i) {
+ let node = root.getChild(i);
+ Assert.equal(node.uri, uris[i], "Found the expected uri");
+ }
+
+ root.containerOpen = false;
+});
diff --git a/toolkit/components/places/tests/queries/test_excludeQueries.js b/toolkit/components/places/tests/queries/test_excludeQueries.js
new file mode 100644
index 0000000000..c48f84c7f4
--- /dev/null
+++ b/toolkit/components/places/tests/queries/test_excludeQueries.js
@@ -0,0 +1,118 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+var bm;
+var fakeQuery;
+var folderShortcut;
+
+add_task(async function setup() {
+ await PlacesUtils.bookmarks.eraseEverything();
+
+ bm = await PlacesUtils.bookmarks.insert({
+ parentGuid: PlacesUtils.bookmarks.unfiledGuid,
+ url: "http://example.com/",
+ title: "a bookmark",
+ });
+ fakeQuery = await PlacesUtils.bookmarks.insert({
+ parentGuid: PlacesUtils.bookmarks.unfiledGuid,
+ url: "place:terms=foo",
+ title: "a bookmark",
+ });
+ folderShortcut = await PlacesUtils.bookmarks.insert({
+ parentGuid: PlacesUtils.bookmarks.unfiledGuid,
+ url: `place:parent=${PlacesUtils.bookmarks.toolbarGuid}`,
+ title: "a bookmark",
+ });
+
+ checkBookmarkObject(bm);
+ checkBookmarkObject(fakeQuery);
+ checkBookmarkObject(folderShortcut);
+});
+
+add_task(async function test_bookmarks_url_query_implicit_exclusions() {
+ // When we run bookmarks url queries, we implicity filter out queries and
+ // folder shortcuts regardless of excludeQueries. They don't make sense to
+ // include in the results.
+ let expectedGuids = [bm.guid];
+ let query = PlacesUtils.history.getNewQuery();
+ let options = PlacesUtils.history.getNewQueryOptions();
+ options.excludeQueries = true;
+ options.queryType = Ci.nsINavHistoryQueryOptions.QUERY_TYPE_BOOKMARKS;
+
+ let root = PlacesUtils.history.executeQuery(query, options).root;
+ root.containerOpen = true;
+
+ Assert.equal(
+ root.childCount,
+ expectedGuids.length,
+ "Checking root child count"
+ );
+ for (let i = 0; i < expectedGuids.length; i++) {
+ Assert.equal(
+ root.getChild(i).bookmarkGuid,
+ expectedGuids[i],
+ "should have got the expected item"
+ );
+ }
+
+ root.containerOpen = false;
+});
+
+add_task(async function test_bookmarks_excludeQueries() {
+ // When excluding queries, we exclude actual queries, but not folder shortcuts.
+ let expectedGuids = [bm.guid, folderShortcut.guid];
+ let query = {},
+ options = {};
+ let queryString = `place:parent=${PlacesUtils.bookmarks.unfiledGuid}&excludeQueries=1`;
+ PlacesUtils.history.queryStringToQuery(queryString, query, options);
+
+ let root = PlacesUtils.history.executeQuery(query.value, options.value).root;
+ root.containerOpen = true;
+
+ Assert.equal(
+ root.childCount,
+ expectedGuids.length,
+ "Checking root child count"
+ );
+ for (let i = 0; i < expectedGuids.length; i++) {
+ Assert.equal(
+ root.getChild(i).bookmarkGuid,
+ expectedGuids[i],
+ "should have got the expected item"
+ );
+ }
+
+ root.containerOpen = false;
+});
+
+add_task(async function test_search_excludesQueries() {
+ // Searching implicity removes queries and folder shortcuts even if excludeQueries
+ // is not specified.
+ let expectedGuids = [bm.guid];
+
+ let query = PlacesUtils.history.getNewQuery();
+ query.searchTerms = "bookmark";
+
+ let options = PlacesUtils.history.getNewQueryOptions();
+ options.queryType = Ci.nsINavHistoryQueryOptions.QUERY_TYPE_BOOKMARKS;
+
+ let root = PlacesUtils.history.executeQuery(query, options).root;
+ root.containerOpen = true;
+
+ Assert.equal(
+ root.childCount,
+ expectedGuids.length,
+ "Checking root child count"
+ );
+ for (let i = 0; i < expectedGuids.length; i++) {
+ Assert.equal(
+ root.getChild(i).bookmarkGuid,
+ expectedGuids[i],
+ "should have got the expected item"
+ );
+ }
+
+ root.containerOpen = false;
+});
diff --git a/toolkit/components/places/tests/queries/test_history_queries_tags_liveUpdate.js b/toolkit/components/places/tests/queries/test_history_queries_tags_liveUpdate.js
new file mode 100644
index 0000000000..aef45ff8f1
--- /dev/null
+++ b/toolkit/components/places/tests/queries/test_history_queries_tags_liveUpdate.js
@@ -0,0 +1,131 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+// This test ensures that tags changes are correctly live-updated in a history
+// query.
+
+let timeInMicroseconds = PlacesUtils.toPRTime(Date.now() - 10000);
+
+function newTimeInMicroseconds() {
+ timeInMicroseconds = timeInMicroseconds + 1000;
+ return timeInMicroseconds;
+}
+
+var gTestData = [
+ {
+ isVisit: true,
+ uri: "http://example.com/1/",
+ lastVisit: newTimeInMicroseconds(),
+ isInQuery: true,
+ isBookmark: true,
+ parentGuid: PlacesUtils.bookmarks.unfiledGuid,
+ index: PlacesUtils.bookmarks.DEFAULT_INDEX,
+ title: "example1",
+ },
+ {
+ isVisit: true,
+ uri: "http://example.com/2/",
+ lastVisit: newTimeInMicroseconds(),
+ isInQuery: true,
+ isBookmark: true,
+ parentGuid: PlacesUtils.bookmarks.unfiledGuid,
+ index: PlacesUtils.bookmarks.DEFAULT_INDEX,
+ title: "example2",
+ },
+ {
+ isVisit: true,
+ uri: "http://example.com/3/",
+ lastVisit: newTimeInMicroseconds(),
+ isInQuery: true,
+ isBookmark: true,
+ parentGuid: PlacesUtils.bookmarks.unfiledGuid,
+ index: PlacesUtils.bookmarks.DEFAULT_INDEX,
+ title: "example3",
+ },
+];
+
+function newQueryWithOptions() {
+ return [
+ PlacesUtils.history.getNewQuery(),
+ PlacesUtils.history.getNewQueryOptions(),
+ ];
+}
+
+function testQueryContents(aQuery, aOptions, aCallback) {
+ let root = PlacesUtils.history.executeQuery(aQuery, aOptions).root;
+ root.containerOpen = true;
+ aCallback(root);
+ root.containerOpen = false;
+}
+
+add_task(async function test_initialize() {
+ await task_populateDB(gTestData);
+});
+
+add_task(function pages_query() {
+ let [query, options] = newQueryWithOptions();
+ options.queryType = Ci.nsINavHistoryQueryOptions.QUERY_TYPE_BOOKMARKS;
+ testQueryContents(query, options, function (root) {
+ compareArrayToResult([gTestData[0], gTestData[1], gTestData[2]], root);
+ for (let i = 0; i < root.childCount; i++) {
+ let node = root.getChild(i);
+ let uri = NetUtil.newURI(node.uri);
+ Assert.equal(node.tags, null);
+ PlacesUtils.tagging.tagURI(uri, ["test-tag"]);
+ Assert.equal(node.tags, "test-tag");
+ PlacesUtils.tagging.untagURI(uri, ["test-tag"]);
+ Assert.equal(node.tags, null);
+ }
+ });
+});
+
+add_task(function visits_query() {
+ let [query, options] = newQueryWithOptions();
+ options.queryType = Ci.nsINavHistoryQueryOptions.QUERY_TYPE_BOOKMARKS;
+ options.resultType = Ci.nsINavHistoryQueryOptions.RESULTS_AS_VISIT;
+ testQueryContents(query, options, function (root) {
+ compareArrayToResult([gTestData[0], gTestData[1], gTestData[2]], root);
+ for (let i = 0; i < root.childCount; i++) {
+ let node = root.getChild(i);
+ let uri = NetUtil.newURI(node.uri);
+ Assert.equal(node.tags, null);
+ PlacesUtils.tagging.tagURI(uri, ["test-tag"]);
+ Assert.equal(node.tags, "test-tag");
+ PlacesUtils.tagging.untagURI(uri, ["test-tag"]);
+ Assert.equal(node.tags, null);
+ }
+ });
+});
+
+add_task(function bookmark_parent_query() {
+ let [query, options] = newQueryWithOptions();
+ query.setParents([PlacesUtils.bookmarks.unfiledGuid]);
+ testQueryContents(query, options, function (root) {
+ compareArrayToResult([gTestData[0], gTestData[1], gTestData[2]], root);
+ for (let i = 0; i < root.childCount; i++) {
+ let node = root.getChild(i);
+ let uri = NetUtil.newURI(node.uri);
+ Assert.equal(node.tags, null);
+ PlacesUtils.tagging.tagURI(uri, ["test-tag"]);
+ Assert.equal(node.tags, "test-tag");
+ PlacesUtils.tagging.untagURI(uri, ["test-tag"]);
+ Assert.equal(node.tags, null);
+ }
+ });
+});
+
+add_task(function history_query() {
+ let [query, options] = newQueryWithOptions();
+ testQueryContents(query, options, function (root) {
+ compareArrayToResult([gTestData[0], gTestData[1], gTestData[2]], root);
+ for (let i = 0; i < root.childCount; i++) {
+ let node = root.getChild(i);
+ let uri = NetUtil.newURI(node.uri);
+ Assert.equal(node.tags, null);
+ PlacesUtils.tagging.tagURI(uri, ["test-tag"]);
+ Assert.equal(node.tags, null);
+ PlacesUtils.tagging.untagURI(uri, ["test-tag"]);
+ Assert.equal(node.tags, null);
+ }
+ });
+});
diff --git a/toolkit/components/places/tests/queries/test_history_queries_titles_liveUpdate.js b/toolkit/components/places/tests/queries/test_history_queries_titles_liveUpdate.js
new file mode 100644
index 0000000000..ac3931892f
--- /dev/null
+++ b/toolkit/components/places/tests/queries/test_history_queries_titles_liveUpdate.js
@@ -0,0 +1,217 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+// This test ensures that tags changes are correctly live-updated in a history
+// query.
+
+let timeInMicroseconds = PlacesUtils.toPRTime(Date.now() - 10000);
+
+function newTimeInMicroseconds() {
+ timeInMicroseconds = timeInMicroseconds + 1000;
+ return timeInMicroseconds;
+}
+
+var gTestData = [
+ {
+ isVisit: true,
+ uri: "http://example.com/1/",
+ lastVisit: newTimeInMicroseconds(),
+ isInQuery: true,
+ title: "title1",
+ },
+ {
+ isVisit: true,
+ uri: "http://example.com/2/",
+ lastVisit: newTimeInMicroseconds(),
+ isInQuery: true,
+ title: "title2",
+ },
+ {
+ isVisit: true,
+ uri: "http://example.com/3/",
+ lastVisit: newTimeInMicroseconds(),
+ isInQuery: true,
+ title: "title3",
+ },
+];
+
+function searchNodeHavingUrl(aRoot, aUrl) {
+ for (let i = 0; i < aRoot.childCount; i++) {
+ if (aRoot.getChild(i).uri == aUrl) {
+ return aRoot.getChild(i);
+ }
+ }
+ return undefined;
+}
+
+function newQueryWithOptions() {
+ return [
+ PlacesUtils.history.getNewQuery(),
+ PlacesUtils.history.getNewQueryOptions(),
+ ];
+}
+
+add_task(async function pages_query() {
+ await task_populateDB(gTestData);
+
+ let [query, options] = newQueryWithOptions();
+ let root = PlacesUtils.history.executeQuery(query, options).root;
+ root.containerOpen = true;
+
+ compareArrayToResult([gTestData[0], gTestData[1], gTestData[2]], root);
+ for (let i = 0; i < root.childCount; i++) {
+ let node = root.getChild(i);
+ Assert.equal(node.title, gTestData[i].title);
+ let uri = NetUtil.newURI(node.uri);
+ await PlacesTestUtils.addVisits({ uri, title: "changedTitle" });
+ Assert.equal(node.title, "changedTitle");
+ await PlacesTestUtils.addVisits({ uri, title: gTestData[i].title });
+ Assert.equal(node.title, gTestData[i].title);
+ }
+
+ root.containerOpen = false;
+ await PlacesUtils.history.clear();
+});
+
+add_task(async function visits_query() {
+ await task_populateDB(gTestData);
+
+ let [query, options] = newQueryWithOptions();
+ options.resultType = Ci.nsINavHistoryQueryOptions.RESULTS_AS_VISIT;
+ let root = PlacesUtils.history.executeQuery(query, options).root;
+ root.containerOpen = true;
+
+ compareArrayToResult([gTestData[0], gTestData[1], gTestData[2]], root);
+
+ for (let testData of gTestData) {
+ let uri = NetUtil.newURI(testData.uri);
+ let node = searchNodeHavingUrl(root, testData.uri);
+ Assert.equal(node.title, testData.title);
+ await PlacesTestUtils.addVisits({ uri, title: "changedTitle" });
+ node = searchNodeHavingUrl(root, testData.uri);
+ Assert.equal(node.title, "changedTitle");
+ await PlacesTestUtils.addVisits({ uri, title: testData.title });
+ node = searchNodeHavingUrl(root, testData.uri);
+ Assert.equal(node.title, testData.title);
+ }
+
+ root.containerOpen = false;
+ await PlacesUtils.history.clear();
+});
+
+add_task(async function pages_searchterm_query() {
+ await task_populateDB(gTestData);
+
+ let [query, options] = newQueryWithOptions();
+ query.searchTerms = "example";
+ let root = PlacesUtils.history.executeQuery(query, options).root;
+ root.containerOpen = true;
+
+ compareArrayToResult([gTestData[0], gTestData[1], gTestData[2]], root);
+ for (let i = 0; i < root.childCount; i++) {
+ let node = root.getChild(i);
+ let uri = NetUtil.newURI(node.uri);
+ Assert.equal(node.title, gTestData[i].title);
+ await PlacesTestUtils.addVisits({ uri, title: "changedTitle" });
+ Assert.equal(node.title, "changedTitle");
+ await PlacesTestUtils.addVisits({ uri, title: gTestData[i].title });
+ Assert.equal(node.title, gTestData[i].title);
+ }
+
+ root.containerOpen = false;
+ await PlacesUtils.history.clear();
+});
+
+add_task(async function visits_searchterm_query() {
+ await task_populateDB(gTestData);
+
+ let [query, options] = newQueryWithOptions();
+ query.searchTerms = "example";
+ options.resultType = Ci.nsINavHistoryQueryOptions.RESULTS_AS_VISIT;
+ let root = PlacesUtils.history.executeQuery(query, options).root;
+ root.containerOpen = true;
+
+ compareArrayToResult([gTestData[0], gTestData[1], gTestData[2]], root);
+ for (let testData of gTestData) {
+ let uri = NetUtil.newURI(testData.uri);
+ let node = searchNodeHavingUrl(root, testData.uri);
+ Assert.equal(node.title, testData.title);
+ await PlacesTestUtils.addVisits({ uri, title: "changedTitle" });
+ node = searchNodeHavingUrl(root, testData.uri);
+ Assert.equal(node.title, "changedTitle");
+ await PlacesTestUtils.addVisits({ uri, title: testData.title });
+ node = searchNodeHavingUrl(root, testData.uri);
+ Assert.equal(node.title, testData.title);
+ }
+
+ root.containerOpen = false;
+ await PlacesUtils.history.clear();
+});
+
+add_task(async function pages_searchterm_is_title_query() {
+ await task_populateDB(gTestData);
+
+ let [query, options] = newQueryWithOptions();
+ query.searchTerms = "match";
+ let root = PlacesUtils.history.executeQuery(query, options).root;
+ root.containerOpen = true;
+ compareArrayToResult([], root);
+ for (let data of gTestData) {
+ let uri = NetUtil.newURI(data.uri);
+ let origTitle = data.title;
+ data.title = "match";
+ await PlacesTestUtils.addVisits({
+ uri,
+ title: data.title,
+ visitDate: data.lastVisit,
+ });
+ compareArrayToResult([data], root);
+ data.title = origTitle;
+ await PlacesTestUtils.addVisits({
+ uri,
+ title: data.title,
+ visitDate: data.lastVisit,
+ });
+ compareArrayToResult([], root);
+ }
+
+ root.containerOpen = false;
+ await PlacesUtils.history.clear();
+});
+
+add_task(async function visits_searchterm_is_title_query() {
+ await task_populateDB(gTestData);
+
+ let [query, options] = newQueryWithOptions();
+ query.searchTerms = "match";
+ options.resultType = Ci.nsINavHistoryQueryOptions.RESULTS_AS_VISIT;
+ let root = PlacesUtils.history.executeQuery(query, options).root;
+ root.containerOpen = true;
+ compareArrayToResult([], root);
+ for (let data of gTestData) {
+ let uri = NetUtil.newURI(data.uri);
+ let origTitle = data.title;
+ data.title = "match";
+
+ info("Adding " + uri.spec);
+ await PlacesTestUtils.addVisits({
+ uri,
+ title: data.title,
+ visitDate: data.lastVisit,
+ });
+
+ compareArrayToResult([data], root);
+ data.title = origTitle;
+ info("Clobbering " + uri.spec);
+ await PlacesTestUtils.addVisits({
+ uri,
+ title: data.title,
+ visitDate: data.lastVisit,
+ });
+
+ compareArrayToResult([], root);
+ }
+
+ root.containerOpen = false;
+ await PlacesUtils.history.clear();
+});
diff --git a/toolkit/components/places/tests/queries/test_options_inherit.js b/toolkit/components/places/tests/queries/test_options_inherit.js
new file mode 100644
index 0000000000..ae43350eda
--- /dev/null
+++ b/toolkit/components/places/tests/queries/test_options_inherit.js
@@ -0,0 +1,118 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/ */
+
+/**
+ * Tests inheritance of certain query options like:
+ * excludeItems, excludeQueries, expandQueries.
+ */
+
+"use strict";
+
+add_task(async function () {
+ await PlacesUtils.bookmarks.insertTree({
+ guid: PlacesUtils.bookmarks.unfiledGuid,
+ children: [
+ {
+ title: "folder",
+ type: PlacesUtils.bookmarks.TYPE_FOLDER,
+ children: [
+ {
+ title: "query",
+ url:
+ "place:queryType=" +
+ Ci.nsINavHistoryQueryOptions.QUERY_TYPE_BOOKMARKS,
+ },
+ { title: "bm", url: "http://example.com" },
+ {
+ type: PlacesUtils.bookmarks.TYPE_SEPARATOR,
+ },
+ ],
+ },
+ { title: "bm", url: "http://example.com" },
+ {
+ type: PlacesUtils.bookmarks.TYPE_SEPARATOR,
+ },
+ ],
+ });
+
+ await test_query({}, 3, 3, 2);
+ await test_query({ expandQueries: false }, 3, 3, 0);
+ await test_query({ excludeItems: true }, 1, 1, 0);
+ await test_query({ excludeItems: true, expandQueries: false }, 1, 1, 0);
+ await test_query({ excludeItems: true, excludeQueries: true }, 1, 0, 0);
+});
+
+async function test_query(
+ opts,
+ expectedRootCc,
+ expectedFolderCc,
+ expectedQueryCc
+) {
+ let query = PlacesUtils.history.getNewQuery();
+ query.setParents([PlacesUtils.bookmarks.unfiledGuid]);
+ let options = PlacesUtils.history.getNewQueryOptions();
+ for (const [o, v] of Object.entries(opts)) {
+ info(`Setting ${o} to ${v}`);
+ options[o] = v;
+ }
+ let root = PlacesUtils.history.executeQuery(query, options).root;
+ root.containerOpen = true;
+
+ Assert.equal(root.childCount, expectedRootCc, "Checking root child count");
+ if (root.childCount > 0) {
+ let folder = root.getChild(0);
+ Assert.equal(folder.title, "folder", "Found the expected folder");
+
+ // Check the folder uri doesn't reflect the root options, since those
+ // options are inherited and not part of this node declaration.
+ checkURIOptions(folder.uri);
+
+ PlacesUtils.asContainer(folder).containerOpen = true;
+ Assert.equal(
+ folder.childCount,
+ expectedFolderCc,
+ "Checking folder child count"
+ );
+ if (folder.childCount) {
+ let placeQuery = folder.getChild(0);
+ PlacesUtils.asQuery(placeQuery).containerOpen = true;
+ Assert.equal(
+ placeQuery.childCount,
+ expectedQueryCc,
+ "Checking query child count"
+ );
+ placeQuery.containerOpen = false;
+ }
+ folder.containerOpen = false;
+ }
+ let f = await PlacesUtils.bookmarks.insert({
+ parentGuid: PlacesUtils.bookmarks.unfiledGuid,
+ type: PlacesUtils.bookmarks.TYPE_FOLDER,
+ });
+ checkURIOptions(root.getChild(root.childCount - 1).uri);
+ await PlacesUtils.bookmarks.remove(f);
+
+ root.containerOpen = false;
+}
+
+function checkURIOptions(uri) {
+ info("Checking options for uri " + uri);
+ let folderOptions = {};
+ PlacesUtils.history.queryStringToQuery(uri, {}, folderOptions);
+ folderOptions = folderOptions.value;
+ Assert.equal(
+ folderOptions.excludeItems,
+ false,
+ "ExcludeItems should not be changed"
+ );
+ Assert.equal(
+ folderOptions.excludeQueries,
+ false,
+ "ExcludeQueries should not be changed"
+ );
+ Assert.equal(
+ folderOptions.expandQueries,
+ true,
+ "ExpandQueries should not be changed"
+ );
+}
diff --git a/toolkit/components/places/tests/queries/test_queryMultipleFolder.js b/toolkit/components/places/tests/queries/test_queryMultipleFolder.js
new file mode 100644
index 0000000000..7c24bef74e
--- /dev/null
+++ b/toolkit/components/places/tests/queries/test_queryMultipleFolder.js
@@ -0,0 +1,106 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+var folderGuids = [];
+var bookmarkGuids = [];
+
+add_task(async function setup() {
+ // adding bookmarks in the folders
+ for (let i = 0; i < 3; ++i) {
+ let folder = await PlacesUtils.bookmarks.insert({
+ parentGuid: PlacesUtils.bookmarks.menuGuid,
+ type: PlacesUtils.bookmarks.TYPE_FOLDER,
+ title: `Folder${i}`,
+ });
+ folderGuids.push(folder.guid);
+
+ for (let j = 0; j < 7; ++j) {
+ let bm = await PlacesUtils.bookmarks.insert({
+ parentGuid: folderGuids[i],
+ url: `http://Bookmark${i}_${j}.com`,
+ title: "",
+ });
+ bookmarkGuids.push(bm.guid);
+ }
+ }
+});
+
+add_task(async function test_queryMultipleFolders_ids() {
+ // using queryStringToQuery
+ let query = {},
+ options = {};
+ let maxResults = 20;
+ let queryString = `place:${folderGuids
+ .map(guid => "parent=" + guid)
+ .join("&")}&sort=5&maxResults=${maxResults}`;
+ PlacesUtils.history.queryStringToQuery(queryString, query, options);
+ let rootNode = PlacesUtils.history.executeQuery(
+ query.value,
+ options.value
+ ).root;
+ rootNode.containerOpen = true;
+ let resultLength = rootNode.childCount;
+ Assert.equal(resultLength, maxResults);
+ for (let i = 0; i < resultLength; ++i) {
+ let node = rootNode.getChild(i);
+ Assert.equal(bookmarkGuids[i], node.bookmarkGuid, node.uri);
+ }
+ rootNode.containerOpen = false;
+
+ // using getNewQuery and getNewQueryOptions
+ query = PlacesUtils.history.getNewQuery();
+ options = PlacesUtils.history.getNewQueryOptions();
+ query.setParents(folderGuids);
+ options.sortingMode = options.SORT_BY_URI_ASCENDING;
+ options.maxResults = maxResults;
+ rootNode = PlacesUtils.history.executeQuery(query, options).root;
+ rootNode.containerOpen = true;
+ resultLength = rootNode.childCount;
+ Assert.equal(resultLength, maxResults);
+ for (let i = 0; i < resultLength; ++i) {
+ let node = rootNode.getChild(i);
+ Assert.equal(bookmarkGuids[i], node.bookmarkGuid, node.uri);
+ }
+ rootNode.containerOpen = false;
+});
+
+add_task(async function test_queryMultipleFolders_guids() {
+ // using queryStringToQuery
+ let query = {},
+ options = {};
+ let maxResults = 20;
+ let queryString = `place:${folderGuids
+ .map(guid => "parent=" + guid)
+ .join("&")}&sort=5&maxResults=${maxResults}`;
+ PlacesUtils.history.queryStringToQuery(queryString, query, options);
+ let rootNode = PlacesUtils.history.executeQuery(
+ query.value,
+ options.value
+ ).root;
+ rootNode.containerOpen = true;
+ let resultLength = rootNode.childCount;
+ Assert.equal(resultLength, maxResults);
+ for (let i = 0; i < resultLength; ++i) {
+ let node = rootNode.getChild(i);
+ Assert.equal(bookmarkGuids[i], node.bookmarkGuid, node.uri);
+ }
+ rootNode.containerOpen = false;
+
+ // using getNewQuery and getNewQueryOptions
+ query = PlacesUtils.history.getNewQuery();
+ options = PlacesUtils.history.getNewQueryOptions();
+ query.setParents(folderGuids);
+ options.sortingMode = options.SORT_BY_URI_ASCENDING;
+ options.maxResults = maxResults;
+ rootNode = PlacesUtils.history.executeQuery(query, options).root;
+ rootNode.containerOpen = true;
+ resultLength = rootNode.childCount;
+ Assert.equal(resultLength, maxResults);
+ for (let i = 0; i < resultLength; ++i) {
+ let node = rootNode.getChild(i);
+ Assert.equal(bookmarkGuids[i], node.bookmarkGuid, node.uri);
+ }
+ rootNode.containerOpen = false;
+});
diff --git a/toolkit/components/places/tests/queries/test_querySerialization.js b/toolkit/components/places/tests/queries/test_querySerialization.js
new file mode 100644
index 0000000000..4c33854718
--- /dev/null
+++ b/toolkit/components/places/tests/queries/test_querySerialization.js
@@ -0,0 +1,718 @@
+/* -*- indent-tabs-mode: nil; js-indent-level: 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/. */
+
+/**
+ * Tests Places query serialization. Associated bug is
+ * https://bugzilla.mozilla.org/show_bug.cgi?id=370197
+ *
+ * The simple idea behind this test is to try out different combinations of
+ * query switches and ensure that queries are the same before serialization
+ * as they are after de-serialization.
+ *
+ * In the code below, "switch" refers to a query option -- "option" in a broad
+ * sense, not nsINavHistoryQueryOptions specifically (which is why we refer to
+ * them as switches, not options). Both nsINavHistoryQuery and
+ * nsINavHistoryQueryOptions allow you to specify switches that affect query
+ * strings. nsINavHistoryQuery instances have attributes hasBeginTime,
+ * hasEndTime, hasSearchTerms, and so on. nsINavHistoryQueryOptions instances
+ * have attributes sortingMode, resultType, excludeItems, etc.
+ *
+ * Ideally we would like to test all 2^N subsets of switches, where N is the
+ * total number of switches; switches might interact in erroneous or other ways
+ * we do not expect. However, since N is large (21 at this time), that's
+ * impractical for a single test in a suite.
+ *
+ * Instead we choose all possible subsets of a certain, smaller size. In fact
+ * we begin by choosing CHOOSE_HOW_MANY_SWITCHES_LO and ramp up to
+ * CHOOSE_HOW_MANY_SWITCHES_HI.
+ *
+ * There are two more wrinkles. First, for some switches we'd like to be able to
+ * test multiple values. For example, it seems like a good idea to test both an
+ * empty string and a non-empty string for switch nsINavHistoryQuery.searchTerms.
+ * When switches have more than one value for a test run, we use the Cartesian
+ * product of their values to generate all possible combinations of values.
+ *
+ * To summarize, here's how this test works:
+ *
+ * - For n = CHOOSE_HOW_MANY_SWITCHES_LO to CHOOSE_HOW_MANY_SWITCHES_HI:
+ * - From the total set of switches choose all possible subsets of size n.
+ * For each of those subsets s:
+ * - Collect the test runs of each switch in subset s and take their
+ * Cartesian product. For each sequence in the product:
+ * - Create nsINavHistoryQuery and nsINavHistoryQueryOptions objects
+ * with the chosen switches and test run values.
+ * - Serialize the query.
+ * - De-serialize and ensure that the de-serialized query objects equal
+ * the originals.
+ */
+
+const CHOOSE_HOW_MANY_SWITCHES_LO = 1;
+const CHOOSE_HOW_MANY_SWITCHES_HI = 2;
+
+// The switches are represented by objects below, in arrays querySwitches and
+// queryOptionSwitches. Use them to set up test runs.
+//
+// Some switches have special properties (where noted), but all switches must
+// have the following properties:
+//
+// matches: A function that takes two nsINavHistoryQuery objects (in the case
+// of nsINavHistoryQuery switches) or two nsINavHistoryQueryOptions
+// objects (for nsINavHistoryQueryOptions switches) and returns true
+// if the values of the switch in the two objects are equal. This is
+// the foundation of how we determine if two queries are equal.
+// runs: An array of functions. Each function takes an nsINavHistoryQuery
+// object and an nsINavHistoryQueryOptions object. The functions
+// should set the attributes of one of the two objects as appropriate
+// to their switches. This is how switch values are set for each test
+// run.
+//
+// The following properties are optional:
+//
+// desc: An informational string to print out during runs when the switch
+// is chosen. Hopefully helpful if the test fails.
+
+// nsINavHistoryQuery switches
+const querySwitches = [
+ // hasBeginTime
+ {
+ // flag and subswitches are used by the flagSwitchMatches function. Several
+ // of the nsINavHistoryQuery switches (like this one) are really guard flags
+ // that indicate if other "subswitches" are enabled.
+ flag: "hasBeginTime",
+ subswitches: ["beginTime", "beginTimeReference", "absoluteBeginTime"],
+ desc: "nsINavHistoryQuery.hasBeginTime",
+ matches: flagSwitchMatches,
+ runs: [
+ function (aQuery, aQueryOptions) {
+ aQuery.beginTime = Date.now() * 1000;
+ aQuery.beginTimeReference = Ci.nsINavHistoryQuery.TIME_RELATIVE_EPOCH;
+ },
+ function (aQuery, aQueryOptions) {
+ aQuery.beginTime = Date.now() * 1000;
+ aQuery.beginTimeReference = Ci.nsINavHistoryQuery.TIME_RELATIVE_TODAY;
+ },
+ ],
+ },
+ // hasEndTime
+ {
+ flag: "hasEndTime",
+ subswitches: ["endTime", "endTimeReference", "absoluteEndTime"],
+ desc: "nsINavHistoryQuery.hasEndTime",
+ matches: flagSwitchMatches,
+ runs: [
+ function (aQuery, aQueryOptions) {
+ aQuery.endTime = Date.now() * 1000;
+ aQuery.endTimeReference = Ci.nsINavHistoryQuery.TIME_RELATIVE_EPOCH;
+ },
+ function (aQuery, aQueryOptions) {
+ aQuery.endTime = Date.now() * 1000;
+ aQuery.endTimeReference = Ci.nsINavHistoryQuery.TIME_RELATIVE_TODAY;
+ },
+ ],
+ },
+ // hasSearchTerms
+ {
+ flag: "hasSearchTerms",
+ subswitches: ["searchTerms"],
+ desc: "nsINavHistoryQuery.hasSearchTerms",
+ matches: flagSwitchMatches,
+ runs: [
+ function (aQuery, aQueryOptions) {
+ aQuery.searchTerms = "shrimp and white wine";
+ },
+ function (aQuery, aQueryOptions) {
+ aQuery.searchTerms = "";
+ },
+ ],
+ },
+ // hasDomain
+ {
+ flag: "hasDomain",
+ subswitches: ["domain", "domainIsHost"],
+ desc: "nsINavHistoryQuery.hasDomain",
+ matches: flagSwitchMatches,
+ runs: [
+ function (aQuery, aQueryOptions) {
+ aQuery.domain = "mozilla.com";
+ aQuery.domainIsHost = false;
+ },
+ function (aQuery, aQueryOptions) {
+ aQuery.domain = "www.mozilla.com";
+ aQuery.domainIsHost = true;
+ },
+ function (aQuery, aQueryOptions) {
+ aQuery.domain = "";
+ },
+ ],
+ },
+ // hasUri
+ {
+ flag: "hasUri",
+ subswitches: ["uri"],
+ desc: "nsINavHistoryQuery.hasUri",
+ matches: flagSwitchMatches,
+ runs: [
+ function (aQuery, aQueryOptions) {
+ aQuery.uri = uri("http://mozilla.com");
+ },
+ ],
+ },
+ // minVisits
+ {
+ // property is used by function simplePropertyMatches.
+ property: "minVisits",
+ desc: "nsINavHistoryQuery.minVisits",
+ matches: simplePropertyMatches,
+ runs: [
+ function (aQuery, aQueryOptions) {
+ aQuery.minVisits = 0x7fffffff; // 2^31 - 1
+ },
+ ],
+ },
+ // maxVisits
+ {
+ property: "maxVisits",
+ desc: "nsINavHistoryQuery.maxVisits",
+ matches: simplePropertyMatches,
+ runs: [
+ function (aQuery, aQueryOptions) {
+ aQuery.maxVisits = 0x7fffffff; // 2^31 - 1
+ },
+ ],
+ },
+ // getFolders
+ {
+ desc: "nsINavHistoryQuery.getParents",
+ matches(aQuery1, aQuery2) {
+ var q1Parents = aQuery1.getParents();
+ var q2Parents = aQuery2.getParents();
+ if (q1Parents.length !== q2Parents.length) {
+ return false;
+ }
+ for (let i = 0; i < q1Parents.length; i++) {
+ if (!q2Parents.includes(q1Parents[i])) {
+ return false;
+ }
+ }
+ for (let i = 0; i < q2Parents.length; i++) {
+ if (!q1Parents.includes(q2Parents[i])) {
+ return false;
+ }
+ }
+ return true;
+ },
+ runs: [
+ function (aQuery, aQueryOptions) {
+ aQuery.setParents([]);
+ },
+ function (aQuery, aQueryOptions) {
+ aQuery.setParents([PlacesUtils.bookmarks.rootGuid]);
+ },
+ function (aQuery, aQueryOptions) {
+ aQuery.setParents([
+ PlacesUtils.bookmarks.rootGuid,
+ PlacesUtils.bookmarks.tagsGuid,
+ ]);
+ },
+ ],
+ },
+ // tags
+ {
+ desc: "nsINavHistoryQuery.getTags",
+ matches(aQuery1, aQuery2) {
+ if (aQuery1.tagsAreNot !== aQuery2.tagsAreNot) {
+ return false;
+ }
+ var q1Tags = aQuery1.tags;
+ var q2Tags = aQuery2.tags;
+ if (q1Tags.length !== q2Tags.length) {
+ return false;
+ }
+ for (let i = 0; i < q1Tags.length; i++) {
+ if (!q2Tags.includes(q1Tags[i])) {
+ return false;
+ }
+ }
+ for (let i = 0; i < q2Tags.length; i++) {
+ if (!q1Tags.includes(q2Tags[i])) {
+ return false;
+ }
+ }
+ return true;
+ },
+ runs: [
+ function (aQuery, aQueryOptions) {
+ aQuery.tags = [];
+ },
+ function (aQuery, aQueryOptions) {
+ aQuery.tags = [""];
+ },
+ function (aQuery, aQueryOptions) {
+ aQuery.tags = [
+ "foo",
+ "七難",
+ "",
+ "いっぱいおっぱい",
+ "Abracadabra",
+ "123",
+ "Here's a pretty long tag name with some = signs and 1 2 3s and spaces oh jeez will it work I hope so!",
+ "アスキーでございません",
+ "あいうえお",
+ ];
+ },
+ function (aQuery, aQueryOptions) {
+ aQuery.tags = [
+ "foo",
+ "七難",
+ "",
+ "いっぱいおっぱい",
+ "Abracadabra",
+ "123",
+ "Here's a pretty long tag name with some = signs and 1 2 3s and spaces oh jeez will it work I hope so!",
+ "アスキーでございません",
+ "あいうえお",
+ ];
+ aQuery.tagsAreNot = true;
+ },
+ ],
+ },
+ // transitions
+ {
+ desc: "tests nsINavHistoryQuery.getTransitions",
+ matches(aQuery1, aQuery2) {
+ var q1Trans = aQuery1.getTransitions();
+ var q2Trans = aQuery2.getTransitions();
+ if (q1Trans.length !== q2Trans.length) {
+ return false;
+ }
+ for (let i = 0; i < q1Trans.length; i++) {
+ if (!q2Trans.includes(q1Trans[i])) {
+ return false;
+ }
+ }
+ for (let i = 0; i < q2Trans.length; i++) {
+ if (!q1Trans.includes(q2Trans[i])) {
+ return false;
+ }
+ }
+ return true;
+ },
+ runs: [
+ function (aQuery, aQueryOptions) {
+ aQuery.setTransitions([]);
+ },
+ function (aQuery, aQueryOptions) {
+ aQuery.setTransitions([Ci.nsINavHistoryService.TRANSITION_DOWNLOAD]);
+ },
+ function (aQuery, aQueryOptions) {
+ aQuery.setTransitions([
+ Ci.nsINavHistoryService.TRANSITION_TYPED,
+ Ci.nsINavHistoryService.TRANSITION_BOOKMARK,
+ ]);
+ },
+ ],
+ },
+];
+
+// nsINavHistoryQueryOptions switches
+const queryOptionSwitches = [
+ // sortingMode
+ {
+ desc: "nsINavHistoryQueryOptions.sortingMode",
+ matches(aOptions1, aOptions2) {
+ if (aOptions1.sortingMode === aOptions2.sortingMode) {
+ return true;
+ }
+ return false;
+ },
+ runs: [
+ function (aQuery, aQueryOptions) {
+ aQueryOptions.sortingMode = aQueryOptions.SORT_BY_DATE_ASCENDING;
+ },
+ ],
+ },
+ // resultType
+ {
+ // property is used by function simplePropertyMatches.
+ property: "resultType",
+ desc: "nsINavHistoryQueryOptions.resultType",
+ matches: simplePropertyMatches,
+ runs: [
+ function (aQuery, aQueryOptions) {
+ aQueryOptions.resultType = aQueryOptions.RESULTS_AS_URI;
+ },
+ ],
+ },
+ // excludeItems
+ {
+ property: "excludeItems",
+ desc: "nsINavHistoryQueryOptions.excludeItems",
+ matches: simplePropertyMatches,
+ runs: [
+ function (aQuery, aQueryOptions) {
+ aQueryOptions.excludeItems = true;
+ },
+ ],
+ },
+ // excludeQueries
+ {
+ property: "excludeQueries",
+ desc: "nsINavHistoryQueryOptions.excludeQueries",
+ matches: simplePropertyMatches,
+ runs: [
+ function (aQuery, aQueryOptions) {
+ aQueryOptions.excludeQueries = true;
+ },
+ ],
+ },
+ // expandQueries
+ {
+ property: "expandQueries",
+ desc: "nsINavHistoryQueryOptions.expandQueries",
+ matches: simplePropertyMatches,
+ runs: [
+ function (aQuery, aQueryOptions) {
+ aQueryOptions.expandQueries = true;
+ },
+ ],
+ },
+ // includeHidden
+ {
+ property: "includeHidden",
+ desc: "nsINavHistoryQueryOptions.includeHidden",
+ matches: simplePropertyMatches,
+ runs: [
+ function (aQuery, aQueryOptions) {
+ aQueryOptions.includeHidden = true;
+ },
+ ],
+ },
+ // maxResults
+ {
+ property: "maxResults",
+ desc: "nsINavHistoryQueryOptions.maxResults",
+ matches: simplePropertyMatches,
+ runs: [
+ function (aQuery, aQueryOptions) {
+ aQueryOptions.maxResults = 0xffffffff; // 2^32 - 1
+ },
+ ],
+ },
+ // queryType
+ {
+ property: "queryType",
+ desc: "nsINavHistoryQueryOptions.queryType",
+ matches: simplePropertyMatches,
+ runs: [
+ function (aQuery, aQueryOptions) {
+ aQueryOptions.queryType = aQueryOptions.QUERY_TYPE_HISTORY;
+ },
+ function (aQuery, aQueryOptions) {
+ aQueryOptions.queryType = aQueryOptions.QUERY_TYPE_BOOKMARKS;
+ },
+ ],
+ },
+];
+
+/**
+ * Enumerates all the sequences of the cartesian product of the arrays contained
+ * in aSequences. Examples:
+ *
+ * cartProd([[1, 2, 3], ["a", "b"]], callback);
+ * // callback is called 3 * 2 = 6 times with the following arrays:
+ * // [1, "a"], [1, "b"], [2, "a"], [2, "b"], [3, "a"], [3, "b"]
+ *
+ * cartProd([["a"], [1, 2, 3], ["X", "Y"]], callback);
+ * // callback is called 1 * 3 * 2 = 6 times with the following arrays:
+ * // ["a", 1, "X"], ["a", 1, "Y"], ["a", 2, "X"], ["a", 2, "Y"],
+ * // ["a", 3, "X"], ["a", 3, "Y"]
+ *
+ * cartProd([[1], [2], [3], [4]], callback);
+ * // callback is called 1 * 1 * 1 * 1 = 1 time with the following array:
+ * // [1, 2, 3, 4]
+ *
+ * cartProd([], callback);
+ * // callback is 0 times
+ *
+ * cartProd([[1, 2, 3, 4]], callback);
+ * // callback is called 4 times with the following arrays:
+ * // [1], [2], [3], [4]
+ *
+ * @param aSequences
+ * an array that contains an arbitrary number of arrays
+ * @param aCallback
+ * a function that is passed each sequence of the product as it's
+ * computed
+ * @return the total number of sequences in the product
+ */
+function cartProd(aSequences, aCallback) {
+ if (aSequences.length === 0) {
+ return 0;
+ }
+
+ // For each sequence in aSequences, we maintain a pointer (an array index,
+ // really) to the element we're currently enumerating in that sequence
+ var seqEltPtrs = aSequences.map(i => 0);
+
+ var numProds = 0;
+ var done = false;
+ while (!done) {
+ numProds++;
+
+ // prod = sequence in product we're currently enumerating
+ let prod = [];
+ for (let i = 0; i < aSequences.length; i++) {
+ prod.push(aSequences[i][seqEltPtrs[i]]);
+ }
+ aCallback(prod);
+
+ // The next sequence in the product differs from the current one by just a
+ // single element. Determine which element that is. We advance the
+ // "rightmost" element pointer to the "right" by one. If we move past the
+ // end of that pointer's sequence, reset the pointer to the first element
+ // in its sequence and then try the sequence to the "left", and so on.
+
+ // seqPtr = index of rightmost input sequence whose element pointer is not
+ // past the end of the sequence
+ let seqPtr = aSequences.length - 1;
+ while (!done) {
+ // Advance the rightmost element pointer.
+ seqEltPtrs[seqPtr]++;
+
+ // The rightmost element pointer is past the end of its sequence.
+ if (seqEltPtrs[seqPtr] >= aSequences[seqPtr].length) {
+ seqEltPtrs[seqPtr] = 0;
+ seqPtr--;
+
+ // All element pointers are past the ends of their sequences.
+ if (seqPtr < 0) {
+ done = true;
+ }
+ } else {
+ break;
+ }
+ }
+ }
+ return numProds;
+}
+
+/**
+ * Enumerates all the subsets in aSet of size aHowMany. There are
+ * C(aSet.length, aHowMany) such subsets. aCallback will be passed each subset
+ * as it is generated. Note that aSet and the subsets enumerated are -- even
+ * though they're arrays -- not sequences; the ordering of their elements is not
+ * important. Example:
+ *
+ * choose([1, 2, 3, 4], 2, callback);
+ * // callback is called C(4, 2) = 6 times with the following sets (arrays):
+ * // [1, 2], [1, 3], [1, 4], [2, 3], [2, 4], [3, 4]
+ *
+ * @param aSet
+ * an array from which to choose elements, aSet.length > 0
+ * @param aHowMany
+ * the number of elements to choose, > 0 and <= aSet.length
+ * @return the total number of sets chosen
+ */
+function choose(aSet, aHowMany, aCallback) {
+ // ptrs = indices of the elements in aSet we're currently choosing
+ var ptrs = [];
+ for (let i = 0; i < aHowMany; i++) {
+ ptrs.push(i);
+ }
+
+ var numFound = 0;
+ var done = false;
+ while (!done) {
+ numFound++;
+ aCallback(ptrs.map(p => aSet[p]));
+
+ // The next subset to be chosen differs from the current one by just a
+ // single element. Determine which element that is. Advance the "rightmost"
+ // pointer to the "right" by one. If we move past the end of set, move the
+ // next non-adjacent rightmost pointer to the right by one, and reset all
+ // succeeding pointers so that they're adjacent to it. When all pointers
+ // are clustered all the way to the right, we're done.
+
+ // Advance the rightmost pointer.
+ ptrs[ptrs.length - 1]++;
+
+ // The rightmost pointer has gone past the end of set.
+ if (ptrs[ptrs.length - 1] >= aSet.length) {
+ // Find the next rightmost pointer that is not adjacent to the current one.
+ let si = aSet.length - 2; // aSet index
+ let pi = ptrs.length - 2; // ptrs index
+ while (pi >= 0 && ptrs[pi] === si) {
+ pi--;
+ si--;
+ }
+
+ // All pointers are adjacent and clustered all the way to the right.
+ if (pi < 0) {
+ done = true;
+ } else {
+ // pi = index of rightmost pointer with a gap between it and its
+ // succeeding pointer. Move it right and reset all succeeding pointers
+ // so that they're adjacent to it.
+ ptrs[pi]++;
+ for (let i = 0; i < ptrs.length - pi - 1; i++) {
+ ptrs[i + pi + 1] = ptrs[pi] + i + 1;
+ }
+ }
+ }
+ }
+ return numFound;
+}
+
+/**
+ * Convenience function for nsINavHistoryQuery switches that act as flags. This
+ * is attached to switch objects. See querySwitches array above.
+ *
+ * @param aQuery1
+ * an nsINavHistoryQuery object
+ * @param aQuery2
+ * another nsINavHistoryQuery object
+ * @return true if this switch is the same in both aQuery1 and aQuery2
+ */
+function flagSwitchMatches(aQuery1, aQuery2) {
+ if (aQuery1[this.flag] && aQuery2[this.flag]) {
+ for (let p in this.subswitches) {
+ if (p in aQuery1 && p in aQuery2) {
+ if (aQuery1[p] instanceof Ci.nsIURI) {
+ if (!aQuery1[p].equals(aQuery2[p])) {
+ return false;
+ }
+ } else if (aQuery1[p] !== aQuery2[p]) {
+ return false;
+ }
+ }
+ }
+ } else if (aQuery1[this.flag] || aQuery2[this.flag]) {
+ return false;
+ }
+
+ return true;
+}
+
+/**
+ * Tests if aObj1 and aObj2 are equal. This function is general and may be used
+ * for either nsINavHistoryQuery or nsINavHistoryQueryOptions objects. aSwitches
+ * determines which set of switches is used for comparison. Pass in either
+ * querySwitches or queryOptionSwitches.
+ *
+ * @param aSwitches
+ * determines which set of switches applies to aObj1 and aObj2, either
+ * querySwitches or queryOptionSwitches
+ * @param aObj1
+ * an nsINavHistoryQuery or nsINavHistoryQueryOptions object
+ * @param aObj2
+ * another nsINavHistoryQuery or nsINavHistoryQueryOptions object
+ * @return true if aObj1 and aObj2 are equal
+ */
+function queryObjsEqual(aSwitches, aObj1, aObj2) {
+ for (let i = 0; i < aSwitches.length; i++) {
+ if (!aSwitches[i].matches(aObj1, aObj2)) {
+ return false;
+ }
+ }
+ return true;
+}
+
+/**
+ * This drives the test runs. See the comment at the top of this file.
+ *
+ * @param aHowManyLo
+ * the size of the switch subsets to start with
+ * @param aHowManyHi
+ * the size of the switch subsets to end with (inclusive)
+ */
+function runQuerySequences(aHowManyLo, aHowManyHi) {
+ var allSwitches = querySwitches.concat(queryOptionSwitches);
+
+ // Choose aHowManyLo switches up to aHowManyHi switches.
+ for (let howMany = aHowManyLo; howMany <= aHowManyHi; howMany++) {
+ let numIters = 0;
+ print("CHOOSING " + howMany + " SWITCHES");
+
+ // Choose all subsets of size howMany from allSwitches.
+ choose(allSwitches, howMany, function (chosenSwitches) {
+ print(numIters);
+ numIters++;
+
+ // Collect the runs.
+ // runs = [ [runs from switch 1], ..., [runs from switch howMany] ]
+ var runs = chosenSwitches.map(function (s) {
+ if (s.desc) {
+ print(" " + s.desc);
+ }
+ return s.runs;
+ });
+
+ // cartProd(runs) => [
+ // [switch 1 run 1, switch 2 run 1, ..., switch howMany run 1 ],
+ // ...,
+ // [switch 1 run 1, switch 2 run 1, ..., switch howMany run N ],
+ // ..., ...,
+ // [switch 1 run N, switch 2 run N, ..., switch howMany run 1 ],
+ // ...,
+ // [switch 1 run N, switch 2 run N, ..., switch howMany run N ],
+ // ]
+ cartProd(runs, function (runSet) {
+ // Create a new query, apply the switches in runSet, and test it.
+ var query = PlacesUtils.history.getNewQuery();
+ var opts = PlacesUtils.history.getNewQueryOptions();
+ for (let i = 0; i < runSet.length; i++) {
+ runSet[i](query, opts);
+ }
+ serializeDeserialize(query, opts);
+ });
+ });
+ }
+ print("\n");
+}
+
+/**
+ * Serializes the nsINavHistoryQuery objects in aQuery and the
+ * nsINavHistoryQueryOptions object aQueryOptions, de-serializes the
+ * serialization, and ensures (using do_check_* functions) that the
+ * de-serialized objects equal the originals.
+ *
+ * @param aQuery
+ * an nsINavHistoryQuery object
+ * @param aQueryOptions
+ * an nsINavHistoryQueryOptions object
+ */
+function serializeDeserialize(aQuery, aQueryOptions) {
+ let queryStr = PlacesUtils.history.queryToQueryString(aQuery, aQueryOptions);
+ print(" " + queryStr);
+ let query2 = {},
+ opts2 = {};
+ PlacesUtils.history.queryStringToQuery(queryStr, query2, opts2);
+ query2 = query2.value;
+ opts2 = opts2.value;
+
+ Assert.ok(queryObjsEqual(querySwitches, aQuery, query2));
+
+ // Finally check the query options objects.
+ Assert.ok(queryObjsEqual(queryOptionSwitches, aQueryOptions, opts2));
+}
+
+/**
+ * Convenience function for switches that have simple values. This is attached
+ * to switch objects. See querySwitches and queryOptionSwitches arrays above.
+ *
+ * @param aObj1
+ * an nsINavHistoryQuery or nsINavHistoryQueryOptions object
+ * @param aObj2
+ * another nsINavHistoryQuery or nsINavHistoryQueryOptions object
+ * @return true if this switch is the same in both aObj1 and aObj2
+ */
+function simplePropertyMatches(aObj1, aObj2) {
+ return aObj1[this.property] === aObj2[this.property];
+}
+
+function run_test() {
+ runQuerySequences(CHOOSE_HOW_MANY_SWITCHES_LO, CHOOSE_HOW_MANY_SWITCHES_HI);
+}
diff --git a/toolkit/components/places/tests/queries/test_query_uri_liveupdate.js b/toolkit/components/places/tests/queries/test_query_uri_liveupdate.js
new file mode 100644
index 0000000000..5ada4a84d4
--- /dev/null
+++ b/toolkit/components/places/tests/queries/test_query_uri_liveupdate.js
@@ -0,0 +1,45 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/ */
+
+add_task(async function test_results_as_tag_query() {
+ let bms = await PlacesUtils.bookmarks.insertTree({
+ guid: PlacesUtils.bookmarks.unfiledGuid,
+ children: [
+ { url: "http://tag1.moz.com/", tags: ["tag1"] },
+ { url: "http://tag2.moz.com/", tags: ["tag2"] },
+ { url: "place:tag=tag1" },
+ ],
+ });
+
+ let root = PlacesUtils.getFolderContents(
+ PlacesUtils.bookmarks.unfiledGuid,
+ false,
+ true
+ ).root;
+ Assert.equal(root.childCount, 3, "We should get 3 results");
+ let queryRoot = root.getChild(2);
+ PlacesUtils.asContainer(queryRoot).containerOpen = true;
+
+ Assert.equal(queryRoot.uri, "place:tag=tag1", "Found the query");
+ Assert.equal(queryRoot.childCount, 1, "We should get 1 result");
+ Assert.equal(
+ queryRoot.getChild(0).uri,
+ "http://tag1.moz.com/",
+ "Found the tagged bookmark"
+ );
+
+ await PlacesUtils.bookmarks.update({
+ guid: bms[2].guid,
+ url: "place:tag=tag2",
+ });
+ Assert.equal(queryRoot.uri, "place:tag=tag2", "Found the query");
+ Assert.equal(queryRoot.childCount, 1, "We should get 1 result");
+ Assert.equal(
+ queryRoot.getChild(0).uri,
+ "http://tag2.moz.com/",
+ "Found the tagged bookmark"
+ );
+
+ queryRoot.containerOpen = false;
+ root.containerOpen = false;
+});
diff --git a/toolkit/components/places/tests/queries/test_redirects.js b/toolkit/components/places/tests/queries/test_redirects.js
new file mode 100644
index 0000000000..b0e7c9b421
--- /dev/null
+++ b/toolkit/components/places/tests/queries/test_redirects.js
@@ -0,0 +1,351 @@
+/* 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/. */
+
+// Array of visits we will add to the database, will be populated later
+// in the test.
+var visits = [];
+
+/**
+ * Takes a sequence of query options, and compare query results obtained through
+ * them with a custom filtered array of visits, based on the values we are
+ * expecting from the query.
+ *
+ * @param aSequence
+ * an array that contains query options in the form:
+ * [includeHidden, maxResults, sortingMode]
+ */
+function check_results_callback(aSequence) {
+ // Sanity check: we should receive 3 parameters.
+ Assert.equal(aSequence.length, 3);
+ let includeHidden = aSequence[0];
+ let maxResults = aSequence[1];
+ let sortingMode = aSequence[2];
+ info(" - - - ");
+ info(
+ "TESTING: includeHidden(" +
+ includeHidden +
+ ")," +
+ " maxResults(" +
+ maxResults +
+ ")," +
+ " sortingMode(" +
+ sortingMode +
+ ")."
+ );
+
+ function isHidden(aVisit) {
+ return (
+ aVisit.transType == Ci.nsINavHistoryService.TRANSITION_FRAMED_LINK ||
+ aVisit.isRedirect
+ );
+ }
+
+ // Build expectedData array.
+ let expectedData = visits.filter(function (aVisit, aIndex, aArray) {
+ // Embed visits never appear in results.
+ if (aVisit.transType == Ci.nsINavHistoryService.TRANSITION_EMBED) {
+ return false;
+ }
+
+ if (!includeHidden && isHidden(aVisit)) {
+ // If the page has any non-hidden visit, then it's visible.
+ if (
+ !visits.filter(function (refVisit) {
+ return refVisit.uri == aVisit.uri && !isHidden(refVisit);
+ }).length
+ ) {
+ return false;
+ }
+ }
+
+ return true;
+ });
+
+ // Remove duplicates, since queries are RESULTS_AS_URI (unique pages).
+ let seen = [];
+ expectedData = expectedData.filter(function (aData) {
+ if (seen.includes(aData.uri)) {
+ return false;
+ }
+ seen.push(aData.uri);
+ return true;
+ });
+
+ // Sort expectedData.
+ function getFirstIndexFor(aEntry) {
+ for (let i = 0; i < visits.length; i++) {
+ if (visits[i].uri == aEntry.uri) {
+ return i;
+ }
+ }
+ return undefined;
+ }
+ function comparator(a, b) {
+ if (sortingMode == Ci.nsINavHistoryQueryOptions.SORT_BY_DATE_DESCENDING) {
+ return b.lastVisit - a.lastVisit;
+ }
+ if (
+ sortingMode == Ci.nsINavHistoryQueryOptions.SORT_BY_VISITCOUNT_DESCENDING
+ ) {
+ return b.visitCount - a.visitCount;
+ }
+ return getFirstIndexFor(a) - getFirstIndexFor(b);
+ }
+ expectedData.sort(comparator);
+
+ // Crop results to maxResults if it's defined.
+ if (maxResults) {
+ expectedData = expectedData.slice(0, maxResults);
+ }
+
+ // Create a new query with required options.
+ let query = PlacesUtils.history.getNewQuery();
+ let options = PlacesUtils.history.getNewQueryOptions();
+ options.includeHidden = includeHidden;
+ options.sortingMode = sortingMode;
+ if (maxResults) {
+ options.maxResults = maxResults;
+ }
+
+ // Compare resultset with expectedData.
+ let result = PlacesUtils.history.executeQuery(query, options);
+ let root = result.root;
+ root.containerOpen = true;
+ compareArrayToResult(expectedData, root);
+ root.containerOpen = false;
+}
+
+/**
+ * Enumerates all the sequences of the cartesian product of the arrays contained
+ * in aSequences. Examples:
+ *
+ * cartProd([[1, 2, 3], ["a", "b"]], callback);
+ * // callback is called 3 * 2 = 6 times with the following arrays:
+ * // [1, "a"], [1, "b"], [2, "a"], [2, "b"], [3, "a"], [3, "b"]
+ *
+ * cartProd([["a"], [1, 2, 3], ["X", "Y"]], callback);
+ * // callback is called 1 * 3 * 2 = 6 times with the following arrays:
+ * // ["a", 1, "X"], ["a", 1, "Y"], ["a", 2, "X"], ["a", 2, "Y"],
+ * // ["a", 3, "X"], ["a", 3, "Y"]
+ *
+ * cartProd([[1], [2], [3], [4]], callback);
+ * // callback is called 1 * 1 * 1 * 1 = 1 time with the following array:
+ * // [1, 2, 3, 4]
+ *
+ * cartProd([], callback);
+ * // callback is 0 times
+ *
+ * cartProd([[1, 2, 3, 4]], callback);
+ * // callback is called 4 times with the following arrays:
+ * // [1], [2], [3], [4]
+ *
+ * @param aSequences
+ * an array that contains an arbitrary number of arrays
+ * @param aCallback
+ * a function that is passed each sequence of the product as it's
+ * computed
+ * @return the total number of sequences in the product
+ */
+function cartProd(aSequences, aCallback) {
+ if (aSequences.length === 0) {
+ return 0;
+ }
+
+ // For each sequence in aSequences, we maintain a pointer (an array index,
+ // really) to the element we're currently enumerating in that sequence
+ let seqEltPtrs = aSequences.map(i => 0);
+
+ let numProds = 0;
+ let done = false;
+ while (!done) {
+ numProds++;
+
+ // prod = sequence in product we're currently enumerating
+ let prod = [];
+ for (let i = 0; i < aSequences.length; i++) {
+ prod.push(aSequences[i][seqEltPtrs[i]]);
+ }
+ aCallback(prod);
+
+ // The next sequence in the product differs from the current one by just a
+ // single element. Determine which element that is. We advance the
+ // "rightmost" element pointer to the "right" by one. If we move past the
+ // end of that pointer's sequence, reset the pointer to the first element
+ // in its sequence and then try the sequence to the "left", and so on.
+
+ // seqPtr = index of rightmost input sequence whose element pointer is not
+ // past the end of the sequence
+ let seqPtr = aSequences.length - 1;
+ while (!done) {
+ // Advance the rightmost element pointer.
+ seqEltPtrs[seqPtr]++;
+
+ // The rightmost element pointer is past the end of its sequence.
+ if (seqEltPtrs[seqPtr] >= aSequences[seqPtr].length) {
+ seqEltPtrs[seqPtr] = 0;
+ seqPtr--;
+
+ // All element pointers are past the ends of their sequences.
+ if (seqPtr < 0) {
+ done = true;
+ }
+ } else {
+ break;
+ }
+ }
+ }
+ return numProds;
+}
+
+/**
+ * Populate the visits array and add visits to the database.
+ * We will generate visit-chains like:
+ * visit -> redirect_temp -> redirect_perm
+ */
+add_task(async function test_add_visits_to_database() {
+ await PlacesUtils.bookmarks.eraseEverything();
+
+ // We don't really bother on this, but we need a time to add visits.
+ let timeInMicroseconds = Date.now() * 1000;
+ let visitCount = 1;
+
+ // Array of all possible transition types we could be redirected from.
+ let t = [
+ Ci.nsINavHistoryService.TRANSITION_LINK,
+ Ci.nsINavHistoryService.TRANSITION_TYPED,
+ Ci.nsINavHistoryService.TRANSITION_BOOKMARK,
+ // Embed visits are not added to the database and we don't want redirects
+ // to them, thus just avoid addition.
+ // Ci.nsINavHistoryService.TRANSITION_EMBED,
+ Ci.nsINavHistoryService.TRANSITION_FRAMED_LINK,
+ // Would make hard sorting by visit date because last_visit_date is actually
+ // calculated excluding download transitions, but the query includes
+ // downloads.
+ // Ci.nsINavHistoryService.TRANSITION_DOWNLOAD,
+ ];
+
+ function newTimeInMicroseconds() {
+ timeInMicroseconds = timeInMicroseconds - 1000;
+ return timeInMicroseconds;
+ }
+
+ // we add a visit for each of the above transition types.
+ t.forEach(transition =>
+ visits.push({
+ isVisit: true,
+ transType: transition,
+ uri: "http://" + transition + ".example.com/",
+ title: transition + "-example",
+ isRedirect: true,
+ lastVisit: newTimeInMicroseconds(),
+ visitCount:
+ transition == Ci.nsINavHistoryService.TRANSITION_EMBED ||
+ transition == Ci.nsINavHistoryService.TRANSITION_FRAMED_LINK
+ ? 0
+ : visitCount++,
+ isInQuery: true,
+ })
+ );
+
+ // Add a REDIRECT_TEMPORARY layer of visits for each of the above visits.
+ t.forEach(transition =>
+ visits.push({
+ isVisit: true,
+ transType: Ci.nsINavHistoryService.TRANSITION_REDIRECT_TEMPORARY,
+ uri: "http://" + transition + ".redirect.temp.example.com/",
+ title: transition + "-redirect-temp-example",
+ lastVisit: newTimeInMicroseconds(),
+ isRedirect: true,
+ referrer: "http://" + transition + ".example.com/",
+ visitCount: visitCount++,
+ isInQuery: true,
+ })
+ );
+
+ // Add a REDIRECT_PERMANENT layer of visits for each of the above redirects.
+ t.forEach(transition =>
+ visits.push({
+ isVisit: true,
+ transType: Ci.nsINavHistoryService.TRANSITION_REDIRECT_PERMANENT,
+ uri: "http://" + transition + ".redirect.perm.example.com/",
+ title: transition + "-redirect-perm-example",
+ lastVisit: newTimeInMicroseconds(),
+ isRedirect: true,
+ referrer: "http://" + transition + ".redirect.temp.example.com/",
+ visitCount: visitCount++,
+ isInQuery: true,
+ })
+ );
+
+ // Add a REDIRECT_PERMANENT layer of visits that loop to the first visit.
+ // These entries should not change visitCount or lastVisit, otherwise
+ // guessing an order would be a nightmare.
+ function getLastValue(aURI, aProperty) {
+ for (let i = 0; i < visits.length; i++) {
+ if (visits[i].uri == aURI) {
+ return visits[i][aProperty];
+ }
+ }
+ do_throw("Unknown uri.");
+ return null;
+ }
+ t.forEach(transition =>
+ visits.push({
+ isVisit: true,
+ transType: Ci.nsINavHistoryService.TRANSITION_REDIRECT_PERMANENT,
+ uri: "http://" + transition + ".example.com/",
+ title: getLastValue("http://" + transition + ".example.com/", "title"),
+ lastVisit: getLastValue(
+ "http://" + transition + ".example.com/",
+ "lastVisit"
+ ),
+ isRedirect: true,
+ referrer: "http://" + transition + ".redirect.perm.example.com/",
+ visitCount: getLastValue(
+ "http://" + transition + ".example.com/",
+ "visitCount"
+ ),
+ isInQuery: true,
+ })
+ );
+
+ // Add an unvisited bookmark in the database, it should never appear.
+ visits.push({
+ isBookmark: true,
+ uri: "http://unvisited.bookmark.com/",
+ parentGuid: PlacesUtils.bookmarks.menuGuid,
+ index: PlacesUtils.bookmarks.DEFAULT_INDEX,
+ title: "Unvisited Bookmark",
+ isInQuery: false,
+ });
+
+ // Put visits in the database.
+ await task_populateDB(visits);
+});
+
+add_task(async function test_redirects() {
+ // Frecency and hidden are updated asynchronously, wait for them.
+ await PlacesTestUtils.promiseAsyncUpdates();
+
+ // This array will be used by cartProd to generate a matrix of all possible
+ // combinations.
+ let includeHidden_options = [true, false];
+ let maxResults_options = [5, 10, 20, null];
+ // These sortingMode are choosen to toggle using special queries for history
+ // menu.
+ let sorting_options = [
+ Ci.nsINavHistoryQueryOptions.SORT_BY_NONE,
+ Ci.nsINavHistoryQueryOptions.SORT_BY_VISITCOUNT_DESCENDING,
+ Ci.nsINavHistoryQueryOptions.SORT_BY_DATE_DESCENDING,
+ ];
+ // Will execute check_results_callback() for each generated combination.
+ cartProd(
+ [includeHidden_options, maxResults_options, sorting_options],
+ check_results_callback
+ );
+
+ await PlacesUtils.bookmarks.eraseEverything();
+
+ await PlacesUtils.history.clear();
+});
diff --git a/toolkit/components/places/tests/queries/test_result_observeHistoryDetails.js b/toolkit/components/places/tests/queries/test_result_observeHistoryDetails.js
new file mode 100644
index 0000000000..83531ee2c4
--- /dev/null
+++ b/toolkit/components/places/tests/queries/test_result_observeHistoryDetails.js
@@ -0,0 +1,155 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+// This test ensures that skipHistoryDetailsNotifications works as expected.
+
+function accumulateNotifications(
+ result,
+ skipHistoryDetailsNotifications = false
+) {
+ let notifications = [];
+ let resultObserver = new Proxy(NavHistoryResultObserver, {
+ get(target, name) {
+ if (name == "check") {
+ result.removeObserver(resultObserver, false);
+ return expectedNotifications =>
+ Assert.deepEqual(notifications, expectedNotifications);
+ }
+ if (name == "skipHistoryDetailsNotifications") {
+ return skipHistoryDetailsNotifications;
+ }
+ // ignore a few uninteresting notifications.
+ if (["QueryInterface", "containerStateChanged"].includes(name)) {
+ return () => {};
+ }
+ return () => {
+ notifications.push(name);
+ };
+ },
+ });
+ result.addObserver(resultObserver, false);
+ return resultObserver;
+}
+
+add_task(async function test_history_query_observe() {
+ let query = PlacesUtils.history.getNewQuery();
+ let options = PlacesUtils.history.getNewQueryOptions();
+ let result = PlacesUtils.history.executeQuery(query, options);
+ let notifications = accumulateNotifications(result);
+ let root = PlacesUtils.asContainer(result.root);
+ root.containerOpen = true;
+
+ await PlacesTestUtils.addVisits({
+ uri: "http://mozilla.org",
+ title: "test",
+ });
+ notifications.check([
+ "nodeHistoryDetailsChanged",
+ "nodeInserted",
+ "nodeTitleChanged",
+ ]);
+
+ root.containerOpen = false;
+ await PlacesUtils.history.clear();
+});
+
+add_task(async function test_history_query_no_observe() {
+ let query = PlacesUtils.history.getNewQuery();
+ let options = PlacesUtils.history.getNewQueryOptions();
+ let result = PlacesUtils.history.executeQuery(query, options);
+ // Even if we opt-out of notifications, this is an history query, thus the
+ // setting is pretty much ignored.
+ let notifications = accumulateNotifications(result, true);
+ let root = PlacesUtils.asContainer(result.root);
+ root.containerOpen = true;
+
+ await PlacesTestUtils.addVisits({
+ uri: "http://mozilla.org",
+ title: "test",
+ });
+ await PlacesTestUtils.addVisits({
+ uri: "http://mozilla2.org",
+ title: "test",
+ });
+
+ notifications.check([
+ "nodeHistoryDetailsChanged",
+ "nodeInserted",
+ "nodeTitleChanged",
+ "nodeHistoryDetailsChanged",
+ "nodeInserted",
+ "nodeTitleChanged",
+ ]);
+
+ root.containerOpen = false;
+ await PlacesUtils.history.clear();
+});
+
+add_task(async function test_bookmarks_query_observe() {
+ let query = PlacesUtils.history.getNewQuery();
+ query.setParents([PlacesUtils.bookmarks.toolbarGuid]);
+ let options = PlacesUtils.history.getNewQueryOptions();
+ options.queryType = options.QUERY_TYPE_BOOKMARKS;
+ let result = PlacesUtils.history.executeQuery(query, options);
+ let notifications = accumulateNotifications(result);
+ let root = PlacesUtils.asContainer(result.root);
+ root.containerOpen = true;
+
+ await PlacesUtils.bookmarks.insert({
+ url: "http://mozilla.org",
+ parentGuid: PlacesUtils.bookmarks.toolbarGuid,
+ title: "test",
+ });
+ await PlacesTestUtils.addVisits({
+ uri: "http://mozilla.org",
+ title: "title",
+ });
+
+ notifications.check([
+ "nodeHistoryDetailsChanged",
+ "nodeInserted",
+ "nodeHistoryDetailsChanged",
+ ]);
+
+ root.containerOpen = false;
+ await PlacesUtils.history.clear();
+ await PlacesUtils.bookmarks.eraseEverything();
+});
+
+add_task(async function test_bookmarks_query_no_observe() {
+ let query = PlacesUtils.history.getNewQuery();
+ query.setParents([PlacesUtils.bookmarks.toolbarGuid]);
+ let options = PlacesUtils.history.getNewQueryOptions();
+ options.queryType = options.QUERY_TYPE_BOOKMARKS;
+ let result = PlacesUtils.history.executeQuery(query, options);
+ let notifications = accumulateNotifications(result, true);
+ let root = PlacesUtils.asContainer(result.root);
+ root.containerOpen = true;
+
+ await PlacesUtils.bookmarks.insert({
+ url: "http://mozilla.org",
+ parentGuid: PlacesUtils.bookmarks.toolbarGuid,
+ title: "test",
+ });
+ await PlacesTestUtils.addVisits({
+ uri: "http://mozilla.org",
+ title: "title",
+ });
+
+ notifications.check(["nodeInserted"]);
+
+ info("Change the sorting mode to one that is based on history");
+ notifications = accumulateNotifications(result, true);
+ result.sortingMode = options.SORT_BY_VISITCOUNT_DESCENDING;
+ notifications.check(["invalidateContainer"]);
+
+ notifications = accumulateNotifications(result, true);
+ await PlacesTestUtils.addVisits({
+ uri: "http://mozilla.org",
+ title: "title",
+ });
+ notifications.check(["nodeHistoryDetailsChanged"]);
+
+ root.containerOpen = false;
+ await PlacesUtils.bookmarks.eraseEverything();
+});
diff --git a/toolkit/components/places/tests/queries/test_results-as-left-pane.js b/toolkit/components/places/tests/queries/test_results-as-left-pane.js
new file mode 100644
index 0000000000..6cec733758
--- /dev/null
+++ b/toolkit/components/places/tests/queries/test_results-as-left-pane.js
@@ -0,0 +1,83 @@
+"use strict";
+
+const expectedRoots = [
+ {
+ title: "OrganizerQueryHistory",
+ uri: `place:sort=${Ci.nsINavHistoryQueryOptions.SORT_BY_DATE_DESCENDING}&type=${Ci.nsINavHistoryQueryOptions.RESULTS_AS_DATE_QUERY}`,
+ guid: "history____v",
+ },
+ {
+ title: "OrganizerQueryDownloads",
+ uri: `place:transition=${Ci.nsINavHistoryService.TRANSITION_DOWNLOAD}&sort=${Ci.nsINavHistoryQueryOptions.SORT_BY_DATE_DESCENDING}`,
+ guid: "downloads__v",
+ },
+ {
+ title: "TagsFolderTitle",
+ uri: `place:sort=${Ci.nsINavHistoryQueryOptions.SORT_BY_TITLE_ASCENDING}&type=${Ci.nsINavHistoryQueryOptions.RESULTS_AS_TAGS_ROOT}`,
+ guid: "tags_______v",
+ },
+ {
+ title: "OrganizerQueryAllBookmarks",
+ uri: `place:type=${Ci.nsINavHistoryQueryOptions.RESULTS_AS_ROOTS_QUERY}`,
+ guid: "allbms_____v",
+ },
+];
+
+const placesStrings = Services.strings.createBundle(
+ "chrome://places/locale/places.properties"
+);
+
+function getLeftPaneQuery() {
+ var query = PlacesUtils.history.getNewQuery();
+
+ // Options
+ var options = PlacesUtils.history.getNewQueryOptions();
+ options.resultType = options.RESULTS_AS_LEFT_PANE_QUERY;
+
+ // Results
+ var result = PlacesUtils.history.executeQuery(query, options);
+ return result.root;
+}
+
+function assertExpectedChildren(root, expectedChildren) {
+ Assert.equal(
+ root.childCount,
+ expectedChildren.length,
+ "Should have the expected number of children."
+ );
+
+ for (let i = 0; i < root.childCount; i++) {
+ Assert.ok(
+ PlacesTestUtils.ComparePlacesURIs(
+ root.getChild(i).uri,
+ expectedChildren[i].uri
+ ),
+ "Should have the correct uri for root ${i}"
+ );
+ Assert.equal(
+ root.getChild(i).title,
+ placesStrings.GetStringFromName(expectedChildren[i].title),
+ "Should have the correct title for root ${i}"
+ );
+ Assert.equal(root.getChild(i).bookmarkGuid, expectedChildren[i].guid);
+ }
+}
+
+/**
+ * This test will test the basic RESULTS_AS_ROOTS_QUERY, that simply returns,
+ * the existing bookmark roots.
+ */
+add_task(async function test_results_as_root() {
+ let root = getLeftPaneQuery();
+ root.containerOpen = true;
+
+ Assert.equal(
+ PlacesUtils.asQuery(root).queryOptions.queryType,
+ Ci.nsINavHistoryQueryOptions.QUERY_TYPE_BOOKMARKS,
+ "Should have a query type of QUERY_TYPE_BOOKMARKS"
+ );
+
+ assertExpectedChildren(root, expectedRoots);
+
+ root.containerOpen = false;
+});
diff --git a/toolkit/components/places/tests/queries/test_results-as-roots.js b/toolkit/components/places/tests/queries/test_results-as-roots.js
new file mode 100644
index 0000000000..2f082d3e0b
--- /dev/null
+++ b/toolkit/components/places/tests/queries/test_results-as-roots.js
@@ -0,0 +1,114 @@
+"use strict";
+
+const MOBILE_BOOKMARKS_PREF = "browser.bookmarks.showMobileBookmarks";
+
+const expectedRoots = [
+ {
+ title: "BookmarksToolbarFolderTitle",
+ uri: `place:parent=${PlacesUtils.bookmarks.toolbarGuid}`,
+ guid: PlacesUtils.bookmarks.virtualToolbarGuid,
+ },
+ {
+ title: "BookmarksMenuFolderTitle",
+ uri: `place:parent=${PlacesUtils.bookmarks.menuGuid}`,
+ guid: PlacesUtils.bookmarks.virtualMenuGuid,
+ },
+ {
+ title: "OtherBookmarksFolderTitle",
+ uri: `place:parent=${PlacesUtils.bookmarks.unfiledGuid}`,
+ guid: PlacesUtils.bookmarks.virtualUnfiledGuid,
+ },
+];
+
+const expectedRootsWithMobile = [
+ ...expectedRoots,
+ {
+ title: "MobileBookmarksFolderTitle",
+ uri: `place:parent=${PlacesUtils.bookmarks.mobileGuid}`,
+ guid: PlacesUtils.bookmarks.virtualMobileGuid,
+ },
+];
+
+const placesStrings = Services.strings.createBundle(
+ "chrome://places/locale/places.properties"
+);
+
+function getAllBookmarksQuery() {
+ var query = PlacesUtils.history.getNewQuery();
+
+ // Options
+ var options = PlacesUtils.history.getNewQueryOptions();
+ options.sortingMode = options.SORT_BY_VISITCOUNT_ASCENDING;
+ options.resultType = options.RESULTS_AS_ROOTS_QUERY;
+
+ // Results
+ var result = PlacesUtils.history.executeQuery(query, options);
+ return result.root;
+}
+
+function assertExpectedChildren(root, expectedChildren) {
+ Assert.equal(
+ root.childCount,
+ expectedChildren.length,
+ "Should have the expected number of children."
+ );
+
+ for (let i = 0; i < root.childCount; i++) {
+ Assert.equal(
+ root.getChild(i).uri,
+ expectedChildren[i].uri,
+ "Should have the correct uri for root ${i}"
+ );
+ Assert.equal(
+ root.getChild(i).title,
+ placesStrings.GetStringFromName(expectedChildren[i].title),
+ "Should have the correct title for root ${i}"
+ );
+ Assert.equal(root.getChild(i).bookmarkGuid, expectedChildren[i].guid);
+ }
+}
+
+/**
+ * This test will test the basic RESULTS_AS_ROOTS_QUERY, that simply returns,
+ * the existing bookmark roots.
+ */
+add_task(async function test_results_as_root() {
+ let root = getAllBookmarksQuery();
+ root.containerOpen = true;
+
+ Assert.equal(
+ PlacesUtils.asQuery(root).queryOptions.queryType,
+ Ci.nsINavHistoryQueryOptions.QUERY_TYPE_BOOKMARKS,
+ "Should have a query type of QUERY_TYPE_BOOKMARKS"
+ );
+
+ assertExpectedChildren(root, expectedRoots);
+
+ root.containerOpen = false;
+});
+
+add_task(async function test_results_as_root_with_mobile() {
+ Services.prefs.setBoolPref(MOBILE_BOOKMARKS_PREF, true);
+
+ let root = getAllBookmarksQuery();
+ root.containerOpen = true;
+
+ assertExpectedChildren(root, expectedRootsWithMobile);
+
+ root.containerOpen = false;
+ Services.prefs.clearUserPref(MOBILE_BOOKMARKS_PREF);
+});
+
+add_task(async function test_results_as_root_remove_mobile_dynamic() {
+ Services.prefs.setBoolPref(MOBILE_BOOKMARKS_PREF, true);
+
+ let root = getAllBookmarksQuery();
+ root.containerOpen = true;
+
+ // Now un-set the pref, and poke the database to update the query.
+ Services.prefs.clearUserPref(MOBILE_BOOKMARKS_PREF);
+
+ assertExpectedChildren(root, expectedRoots);
+
+ root.containerOpen = false;
+});
diff --git a/toolkit/components/places/tests/queries/test_results-as-tag-query.js b/toolkit/components/places/tests/queries/test_results-as-tag-query.js
new file mode 100644
index 0000000000..0d4670b658
--- /dev/null
+++ b/toolkit/components/places/tests/queries/test_results-as-tag-query.js
@@ -0,0 +1,63 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/ */
+
+const testData = {
+ "http://foo.com/": ["tag1", "tag 2", "Space ☺️ Between"].sort(),
+ "http://bar.com/": ["tag1", "tag 2"].sort(),
+ "http://baz.com/": ["tag 2", "Space ☺️ Between"].sort(),
+ "http://qux.com/": ["Space ☺️ Between"],
+};
+
+const formattedTestData = [];
+for (const [uri, tagArray] of Object.entries(testData)) {
+ formattedTestData.push({
+ title: `Title of ${uri}`,
+ uri,
+ isBookmark: true,
+ isTag: true,
+ tagArray,
+ });
+}
+
+add_task(async function test_results_as_tags_root() {
+ await task_populateDB(formattedTestData);
+
+ // Construct URL - tag mapping from tag query.
+ const actualData = {};
+ for (const uri in testData) {
+ if (testData.hasOwnProperty(uri)) {
+ actualData[uri] = [];
+ }
+ }
+
+ const options = PlacesUtils.history.getNewQueryOptions();
+ options.resultType = options.RESULTS_AS_TAGS_ROOT;
+ const query = PlacesUtils.history.getNewQuery();
+ const root = PlacesUtils.history.executeQuery(query, options).root;
+
+ root.containerOpen = true;
+ Assert.equal(root.childCount, 3, "We should get as many results as tags.");
+ displayResultSet(root);
+
+ for (let i = 0; i < root.childCount; ++i) {
+ const node = root.getChild(i);
+ const tagName = node.title;
+ Assert.equal(
+ node.type,
+ node.RESULT_TYPE_QUERY,
+ "Result type should be RESULT_TYPE_QUERY."
+ );
+ const subRoot = node.QueryInterface(Ci.nsINavHistoryContainerResultNode);
+ subRoot.containerOpen = true;
+ for (let j = 0; j < subRoot.childCount; ++j) {
+ actualData[subRoot.getChild(j).uri].push(tagName);
+ actualData[subRoot.getChild(j).uri].sort();
+ }
+ }
+
+ Assert.deepEqual(
+ actualData,
+ testData,
+ "URI-tag mapping should be same from query and initial data."
+ );
+});
diff --git a/toolkit/components/places/tests/queries/test_results-as-visit.js b/toolkit/components/places/tests/queries/test_results-as-visit.js
new file mode 100644
index 0000000000..256e756c98
--- /dev/null
+++ b/toolkit/components/places/tests/queries/test_results-as-visit.js
@@ -0,0 +1,158 @@
+/* -*- indent-tabs-mode: nil; js-indent-level: 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/. */
+var testData = [];
+var timeInMicroseconds = PlacesUtils.toPRTime(Date.now() - 10000);
+
+function newTimeInMicroseconds() {
+ timeInMicroseconds = timeInMicroseconds + 1000;
+ return timeInMicroseconds;
+}
+
+function createTestData() {
+ function generateVisits(aPage) {
+ for (var i = 0; i < aPage.visitCount; i++) {
+ testData.push({
+ isInQuery: aPage.inQuery,
+ isVisit: true,
+ title: aPage.title,
+ uri: aPage.uri,
+ lastVisit: newTimeInMicroseconds(),
+ isTag: aPage.tags && !!aPage.tags.length,
+ tagArray: aPage.tags,
+ });
+ }
+ }
+
+ var pages = [
+ {
+ uri: "http://foo.com/",
+ title: "amo",
+ tags: ["moz"],
+ visitCount: 3,
+ inQuery: false,
+ },
+ {
+ uri: "http://moilla.com/",
+ title: "bMoz",
+ tags: ["bugzilla"],
+ visitCount: 5,
+ inQuery: true,
+ },
+ {
+ uri: "http://foo.mail.com/changeme1.html",
+ title: "c Moz",
+ visitCount: 7,
+ inQuery: true,
+ },
+ {
+ uri: "http://foo.mail.com/changeme2.html",
+ tags: ["moz"],
+ title: "",
+ visitCount: 1,
+ inQuery: false,
+ },
+ {
+ uri: "http://foo.mail.com/changeme3.html",
+ title: "zydeco",
+ visitCount: 5,
+ inQuery: false,
+ },
+ ];
+ pages.forEach(generateVisits);
+}
+
+/**
+ * This test will test Queries that use relative search terms and URI options
+ */
+add_task(async function test_results_as_visit() {
+ createTestData();
+ await task_populateDB(testData);
+ var query = PlacesUtils.history.getNewQuery();
+ query.searchTerms = "moz";
+ query.minVisits = 2;
+
+ // Options
+ var options = PlacesUtils.history.getNewQueryOptions();
+ options.sortingMode = options.SORT_BY_VISITCOUNT_ASCENDING;
+ options.resultType = options.RESULTS_AS_VISIT;
+
+ // Results
+ var result = PlacesUtils.history.executeQuery(query, options);
+ var root = result.root;
+ root.containerOpen = true;
+
+ info("Number of items in result set: " + root.childCount);
+ for (let i = 0; i < root.childCount; ++i) {
+ info(
+ "result: " + root.getChild(i).uri + " Title: " + root.getChild(i).title
+ );
+ }
+
+ // Check our inital result set
+ compareArrayToResult(testData, root);
+
+ // If that passes, check liveupdate
+ // Add to the query set
+ info("Adding item to query");
+ var tmp = [];
+ for (let i = 0; i < 2; i++) {
+ tmp.push({
+ isVisit: true,
+ uri: "http://foo.com/added.html",
+ title: "ab moz",
+ });
+ }
+ await task_populateDB(tmp);
+ for (let i = 0; i < 2; i++) {
+ Assert.equal(root.getChild(i).title, "ab moz");
+ }
+
+ // Update an existing URI
+ info("Updating Item");
+ var change2 = [
+ { isVisit: true, title: "moz", uri: "http://foo.mail.com/changeme2.html" },
+ ];
+ await task_populateDB(change2);
+ Assert.ok(nodeInResult(change2, root));
+
+ // Update some visits - add one and take one out of query set, and simply
+ // change one so that it still applies to the query.
+ info("Updating More Items");
+ var change3 = [
+ {
+ isVisit: true,
+ lastVisit: newTimeInMicroseconds(),
+ uri: "http://foo.mail.com/changeme1.html",
+ title: "foo",
+ },
+ {
+ isVisit: true,
+ lastVisit: newTimeInMicroseconds(),
+ uri: "http://foo.mail.com/changeme3.html",
+ title: "moz",
+ isTag: true,
+ tagArray: ["foo", "moz"],
+ },
+ ];
+ await task_populateDB(change3);
+ Assert.ok(!nodeInResult({ uri: "http://foo.mail.com/changeme1.html" }, root));
+ Assert.ok(nodeInResult({ uri: "http://foo.mail.com/changeme3.html" }, root));
+
+ // And now, delete one
+ info("Delete item outside of batch");
+ var change4 = [
+ {
+ isVisit: true,
+ lastVisit: newTimeInMicroseconds(),
+ uri: "http://moilla.com/",
+ title: "mo,z",
+ },
+ ];
+ await task_populateDB(change4);
+ Assert.ok(!nodeInResult(change4, root));
+
+ root.containerOpen = false;
+});
diff --git a/toolkit/components/places/tests/queries/test_searchTerms_includeHidden.js b/toolkit/components/places/tests/queries/test_searchTerms_includeHidden.js
new file mode 100644
index 0000000000..224feb4f0c
--- /dev/null
+++ b/toolkit/components/places/tests/queries/test_searchTerms_includeHidden.js
@@ -0,0 +1,74 @@
+/* -*- indent-tabs-mode: nil; js-indent-level: 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/. */
+
+// Tests the interaction of includeHidden and searchTerms search options.
+
+var timeInMicroseconds = Date.now() * 1000;
+
+const VISITS = [
+ {
+ isVisit: true,
+ transType: TRANSITION_TYPED,
+ uri: "http://redirect.example.com/",
+ title: "example",
+ isRedirect: true,
+ lastVisit: timeInMicroseconds--,
+ },
+ {
+ isVisit: true,
+ transType: TRANSITION_TYPED,
+ uri: "http://target.example.com/",
+ title: "example",
+ lastVisit: timeInMicroseconds--,
+ },
+];
+
+const HIDDEN_VISITS = [
+ {
+ isVisit: true,
+ transType: TRANSITION_FRAMED_LINK,
+ uri: "http://hidden.example.com/",
+ title: "red",
+ lastVisit: timeInMicroseconds--,
+ },
+];
+
+const TEST_DATA = [
+ { searchTerms: "example", includeHidden: true, expectedResults: 2 },
+ { searchTerms: "example", includeHidden: false, expectedResults: 1 },
+ { searchTerms: "red", includeHidden: true, expectedResults: 1 },
+];
+
+add_task(async function test_initalize() {
+ await task_populateDB(VISITS);
+});
+
+add_task(async function test_searchTerms_includeHidden() {
+ for (let data of TEST_DATA) {
+ let query = PlacesUtils.history.getNewQuery();
+ query.searchTerms = data.searchTerms;
+ let options = PlacesUtils.history.getNewQueryOptions();
+ options.includeHidden = data.includeHidden;
+
+ let root = PlacesUtils.history.executeQuery(query, options).root;
+ root.containerOpen = true;
+
+ let cc = root.childCount;
+ // Live update with hidden visits.
+ await task_populateDB(HIDDEN_VISITS);
+ let cc_update = root.childCount;
+
+ root.containerOpen = false;
+
+ Assert.equal(cc, data.expectedResults);
+ Assert.equal(
+ cc_update,
+ data.expectedResults + (data.includeHidden ? 1 : 0)
+ );
+
+ await PlacesUtils.history.remove("http://hidden.example.com/");
+ }
+});
diff --git a/toolkit/components/places/tests/queries/test_searchTerms_time.js b/toolkit/components/places/tests/queries/test_searchTerms_time.js
new file mode 100644
index 0000000000..39fd1353eb
--- /dev/null
+++ b/toolkit/components/places/tests/queries/test_searchTerms_time.js
@@ -0,0 +1,109 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+// This test ensures that visitis are correctly live-updated in a history
+// query filtered on searchterms and time.
+
+const USEC_PER_DAY = 86400000000;
+const now = PlacesUtils.toPRTime(new Date());
+
+add_task(async function pages_query() {
+ let query = PlacesUtils.history.getNewQuery();
+ query.beginTime = now - 15 * USEC_PER_DAY;
+ query.endTime = now - 5 * USEC_PER_DAY;
+ query.beginTimeReference = PlacesUtils.history.TIME_RELATIVE_EPOCH;
+ query.endTimeReference = PlacesUtils.history.TIME_RELATIVE_EPOCH;
+ query.searchTerms = "mo";
+
+ let options = PlacesUtils.history.getNewQueryOptions();
+ options.sortingMode = options.SORT_BY_URI_ASCENDING;
+ options.resultType = options.RESULTS_AS_URI;
+ await testQuery(query, options);
+
+ options.sortingMode = options.SORT_BY_DATE_ASCENDING;
+ options.resultType = options.RESULTS_AS_VISITS;
+ await testQuery(query, options);
+});
+
+async function testQuery(query, options) {
+ let root = PlacesUtils.history.executeQuery(query, options).root;
+ root.containerOpen = true;
+
+ Assert.equal(root.childCount, 0, "There should be zero results initially");
+
+ await PlacesTestUtils.addVisits([
+ // SearchTerms matching URL but out of the timeframe.
+ {
+ url: "https://test.moz.org",
+ title: "abc",
+ visitDate: now - 2 * USEC_PER_DAY,
+ },
+ // In the timeframe but no searchTerms match.
+ {
+ url: "https://test.def.org",
+ title: "def",
+ visitDate: now - 10 * USEC_PER_DAY,
+ },
+ // In the timeframe, matching title.
+ {
+ url: "https://test.ghi.org",
+ title: "amo",
+ visitDate: now - 10 * USEC_PER_DAY,
+ },
+ ]);
+
+ Assert.equal(root.childCount, 1, "Check matching results");
+ let node = root.getChild(0);
+ Assert.equal(node.title, "amo");
+
+ // Change title so it's no longer matching.
+ await PlacesTestUtils.addVisits({
+ url: "https://test.ghi.org",
+ title: "ghi",
+ visitDate: now - 10 * USEC_PER_DAY,
+ });
+
+ Assert.equal(root.childCount, 0, "Check matching results");
+
+ // Add visit in the timeframe.
+ await PlacesTestUtils.addVisits({
+ url: "https://test.moz.org",
+ title: "abc",
+ visitDate: now - 10 * USEC_PER_DAY,
+ });
+
+ Assert.equal(root.childCount, 1, "Check matching results");
+ node = root.getChild(0);
+ Assert.equal(node.title, "abc");
+
+ // Remove visit in the timeframe.
+ await PlacesUtils.history.removeVisitsByFilter({
+ beginDate: PlacesUtils.toDate(now - 15 * USEC_PER_DAY),
+ endDate: PlacesUtils.toDate(now - 5 * USEC_PER_DAY),
+ });
+ await PlacesTestUtils.dumpTable({
+ table: "moz_places",
+ columns: ["id", "url"],
+ });
+ await PlacesTestUtils.dumpTable({
+ table: "moz_historyvisits",
+ columns: ["place_id", "visit_date"],
+ });
+
+ Assert.equal(root.childCount, 0, "Check matching results");
+
+ // Add matching visit out of the timeframe.
+ await PlacesTestUtils.addVisits(
+ // SearchTerms matching URL but out of the timeframe.
+ {
+ url: "https://test.mozilla.org",
+ title: "mozilla",
+ visitDate: now - 2 * USEC_PER_DAY,
+ }
+ );
+
+ Assert.equal(root.childCount, 0, "Check matching results");
+
+ root.containerOpen = false;
+ await PlacesUtils.history.clear();
+}
diff --git a/toolkit/components/places/tests/queries/test_search_tags.js b/toolkit/components/places/tests/queries/test_search_tags.js
new file mode 100644
index 0000000000..4f8cface07
--- /dev/null
+++ b/toolkit/components/places/tests/queries/test_search_tags.js
@@ -0,0 +1,73 @@
+/* -*- indent-tabs-mode: nil; js-indent-level: 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/. */
+
+add_task(async function test_search_for_tagged_bookmarks() {
+ const testURI = "http://a1.com";
+
+ let folder = await PlacesUtils.bookmarks.insert({
+ parentGuid: PlacesUtils.bookmarks.menuGuid,
+ title: "bug 395101 test",
+ type: PlacesUtils.bookmarks.TYPE_FOLDER,
+ });
+
+ let bookmark = await PlacesUtils.bookmarks.insert({
+ parentGuid: folder.guid,
+ title: "1 title",
+ url: testURI,
+ });
+
+ // tag the bookmarked URI
+ PlacesUtils.tagging.tagURI(uri(testURI), [
+ "elephant",
+ "walrus",
+ "giraffe",
+ "turkey",
+ "hiPPo",
+ "BABOON",
+ "alf",
+ ]);
+
+ // search for the bookmark, using a tag
+ var query = PlacesUtils.history.getNewQuery();
+ query.searchTerms = "elephant";
+ var options = PlacesUtils.history.getNewQueryOptions();
+ options.queryType = Ci.nsINavHistoryQueryOptions.QUERY_TYPE_BOOKMARKS;
+ query.setParents([folder.guid]);
+
+ var result = PlacesUtils.history.executeQuery(query, options);
+ var rootNode = result.root;
+ rootNode.containerOpen = true;
+
+ Assert.equal(rootNode.childCount, 1);
+ Assert.equal(rootNode.getChild(0).bookmarkGuid, bookmark.guid);
+ rootNode.containerOpen = false;
+
+ // partial matches are okay
+ query.searchTerms = "wal";
+ result = PlacesUtils.history.executeQuery(query, options);
+ rootNode = result.root;
+ rootNode.containerOpen = true;
+ Assert.equal(rootNode.childCount, 1);
+ rootNode.containerOpen = false;
+
+ // case insensitive search term
+ query.searchTerms = "WALRUS";
+ result = PlacesUtils.history.executeQuery(query, options);
+ rootNode = result.root;
+ rootNode.containerOpen = true;
+ Assert.equal(rootNode.childCount, 1);
+ Assert.equal(rootNode.getChild(0).bookmarkGuid, bookmark.guid);
+ rootNode.containerOpen = false;
+
+ // case insensitive tag
+ query.searchTerms = "baboon";
+ result = PlacesUtils.history.executeQuery(query, options);
+ rootNode = result.root;
+ rootNode.containerOpen = true;
+ Assert.equal(rootNode.childCount, 1);
+ Assert.equal(rootNode.getChild(0).bookmarkGuid, bookmark.guid);
+ rootNode.containerOpen = false;
+});
diff --git a/toolkit/components/places/tests/queries/test_searchterms-bookmarklets.js b/toolkit/components/places/tests/queries/test_searchterms-bookmarklets.js
new file mode 100644
index 0000000000..8eebf68cad
--- /dev/null
+++ b/toolkit/components/places/tests/queries/test_searchterms-bookmarklets.js
@@ -0,0 +1,63 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+// Check that bookmarklets are returned by searches with searchTerms.
+
+var testData = [
+ {
+ isInQuery: true,
+ isBookmark: true,
+ title: "bookmark 1",
+ uri: "http://mozilla.org/script/",
+ },
+
+ {
+ isInQuery: true,
+ isBookmark: true,
+ title: "bookmark 2",
+ uri: "javascript:alert('moz');",
+ },
+];
+
+add_task(async function test_initalize() {
+ await task_populateDB(testData);
+});
+
+add_test(function test_search_by_title() {
+ let query = PlacesUtils.history.getNewQuery();
+ query.searchTerms = "bookmark";
+ let options = PlacesUtils.history.getNewQueryOptions();
+ options.queryType = options.QUERY_TYPE_BOOKMARKS;
+ let root = PlacesUtils.history.executeQuery(query, options).root;
+ root.containerOpen = true;
+ compareArrayToResult(testData, root);
+ root.containerOpen = false;
+
+ run_next_test();
+});
+
+add_test(function test_search_by_schemeToken() {
+ let query = PlacesUtils.history.getNewQuery();
+ query.searchTerms = "script";
+ let options = PlacesUtils.history.getNewQueryOptions();
+ options.queryType = options.QUERY_TYPE_BOOKMARKS;
+ let root = PlacesUtils.history.executeQuery(query, options).root;
+ root.containerOpen = true;
+ compareArrayToResult(testData, root);
+ root.containerOpen = false;
+
+ run_next_test();
+});
+
+add_test(function test_search_by_uriAndTitle() {
+ let query = PlacesUtils.history.getNewQuery();
+ query.searchTerms = "moz";
+ let options = PlacesUtils.history.getNewQueryOptions();
+ options.queryType = options.QUERY_TYPE_BOOKMARKS;
+ let root = PlacesUtils.history.executeQuery(query, options).root;
+ root.containerOpen = true;
+ compareArrayToResult(testData, root);
+ root.containerOpen = false;
+
+ run_next_test();
+});
diff --git a/toolkit/components/places/tests/queries/test_searchterms-domain.js b/toolkit/components/places/tests/queries/test_searchterms-domain.js
new file mode 100644
index 0000000000..45a0a7d542
--- /dev/null
+++ b/toolkit/components/places/tests/queries/test_searchterms-domain.js
@@ -0,0 +1,197 @@
+/* -*- indent-tabs-mode: nil; js-indent-level: 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/. */
+
+// The test data for our database, note that the ordering of the results that
+// will be returned by the query (the isInQuery: true objects) is IMPORTANT.
+// see compareArrayToResult in head_queries.js for more info.
+var testData = [
+ // Test ftp protocol - vary the title length, embed search term
+ {
+ isInQuery: true,
+ isVisit: true,
+ isDetails: true,
+ uri: "ftp://foo.com/ftp",
+ lastVisit: lastweek,
+ title: "hugelongconfmozlagurationofwordswithasearchtermsinit whoo-hoo",
+ },
+
+ // Test flat domain with annotation, search term in sentence
+ {
+ isInQuery: true,
+ isVisit: true,
+ isDetails: true,
+ isPageAnnotation: true,
+ uri: "http://foo.com/",
+ annoName: "moz/test",
+ annoVal: "val",
+ lastVisit: lastweek,
+ title: "you know, moz is cool",
+ },
+
+ // Test subdomain included with isRedirect=true, different transtype
+ {
+ isInQuery: true,
+ isVisit: true,
+ isDetails: true,
+ title: "amozzie",
+ isRedirect: true,
+ uri: "http://mail.foo.com/redirect",
+ lastVisit: old,
+ referrer: "http://myreferrer.com",
+ transType: PlacesUtils.history.TRANSITION_LINK,
+ },
+
+ // Test subdomain inclued, search term at end
+ {
+ isInQuery: true,
+ isVisit: true,
+ isDetails: true,
+ uri: "http://mail.foo.com/yiihah",
+ title: "blahmoz",
+ lastVisit: daybefore,
+ },
+
+ // Test www. style URI is included, with a tag
+ {
+ isInQuery: false,
+ isVisit: true,
+ isDetails: true,
+ isTag: true,
+ uri: "http://www.foo.com/yiihah",
+ tagArray: ["moz"],
+ lastVisit: yesterday,
+ title: "foo",
+ },
+
+ // Test https protocol
+ {
+ isInQuery: true,
+ isVisit: true,
+ isDetails: true,
+ title: "moz",
+ uri: "https://foo.com/",
+ lastVisit: today,
+ },
+
+ // Begin the invalid queries: wrong search term
+ {
+ isInQuery: false,
+ isVisit: true,
+ isDetails: true,
+ title: "m o z",
+ uri: "http://foo.com/tooearly.php",
+ lastVisit: today,
+ },
+
+ // Test bad URI
+ {
+ isInQuery: false,
+ isVisit: true,
+ isDetails: true,
+ title: "moz",
+ uri: "http://sffoo.com/justwrong.htm",
+ lastVisit: yesterday,
+ },
+
+ // Test what we do with escaping in titles
+ {
+ isInQuery: false,
+ isVisit: true,
+ isDetails: true,
+ title: "m%0o%0z",
+ uri: "http://foo.com/changeme1.htm",
+ lastVisit: yesterday,
+ },
+
+ // Test another invalid title - for updating later
+ {
+ isInQuery: false,
+ isVisit: true,
+ isDetails: true,
+ title: "m,oz",
+ uri: "http://foo.com/changeme2.htm",
+ lastVisit: yesterday,
+ },
+];
+
+/**
+ * This test will test Queries that use relative search terms and domain options
+ */
+add_task(async function test_searchterms_domain() {
+ await task_populateDB(testData);
+ var query = PlacesUtils.history.getNewQuery();
+ query.searchTerms = "moz";
+ query.domain = "foo.com";
+ query.domainIsHost = false;
+
+ // Options
+ var options = PlacesUtils.history.getNewQueryOptions();
+ options.sortingMode = options.SORT_BY_DATE_ASCENDING;
+ options.resultType = options.RESULTS_AS_URI;
+
+ // Results
+ var result = PlacesUtils.history.executeQuery(query, options);
+ var root = result.root;
+ root.containerOpen = true;
+
+ info("Number of items in result set: " + root.childCount);
+ for (var i = 0; i < root.childCount; ++i) {
+ info(
+ "result: " + root.getChild(i).uri + " Title: " + root.getChild(i).title
+ );
+ }
+
+ // Check our inital result set
+ compareArrayToResult(testData, root);
+
+ // If that passes, check liveupdate
+ // Add to the query set
+ info("Adding item to query");
+ var change1 = [
+ {
+ isVisit: true,
+ isDetails: true,
+ uri: "http://foo.com/added.htm",
+ title: "moz",
+ transType: PlacesUtils.history.TRANSITION_LINK,
+ },
+ ];
+ await task_populateDB(change1);
+ Assert.ok(nodeInResult(change1, root));
+
+ // Update an existing URI
+ info("Updating Item");
+ var change2 = [
+ { isDetails: true, uri: "http://foo.com/changeme1.htm", title: "moz" },
+ ];
+ await task_populateDB(change2);
+ Assert.ok(nodeInResult(change2, root));
+
+ // Add one and take one out of query set, and simply change one so that it
+ // still applies to the query.
+ info("Updating More Items");
+ var change3 = [
+ { isDetails: true, uri: "http://foo.com/changeme2.htm", title: "moz" },
+ {
+ isDetails: true,
+ uri: "http://mail.foo.com/yiihah",
+ title: "moz now updated",
+ },
+ { isDetails: true, uri: "ftp://foo.com/ftp", title: "gone" },
+ ];
+ await task_populateDB(change3);
+ Assert.ok(nodeInResult({ uri: "http://foo.com/changeme2.htm" }, root));
+ Assert.ok(nodeInResult({ uri: "http://mail.foo.com/yiihah" }, root));
+ Assert.ok(!nodeInResult({ uri: "ftp://foo.com/ftp" }, root));
+
+ // And now, delete one
+ info("Deleting items");
+ var change4 = [{ isDetails: true, uri: "https://foo.com/", title: "mo,z" }];
+ await task_populateDB(change4);
+ Assert.ok(!nodeInResult(change4, root));
+
+ root.containerOpen = false;
+});
diff --git a/toolkit/components/places/tests/queries/test_searchterms-uri.js b/toolkit/components/places/tests/queries/test_searchterms-uri.js
new file mode 100644
index 0000000000..27b9a28c71
--- /dev/null
+++ b/toolkit/components/places/tests/queries/test_searchterms-uri.js
@@ -0,0 +1,125 @@
+/* -*- indent-tabs-mode: nil; js-indent-level: 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/. */
+
+// The test data for our database, note that the ordering of the results that
+// will be returned by the query (the isInQuery: true objects) is IMPORTANT.
+// see compareArrayToResult in head_queries.js for more info.
+var testData = [
+ // Test flat domain with annotation, search term in sentence
+ {
+ isInQuery: true,
+ isVisit: true,
+ isDetails: true,
+ isPageAnnotation: true,
+ uri: "http://foo.com/",
+ annoName: "moz/test",
+ annoVal: "val",
+ lastVisit: lastweek,
+ title: "you know, moz is cool",
+ },
+
+ // Test https protocol
+ {
+ isInQuery: false,
+ isVisit: true,
+ isDetails: true,
+ title: "moz",
+ uri: "https://foo.com/",
+ lastVisit: today,
+ },
+
+ // Begin the invalid queries: wrong search term
+ {
+ isInQuery: false,
+ isVisit: true,
+ isDetails: true,
+ title: "m o z",
+ uri: "http://foo.com/wrongsearch.php",
+ lastVisit: today,
+ },
+
+ // Test subdomain inclued, search term at end
+ {
+ isInQuery: false,
+ isVisit: true,
+ isDetails: true,
+ uri: "http://mail.foo.com/yiihah",
+ title: "blahmoz",
+ lastVisit: daybefore,
+ },
+
+ // Test ftp protocol - vary the title length, embed search term
+ {
+ isInQuery: false,
+ isVisit: true,
+ isDetails: true,
+ uri: "ftp://foo.com/ftp",
+ lastVisit: lastweek,
+ title: "hugelongconfmozlagurationofwordswithasearchtermsinit whoo-hoo",
+ },
+
+ // Test what we do with escaping in titles
+ {
+ isInQuery: false,
+ isVisit: true,
+ isDetails: true,
+ title: "m%0o%0z",
+ uri: "http://foo.com/changeme1.htm",
+ lastVisit: yesterday,
+ },
+
+ // Test another invalid title - for updating later
+ {
+ isInQuery: false,
+ isVisit: true,
+ isDetails: true,
+ title: "m,oz",
+ uri: "http://foo.com/changeme2.htm",
+ lastVisit: yesterday,
+ },
+];
+
+/**
+ * This test will test Queries that use relative search terms and URI options
+ */
+add_task(async function test_searchterms_uri() {
+ await task_populateDB(testData);
+ var query = PlacesUtils.history.getNewQuery();
+ query.searchTerms = "moz";
+ query.uri = uri("http://foo.com");
+
+ // Options
+ var options = PlacesUtils.history.getNewQueryOptions();
+ options.sortingMode = options.SORT_BY_DATE_ASCENDING;
+ options.resultType = options.RESULTS_AS_URI;
+
+ // Results
+ var result = PlacesUtils.history.executeQuery(query, options);
+ var root = result.root;
+ root.containerOpen = true;
+
+ info("Number of items in result set: " + root.childCount);
+ for (var i = 0; i < root.childCount; ++i) {
+ info(
+ "result: " + root.getChild(i).uri + " Title: " + root.getChild(i).title
+ );
+ }
+
+ // Check our inital result set
+ compareArrayToResult(testData, root);
+
+ // live update.
+ info("change title");
+ var change1 = [{ isDetails: true, uri: "http://foo.com/", title: "mo" }];
+ await task_populateDB(change1);
+
+ Assert.ok(!nodeInResult({ uri: "http://foo.com/" }, root));
+ var change2 = [{ isDetails: true, uri: "http://foo.com/", title: "moz" }];
+ await task_populateDB(change2);
+ Assert.ok(nodeInResult({ uri: "http://foo.com/" }, root));
+
+ root.containerOpen = false;
+});
diff --git a/toolkit/components/places/tests/queries/test_sort-date-site-grouping.js b/toolkit/components/places/tests/queries/test_sort-date-site-grouping.js
new file mode 100644
index 0000000000..358ab45fdb
--- /dev/null
+++ b/toolkit/components/places/tests/queries/test_sort-date-site-grouping.js
@@ -0,0 +1,223 @@
+/* -*- indent-tabs-mode: nil; js-indent-level: 2 -*- */
+/* vim:set ts=2 sw=2 sts=2 et: */
+/* ***** BEGIN LICENSE BLOCK *****
+ Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/
+ * ***** END LICENSE BLOCK ***** */
+
+// This test ensures that the date and site type of |place:| query maintains
+// its quantifications correctly. Namely, it ensures that the date part of the
+// query is not lost when the domain queries are made.
+
+// We specifically craft these entries so that if a by Date and Site sorting is
+// applied, we find one domain in the today range, and two domains in the older
+// than six months range.
+// The correspondence between item in |testData| and date range is stored in
+// leveledTestData.
+var testData = [
+ {
+ isVisit: true,
+ uri: "file:///directory/1",
+ lastVisit: today,
+ title: "test visit",
+ isInQuery: true,
+ },
+ {
+ isVisit: true,
+ uri: "http://example.com/1",
+ lastVisit: today,
+ title: "test visit",
+ isInQuery: true,
+ },
+ {
+ isVisit: true,
+ uri: "http://example.com/2",
+ lastVisit: today,
+ title: "test visit",
+ isInQuery: true,
+ },
+ {
+ isVisit: true,
+ uri: "file:///directory/2",
+ lastVisit: olderthansixmonths,
+ title: "test visit",
+ isInQuery: true,
+ },
+ {
+ isVisit: true,
+ uri: "http://example.com/3",
+ lastVisit: olderthansixmonths,
+ title: "test visit",
+ isInQuery: true,
+ },
+ {
+ isVisit: true,
+ uri: "http://example.com/4",
+ lastVisit: olderthansixmonths,
+ title: "test visit",
+ isInQuery: true,
+ },
+ {
+ isVisit: true,
+ uri: "http://example.net/1",
+ lastVisit: olderthansixmonths + 1000,
+ title: "test visit",
+ isInQuery: true,
+ },
+];
+var leveledTestData = [
+ // Today
+ [
+ [0], // Today, local files
+ [1, 2],
+ ], // Today, example.com
+ // Older than six months
+ [
+ [3], // Older than six months, local files
+ [4, 5], // Older than six months, example.com
+ [6], // Older than six months, example.net
+ ],
+];
+
+// This test data is meant for live updating. The |levels| property indicates
+// date range index and then domain index.
+var testDataAddedLater = [
+ {
+ isVisit: true,
+ uri: "http://example.com/5",
+ lastVisit: olderthansixmonths,
+ title: "test visit",
+ isInQuery: true,
+ levels: [1, 1],
+ },
+ {
+ isVisit: true,
+ uri: "http://example.com/6",
+ lastVisit: olderthansixmonths,
+ title: "test visit",
+ isInQuery: true,
+ levels: [1, 1],
+ },
+ {
+ isVisit: true,
+ uri: "http://example.com/7",
+ lastVisit: today,
+ title: "test visit",
+ isInQuery: true,
+ levels: [0, 1],
+ },
+ {
+ isVisit: true,
+ uri: "file:///directory/3",
+ lastVisit: today,
+ title: "test visit",
+ isInQuery: true,
+ levels: [0, 0],
+ },
+];
+
+add_task(async function test_sort_date_site_grouping() {
+ await task_populateDB(testData);
+
+ // On Linux, the (local files) folder is shown after sites unlike Mac/Windows.
+ // Thus, we avoid running this test on Linux but this should be re-enabled
+ // after bug 624024 is resolved.
+ let isLinux = "@mozilla.org/gnome-gconf-service;1" in Cc;
+ if (isLinux) {
+ return;
+ }
+
+ // In this test, there are three levels of results:
+ // 1st: Date queries. e.g., today, last week, or older than 6 months.
+ // 2nd: Domain queries restricted to a date. e.g. mozilla.com today.
+ // 3rd: Actual visits. e.g. mozilla.com/index.html today.
+ //
+ // We store all the third level result roots so that we can easily close all
+ // containers and test live updating into specific results.
+ let roots = [];
+
+ let query = PlacesUtils.history.getNewQuery();
+ let options = PlacesUtils.history.getNewQueryOptions();
+ options.resultType = Ci.nsINavHistoryQueryOptions.RESULTS_AS_DATE_SITE_QUERY;
+
+ let root = PlacesUtils.history.executeQuery(query, options).root;
+ root.containerOpen = true;
+
+ // This corresponds to the number of date ranges.
+ Assert.equal(root.childCount, leveledTestData.length);
+
+ // We pass off to |checkFirstLevel| to check the first level of results.
+ for (let index = 0; index < leveledTestData.length; index++) {
+ let node = root.getChild(index);
+ checkFirstLevel(index, node, roots);
+ }
+
+ // Test live updating.
+ for (let visit of testDataAddedLater) {
+ await task_populateDB([visit]);
+ let oldLength = testData.length;
+ let i = visit.levels[0];
+ let j = visit.levels[1];
+ testData.push(visit);
+ leveledTestData[i][j].push(oldLength);
+ compareArrayToResult(
+ leveledTestData[i][j].map(x => testData[x]),
+ roots[i][j]
+ );
+ }
+
+ for (let i = 0; i < roots.length; i++) {
+ for (let j = 0; j < roots[i].length; j++) {
+ roots[i][j].containerOpen = false;
+ }
+ }
+
+ root.containerOpen = false;
+});
+
+function checkFirstLevel(index, node, roots) {
+ PlacesUtils.asContainer(node).containerOpen = true;
+
+ Assert.ok(PlacesUtils.nodeIsDay(node));
+ PlacesUtils.asQuery(node);
+ let query = node.query;
+ let options = node.queryOptions;
+
+ Assert.ok(query.hasBeginTime && query.hasEndTime);
+
+ // Here we check the second level of results.
+ let root = PlacesUtils.history.executeQuery(query, options).root;
+ roots.push([]);
+ root.containerOpen = true;
+
+ Assert.equal(root.childCount, leveledTestData[index].length);
+ for (var secondIndex = 0; secondIndex < root.childCount; secondIndex++) {
+ let child = PlacesUtils.asQuery(root.getChild(secondIndex));
+ checkSecondLevel(index, secondIndex, child, roots);
+ }
+ root.containerOpen = false;
+ node.containerOpen = false;
+}
+
+function checkSecondLevel(index, secondIndex, child, roots) {
+ let query = child.query;
+ let options = child.queryOptions;
+
+ Assert.ok(query.hasDomain);
+ Assert.ok(query.hasBeginTime && query.hasEndTime);
+
+ let root = PlacesUtils.history.executeQuery(query, options).root;
+ // We should now have that roots[index][secondIndex] is set to the second
+ // level's results root.
+ roots[index].push(root);
+
+ // We pass off to compareArrayToResult to check the third level of
+ // results.
+ root.containerOpen = true;
+ compareArrayToResult(
+ leveledTestData[index][secondIndex].map(x => testData[x]),
+ root
+ );
+ // We close |root|'s container later so that we can test live
+ // updates into it.
+}
diff --git a/toolkit/components/places/tests/queries/test_sorting.js b/toolkit/components/places/tests/queries/test_sorting.js
new file mode 100644
index 0000000000..41059c0823
--- /dev/null
+++ b/toolkit/components/places/tests/queries/test_sorting.js
@@ -0,0 +1,961 @@
+/* -*- indent-tabs-mode: nil; js-indent-level: 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/. */
+
+var tests = [];
+
+tests.push({
+ _sortingMode: Ci.nsINavHistoryQueryOptions.SORT_BY_NONE,
+
+ async setup() {
+ info("Sorting test 1: SORT BY NONE");
+
+ this._unsortedData = [
+ {
+ isBookmark: true,
+ uri: "http://example.com/b",
+ parentGuid: PlacesUtils.bookmarks.toolbarGuid,
+ index: PlacesUtils.bookmarks.DEFAULT_INDEX,
+ title: "y",
+ isInQuery: true,
+ },
+
+ {
+ isBookmark: true,
+ uri: "http://example.com/a",
+ parentGuid: PlacesUtils.bookmarks.toolbarGuid,
+ index: PlacesUtils.bookmarks.DEFAULT_INDEX,
+ title: "z",
+ isInQuery: true,
+ },
+
+ {
+ isBookmark: true,
+ uri: "http://example.com/c",
+ parentGuid: PlacesUtils.bookmarks.toolbarGuid,
+ index: PlacesUtils.bookmarks.DEFAULT_INDEX,
+ title: "x",
+ isInQuery: true,
+ },
+ ];
+
+ this._sortedData = this._unsortedData;
+
+ // This function in head_queries.js creates our database with the above data
+ await task_populateDB(this._unsortedData);
+ },
+
+ check() {
+ // Query
+ var query = PlacesUtils.history.getNewQuery();
+ query.setParents([PlacesUtils.bookmarks.toolbarGuid]);
+
+ // query options
+ var options = PlacesUtils.history.getNewQueryOptions();
+ options.sortingMode = this._sortingMode;
+
+ // Results - this gets the result set and opens it for reading and modification.
+ var result = PlacesUtils.history.executeQuery(query, options);
+ var root = result.root;
+ root.containerOpen = true;
+ compareArrayToResult(this._sortedData, root);
+ root.containerOpen = false;
+ },
+
+ check_reverse() {
+ // no reverse sorting for SORT BY NONE
+ },
+});
+
+tests.push({
+ _sortingMode: Ci.nsINavHistoryQueryOptions.SORT_BY_TITLE_ASCENDING,
+
+ async setup() {
+ info("Sorting test 2: SORT BY TITLE");
+
+ this._unsortedData = [
+ {
+ isBookmark: true,
+ uri: "http://example.com/b1",
+ parentGuid: PlacesUtils.bookmarks.toolbarGuid,
+ index: PlacesUtils.bookmarks.DEFAULT_INDEX,
+ title: "y",
+ isInQuery: true,
+ },
+
+ {
+ isBookmark: true,
+ uri: "http://example.com/a",
+ parentGuid: PlacesUtils.bookmarks.toolbarGuid,
+ index: PlacesUtils.bookmarks.DEFAULT_INDEX,
+ title: "z",
+ isInQuery: true,
+ },
+
+ {
+ isBookmark: true,
+ uri: "http://example.com/c",
+ parentGuid: PlacesUtils.bookmarks.toolbarGuid,
+ index: PlacesUtils.bookmarks.DEFAULT_INDEX,
+ title: "x",
+ isInQuery: true,
+ },
+
+ // if titles are equal, should fall back to URI
+ {
+ isBookmark: true,
+ uri: "http://example.com/b2",
+ parentGuid: PlacesUtils.bookmarks.toolbarGuid,
+ index: PlacesUtils.bookmarks.DEFAULT_INDEX,
+ title: "y",
+ isInQuery: true,
+ },
+ ];
+
+ this._sortedData = [
+ this._unsortedData[2],
+ this._unsortedData[0],
+ this._unsortedData[3],
+ this._unsortedData[1],
+ ];
+
+ // This function in head_queries.js creates our database with the above data
+ await task_populateDB(this._unsortedData);
+ },
+
+ check() {
+ // Query
+ var query = PlacesUtils.history.getNewQuery();
+ query.setParents([PlacesUtils.bookmarks.toolbarGuid]);
+
+ // query options
+ var options = PlacesUtils.history.getNewQueryOptions();
+ options.sortingMode = this._sortingMode;
+
+ // Results - this gets the result set and opens it for reading and modification.
+ var result = PlacesUtils.history.executeQuery(query, options);
+ var root = result.root;
+ root.containerOpen = true;
+ compareArrayToResult(this._sortedData, root);
+ root.containerOpen = false;
+ },
+
+ check_reverse() {
+ this._sortingMode = Ci.nsINavHistoryQueryOptions.SORT_BY_TITLE_DESCENDING;
+ this._sortedData.reverse();
+ this.check();
+ },
+});
+
+tests.push({
+ _sortingMode: Ci.nsINavHistoryQueryOptions.SORT_BY_DATE_ASCENDING,
+
+ async setup() {
+ info("Sorting test 3: SORT BY DATE");
+
+ var timeInMicroseconds = Date.now() * 1000;
+ this._unsortedData = [
+ {
+ isVisit: true,
+ isDetails: true,
+ isBookmark: true,
+ parentGuid: PlacesUtils.bookmarks.toolbarGuid,
+ index: 0,
+ uri: "http://example.com/c1",
+ lastVisit: timeInMicroseconds - 2000,
+ title: "x1",
+ isInQuery: true,
+ },
+
+ {
+ isVisit: true,
+ isDetails: true,
+ isBookmark: true,
+ parentGuid: PlacesUtils.bookmarks.toolbarGuid,
+ index: 1,
+ uri: "http://example.com/a",
+ lastVisit: timeInMicroseconds - 1000,
+ title: "z",
+ isInQuery: true,
+ },
+
+ {
+ isVisit: true,
+ isDetails: true,
+ isBookmark: true,
+ parentGuid: PlacesUtils.bookmarks.toolbarGuid,
+ index: 2,
+ uri: "http://example.com/b",
+ lastVisit: timeInMicroseconds - 3000,
+ title: "y",
+ isInQuery: true,
+ },
+
+ // if dates are equal, should fall back to title
+ {
+ isVisit: true,
+ isDetails: true,
+ isBookmark: true,
+ parentGuid: PlacesUtils.bookmarks.toolbarGuid,
+ index: 3,
+ uri: "http://example.com/c2",
+ lastVisit: timeInMicroseconds - 2000,
+ title: "x2",
+ isInQuery: true,
+ },
+
+ // if dates and title are equal, should fall back to bookmark index
+ {
+ isVisit: true,
+ isDetails: true,
+ isBookmark: true,
+ parentGuid: PlacesUtils.bookmarks.toolbarGuid,
+ index: 4,
+ uri: "http://example.com/c2",
+ lastVisit: timeInMicroseconds - 2000,
+ title: "x2",
+ isInQuery: true,
+ },
+ ];
+
+ this._sortedData = [
+ this._unsortedData[2],
+ this._unsortedData[0],
+ this._unsortedData[3],
+ this._unsortedData[4],
+ this._unsortedData[1],
+ ];
+
+ // This function in head_queries.js creates our database with the above data
+ await task_populateDB(this._unsortedData);
+ },
+
+ check() {
+ // Query
+ var query = PlacesUtils.history.getNewQuery();
+ query.setParents([PlacesUtils.bookmarks.toolbarGuid]);
+
+ // query options
+ var options = PlacesUtils.history.getNewQueryOptions();
+ options.sortingMode = this._sortingMode;
+
+ // Results - this gets the result set and opens it for reading and modification.
+ var result = PlacesUtils.history.executeQuery(query, options);
+ var root = result.root;
+ root.containerOpen = true;
+ compareArrayToResult(this._sortedData, root);
+ root.containerOpen = false;
+ },
+
+ check_reverse() {
+ this._sortingMode = Ci.nsINavHistoryQueryOptions.SORT_BY_DATE_DESCENDING;
+ this._sortedData.reverse();
+ this.check();
+ },
+});
+
+tests.push({
+ _sortingMode: Ci.nsINavHistoryQueryOptions.SORT_BY_URI_ASCENDING,
+
+ async setup() {
+ info("Sorting test 4: SORT BY URI");
+
+ var timeInMicroseconds = Date.now() * 1000;
+ this._unsortedData = [
+ {
+ isBookmark: true,
+ isDetails: true,
+ lastVisit: timeInMicroseconds,
+ uri: "http://example.com/b",
+ parentGuid: PlacesUtils.bookmarks.toolbarGuid,
+ index: 0,
+ title: "y",
+ isInQuery: true,
+ },
+
+ {
+ isBookmark: true,
+ uri: "http://example.com/c",
+ parentGuid: PlacesUtils.bookmarks.toolbarGuid,
+ index: 1,
+ title: "x",
+ isInQuery: true,
+ },
+
+ {
+ isBookmark: true,
+ uri: "http://example.com/a",
+ parentGuid: PlacesUtils.bookmarks.toolbarGuid,
+ index: 2,
+ title: "z",
+ isInQuery: true,
+ },
+
+ // if URIs are equal, should fall back to date
+ {
+ isBookmark: true,
+ isDetails: true,
+ lastVisit: timeInMicroseconds + 1000,
+ uri: "http://example.com/c",
+ parentGuid: PlacesUtils.bookmarks.toolbarGuid,
+ index: 3,
+ title: "x",
+ isInQuery: true,
+ },
+
+ // if no URI (e.g., node is a folder), should fall back to title
+ {
+ isFolder: true,
+ parentGuid: PlacesUtils.bookmarks.toolbarGuid,
+ index: 4,
+ title: "y",
+ isInQuery: true,
+ },
+
+ // if URIs and dates are equal, should fall back to bookmark index
+ {
+ isBookmark: true,
+ isDetails: true,
+ lastVisit: timeInMicroseconds + 1000,
+ uri: "http://example.com/c",
+ parentGuid: PlacesUtils.bookmarks.toolbarGuid,
+ index: 5,
+ title: "x",
+ isInQuery: true,
+ },
+
+ // if no URI and titles are equal, should fall back to bookmark index
+ {
+ isFolder: true,
+ parentGuid: PlacesUtils.bookmarks.toolbarGuid,
+ index: 6,
+ title: "y",
+ isInQuery: true,
+ },
+
+ // if no URI and titles are equal, should fall back to title
+ {
+ isFolder: true,
+ parentGuid: PlacesUtils.bookmarks.toolbarGuid,
+ index: 7,
+ title: "z",
+ isInQuery: true,
+ },
+
+ // Separator should go after folders.
+ {
+ isSeparator: true,
+ parentGuid: PlacesUtils.bookmarks.toolbarGuid,
+ index: 8,
+ isInQuery: true,
+ },
+ ];
+
+ this._sortedData = [
+ this._unsortedData[4],
+ this._unsortedData[6],
+ this._unsortedData[7],
+ this._unsortedData[8],
+ this._unsortedData[2],
+ this._unsortedData[0],
+ this._unsortedData[1],
+ this._unsortedData[3],
+ this._unsortedData[5],
+ ];
+
+ // This function in head_queries.js creates our database with the above data
+ await task_populateDB(this._unsortedData);
+ },
+
+ check() {
+ // Query
+ var query = PlacesUtils.history.getNewQuery();
+ query.setParents([PlacesUtils.bookmarks.toolbarGuid]);
+
+ // query options
+ var options = PlacesUtils.history.getNewQueryOptions();
+ options.sortingMode = this._sortingMode;
+
+ // Results - this gets the result set and opens it for reading and modification.
+ var result = PlacesUtils.history.executeQuery(query, options);
+ var root = result.root;
+ root.containerOpen = true;
+ compareArrayToResult(this._sortedData, root);
+ root.containerOpen = false;
+ },
+
+ check_reverse() {
+ this._sortingMode = Ci.nsINavHistoryQueryOptions.SORT_BY_URI_DESCENDING;
+ this._sortedData.reverse();
+ this.check();
+ },
+});
+
+tests.push({
+ _sortingMode: Ci.nsINavHistoryQueryOptions.SORT_BY_VISITCOUNT_ASCENDING,
+
+ async setup() {
+ info("Sorting test 5: SORT BY VISITCOUNT");
+
+ var timeInMicroseconds = Date.now() * 1000;
+ this._unsortedData = [
+ {
+ isBookmark: true,
+ uri: "http://example.com/a",
+ lastVisit: timeInMicroseconds,
+ title: "z",
+ parentGuid: PlacesUtils.bookmarks.toolbarGuid,
+ index: 0,
+ isInQuery: true,
+ },
+
+ {
+ isBookmark: true,
+ uri: "http://example.com/c",
+ lastVisit: timeInMicroseconds,
+ title: "x",
+ parentGuid: PlacesUtils.bookmarks.toolbarGuid,
+ index: 1,
+ isInQuery: true,
+ },
+
+ {
+ isBookmark: true,
+ uri: "http://example.com/b1",
+ lastVisit: timeInMicroseconds,
+ title: "y1",
+ parentGuid: PlacesUtils.bookmarks.toolbarGuid,
+ index: 2,
+ isInQuery: true,
+ },
+
+ // if visitCounts are equal, should fall back to date
+ {
+ isBookmark: true,
+ uri: "http://example.com/b2",
+ lastVisit: timeInMicroseconds + 1000,
+ title: "y2a",
+ parentGuid: PlacesUtils.bookmarks.toolbarGuid,
+ index: 3,
+ isInQuery: true,
+ },
+
+ // if visitCounts and dates are equal, should fall back to bookmark index
+ {
+ isBookmark: true,
+ uri: "http://example.com/b2",
+ lastVisit: timeInMicroseconds + 1000,
+ title: "y2b",
+ parentGuid: PlacesUtils.bookmarks.toolbarGuid,
+ index: 4,
+ isInQuery: true,
+ },
+ ];
+
+ this._sortedData = [
+ this._unsortedData[0],
+ this._unsortedData[2],
+ this._unsortedData[3],
+ this._unsortedData[4],
+ this._unsortedData[1],
+ ];
+
+ // This function in head_queries.js creates our database with the above data
+ await task_populateDB(this._unsortedData);
+ // add visits to increase visit count
+ await PlacesTestUtils.addVisits([
+ {
+ uri: uri("http://example.com/a"),
+ transition: TRANSITION_TYPED,
+ visitDate: timeInMicroseconds,
+ },
+ {
+ uri: uri("http://example.com/b1"),
+ transition: TRANSITION_TYPED,
+ visitDate: timeInMicroseconds,
+ },
+ {
+ uri: uri("http://example.com/b1"),
+ transition: TRANSITION_TYPED,
+ visitDate: timeInMicroseconds,
+ },
+ {
+ uri: uri("http://example.com/b2"),
+ transition: TRANSITION_TYPED,
+ visitDate: timeInMicroseconds + 1000,
+ },
+ {
+ uri: uri("http://example.com/b2"),
+ transition: TRANSITION_TYPED,
+ visitDate: timeInMicroseconds + 1000,
+ },
+ {
+ uri: uri("http://example.com/c"),
+ transition: TRANSITION_TYPED,
+ visitDate: timeInMicroseconds,
+ },
+ {
+ uri: uri("http://example.com/c"),
+ transition: TRANSITION_TYPED,
+ visitDate: timeInMicroseconds,
+ },
+ {
+ uri: uri("http://example.com/c"),
+ transition: TRANSITION_TYPED,
+ visitDate: timeInMicroseconds,
+ },
+ ]);
+ },
+
+ check() {
+ // Query
+ var query = PlacesUtils.history.getNewQuery();
+ query.setParents([PlacesUtils.bookmarks.toolbarGuid]);
+
+ // query options
+ var options = PlacesUtils.history.getNewQueryOptions();
+ options.sortingMode = this._sortingMode;
+
+ // Results - this gets the result set and opens it for reading and modification.
+ var result = PlacesUtils.history.executeQuery(query, options);
+ var root = result.root;
+ root.containerOpen = true;
+ compareArrayToResult(this._sortedData, root);
+ root.containerOpen = false;
+ },
+
+ check_reverse() {
+ this._sortingMode =
+ Ci.nsINavHistoryQueryOptions.SORT_BY_VISITCOUNT_DESCENDING;
+ this._sortedData.reverse();
+ this.check();
+ },
+});
+
+tests.push({
+ _sortingMode: Ci.nsINavHistoryQueryOptions.SORT_BY_DATEADDED_ASCENDING,
+
+ async setup() {
+ info("Sorting test 7: SORT BY DATEADDED");
+
+ var timeInMicroseconds = Date.now() * 1000;
+ this._unsortedData = [
+ {
+ isBookmark: true,
+ uri: "http://example.com/b1",
+ parentGuid: PlacesUtils.bookmarks.toolbarGuid,
+ index: 0,
+ title: "y1",
+ dateAdded: timeInMicroseconds - 1000,
+ isInQuery: true,
+ },
+
+ {
+ isBookmark: true,
+ uri: "http://example.com/a",
+ parentGuid: PlacesUtils.bookmarks.toolbarGuid,
+ index: 1,
+ title: "z",
+ dateAdded: timeInMicroseconds - 2000,
+ isInQuery: true,
+ },
+
+ {
+ isBookmark: true,
+ uri: "http://example.com/c",
+ parentGuid: PlacesUtils.bookmarks.toolbarGuid,
+ index: 2,
+ title: "x",
+ dateAdded: timeInMicroseconds,
+ isInQuery: true,
+ },
+
+ // if dateAddeds are equal, should fall back to title
+ {
+ isBookmark: true,
+ uri: "http://example.com/b2",
+ parentGuid: PlacesUtils.bookmarks.toolbarGuid,
+ index: 3,
+ title: "y2",
+ dateAdded: timeInMicroseconds - 1000,
+ isInQuery: true,
+ },
+
+ // if dateAddeds and titles are equal, should fall back to bookmark index
+ {
+ isBookmark: true,
+ uri: "http://example.com/b3",
+ parentGuid: PlacesUtils.bookmarks.toolbarGuid,
+ index: 4,
+ title: "y3",
+ dateAdded: timeInMicroseconds - 1000,
+ isInQuery: true,
+ },
+ ];
+
+ this._sortedData = [
+ this._unsortedData[1],
+ this._unsortedData[0],
+ this._unsortedData[3],
+ this._unsortedData[4],
+ this._unsortedData[2],
+ ];
+
+ // This function in head_queries.js creates our database with the above data
+ await task_populateDB(this._unsortedData);
+ },
+
+ check() {
+ // Query
+ var query = PlacesUtils.history.getNewQuery();
+ query.setParents([PlacesUtils.bookmarks.toolbarGuid]);
+
+ // query options
+ var options = PlacesUtils.history.getNewQueryOptions();
+ options.sortingMode = this._sortingMode;
+
+ // Results - this gets the result set and opens it for reading and modification.
+ var result = PlacesUtils.history.executeQuery(query, options);
+ var root = result.root;
+ root.containerOpen = true;
+ compareArrayToResult(this._sortedData, root);
+ root.containerOpen = false;
+ },
+
+ check_reverse() {
+ this._sortingMode =
+ Ci.nsINavHistoryQueryOptions.SORT_BY_DATEADDED_DESCENDING;
+ this._sortedData.reverse();
+ this.check();
+ },
+});
+
+tests.push({
+ _sortingMode: Ci.nsINavHistoryQueryOptions.SORT_BY_LASTMODIFIED_ASCENDING,
+
+ async setup() {
+ info("Sorting test 8: SORT BY LASTMODIFIED");
+
+ var timeInMicroseconds = Date.now() * 1000;
+ var timeAddedInMicroseconds = timeInMicroseconds - 10000;
+
+ this._unsortedData = [
+ {
+ isBookmark: true,
+ uri: "http://example.com/b1",
+ parentGuid: PlacesUtils.bookmarks.toolbarGuid,
+ index: 0,
+ title: "y1",
+ dateAdded: timeAddedInMicroseconds,
+ lastModified: timeInMicroseconds - 1000,
+ isInQuery: true,
+ },
+
+ {
+ isBookmark: true,
+ uri: "http://example.com/a",
+ parentGuid: PlacesUtils.bookmarks.toolbarGuid,
+ index: 1,
+ title: "z",
+ dateAdded: timeAddedInMicroseconds,
+ lastModified: timeInMicroseconds - 2000,
+ isInQuery: true,
+ },
+
+ {
+ isBookmark: true,
+ uri: "http://example.com/c",
+ parentGuid: PlacesUtils.bookmarks.toolbarGuid,
+ index: 2,
+ title: "x",
+ dateAdded: timeAddedInMicroseconds,
+ lastModified: timeInMicroseconds,
+ isInQuery: true,
+ },
+
+ // if lastModifieds are equal, should fall back to title
+ {
+ isBookmark: true,
+ uri: "http://example.com/b2",
+ parentGuid: PlacesUtils.bookmarks.toolbarGuid,
+ index: 3,
+ title: "y2",
+ dateAdded: timeAddedInMicroseconds,
+ lastModified: timeInMicroseconds - 1000,
+ isInQuery: true,
+ },
+
+ // if lastModifieds and titles are equal, should fall back to bookmark
+ // index
+ {
+ isBookmark: true,
+ uri: "http://example.com/b3",
+ parentGuid: PlacesUtils.bookmarks.toolbarGuid,
+ index: 4,
+ title: "y3",
+ dateAdded: timeAddedInMicroseconds,
+ lastModified: timeInMicroseconds - 1000,
+ isInQuery: true,
+ },
+ ];
+
+ this._sortedData = [
+ this._unsortedData[1],
+ this._unsortedData[0],
+ this._unsortedData[3],
+ this._unsortedData[4],
+ this._unsortedData[2],
+ ];
+
+ // This function in head_queries.js creates our database with the above data
+ await task_populateDB(this._unsortedData);
+ },
+
+ check() {
+ // Query
+ var query = PlacesUtils.history.getNewQuery();
+ query.setParents([PlacesUtils.bookmarks.toolbarGuid]);
+
+ // query options
+ var options = PlacesUtils.history.getNewQueryOptions();
+ options.sortingMode = this._sortingMode;
+
+ // Results - this gets the result set and opens it for reading and modification.
+ var result = PlacesUtils.history.executeQuery(query, options);
+ var root = result.root;
+ root.containerOpen = true;
+ compareArrayToResult(this._sortedData, root);
+ root.containerOpen = false;
+ },
+
+ check_reverse() {
+ this._sortingMode =
+ Ci.nsINavHistoryQueryOptions.SORT_BY_LASTMODIFIED_DESCENDING;
+ this._sortedData.reverse();
+ this.check();
+ },
+});
+
+tests.push({
+ _sortingMode: Ci.nsINavHistoryQueryOptions.SORT_BY_TAGS_ASCENDING,
+
+ async setup() {
+ info("Sorting test 9: SORT BY TAGS");
+
+ this._unsortedData = [
+ {
+ isBookmark: true,
+ uri: "http://url2.com/",
+ parentGuid: PlacesUtils.bookmarks.toolbarGuid,
+ index: PlacesUtils.bookmarks.DEFAULT_INDEX,
+ title: "title x",
+ isTag: true,
+ tagArray: ["x", "y", "z"],
+ isInQuery: true,
+ },
+
+ {
+ isBookmark: true,
+ uri: "http://url1a.com/",
+ parentGuid: PlacesUtils.bookmarks.toolbarGuid,
+ index: PlacesUtils.bookmarks.DEFAULT_INDEX,
+ title: "title y1",
+ isTag: true,
+ tagArray: ["a", "b"],
+ isInQuery: true,
+ },
+
+ {
+ isBookmark: true,
+ uri: "http://url3a.com/",
+ parentGuid: PlacesUtils.bookmarks.toolbarGuid,
+ index: PlacesUtils.bookmarks.DEFAULT_INDEX,
+ title: "title w1",
+ isInQuery: true,
+ },
+
+ {
+ isBookmark: true,
+ uri: "http://url0.com/",
+ parentGuid: PlacesUtils.bookmarks.toolbarGuid,
+ index: PlacesUtils.bookmarks.DEFAULT_INDEX,
+ title: "title z",
+ isTag: true,
+ tagArray: ["a", "y", "z"],
+ isInQuery: true,
+ },
+
+ // if tags are equal, should fall back to title
+ {
+ isBookmark: true,
+ uri: "http://url1b.com/",
+ parentGuid: PlacesUtils.bookmarks.toolbarGuid,
+ index: PlacesUtils.bookmarks.DEFAULT_INDEX,
+ title: "title y2",
+ isTag: true,
+ tagArray: ["b", "a"],
+ isInQuery: true,
+ },
+
+ // if tags are equal, should fall back to title
+ {
+ isBookmark: true,
+ uri: "http://url3b.com/",
+ parentGuid: PlacesUtils.bookmarks.toolbarGuid,
+ index: PlacesUtils.bookmarks.DEFAULT_INDEX,
+ title: "title w2",
+ isInQuery: true,
+ },
+ ];
+
+ this._sortedData = [
+ this._unsortedData[2],
+ this._unsortedData[5],
+ this._unsortedData[1],
+ this._unsortedData[4],
+ this._unsortedData[3],
+ this._unsortedData[0],
+ ];
+
+ // This function in head_queries.js creates our database with the above data
+ await task_populateDB(this._unsortedData);
+ },
+
+ check() {
+ // Query
+ var query = PlacesUtils.history.getNewQuery();
+ query.setParents([PlacesUtils.bookmarks.toolbarGuid]);
+
+ // query options
+ var options = PlacesUtils.history.getNewQueryOptions();
+ options.sortingMode = this._sortingMode;
+
+ // Results - this gets the result set and opens it for reading and modification.
+ var result = PlacesUtils.history.executeQuery(query, options);
+ var root = result.root;
+ root.containerOpen = true;
+ compareArrayToResult(this._sortedData, root);
+ root.containerOpen = false;
+ },
+
+ check_reverse() {
+ this._sortingMode = Ci.nsINavHistoryQueryOptions.SORT_BY_TAGS_DESCENDING;
+ this._sortedData.reverse();
+ this.check();
+ },
+});
+
+// SORT_BY_FRECENCY_*
+
+tests.push({
+ _sortingMode: Ci.nsINavHistoryQueryOptions.SORT_BY_FRECENCY_ASCENDING,
+
+ async setup() {
+ info("Sorting test 13: SORT BY FRECENCY ");
+
+ let timeInMicroseconds = PlacesUtils.toPRTime(Date.now() - 10000);
+
+ function newTimeInMicroseconds() {
+ timeInMicroseconds = timeInMicroseconds + 1000;
+ return timeInMicroseconds;
+ }
+
+ this._unsortedData = [
+ {
+ isVisit: true,
+ isDetails: true,
+ uri: "http://moz.com/",
+ lastVisit: newTimeInMicroseconds(),
+ title: "I",
+ isInQuery: true,
+ },
+
+ {
+ isVisit: true,
+ isDetails: true,
+ uri: "http://moz.com/",
+ lastVisit: newTimeInMicroseconds(),
+ title: "I",
+ isInQuery: true,
+ },
+
+ {
+ isVisit: true,
+ isDetails: true,
+ uri: "http://moz.com/",
+ lastVisit: newTimeInMicroseconds(),
+ title: "I",
+ isInQuery: true,
+ },
+
+ {
+ isVisit: true,
+ isDetails: true,
+ uri: "http://is.com/",
+ lastVisit: newTimeInMicroseconds(),
+ title: "love",
+ isInQuery: true,
+ },
+
+ {
+ isVisit: true,
+ isDetails: true,
+ uri: "http://best.com/",
+ lastVisit: newTimeInMicroseconds(),
+ title: "moz",
+ isInQuery: true,
+ },
+
+ {
+ isVisit: true,
+ isDetails: true,
+ uri: "http://best.com/",
+ lastVisit: newTimeInMicroseconds(),
+ title: "moz",
+ isInQuery: true,
+ },
+ ];
+
+ this._sortedData = [
+ this._unsortedData[3],
+ this._unsortedData[5],
+ this._unsortedData[2],
+ ];
+
+ // This function in head_queries.js creates our database with the above data
+ await task_populateDB(this._unsortedData);
+ },
+
+ check() {
+ var query = PlacesUtils.history.getNewQuery();
+ var options = PlacesUtils.history.getNewQueryOptions();
+ options.sortingMode = this._sortingMode;
+
+ var root = PlacesUtils.history.executeQuery(query, options).root;
+ root.containerOpen = true;
+ compareArrayToResult(this._sortedData, root);
+ root.containerOpen = false;
+ },
+
+ check_reverse() {
+ this._sortingMode =
+ Ci.nsINavHistoryQueryOptions.SORT_BY_FRECENCY_DESCENDING;
+ this._sortedData.reverse();
+ this.check();
+ },
+});
+
+add_task(async function test_sorting() {
+ for (let test of tests) {
+ await test.setup();
+ await PlacesTestUtils.promiseAsyncUpdates();
+ test.check();
+ // sorting reversed, usually SORT_BY have ASC and DESC
+ test.check_reverse();
+ // Execute cleanup tasks
+ await PlacesUtils.bookmarks.eraseEverything();
+ await PlacesUtils.history.clear();
+ }
+});
diff --git a/toolkit/components/places/tests/queries/test_tags.js b/toolkit/components/places/tests/queries/test_tags.js
new file mode 100644
index 0000000000..17ad3478ce
--- /dev/null
+++ b/toolkit/components/places/tests/queries/test_tags.js
@@ -0,0 +1,626 @@
+/* -*- indent-tabs-mode: nil; js-indent-level: 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/. */
+
+/**
+ * Tests bookmark queries with tags. See bug 399799.
+ */
+
+"use strict";
+
+add_task(async function tags_getter_setter() {
+ info("Tags getter/setter should work correctly");
+ info("Without setting tags, tags getter should return empty array");
+ var [query] = makeQuery();
+ Assert.equal(query.tags.length, 0);
+
+ info("Setting tags to an empty array, tags getter should return empty array");
+ [query] = makeQuery([]);
+ Assert.equal(query.tags.length, 0);
+
+ info("Setting a few tags, tags getter should return correct array");
+ var tags = ["bar", "baz", "foo"];
+ [query] = makeQuery(tags);
+ setsAreEqual(query.tags, tags, true);
+
+ info("Setting some dupe tags, tags getter return unique tags");
+ [query] = makeQuery(["foo", "foo", "bar", "foo", "baz", "bar"]);
+ setsAreEqual(query.tags, ["bar", "baz", "foo"], true);
+});
+
+add_task(async function invalid_setter_calls() {
+ info("Invalid calls to tags setter should fail");
+ try {
+ var query = PlacesUtils.history.getNewQuery();
+ query.tags = null;
+ do_throw("Passing null to SetTags should fail");
+ } catch (exc) {}
+
+ try {
+ query = PlacesUtils.history.getNewQuery();
+ query.tags = "this should not work";
+ do_throw("Passing a string to SetTags should fail");
+ } catch (exc) {}
+
+ try {
+ makeQuery([null]);
+ do_throw("Passing one-element array with null to SetTags should fail");
+ } catch (exc) {}
+
+ try {
+ makeQuery([undefined]);
+ do_throw("Passing one-element array with undefined to SetTags should fail");
+ } catch (exc) {}
+
+ try {
+ makeQuery(["foo", null, "bar"]);
+ do_throw("Passing mixture of tags and null to SetTags should fail");
+ } catch (exc) {}
+
+ try {
+ makeQuery(["foo", undefined, "bar"]);
+ do_throw("Passing mixture of tags and undefined to SetTags should fail");
+ } catch (exc) {}
+
+ try {
+ makeQuery([1, 2, 3]);
+ do_throw("Passing numbers to SetTags should fail");
+ } catch (exc) {}
+
+ try {
+ makeQuery(["foo", 1, 2, 3]);
+ do_throw("Passing mixture of tags and numbers to SetTags should fail");
+ } catch (exc) {}
+
+ try {
+ var str = PlacesUtils.toISupportsString("foo");
+ query = PlacesUtils.history.getNewQuery();
+ query.tags = str;
+ do_throw("Passing nsISupportsString to SetTags should fail");
+ } catch (exc) {}
+
+ try {
+ makeQuery([str]);
+ do_throw("Passing array of nsISupportsStrings to SetTags should fail");
+ } catch (exc) {}
+});
+
+add_task(async function not_setting_tags() {
+ info("Not setting tags at all should not affect query URI");
+ checkQueryURI();
+});
+
+add_task(async function empty_array_tags() {
+ info("Setting tags with an empty array should not affect query URI");
+ checkQueryURI([]);
+});
+
+add_task(async function set_tags() {
+ info("Setting some tags should result in correct query URI");
+ checkQueryURI([
+ "foo",
+ "七難",
+ "",
+ "いっぱいおっぱい",
+ "Abracadabra",
+ "123",
+ "Here's a pretty long tag name with some = signs and 1 2 3s and spaces oh jeez will it work I hope so!",
+ "アスキーでございません",
+ "あいうえお",
+ ]);
+});
+
+add_task(async function no_tags_tagsAreNot() {
+ info(
+ "Not setting tags at all but setting tagsAreNot should " +
+ "affect query URI"
+ );
+ checkQueryURI(null, true);
+});
+
+add_task(async function empty_array_tags_tagsAreNot() {
+ info(
+ "Setting tags with an empty array and setting tagsAreNot " +
+ "should affect query URI"
+ );
+ checkQueryURI([], true);
+});
+
+add_task(async function () {
+ info(
+ "Setting some tags and setting tagsAreNot should result in " +
+ "correct query URI"
+ );
+ checkQueryURI(
+ [
+ "foo",
+ "七難",
+ "",
+ "いっぱいおっぱい",
+ "Abracadabra",
+ "123",
+ "Here's a pretty long tag name with some = signs and 1 2 3s and spaces oh jeez will it work I hope so!",
+ "アスキーでございません",
+ "あいうえお",
+ ],
+ true
+ );
+});
+
+add_task(async function tag() {
+ info("Querying on tag associated with a URI should return that URI");
+ await task_doWithBookmark(["foo", "bar", "baz"], function (aURI) {
+ var [query, opts] = makeQuery(["foo"]);
+ executeAndCheckQueryResults(query, opts, [aURI.spec]);
+ [query, opts] = makeQuery(["bar"]);
+ executeAndCheckQueryResults(query, opts, [aURI.spec]);
+ [query, opts] = makeQuery(["baz"]);
+ executeAndCheckQueryResults(query, opts, [aURI.spec]);
+ });
+});
+
+add_task(async function many_tags() {
+ info("Querying on many tags associated with a URI should return that URI");
+ await task_doWithBookmark(["foo", "bar", "baz"], function (aURI) {
+ var [query, opts] = makeQuery(["foo", "bar"]);
+ executeAndCheckQueryResults(query, opts, [aURI.spec]);
+ [query, opts] = makeQuery(["foo", "baz"]);
+ executeAndCheckQueryResults(query, opts, [aURI.spec]);
+ [query, opts] = makeQuery(["bar", "baz"]);
+ executeAndCheckQueryResults(query, opts, [aURI.spec]);
+ [query, opts] = makeQuery(["foo", "bar", "baz"]);
+ executeAndCheckQueryResults(query, opts, [aURI.spec]);
+ });
+});
+
+add_task(async function repeated_tag() {
+ info("Specifying the same tag multiple times should not matter");
+ await task_doWithBookmark(["foo", "bar", "baz"], function (aURI) {
+ var [query, opts] = makeQuery(["foo", "foo"]);
+ executeAndCheckQueryResults(query, opts, [aURI.spec]);
+ [query, opts] = makeQuery(["foo", "foo", "foo", "bar", "bar", "baz"]);
+ executeAndCheckQueryResults(query, opts, [aURI.spec]);
+ });
+});
+
+add_task(async function many_tags_no_bookmark() {
+ info(
+ "Querying on many tags associated with a URI and tags not associated " +
+ "with that URI should not return that URI"
+ );
+ await task_doWithBookmark(["foo", "bar", "baz"], function (aURI) {
+ var [query, opts] = makeQuery(["foo", "bogus"]);
+ executeAndCheckQueryResults(query, opts, []);
+ [query, opts] = makeQuery(["foo", "bar", "bogus"]);
+ executeAndCheckQueryResults(query, opts, []);
+ [query, opts] = makeQuery(["foo", "bar", "baz", "bogus"]);
+ executeAndCheckQueryResults(query, opts, []);
+ });
+});
+
+add_task(async function nonexistent_tags() {
+ info("Querying on nonexistent tag should return no results");
+ await task_doWithBookmark(["foo", "bar", "baz"], function (aURI) {
+ var [query, opts] = makeQuery(["bogus"]);
+ executeAndCheckQueryResults(query, opts, []);
+ [query, opts] = makeQuery(["bogus", "gnarly"]);
+ executeAndCheckQueryResults(query, opts, []);
+ });
+});
+
+add_task(async function tagsAreNot() {
+ info("Querying bookmarks using tagsAreNot should work correctly");
+ var urisAndTags = {
+ "http://example.com/1": ["foo", "bar"],
+ "http://example.com/2": ["baz", "qux"],
+ "http://example.com/3": null,
+ };
+
+ info("Add bookmarks and tag the URIs");
+ for (let [pURI, tags] of Object.entries(urisAndTags)) {
+ let nsiuri = uri(pURI);
+ await addBookmark(nsiuri);
+ if (tags) {
+ PlacesUtils.tagging.tagURI(nsiuri, tags);
+ }
+ }
+
+ info(' Querying for "foo" should match only /2 and /3');
+ var [query, opts] = makeQuery(["foo"], true);
+ queryResultsAre(PlacesUtils.history.executeQuery(query, opts).root, [
+ "http://example.com/2",
+ "http://example.com/3",
+ ]);
+
+ info(' Querying for "foo" and "bar" should match only /2 and /3');
+ [query, opts] = makeQuery(["foo", "bar"], true);
+ queryResultsAre(PlacesUtils.history.executeQuery(query, opts).root, [
+ "http://example.com/2",
+ "http://example.com/3",
+ ]);
+
+ info(' Querying for "foo" and "bogus" should match only /2 and /3');
+ [query, opts] = makeQuery(["foo", "bogus"], true);
+ queryResultsAre(PlacesUtils.history.executeQuery(query, opts).root, [
+ "http://example.com/2",
+ "http://example.com/3",
+ ]);
+
+ info(' Querying for "foo" and "baz" should match only /3');
+ [query, opts] = makeQuery(["foo", "baz"], true);
+ queryResultsAre(PlacesUtils.history.executeQuery(query, opts).root, [
+ "http://example.com/3",
+ ]);
+
+ info(' Querying for "bogus" should match all');
+ [query, opts] = makeQuery(["bogus"], true);
+ queryResultsAre(PlacesUtils.history.executeQuery(query, opts).root, [
+ "http://example.com/1",
+ "http://example.com/2",
+ "http://example.com/3",
+ ]);
+
+ // Clean up.
+ for (let [pURI, tags] of Object.entries(urisAndTags)) {
+ let nsiuri = uri(pURI);
+ if (tags) {
+ PlacesUtils.tagging.untagURI(nsiuri, tags);
+ }
+ }
+ await task_cleanDatabase();
+});
+
+add_task(async function duplicate_tags() {
+ info(
+ "Duplicate existing tags (i.e., multiple tag folders with " +
+ "same name) should not throw off query results"
+ );
+ var tagName = "foo";
+
+ info("Add bookmark and tag it normally");
+ await addBookmark(TEST_URI);
+ PlacesUtils.tagging.tagURI(TEST_URI, [tagName]);
+
+ info("Manually create tag folder with same name as tag and insert bookmark");
+ let dupTag = await PlacesUtils.bookmarks.insert({
+ parentGuid: PlacesUtils.bookmarks.tagsGuid,
+ type: PlacesUtils.bookmarks.TYPE_FOLDER,
+ title: tagName,
+ });
+
+ await PlacesUtils.bookmarks.insert({
+ parentGuid: dupTag.guid,
+ title: "title",
+ url: TEST_URI,
+ });
+
+ info("Querying for tag should match URI");
+ var [query, opts] = makeQuery([tagName]);
+ queryResultsAre(PlacesUtils.history.executeQuery(query, opts).root, [
+ TEST_URI.spec,
+ ]);
+
+ PlacesUtils.tagging.untagURI(TEST_URI, [tagName]);
+ await task_cleanDatabase();
+});
+
+add_task(async function folder_named_as_tag() {
+ info(
+ "Regular folders with the same name as tag should not throw " +
+ "off query results"
+ );
+ var tagName = "foo";
+
+ info("Add bookmark and tag it");
+ await addBookmark(TEST_URI);
+ PlacesUtils.tagging.tagURI(TEST_URI, [tagName]);
+
+ info("Create folder with same name as tag");
+ await PlacesUtils.bookmarks.insert({
+ parentGuid: PlacesUtils.bookmarks.unfiledGuid,
+ type: PlacesUtils.bookmarks.TYPE_FOLDER,
+ title: tagName,
+ });
+
+ info("Querying for tag should match URI");
+ var [query, opts] = makeQuery([tagName]);
+ queryResultsAre(PlacesUtils.history.executeQuery(query, opts).root, [
+ TEST_URI.spec,
+ ]);
+
+ PlacesUtils.tagging.untagURI(TEST_URI, [tagName]);
+ await task_cleanDatabase();
+});
+
+add_task(async function ORed_queries() {
+ info("Multiple queries ORed together should work");
+ var urisAndTags = {
+ "http://example.com/1": [],
+ "http://example.com/2": [],
+ };
+
+ // Search with lots of tags to make sure tag parameter substitution in SQL
+ // can handle it with more than one query.
+ for (let i = 0; i < 11; i++) {
+ urisAndTags["http://example.com/1"].push("/1 tag " + i);
+ urisAndTags["http://example.com/2"].push("/2 tag " + i);
+ }
+
+ info("Add bookmarks and tag the URIs");
+ for (let [pURI, tags] of Object.entries(urisAndTags)) {
+ let nsiuri = uri(pURI);
+ await addBookmark(nsiuri);
+ if (tags) {
+ PlacesUtils.tagging.tagURI(nsiuri, tags);
+ }
+ }
+
+ // Clean up.
+ for (let [pURI, tags] of Object.entries(urisAndTags)) {
+ let nsiuri = uri(pURI);
+ if (tags) {
+ PlacesUtils.tagging.untagURI(nsiuri, tags);
+ }
+ }
+ await task_cleanDatabase();
+});
+
+add_task(async function tag_casing() {
+ info(
+ "Querying on associated tags should return " +
+ "correct results irrespective of casing of tags."
+ );
+ await task_doWithBookmark(["fOo", "bAr"], function (aURI) {
+ let [query, opts] = makeQuery(["Foo"]);
+ executeAndCheckQueryResults(query, opts, [aURI.spec]);
+ [query, opts] = makeQuery(["Foo", "Bar"]);
+ executeAndCheckQueryResults(query, opts, [aURI.spec]);
+ [query, opts] = makeQuery(["Foo"], true);
+ executeAndCheckQueryResults(query, opts, []);
+ [query, opts] = makeQuery(["Bogus"], true);
+ executeAndCheckQueryResults(query, opts, [aURI.spec]);
+ });
+});
+
+add_task(async function tag_casing_l10n() {
+ info(
+ "Querying on associated tags should return " +
+ "correct results irrespective of casing of tags with international strings."
+ );
+ // \u041F is a lowercase \u043F
+ await task_doWithBookmark(
+ ["\u041F\u0442\u0438\u0446\u044B"],
+ function (aURI) {
+ let [query, opts] = makeQuery(["\u041F\u0442\u0438\u0446\u044B"]);
+ executeAndCheckQueryResults(query, opts, [aURI.spec]);
+ [query, opts] = makeQuery(["\u043F\u0442\u0438\u0446\u044B"]);
+ executeAndCheckQueryResults(query, opts, [aURI.spec]);
+ }
+ );
+ await task_doWithBookmark(
+ ["\u043F\u0442\u0438\u0446\u044B"],
+ function (aURI) {
+ let [query, opts] = makeQuery(["\u041F\u0442\u0438\u0446\u044B"]);
+ executeAndCheckQueryResults(query, opts, [aURI.spec]);
+ [query, opts] = makeQuery(["\u043F\u0442\u0438\u0446\u044B"]);
+ executeAndCheckQueryResults(query, opts, [aURI.spec]);
+ }
+ );
+});
+
+add_task(async function tag_special_char() {
+ info(
+ "Querying on associated tags should return " +
+ "correct results even if tags contain special characters."
+ );
+ await task_doWithBookmark(["Space ☺️ Between"], function (aURI) {
+ let [query, opts] = makeQuery(["Space ☺️ Between"]);
+ executeAndCheckQueryResults(query, opts, [aURI.spec]);
+ [query, opts] = makeQuery(["Space ☺️ Between"], true);
+ executeAndCheckQueryResults(query, opts, []);
+ [query, opts] = makeQuery(["Bogus"], true);
+ executeAndCheckQueryResults(query, opts, [aURI.spec]);
+ });
+});
+
+// The tag keys in query URIs, i.e., "place:tag=foo&!tags=1"
+// --- -----
+const QUERY_KEY_TAG = "tag";
+const QUERY_KEY_NOT_TAGS = "!tags";
+
+const TEST_URI = uri("http://example.com/");
+
+/**
+ * Adds a bookmark.
+ *
+ * @param aURI
+ * URI of the page (an nsIURI)
+ */
+function addBookmark(aURI) {
+ return PlacesUtils.bookmarks.insert({
+ parentGuid: PlacesUtils.bookmarks.unfiledGuid,
+ title: aURI.spec,
+ url: aURI,
+ });
+}
+
+/**
+ * Asynchronous task that removes all pages from history and bookmarks.
+ */
+async function task_cleanDatabase(aCallback) {
+ await PlacesUtils.bookmarks.eraseEverything();
+ await PlacesUtils.history.clear();
+}
+
+/**
+ * Sets up a query with the specified tags, converts it to a URI, and makes sure
+ * the URI is what we expect it to be.
+ *
+ * @param aTags
+ * The query's tags will be set to those in this array
+ * @param aTagsAreNot
+ * The query's tagsAreNot property will be set to this
+ */
+function checkQueryURI(aTags, aTagsAreNot) {
+ var pairs = (aTags || []).sort().map(t => QUERY_KEY_TAG + "=" + encodeTag(t));
+ if (aTagsAreNot) {
+ pairs.push(QUERY_KEY_NOT_TAGS + "=1");
+ }
+ var expURI = "place:" + pairs.join("&");
+ var [query, opts] = makeQuery(aTags, aTagsAreNot);
+ var actualURI = queryURI(query, opts);
+ info("Query URI should be what we expect for the given tags");
+ Assert.equal(actualURI, expURI);
+}
+
+/**
+ * Asynchronous task that executes a callback task in a "scoped" database state.
+ * A bookmark is added and tagged before the callback is called, and afterward
+ * the database is cleared.
+ *
+ * @param aTags
+ * A bookmark will be added and tagged with this array of tags
+ * @param aCallback
+ * A task function that will be called after the bookmark has been tagged
+ */
+async function task_doWithBookmark(aTags, aCallback) {
+ await addBookmark(TEST_URI);
+ PlacesUtils.tagging.tagURI(TEST_URI, aTags);
+ await aCallback(TEST_URI);
+ PlacesUtils.tagging.untagURI(TEST_URI, aTags);
+ await task_cleanDatabase();
+}
+
+/**
+ * queryToQueryString() encodes every character in the query URI that doesn't
+ * match /[a-zA-Z]/. There's no simple JavaScript function that does the same,
+ * but encodeURIComponent() comes close, only missing some punctuation. This
+ * function takes care of all of that.
+ *
+ * @param aTag
+ * A tag name to encode
+ * @return A UTF-8 escaped string suitable for inclusion in a query URI
+ */
+function encodeTag(aTag) {
+ return encodeURIComponent(aTag).replace(
+ /[-_.!~*'()]/g, // '
+ s => "%" + s.charCodeAt(0).toString(16)
+ );
+}
+
+/**
+ * Executes the given query and compares the results to the given URIs.
+ * See queryResultsAre().
+ *
+ * @param aQuery
+ * An nsINavHistoryQuery
+ * @param aQueryOpts
+ * An nsINavHistoryQueryOptions
+ * @param aExpectedURIs
+ * Array of URIs (as strings) that aResultRoot should contain
+ */
+function executeAndCheckQueryResults(aQuery, aQueryOpts, aExpectedURIs) {
+ var root = PlacesUtils.history.executeQuery(aQuery, aQueryOpts).root;
+ root.containerOpen = true;
+ queryResultsAre(root, aExpectedURIs);
+ root.containerOpen = false;
+}
+
+/**
+ * Returns new query and query options objects. The query's tags will be
+ * set to aTags. aTags may be null, in which case setTags() is not called at
+ * all on the query.
+ *
+ * @param aTags
+ * The query's tags will be set to those in this array
+ * @param aTagsAreNot
+ * The query's tagsAreNot property will be set to this
+ * @return [query, queryOptions]
+ */
+function makeQuery(aTags, aTagsAreNot) {
+ aTagsAreNot = !!aTagsAreNot;
+ info(
+ "Making a query " +
+ (aTags
+ ? "with tags " + aTags.toSource()
+ : "without calling setTags() at all") +
+ " and with tagsAreNot=" +
+ aTagsAreNot
+ );
+ var query = PlacesUtils.history.getNewQuery();
+ query.tagsAreNot = aTagsAreNot;
+ if (aTags) {
+ query.tags = aTags;
+ var uniqueTags = [];
+ aTags.forEach(function (t) {
+ if (typeof t === "string" && !uniqueTags.includes(t)) {
+ uniqueTags.push(t);
+ }
+ });
+ uniqueTags.sort();
+ }
+
+ info("Made query should be correct for tags and tagsAreNot");
+ if (uniqueTags) {
+ setsAreEqual(query.tags, uniqueTags, true);
+ }
+ var expCount = uniqueTags ? uniqueTags.length : 0;
+ Assert.equal(query.tags.length, expCount);
+ Assert.equal(query.tagsAreNot, aTagsAreNot);
+
+ return [query, PlacesUtils.history.getNewQueryOptions()];
+}
+
+/**
+ * Ensures that the URIs of aResultRoot are the same as those in aExpectedURIs.
+ *
+ * @param aResultRoot
+ * The nsINavHistoryContainerResultNode root of an nsINavHistoryResult
+ * @param aExpectedURIs
+ * Array of URIs (as strings) that aResultRoot should contain
+ */
+function queryResultsAre(aResultRoot, aExpectedURIs) {
+ var rootWasOpen = aResultRoot.containerOpen;
+ if (!rootWasOpen) {
+ aResultRoot.containerOpen = true;
+ }
+ var actualURIs = [];
+ for (let i = 0; i < aResultRoot.childCount; i++) {
+ actualURIs.push(aResultRoot.getChild(i).uri);
+ }
+ setsAreEqual(actualURIs, aExpectedURIs);
+ if (!rootWasOpen) {
+ aResultRoot.containerOpen = false;
+ }
+}
+
+/**
+ * Converts the given query into its query URI.
+ *
+ * @param aQuery
+ * An nsINavHistoryQuery
+ * @param aQueryOpts
+ * An nsINavHistoryQueryOptions
+ * @return The query's URI
+ */
+function queryURI(aQuery, aQueryOpts) {
+ return PlacesUtils.history.queryToQueryString(aQuery, aQueryOpts);
+}
+
+/**
+ * Ensures that the arrays contain the same elements and, optionally, in the
+ * same order.
+ */
+function setsAreEqual(aArr1, aArr2, aIsOrdered) {
+ Assert.equal(aArr1.length, aArr2.length);
+ if (aIsOrdered) {
+ for (let i = 0; i < aArr1.length; i++) {
+ Assert.equal(aArr1[i], aArr2[i]);
+ }
+ } else {
+ aArr1.forEach(u => Assert.ok(aArr2.includes(u)));
+ aArr2.forEach(u => Assert.ok(aArr1.includes(u)));
+ }
+}
diff --git a/toolkit/components/places/tests/queries/test_transitions.js b/toolkit/components/places/tests/queries/test_transitions.js
new file mode 100644
index 0000000000..3055f28e9f
--- /dev/null
+++ b/toolkit/components/places/tests/queries/test_transitions.js
@@ -0,0 +1,175 @@
+/* -*- indent-tabs-mode: nil; js-indent-level: 2 -*- */
+/* vim:set ts=2 sw=2 sts=2 et: */
+/* ***** BEGIN LICENSE BLOCK *****
+ Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/
+ * ***** END LICENSE BLOCK ***** */
+var testData = [
+ {
+ isVisit: true,
+ title: "page 0",
+ uri: "http://mozilla.com/",
+ transType: Ci.nsINavHistoryService.TRANSITION_TYPED,
+ },
+ {
+ isVisit: true,
+ title: "page 1",
+ uri: "http://google.com/",
+ transType: Ci.nsINavHistoryService.TRANSITION_DOWNLOAD,
+ },
+ {
+ isVisit: true,
+ title: "page 2",
+ uri: "http://microsoft.com/",
+ transType: Ci.nsINavHistoryService.TRANSITION_DOWNLOAD,
+ },
+ {
+ isVisit: true,
+ title: "page 3",
+ uri: "http://en.wikipedia.org/",
+ transType: Ci.nsINavHistoryService.TRANSITION_BOOKMARK,
+ },
+ {
+ isVisit: true,
+ title: "page 4",
+ uri: "http://fr.wikipedia.org/",
+ transType: Ci.nsINavHistoryService.TRANSITION_DOWNLOAD,
+ },
+ {
+ isVisit: true,
+ title: "page 5",
+ uri: "http://apple.com/",
+ transType: Ci.nsINavHistoryService.TRANSITION_TYPED,
+ },
+ {
+ isVisit: true,
+ title: "page 6",
+ uri: "http://campus-bike-store.com/",
+ transType: Ci.nsINavHistoryService.TRANSITION_DOWNLOAD,
+ },
+ {
+ isVisit: true,
+ title: "page 7",
+ uri: "http://uwaterloo.ca/",
+ transType: Ci.nsINavHistoryService.TRANSITION_TYPED,
+ },
+ {
+ isVisit: true,
+ title: "page 8",
+ uri: "http://pugcleaner.com/",
+ transType: Ci.nsINavHistoryService.TRANSITION_BOOKMARK,
+ },
+ {
+ isVisit: true,
+ title: "page 9",
+ uri: "http://de.wikipedia.org/",
+ transType: Ci.nsINavHistoryService.TRANSITION_TYPED,
+ },
+ {
+ isVisit: true,
+ title: "arewefastyet",
+ uri: "http://arewefastyet.com/",
+ transType: Ci.nsINavHistoryService.TRANSITION_DOWNLOAD,
+ },
+ {
+ isVisit: true,
+ title: "arewefastyet",
+ uri: "http://arewefastyet.com/",
+ transType: Ci.nsINavHistoryService.TRANSITION_BOOKMARK,
+ },
+];
+// sets of indices of testData array by transition type
+var testDataTyped = [0, 5, 7, 9];
+var testDataDownload = [1, 2, 4, 6, 10];
+var testDataBookmark = [3, 8, 11];
+
+add_task(async function test_transitions() {
+ let timeNow = Date.now();
+ for (let item of testData) {
+ await PlacesTestUtils.addVisits({
+ uri: uri(item.uri),
+ transition: item.transType,
+ visitDate: timeNow++ * 1000,
+ title: item.title,
+ });
+ }
+
+ // dump_table("moz_places");
+ // dump_table("moz_historyvisits");
+
+ var numSortFunc = function (a, b) {
+ return a - b;
+ };
+ var arrs = testDataTyped
+ .concat(testDataDownload)
+ .concat(testDataBookmark)
+ .sort(numSortFunc);
+
+ // Four tests which compare the result of a query to an expected set.
+ var data = arrs.filter(function (index) {
+ return (
+ testData[index].uri.match(/arewefastyet\.com/) &&
+ testData[index].transType == Ci.nsINavHistoryService.TRANSITION_DOWNLOAD
+ );
+ });
+
+ compareQueryToTestData(
+ "place:domain=arewefastyet.com&transition=" +
+ Ci.nsINavHistoryService.TRANSITION_DOWNLOAD,
+ data.slice()
+ );
+
+ compareQueryToTestData(
+ "place:transition=" + Ci.nsINavHistoryService.TRANSITION_DOWNLOAD,
+ testDataDownload.slice()
+ );
+
+ compareQueryToTestData(
+ "place:transition=" + Ci.nsINavHistoryService.TRANSITION_TYPED,
+ testDataTyped.slice()
+ );
+
+ compareQueryToTestData(
+ "place:transition=" +
+ Ci.nsINavHistoryService.TRANSITION_DOWNLOAD +
+ "&transition=" +
+ Ci.nsINavHistoryService.TRANSITION_BOOKMARK,
+ data
+ );
+
+ // Tests the live update property of transitions.
+ var query = {};
+ var options = {};
+ PlacesUtils.history.queryStringToQuery(
+ "place:transition=" + Ci.nsINavHistoryService.TRANSITION_DOWNLOAD,
+ query,
+ options
+ );
+ var result = PlacesUtils.history.executeQuery(query.value, options.value);
+ var root = result.root;
+ root.containerOpen = true;
+ Assert.equal(testDataDownload.length, root.childCount);
+ await PlacesTestUtils.addVisits({
+ uri: uri("http://getfirefox.com"),
+ transition: TRANSITION_DOWNLOAD,
+ });
+ Assert.equal(testDataDownload.length + 1, root.childCount);
+ root.containerOpen = false;
+});
+
+/*
+ * Takes a query and a set of indices. The indices correspond to elements
+ * of testData that are the result of the query.
+ */
+function compareQueryToTestData(queryStr, data) {
+ var query = {};
+ var options = {};
+ PlacesUtils.history.queryStringToQuery(queryStr, query, options);
+ var result = PlacesUtils.history.executeQuery(query.value, options.value);
+ var root = result.root;
+ for (var i = 0; i < data.length; i++) {
+ data[i] = testData[data[i]];
+ data[i].isInQuery = true;
+ }
+ compareArrayToResult(data, root);
+}
diff --git a/toolkit/components/places/tests/queries/xpcshell.toml b/toolkit/components/places/tests/queries/xpcshell.toml
new file mode 100644
index 0000000000..b171df8c00
--- /dev/null
+++ b/toolkit/components/places/tests/queries/xpcshell.toml
@@ -0,0 +1,57 @@
+[DEFAULT]
+head = "head_queries.js"
+skip-if = ["os == 'android'"]
+
+["test_async.js"]
+
+["test_bookmarks.js"]
+
+["test_containersQueries_sorting.js"]
+
+["test_downloadHistory_liveUpdate.js"]
+
+["test_excludeQueries.js"]
+
+["test_history_queries_tags_liveUpdate.js"]
+
+["test_history_queries_titles_liveUpdate.js"]
+
+["test_options_inherit.js"]
+
+["test_queryMultipleFolder.js"]
+
+["test_querySerialization.js"]
+
+["test_query_uri_liveupdate.js"]
+
+["test_redirects.js"]
+
+["test_result_observeHistoryDetails.js"]
+
+["test_results-as-left-pane.js"]
+
+["test_results-as-roots.js"]
+
+["test_results-as-tag-query.js"]
+
+["test_results-as-visit.js"]
+
+["test_searchTerms_includeHidden.js"]
+
+["test_searchTerms_time.js"]
+
+["test_search_tags.js"]
+
+["test_searchterms-bookmarklets.js"]
+
+["test_searchterms-domain.js"]
+
+["test_searchterms-uri.js"]
+
+["test_sort-date-site-grouping.js"]
+
+["test_sorting.js"]
+
+["test_tags.js"]
+
+["test_transitions.js"]
diff --git a/toolkit/components/places/tests/sync/head_sync.js b/toolkit/components/places/tests/sync/head_sync.js
new file mode 100644
index 0000000000..7dd69e275b
--- /dev/null
+++ b/toolkit/components/places/tests/sync/head_sync.js
@@ -0,0 +1,461 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/ */
+
+// Import common head.
+{
+ /* import-globals-from ../head_common.js */
+ let commonFile = do_get_file("../head_common.js", false);
+ let uri = Services.io.newFileURI(commonFile);
+ Services.scriptloader.loadSubScript(uri.spec, this);
+}
+
+// Put any other stuff relative to this test folder below.
+
+var { CanonicalJSON } = ChromeUtils.importESModule(
+ "resource://gre/modules/CanonicalJSON.sys.mjs"
+);
+var { Log } = ChromeUtils.importESModule("resource://gre/modules/Log.sys.mjs");
+
+var { PlacesSyncUtils } = ChromeUtils.importESModule(
+ "resource://gre/modules/PlacesSyncUtils.sys.mjs"
+);
+var { SyncedBookmarksMirror } = ChromeUtils.importESModule(
+ "resource://gre/modules/SyncedBookmarksMirror.sys.mjs"
+);
+var { CommonUtils } = ChromeUtils.importESModule(
+ "resource://services-common/utils.sys.mjs"
+);
+var { FileTestUtils } = ChromeUtils.importESModule(
+ "resource://testing-common/FileTestUtils.sys.mjs"
+);
+var {
+ HTTP_400,
+ HTTP_401,
+ HTTP_402,
+ HTTP_403,
+ HTTP_404,
+ HTTP_405,
+ HTTP_406,
+ HTTP_407,
+ HTTP_408,
+ HTTP_409,
+ HTTP_410,
+ HTTP_411,
+ HTTP_412,
+ HTTP_413,
+ HTTP_414,
+ HTTP_415,
+ HTTP_417,
+ HTTP_500,
+ HTTP_501,
+ HTTP_502,
+ HTTP_503,
+ HTTP_504,
+ HTTP_505,
+ HttpError,
+ HttpServer,
+} = ChromeUtils.importESModule("resource://testing-common/httpd.sys.mjs");
+
+// These titles are defined in Database::CreateBookmarkRoots
+const BookmarksMenuTitle = "menu";
+const BookmarksToolbarTitle = "toolbar";
+const UnfiledBookmarksTitle = "unfiled";
+const MobileBookmarksTitle = "mobile";
+
+function run_test() {
+ let bufLog = Log.repository.getLogger("Sync.Engine.Bookmarks.Mirror");
+ bufLog.level = Log.Level.All;
+
+ let sqliteLog = Log.repository.getLogger("Sqlite");
+ sqliteLog.level = Log.Level.Error;
+
+ let formatter = new Log.BasicFormatter();
+ let appender = new Log.DumpAppender(formatter);
+ appender.level = Log.Level.All;
+
+ for (let log of [bufLog, sqliteLog]) {
+ log.addAppender(appender);
+ }
+
+ do_get_profile();
+ run_next_test();
+}
+
+// A test helper to insert local roots directly into Places, since the public
+// bookmarks APIs no longer support custom roots.
+async function insertLocalRoot({ guid, title }) {
+ await PlacesUtils.withConnectionWrapper(
+ "insertLocalRoot",
+ async function (db) {
+ let dateAdded = PlacesUtils.toPRTime(new Date());
+ await db.execute(
+ `
+ INSERT INTO moz_bookmarks(guid, type, parent, position, title,
+ dateAdded, lastModified)
+ VALUES(:guid, :type, (SELECT id FROM moz_bookmarks
+ WHERE guid = :parentGuid),
+ (SELECT COUNT(*) FROM moz_bookmarks
+ WHERE parent = (SELECT id FROM moz_bookmarks
+ WHERE guid = :parentGuid)),
+ :title, :dateAdded, :dateAdded)`,
+ {
+ guid,
+ type: PlacesUtils.bookmarks.TYPE_FOLDER,
+ parentGuid: PlacesUtils.bookmarks.rootGuid,
+ title,
+ dateAdded,
+ }
+ );
+ }
+ );
+}
+
+// Returns a `CryptoWrapper`-like object that wraps the Sync record cleartext.
+// This exists to avoid importing `record.js` from Sync.
+function makeRecord(cleartext) {
+ return new Proxy(
+ { cleartext },
+ {
+ get(target, property, receiver) {
+ if (property == "cleartext") {
+ return target.cleartext;
+ }
+ if (property == "cleartextToString") {
+ return () => JSON.stringify(target.cleartext);
+ }
+ return target.cleartext[property];
+ },
+ set(target, property, value, receiver) {
+ if (property == "cleartext") {
+ target.cleartext = value;
+ } else if (property != "cleartextToString") {
+ target.cleartext[property] = value;
+ }
+ },
+ has(target, property) {
+ return property == "cleartext" || property in target.cleartext;
+ },
+ deleteProperty(target, property) {},
+ ownKeys(target) {
+ return ["cleartext", ...Reflect.ownKeys(target)];
+ },
+ }
+ );
+}
+
+async function storeRecords(buf, records, options) {
+ await buf.store(records.map(makeRecord), options);
+}
+
+async function storeChangesInMirror(buf, changesToUpload) {
+ let cleartexts = [];
+ for (let recordId in changesToUpload) {
+ changesToUpload[recordId].synced = true;
+ cleartexts.push(changesToUpload[recordId].cleartext);
+ }
+ await storeRecords(buf, cleartexts, { needsMerge: false });
+ await PlacesSyncUtils.bookmarks.pushChanges(changesToUpload);
+}
+
+function inspectChangeRecords(changeRecords) {
+ let results = { updated: [], deleted: [] };
+ for (let [id, record] of Object.entries(changeRecords)) {
+ (record.tombstone ? results.deleted : results.updated).push(id);
+ }
+ results.updated.sort();
+ results.deleted.sort();
+ return results;
+}
+
+async function promiseManyDatesAdded(guids) {
+ let datesAdded = new Map();
+ let db = await PlacesUtils.promiseDBConnection();
+ for (let chunk of PlacesUtils.chunkArray(guids, 100)) {
+ let rows = await db.executeCached(
+ `
+ SELECT guid, dateAdded FROM moz_bookmarks
+ WHERE guid IN (${new Array(chunk.length).fill("?").join(",")})`,
+ chunk
+ );
+ if (rows.length != chunk.length) {
+ throw new TypeError("Can't fetch date added for nonexistent items");
+ }
+ for (let row of rows) {
+ let dateAdded = row.getResultByName("dateAdded") / 1000;
+ datesAdded.set(row.getResultByName("guid"), dateAdded);
+ }
+ }
+ return datesAdded;
+}
+
+async function fetchLocalTree(rootGuid) {
+ function bookmarkNodeToInfo(node) {
+ let { guid, index, title, typeCode: type } = node;
+ let itemInfo = { guid, index, title, type };
+ if (node.annos) {
+ let syncableAnnos = node.annos.filter(anno =>
+ [PlacesUtils.LMANNO_FEEDURI, PlacesUtils.LMANNO_SITEURI].includes(
+ anno.name
+ )
+ );
+ if (syncableAnnos.length) {
+ itemInfo.annos = syncableAnnos;
+ }
+ }
+ if (node.uri) {
+ itemInfo.url = node.uri;
+ }
+ if (node.keyword) {
+ itemInfo.keyword = node.keyword;
+ }
+ if (node.children) {
+ itemInfo.children = node.children.map(bookmarkNodeToInfo);
+ }
+ if (node.tags) {
+ itemInfo.tags = node.tags.split(",").sort();
+ }
+ return itemInfo;
+ }
+ let root = await PlacesUtils.promiseBookmarksTree(rootGuid);
+ return bookmarkNodeToInfo(root);
+}
+
+async function assertLocalTree(rootGuid, expected, message) {
+ let actual = await fetchLocalTree(rootGuid);
+ if (!ObjectUtils.deepEqual(actual, expected)) {
+ info(
+ `Expected structure for ${rootGuid}: ${CanonicalJSON.stringify(expected)}`
+ );
+ info(
+ `Actual structure for ${rootGuid}: ${CanonicalJSON.stringify(actual)}`
+ );
+ throw new Assert.constructor.AssertionError({ actual, expected, message });
+ }
+}
+
+function makeLivemarkServer() {
+ let server = new HttpServer();
+ server.registerPrefixHandler("/feed/", do_get_file("./livemark.xml"));
+ server.start(-1);
+ return {
+ server,
+ get site() {
+ let { identity } = server;
+ let host = identity.primaryHost.includes(":")
+ ? `[${identity.primaryHost}]`
+ : identity.primaryHost;
+ return `${identity.primaryScheme}://${host}:${identity.primaryPort}`;
+ },
+ stopServer() {
+ return new Promise(resolve => server.stop(resolve));
+ },
+ };
+}
+
+function shuffle(array) {
+ let results = [];
+ for (let i = 0; i < array.length; ++i) {
+ let randomIndex = Math.floor(Math.random() * (i + 1));
+ results[i] = results[randomIndex];
+ results[randomIndex] = array[i];
+ }
+ return results;
+}
+
+async function fetchAllKeywords(info) {
+ let entries = [];
+ await PlacesUtils.keywords.fetch(info, entry => entries.push(entry));
+ return entries;
+}
+
+async function openMirror(name, options = {}) {
+ let buf = await SyncedBookmarksMirror.open({
+ path: `${name}_buf.sqlite`,
+ recordStepTelemetry(...args) {
+ if (options.recordStepTelemetry) {
+ options.recordStepTelemetry.call(this, ...args);
+ }
+ },
+ recordValidationTelemetry(...args) {
+ if (options.recordValidationTelemetry) {
+ options.recordValidationTelemetry.call(this, ...args);
+ }
+ },
+ });
+ return buf;
+}
+
+function BookmarkObserver({ ignoreDates = true, skipTags = false } = {}) {
+ this.notifications = [];
+ this.ignoreDates = ignoreDates;
+ this.skipTags = skipTags;
+ this.handlePlacesEvents = this.handlePlacesEvents.bind(this);
+}
+
+BookmarkObserver.prototype = {
+ handlePlacesEvents(events) {
+ for (let event of events) {
+ switch (event.type) {
+ case "bookmark-added": {
+ if (this.skipTags && event.isTagging) {
+ continue;
+ }
+ let params = {
+ itemId: event.id,
+ parentId: event.parentId,
+ index: event.index,
+ type: event.itemType,
+ urlHref: event.url,
+ title: event.title,
+ guid: event.guid,
+ parentGuid: event.parentGuid,
+ source: event.source,
+ tags: event.tags,
+ frecency: event.frecency,
+ hidden: event.hidden,
+ visitCount: event.visitCount,
+ };
+ if (!this.ignoreDates) {
+ params.dateAdded = event.dateAdded;
+ params.lastVisitDate = event.lastVisitDate;
+ }
+ this.notifications.push({ name: "bookmark-added", params });
+ break;
+ }
+ case "bookmark-removed": {
+ if (this.skipTags && event.isTagging) {
+ continue;
+ }
+ // Since we are now skipping tags on the listener side we don't
+ // prevent unTagging notifications from going out. These events cause empty
+ // tags folders to be removed which creates another bookmark-removed notification
+ if (
+ this.skipTags &&
+ event.parentGuid == PlacesUtils.bookmarks.tagsGuid
+ ) {
+ continue;
+ }
+ let params = {
+ itemId: event.id,
+ parentId: event.parentId,
+ index: event.index,
+ type: event.itemType,
+ urlHref: event.url || null,
+ title: event.title,
+ guid: event.guid,
+ parentGuid: event.parentGuid,
+ source: event.source,
+ };
+ this.notifications.push({ name: "bookmark-removed", params });
+ break;
+ }
+ case "bookmark-moved": {
+ const params = {
+ itemId: event.id,
+ type: event.itemType,
+ urlHref: event.url,
+ source: event.source,
+ guid: event.guid,
+ newIndex: event.index,
+ newParentGuid: event.parentGuid,
+ oldIndex: event.oldIndex,
+ oldParentGuid: event.oldParentGuid,
+ isTagging: event.isTagging,
+ title: event.title,
+ tags: event.tags,
+ frecency: event.frecency,
+ hidden: event.hidden,
+ visitCount: event.visitCount,
+ dateAdded: event.dateAdded,
+ lastVisitDate: event.lastVisitDate,
+ };
+ this.notifications.push({ name: "bookmark-moved", params });
+ break;
+ }
+ case "bookmark-guid-changed": {
+ const params = {
+ itemId: event.id,
+ type: event.itemType,
+ urlHref: event.url,
+ guid: event.guid,
+ parentGuid: event.parentGuid,
+ source: event.source,
+ isTagging: event.isTagging,
+ };
+ this.notifications.push({ name: "bookmark-guid-changed", params });
+ break;
+ }
+ case "bookmark-title-changed": {
+ const params = {
+ itemId: event.id,
+ guid: event.guid,
+ title: event.title,
+ parentGuid: event.parentGuid,
+ };
+ this.notifications.push({ name: "bookmark-title-changed", params });
+ break;
+ }
+ case "bookmark-url-changed": {
+ const params = {
+ itemId: event.id,
+ type: event.itemType,
+ urlHref: event.url,
+ guid: event.guid,
+ parentGuid: event.parentGuid,
+ source: event.source,
+ isTagging: event.isTagging,
+ };
+ this.notifications.push({ name: "bookmark-url-changed", params });
+ break;
+ }
+ }
+ }
+ },
+
+ check(expectedNotifications) {
+ PlacesUtils.observers.removeListener(
+ [
+ "bookmark-added",
+ "bookmark-removed",
+ "bookmark-moved",
+ "bookmark-guid-changed",
+ "bookmark-title-changed",
+ "bookmark-url-changed",
+ ],
+ this.handlePlacesEvents
+ );
+ if (!ObjectUtils.deepEqual(this.notifications, expectedNotifications)) {
+ info(`Expected notifications: ${JSON.stringify(expectedNotifications)}`);
+ info(`Actual notifications: ${JSON.stringify(this.notifications)}`);
+ throw new Assert.constructor.AssertionError({
+ actual: this.notifications,
+ expected: expectedNotifications,
+ });
+ }
+ },
+};
+
+function expectBookmarkChangeNotifications(options) {
+ let observer = new BookmarkObserver(options);
+ PlacesUtils.observers.addListener(
+ [
+ "bookmark-added",
+ "bookmark-removed",
+ "bookmark-moved",
+ "bookmark-guid-changed",
+ "bookmark-title-changed",
+ "bookmark-url-changed",
+ ],
+ observer.handlePlacesEvents
+ );
+ return observer;
+}
+
+// Copies a support file to a temporary fixture file, allowing the support
+// file to be reused for multiple tests.
+async function setupFixtureFile(fixturePath) {
+ let fixtureFile = do_get_file(fixturePath);
+ let tempFile = FileTestUtils.getTempFile(fixturePath);
+ await IOUtils.copy(fixtureFile.path, tempFile.path);
+ return tempFile;
+}
diff --git a/toolkit/components/places/tests/sync/mirror_corrupt.sqlite b/toolkit/components/places/tests/sync/mirror_corrupt.sqlite
new file mode 100644
index 0000000000..ed3613447c
--- /dev/null
+++ b/toolkit/components/places/tests/sync/mirror_corrupt.sqlite
@@ -0,0 +1 @@
+Not a database!
diff --git a/toolkit/components/places/tests/sync/mirror_v1.sqlite b/toolkit/components/places/tests/sync/mirror_v1.sqlite
new file mode 100644
index 0000000000..f0b8853616
Binary files /dev/null and b/toolkit/components/places/tests/sync/mirror_v1.sqlite differ
diff --git a/toolkit/components/places/tests/sync/mirror_v5.sqlite b/toolkit/components/places/tests/sync/mirror_v5.sqlite
new file mode 100644
index 0000000000..2a798ae908
Binary files /dev/null and b/toolkit/components/places/tests/sync/mirror_v5.sqlite differ
diff --git a/toolkit/components/places/tests/sync/mirror_v8.sqlite b/toolkit/components/places/tests/sync/mirror_v8.sqlite
new file mode 100644
index 0000000000..94d559f08d
Binary files /dev/null and b/toolkit/components/places/tests/sync/mirror_v8.sqlite differ
diff --git a/toolkit/components/places/tests/sync/sync_utils_bookmarks.html b/toolkit/components/places/tests/sync/sync_utils_bookmarks.html
new file mode 100644
index 0000000000..53ad366b1f
--- /dev/null
+++ b/toolkit/components/places/tests/sync/sync_utils_bookmarks.html
@@ -0,0 +1,18 @@
+
+
+
+Bookmarks
+