summaryrefslogtreecommitdiffstats
path: root/toolkit/components/places/tests/queries/test_tags.js
diff options
context:
space:
mode:
Diffstat (limited to 'toolkit/components/places/tests/queries/test_tags.js')
-rw-r--r--toolkit/components/places/tests/queries/test_tags.js626
1 files changed, 626 insertions, 0 deletions
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)));
+ }
+}