summaryrefslogtreecommitdiffstats
path: root/toolkit/components/places/tests/queries/test_tags.js
blob: 23a332f20b753563b79f8be99b188de73bfb523e (plain)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
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 () {
    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 () {
    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() {
  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)));
  }
}